feat(webui): stateless signed math-captcha tokens

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-08-08 09:21:28 +08:00
parent 4a652fd67f
commit f5776cdf49
2 changed files with 107 additions and 0 deletions
+49
View File
@@ -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);
});
});
+58
View File
@@ -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<CaptchaPayload>;
try {
payload = JSON.parse(
Buffer.from(encoded, "base64url").toString("utf8")
) as Partial<CaptchaPayload>;
} 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);
}