feat(webui): stateless signed math-captcha tokens
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user