New Captcha Package added

This commit is contained in:
2026-07-25 13:38:18 +05:30
parent d0aded0392
commit 8b728a3e5d
157 changed files with 12092 additions and 1913 deletions
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, test } from "bun:test";
import { join } from "node:path";
import {
createAssetAudioRenderer,
resolveCaptchaAudioAssetsDir,
} from "../src/audio/renderer.ts";
const packageRoot = join(import.meta.dir, "..");
const expectedAssets = join(packageRoot, "assets", "audio");
describe("CAPTCHA audio renderer", () => {
test("resolves the packaged audio directory", () => {
expect(resolveCaptchaAudioAssetsDir(expectedAssets)).toBe(expectedAssets);
});
test("renders a playable PCM WAV challenge", async () => {
const renderer = createAssetAudioRenderer({ assetsDir: expectedAssets, gapMs: 100 });
const bytes = await renderer.render(["one", "two", "three"], "en-IN");
expect(bytes.byteLength).toBeGreaterThan(44);
expect(new TextDecoder().decode(bytes.slice(0, 4))).toBe("RIFF");
expect(new TextDecoder().decode(bytes.slice(8, 12))).toBe("WAVE");
});
});
@@ -0,0 +1,49 @@
import { expect, test } from "bun:test";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
const packageRoot = join(import.meta.dir, "..");
test("Captcha browser runtime is valid JavaScript and exposes the expected lifecycle", async () => {
const source = await readFile(join(packageRoot, "assets/client/captcha.js"), "utf8");
expect(() => new Function(source)).not.toThrow();
expect(source).toContain("__wrnexusCaptchaRuntime");
expect(source).toContain("createChallenge");
expect(source).toContain("verify(state)");
expect(source).toContain("mountExternal");
expect(source).toContain("captchaDisturbance");
expect(source).toContain("captchaImageStyle");
expect(source).toContain("captchaAllowedStyles");
expect(source).toContain("captchaExcludedStyles");
expect(source).toContain("captchaRandomizeStyle");
expect(source).toContain("captchaResolvedImageStyle");
expect(source).toContain("captchaShowListen");
expect(source).toContain("normalizeSize");
expect(source).toContain("verifyNotRobot");
expect(source).toContain("stopImmediatePropagation");
expect(source).toContain('addEventListener("submit"');
expect(source).toContain("MutationObserver");
expect(source).toContain('new CustomEvent(name');
});
test("Captcha component delegates native browser work to the packaged runtime", async () => {
const source = await readFile(join(packageRoot, "components/Captcha.wrn"), "utf8");
expect(source).toContain('src="/assets/wrnexus/captcha.js"');
expect(source).not.toContain('src="/__wrnexus/captcha.js"');
expect(source).toContain("data-wrn-captcha");
expect(source).toContain("data-captcha-response");
expect(source).toContain("data-captcha-disturbance");
expect(source).toContain("data-captcha-image-style");
expect(source).toContain("data-captcha-allowed-styles");
expect(source).toContain("data-captcha-excluded-styles");
expect(source).toContain("data-captcha-randomize-style");
expect(source).toContain("data-captcha-show-listen");
expect(source).toContain("data-captcha-not-robot-button");
expect(source).not.toContain("lifecycle {");
expect(source).not.toContain("async function");
expect(source).not.toContain("await ");
expect(source).not.toContain("try {");
expect(source).not.toContain("setTimeout(function");
});
+38
View File
@@ -0,0 +1,38 @@
import { expect, test } from "bun:test";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { parse } from "@wrnexus/syntax";
const componentPath = join(import.meta.dir, "../components/Captcha.wrn");
test("Captcha.wrn parses and exposes the full public contract", async () => {
const source = await readFile(componentPath, "utf8");
const ast = parse(source);
expect(ast.kind).toBe("component");
expect(ast.name).toBe("Captcha");
for (const prop of ["provider", "siteKey", "type", "action", "presentation", "difficulty", "disturbance", "imageStyle", "allowedStyles", "excludedStyles", "randomizeStyle", "size", "color", "class", "showListen"]) {
expect(source).toContain(`${prop} =`);
}
for (const event of ["ready", "challenge", "input", "verify", "success", "failure", "expired", "refresh", "audioStart", "audioEnd", "error"]) {
expect(source).toContain(`@event ${event} = function`);
}
expect(source).toContain("data-captcha-disturbance='{disturbance}'");
expect(source).toContain("data-captcha-image-style='{imageStyle}'");
expect(source).toContain("data-captcha-allowed-styles='{allowedStyles}'");
expect(source).toContain("data-captcha-excluded-styles='{excludedStyles}'");
expect(source).toContain("data-captcha-randomize-style='{randomizeStyle}'");
expect(source).toContain("data-captcha-show-listen='{showListen}'");
expect(source).toContain("data-captcha-not-robot-button");
expect(source).toContain("data-[captcha-size=compact]");
expect(source).toContain("data-[captcha-size=compact]:max-w-xs");
expect(source).toContain("group-data-[captcha-size=compact]/captcha:max-h-24");
expect(source).toContain("group-data-[captcha-size=compact]/captcha:h-8");
expect(source).toContain("group-data-[captcha-size=compact]/captcha:hidden");
expect(source).toContain("data-[captcha-size=big]");
expect(source).toContain("{...attrs}");
expect(source).toContain("--wire-");
expect(source).not.toMatch(/=\{/);
expect(source).toContain('src="/assets/wrnexus/captcha.js"');
expect(source).not.toContain('src="/__wrnexus/captcha.js"');
expect(source).not.toContain("lifecycle {");
});
+203
View File
@@ -0,0 +1,203 @@
import { describe, expect, test } from "bun:test";
import { createCaptchaEngine } from "../src/engine.ts";
import { MemoryCaptchaStore } from "../src/stores/memory.ts";
import type { CaptchaChallengeGenerator } from "../src/types.ts";
function fixture() {
let now = 1_700_000_000_000;
let seed = 7;
const generator: CaptchaChallengeGenerator = {
type: "number",
generate: () => ({
type: "number",
presentation: "visual",
prompt: "Enter 42",
answer: "42",
answerKind: "text",
inputMode: "numeric",
audioSequence: ["four", "two"],
}),
};
const engine = createCaptchaEngine({
secret: "captcha-test-secret-with-at-least-thirty-two-characters",
store: new MemoryCaptchaStore(),
generators: [generator],
defaultType: "number",
minCompletionMs: 0,
now: () => now,
randomBytes(length) {
const bytes = new Uint8Array(length);
for (let index = 0; index < length; index += 1) {
seed = (seed * 1664525 + 1013904223) >>> 0;
bytes[index] = seed & 255;
}
return bytes;
},
audioRenderer: { contentType: "audio/wav", async render() { return new Uint8Array([82, 73, 70, 70]); } },
});
return { engine, advance: (milliseconds: number) => { now += milliseconds; } };
}
describe("self-hosted CAPTCHA engine", () => {
test("creates, solves, and consumes a challenge and response token", async () => {
const { engine } = fixture();
const challenge = await engine.create({ action: "signup", hostname: "example.test", sessionId: "s1" });
const solved = await engine.verify({ challengeId: challenge.id, action: "signup", answer: "42", hostname: "example.test", sessionId: "s1" });
expect(solved.success).toBe(true);
expect(solved.responseToken).toBeString();
const accepted = await engine.verifyResponseToken({ responseToken: solved.responseToken, action: "signup", hostname: "example.test", sessionId: "s1" });
expect(accepted.success).toBe(true);
const replay = await engine.verifyResponseToken({ responseToken: solved.responseToken, action: "signup", hostname: "example.test", sessionId: "s1" });
expect(replay).toMatchObject({ success: false, code: "already-used" });
});
test("rejects wrong answers and enforces attempt limits", async () => {
const { engine } = fixture();
const challenge = await engine.create({ action: "login", maxAttempts: 2 });
expect(await engine.verify({ challengeId: challenge.id, action: "login", answer: "1" })).toMatchObject({ success: false, code: "incorrect-answer" });
expect(await engine.verify({ challengeId: challenge.id, action: "login", answer: "2" })).toMatchObject({ success: false, code: "attempts-exhausted" });
});
test("binds challenges to actions, hosts, and sessions", async () => {
const { engine } = fixture();
const challenge = await engine.create({ action: "checkout", hostname: "shop.test", sessionId: "abc" });
expect(await engine.verify({ challengeId: challenge.id, action: "login", answer: "42", hostname: "shop.test", sessionId: "abc" })).toMatchObject({ code: "action-mismatch" });
expect(await engine.verify({ challengeId: challenge.id, action: "checkout", answer: "42", hostname: "other.test", sessionId: "abc" })).toMatchObject({ code: "hostname-mismatch" });
expect(await engine.verify({ challengeId: challenge.id, action: "checkout", answer: "42", hostname: "shop.test", sessionId: "wrong" })).toMatchObject({ code: "session-mismatch" });
});
test("expires challenges", async () => {
const { engine, advance } = fixture();
const challenge = await engine.create({ action: "contact", expiresInMs: 1000 });
advance(1001);
expect(await engine.verify({ challengeId: challenge.id, action: "contact", answer: "42" })).toMatchObject({ success: false, code: "expired" });
});
test("protects audio with an unguessable challenge key", async () => {
const { engine } = fixture();
const challenge = await engine.create({ action: "contact", presentation: "audio" });
expect(challenge.audioUrl).toContain("/audio/");
const url = new URL(challenge.audioUrl!, "https://example.test");
expect(await engine.renderAudio(challenge.id, "wrong")).toBeUndefined();
const rendered = await engine.renderAudio(challenge.id, url.searchParams.get("key")!);
expect(rendered?.contentType).toBe("audio/wav");
});
test("creates and verifies the not-robot checkbox challenge after the minimum completion time", async () => {
let now = 1_700_000_000_000;
let seed = 19;
const engine = createCaptchaEngine({
secret: "not-robot-test-secret-with-at-least-thirty-two-characters",
store: new MemoryCaptchaStore(),
minCompletionMs: 800,
now: () => now,
randomBytes(length) {
const bytes = new Uint8Array(length);
for (let index = 0; index < length; index += 1) {
seed = (seed * 1664525 + 1013904223) >>> 0;
bytes[index] = seed & 255;
}
return bytes;
},
});
const challenge = await engine.create({ action: "not-robot-demo", type: "not-robot" });
expect(challenge).toMatchObject({
type: "not-robot",
presentation: "invisible",
inputMode: "none",
});
expect(challenge.metadata).toMatchObject({
interaction: "checkbox",
minCompletionMs: 800,
});
const tooFast = await engine.verify({
challengeId: challenge.id,
action: "not-robot-demo",
honeypot: "",
timingToken: challenge.timingToken,
});
expect(tooFast).toMatchObject({ success: false, code: "risk-rejected" });
now += 800;
const solved = await engine.verify({
challengeId: challenge.id,
action: "not-robot-demo",
honeypot: "",
timingToken: challenge.timingToken,
});
expect(solved.success).toBe(true);
expect(solved.responseToken).toBeString();
});
test("normalizes and validates visual disturbance percentages", async () => {
const { engine } = fixture();
const easy = await engine.create({ action: "image-easy", disturbance: 25 });
expect(easy.metadata?.disturbance).toBe(25);
const hard = await engine.create({ action: "image-hard", disturbance: 75 });
expect(hard.metadata?.disturbance).toBe(75);
await expect(engine.create({ action: "too-easy", disturbance: 24 })).rejects.toThrow(
"disturbance must be between 25 and 75",
);
await expect(engine.create({ action: "too-hard", disturbance: 76 })).rejects.toThrow(
"disturbance must be between 25 and 75",
);
});
test("resolves explicit and random image renderer styles", async () => {
const { engine } = fixture();
const explicit = await engine.create({
action: "styled-explicit",
imageStyle: "spiderweb",
});
expect(explicit.metadata).toMatchObject({
requestedImageStyle: "spiderweb",
imageStyle: "spiderweb",
});
const pooled = await engine.create({
action: "styled-random",
imageStyle: "random",
allowedStyles: ["snow", "wave"],
});
expect(["snow", "wave"]).toContain(pooled.metadata?.imageStyle);
expect(pooled.metadata?.imageStylePool).toEqual(["snow", "wave"]);
const forced = await engine.create({
action: "styled-forced-random",
imageStyle: "classic",
randomizeStyle: true,
allowedStyles: "cut,striped",
});
expect(["cut", "striped"]).toContain(forced.metadata?.imageStyle);
expect(forced.metadata?.requestedImageStyle).toBe("classic");
});
test("validates image renderer style pools", async () => {
const { engine } = fixture();
await expect(engine.create({
action: "unknown-style",
imageStyle: "unknown" as never,
})).rejects.toThrow("imageStyle must be one of");
await expect(engine.create({
action: "empty-style-pool",
allowedStyles: ["snow"],
excludedStyles: ["snow"],
})).rejects.toThrow("No CAPTCHA image styles remain");
await expect(engine.create({
action: "excluded-explicit-style",
imageStyle: "classic",
excludedStyles: ["classic"],
})).rejects.toThrow("is not available in the configured style pool");
});
});
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, test } from "bun:test";
import { createCaptchaHttpHandlers } from "../src/http.ts";
import type { CaptchaEngine } from "../src/types.ts";
const engine: CaptchaEngine = {
provider: "self-hosted",
basePath: "/api/captcha",
async create(options) { return { id: "c1", provider: "self-hosted", type: "number", presentation: "visual", action: options.action, prompt: "Enter 1", createdAt: 1, expiresAt: 2, responseField: "wrn-captcha-response" }; },
async verify(input) { return { success: input.answer === "1", provider: "self-hosted", action: input.action, code: input.answer === "1" ? undefined : "incorrect-answer" }; },
async verifyResponseToken(input) { return { success: true, provider: "self-hosted", action: input.action }; },
async renderAudio() { return { bytes: new Uint8Array([1, 2, 3]), contentType: "audio/wav" }; },
async gc() {},
};
describe("CAPTCHA HTTP handlers", () => {
test("creates and verifies same-origin challenges", async () => {
const handlers = createCaptchaHttpHandlers(engine);
const created = await handlers.handle(new Request("https://example.test/api/captcha/challenge", { method: "POST", headers: { origin: "https://example.test", "content-type": "application/json" }, body: JSON.stringify({ action: "signup" }) }));
expect(created?.status).toBe(201);
const verified = await handlers.handle(new Request("https://example.test/api/captcha/verify", { method: "POST", headers: { origin: "https://example.test", "content-type": "application/json" }, body: JSON.stringify({ action: "signup", answer: "1" }) }));
expect(verified?.status).toBe(200);
});
test("rejects cross-origin requests", async () => {
const handlers = createCaptchaHttpHandlers(engine);
const response = await handlers.create(new Request("https://example.test/api/captcha/challenge", { method: "POST", headers: { origin: "https://evil.test", "content-type": "application/json" }, body: "{}" }));
expect(response.status).toBe(403);
});
});
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, test } from "bun:test";
import { captchaPageGate } from "../src/middleware.ts";
import type { CaptchaEngine } from "../src/types.ts";
function fakeEngine(): CaptchaEngine {
return {
provider: "self-hosted",
basePath: "/api/captcha",
async create() {
throw new Error("not used");
},
async verify() {
throw new Error("not used");
},
async verifyResponseToken(input) {
return input.responseToken === "verified-token"
? { success: true, provider: "self-hosted", action: input.action }
: { success: false, provider: "self-hosted", action: input.action, code: "invalid-input" };
},
async renderAudio() {
return undefined;
},
async gc() {},
};
}
function sessionFixture() {
const values = new Map<string, unknown>();
return {
id: () => "session-1",
get<T>(key: string): T | undefined {
return values.get(key) as T | undefined;
},
set<T>(key: string, value: T): void {
values.set(key, value);
},
delete(key: string): void {
values.delete(key);
},
};
}
describe("CAPTCHA page gate", () => {
test("accepts a verified form token and grants the protected route", async () => {
const session = sessionFixture();
const gate = captchaPageGate({
action: "protected-page-access",
engine: fakeEngine(),
challengePath: "/captcha",
policy: {
mode: "session",
verifiedForMs: 15 * 60_000,
routeGroups: ["/protected"],
},
});
const body = new URLSearchParams({
returnTo: "/protected",
"wrn-captcha-response": "verified-token",
});
const request = new Request("https://example.test/api/page-grant", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body,
});
const context = {
req: request,
url: new URL(request.url),
session,
ip: "127.0.0.1",
locals: {},
};
const granted = await gate(context, () =>
Response.redirect("https://example.test/protected", 303),
);
expect(granted.status).toBe(303);
const protectedRequest = new Request("https://example.test/protected");
const protectedContext = {
...context,
req: protectedRequest,
url: new URL(protectedRequest.url),
locals: {},
};
const allowed = await gate(protectedContext, () => new Response("unlocked"));
expect(await allowed.text()).toBe("unlocked");
});
});
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, test } from "bun:test";
import { evaluateCaptchaRisk, shouldRequireCaptcha, validCaptchaGrant } from "../src/policy.ts";
describe("adaptive CAPTCHA policy", () => {
test("raises risk from automation signals", () => {
const result = evaluateCaptchaRisk({ failedAttempts: 3, completionMs: 200, suspiciousHeaders: true });
expect(result.challenge).toBe(true);
expect(result.reasons).toContain("too-fast");
});
test("honours explicit policy actions", () => {
expect(shouldRequireCaptcha("signup", { alwaysForActions: ["signup"] }).challenge).toBe(true);
expect(shouldRequireCaptcha("health", { neverForActions: ["health"] }).challenge).toBe(false);
});
test("accepts an unexpired route grant", () => {
const grant = validCaptchaGrant([{ action: "page", routeGroup: "/reports", provider: "self-hosted", expiresAt: 200 }], "page", 100, "/reports");
expect(grant?.provider).toBe("self-hosted");
});
});
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, test } from "bun:test";
import { turnstileProvider } from "../src/providers/turnstile.ts";
import { recaptchaProvider } from "../src/providers/recaptcha.ts";
import { hcaptchaProvider } from "../src/providers/hcaptcha.ts";
const okFetch = (payload: Record<string, unknown>) => async (_url: string | URL | Request, init?: RequestInit) => {
expect(init?.method).toBe("POST");
expect(init?.headers).toMatchObject({ "content-type": "application/x-www-form-urlencoded" });
return Response.json(payload);
};
describe("hosted providers", () => {
test("verifies Turnstile hostname and action", async () => {
const provider = turnstileProvider({
secretKey: "secret",
expectedHostnames: ["example.test"],
expectedAction: "signup",
fetch: okFetch({ success: true, hostname: "example.test", action: "signup" }) as typeof fetch,
});
expect(await provider.verify({ action: "signup", providerToken: "token" })).toMatchObject({ success: true, provider: "turnstile" });
});
test("maps provider duplicate failures", async () => {
const provider = recaptchaProvider({
secretKey: "secret",
fetch: okFetch({ success: false, "error-codes": ["timeout-or-duplicate"] }) as typeof fetch,
});
expect(await provider.verify({ action: "login", providerToken: "token" })).toMatchObject({ success: false, code: "already-used" });
});
test("sends hCaptcha site key when configured", async () => {
let submitted = "";
const provider = hcaptchaProvider({
secretKey: "secret",
siteKey: "site-key",
fetch: (async (_url, init) => {
submitted = String(init?.body);
return Response.json({ success: true, hostname: "example.test" });
}) as typeof fetch,
});
expect((await provider.verify({ action: "contact", providerToken: "token" })).success).toBe(true);
expect(submitted).toContain("sitekey=site-key");
});
});
+112
View File
@@ -0,0 +1,112 @@
import { describe, expect, test } from "bun:test";
import {
CAPTCHA_CONCRETE_IMAGE_STYLES,
CAPTCHA_IMAGE_STYLES,
normalizeCaptchaImageStyleList,
resolveCaptchaImageStyle,
} from "../src/challenges/styles.ts";
import { renderTextChallenge } from "../src/challenges/visual.ts";
import type {
CaptchaConcreteImageStyle,
CaptchaGeneratorContext,
} from "../src/types.ts";
function random(seedValue = 17) {
let seed = seedValue >>> 0;
const next = () => {
seed = (seed * 1664525 + 1013904223) >>> 0;
return seed;
};
return {
int(min: number, max: number) {
return min + (next() % (max - min + 1));
},
float() {
return next() / 0xffff_ffff;
},
};
}
function context(style: CaptchaConcreteImageStyle): CaptchaGeneratorContext {
const source = random(CAPTCHA_CONCRETE_IMAGE_STYLES.indexOf(style) + 101);
return {
difficulty: "normal",
disturbance: 50,
imageStyle: style,
requestedImageStyle: style,
imageStylePool: [style],
locale: "en",
length: 6,
caseSensitive: false,
minCompletionMs: 800,
randomInt: source.int,
randomFloat: source.float,
randomId: (bytes = 18) => `${style}-${bytes}`,
};
}
function decodeDataUri(uri: string): Uint8Array {
const encoded = uri.slice(uri.indexOf(",") + 1);
return Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0));
}
describe("CAPTCHA image renderer styles", () => {
test("publishes the complete renderer catalog", () => {
expect(CAPTCHA_IMAGE_STYLES).toContain("random");
expect(CAPTCHA_CONCRETE_IMAGE_STYLES).toHaveLength(18);
expect(CAPTCHA_CONCRETE_IMAGE_STYLES).toEqual([
"classic",
"collision",
"snow",
"corrosion",
"spiderweb",
"cross-shadow",
"split",
"split2",
"cut",
"darts",
"distortion",
"stitch",
"striped",
"wave",
"grid-noise",
"scribble",
"pixel",
"broken-lines",
]);
});
test("renders a valid PNG for every concrete style", () => {
const images = new Set<string>();
for (const style of CAPTCHA_CONCRETE_IMAGE_STYLES) {
const image = renderTextChallenge("A7K93P", context(style));
expect(image.startsWith("data:image/png;base64,")).toBe(true);
const bytes = decodeDataUri(image);
expect([...bytes.slice(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10]);
expect(bytes.byteLength).toBeGreaterThan(1_000);
images.add(image);
}
expect(images.size).toBe(CAPTCHA_CONCRETE_IMAGE_STYLES.length);
});
test("normalizes comma-separated renderer pools", () => {
expect(normalizeCaptchaImageStyleList("snow, wave, snow", "allowedStyles")).toEqual([
"snow",
"wave",
]);
expect(() => normalizeCaptchaImageStyleList("snow,unknown", "allowedStyles")).toThrow(
"allowedStyles contains an unknown image style",
);
});
test("selects random styles only from the active pool", () => {
const resolved = resolveCaptchaImageStyle({
imageStyle: "random",
allowedStyles: ["classic", "snow", "wave"],
excludedStyles: ["snow"],
randomInt: () => 1,
});
expect(resolved.pool).toEqual(["classic", "wave"]);
expect(resolved.resolved).toBe("wave");
});
});