diff --git a/src/lib/server/captcha.test.ts b/src/lib/server/captcha.test.ts new file mode 100644 index 0000000..cce7dea --- /dev/null +++ b/src/lib/server/captcha.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +process.env.WEBUI_AUTH_SECRET = "test-secret-with-at-least-32-characters"; + +const { createCaptcha, verifyCaptcha } = await import("./captcha"); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("captcha", () => { + it("round-trips: correct answer verifies", () => { + const { id, question } = createCaptcha(); + const match = question.match(/^(\d+) \+ (\d+) = \?$/); + expect(match).not.toBeNull(); + const answer = String(Number(match![1]) + Number(match![2])); + expect(verifyCaptcha(id, answer)).toBe(true); + }); + + it("rejects a wrong answer", () => { + const { id, question } = createCaptcha(); + const match = question.match(/^(\d+) \+ (\d+) = \?/)!; + const wrong = String(Number(match[1]) + Number(match[2]) + 1); + expect(verifyCaptcha(id, wrong)).toBe(false); + }); + + it("rejects a tampered payload", () => { + const { id } = createCaptcha(); + const [payload] = id.split("."); + const forged = `${payload}.${"a".repeat(43)}`; + expect(verifyCaptcha(forged, "2")).toBe(false); + }); + + it("rejects an expired captcha", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-08T00:00:00Z")); + const { id, question } = createCaptcha(); + const match = question.match(/^(\d+) \+ (\d+) = \?/)!; + const answer = String(Number(match[1]) + Number(match[2])); + vi.setSystemTime(new Date("2026-08-08T00:06:00Z")); + expect(verifyCaptcha(id, answer)).toBe(false); + }); + + it("rejects malformed inputs", () => { + expect(verifyCaptcha(undefined, "2")).toBe(false); + expect(verifyCaptcha("not-a-token", "2")).toBe(false); + expect(verifyCaptcha(123, "2")).toBe(false); + }); +}); diff --git a/src/lib/server/captcha.ts b/src/lib/server/captcha.ts new file mode 100644 index 0000000..574217a --- /dev/null +++ b/src/lib/server/captcha.ts @@ -0,0 +1,58 @@ +import { createHmac, randomInt, timingSafeEqual } from "node:crypto"; +import { authSecret } from "@/lib/auth"; + +const CAPTCHA_TTL_MS = 5 * 60 * 1000; + +interface CaptchaPayload { + a: number; + b: number; + exp: number; +} + +function sign(encoded: string, secret: string): string { + return createHmac("sha256", secret).update(encoded).digest("base64url"); +} + +export function createCaptcha(): { id: string; question: string } { + const secret = authSecret(); + if (!secret) throw new Error("WEBUI_AUTH_SECRET is not configured."); + const a = randomInt(1, 10); + const b = randomInt(1, 10); + const payload: CaptchaPayload = { a, b, exp: Date.now() + CAPTCHA_TTL_MS }; + const encoded = Buffer.from(JSON.stringify(payload)).toString("base64url"); + return { + id: `${encoded}.${sign(encoded, secret)}`, + question: `${a} + ${b} = ?`, + }; +} + +export function verifyCaptcha(id: unknown, answer: unknown): boolean { + const secret = authSecret(); + if (!secret || typeof id !== "string" || typeof answer !== "string") { + return false; + } + const [encoded, signature] = id.split("."); + if (!encoded || !signature) return false; + const expected = Buffer.from(sign(encoded, secret)); + const provided = Buffer.from(signature); + if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) { + return false; + } + let payload: Partial; + try { + payload = JSON.parse( + Buffer.from(encoded, "base64url").toString("utf8") + ) as Partial; + } catch { + return false; + } + if ( + typeof payload.a !== "number" || + typeof payload.b !== "number" || + typeof payload.exp !== "number" || + payload.exp < Date.now() + ) { + return false; + } + return answer.trim() === String(payload.a + payload.b); +}