release: WRNexusJS 0.4.0
This commit is contained in:
@@ -126,10 +126,7 @@ export function resolveCaptchaAudioAssetsDir(explicitDir?: string): string {
|
||||
|
||||
try {
|
||||
const packageEntry = createRequire(import.meta.url).resolve("@wrnexus/captcha/audio");
|
||||
addCandidate(
|
||||
candidates,
|
||||
join(dirname(dirname(dirname(packageEntry))), "assets", "audio"),
|
||||
);
|
||||
addCandidate(candidates, join(dirname(dirname(dirname(packageEntry))), "assets", "audio"));
|
||||
} catch {
|
||||
// The source-relative and cwd fallbacks below still support direct source use.
|
||||
}
|
||||
@@ -140,7 +137,10 @@ export function resolveCaptchaAudioAssetsDir(explicitDir?: string): string {
|
||||
let current = process.cwd();
|
||||
for (let depth = 0; depth < 8; depth += 1) {
|
||||
addCandidate(candidates, join(current, "packages", "captcha", "assets", "audio"));
|
||||
addCandidate(candidates, join(current, "node_modules", "@wrnexus", "captcha", "assets", "audio"));
|
||||
addCandidate(
|
||||
candidates,
|
||||
join(current, "node_modules", "@wrnexus", "captcha", "assets", "audio"),
|
||||
);
|
||||
|
||||
const parent = dirname(current);
|
||||
if (parent === current) break;
|
||||
@@ -190,9 +190,7 @@ export class AssetAudioRenderer implements CaptchaAudioRenderer {
|
||||
}
|
||||
if (!sequence.length) throw new Error("Cannot render an empty CAPTCHA audio sequence");
|
||||
|
||||
const clips = await Promise.all(
|
||||
sequence.map((token) => this.load(language, safeToken(token))),
|
||||
);
|
||||
const clips = await Promise.all(sequence.map((token) => this.load(language, safeToken(token))));
|
||||
const first = clips[0]!;
|
||||
|
||||
for (const clip of clips) {
|
||||
@@ -223,17 +221,13 @@ export class AssetAudioRenderer implements CaptchaAudioRenderer {
|
||||
const cached = this.cache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const bytes = new Uint8Array(
|
||||
await readFile(join(this.assetsDir, language, `${token}.wav`)),
|
||||
);
|
||||
const bytes = new Uint8Array(await readFile(join(this.assetsDir, language, `${token}.wav`)));
|
||||
const parsed = parseWav(bytes);
|
||||
this.cache.set(key, parsed);
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
export function createAssetAudioRenderer(
|
||||
options?: AssetAudioRendererOptions,
|
||||
): AssetAudioRenderer {
|
||||
export function createAssetAudioRenderer(options?: AssetAudioRendererOptions): AssetAudioRenderer {
|
||||
return new AssetAudioRenderer(options);
|
||||
}
|
||||
|
||||
@@ -51,7 +51,13 @@ export class CalculationCaptchaGenerator implements CaptchaChallengeGenerator {
|
||||
answerKind: "text",
|
||||
image: renderTextChallenge(expression, context),
|
||||
inputMode: "numeric",
|
||||
audioSequence: ["what", "is", ...numberTokens(left), ...(OPERATOR_WORDS[operator] ?? []), ...numberTokens(right)],
|
||||
audioSequence: [
|
||||
"what",
|
||||
"is",
|
||||
...numberTokens(left),
|
||||
...(OPERATOR_WORDS[operator] ?? []),
|
||||
...numberTokens(right),
|
||||
],
|
||||
metadata: { operator, imageStyle: context.imageStyle },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,15 @@ import type {
|
||||
CaptchaImageItem,
|
||||
GeneratedCaptchaChallenge,
|
||||
} from "../types.ts";
|
||||
import { createImage, drawLine, fillCircle, fillPolygon, fillRect, pngDataUri, setPixel } from "./png.ts";
|
||||
import {
|
||||
createImage,
|
||||
drawLine,
|
||||
fillCircle,
|
||||
fillPolygon,
|
||||
fillRect,
|
||||
pngDataUri,
|
||||
setPixel,
|
||||
} from "./png.ts";
|
||||
|
||||
const SHAPES = ["circle", "square", "triangle", "diamond", "star"] as const;
|
||||
type Shape = (typeof SHAPES)[number];
|
||||
@@ -39,9 +47,26 @@ function shapeImage(shape: Shape, context: CaptchaGeneratorContext): string {
|
||||
if (shape === "circle") fillCircle(image, cx, cy, size, color);
|
||||
else if (shape === "square") fillRect(image, cx - size, cy - size, size * 2, size * 2, color);
|
||||
else if (shape === "triangle") {
|
||||
fillPolygon(image, [[cx, cy - size], [cx - size, cy + size], [cx + size, cy + size]], color);
|
||||
fillPolygon(
|
||||
image,
|
||||
[
|
||||
[cx, cy - size],
|
||||
[cx - size, cy + size],
|
||||
[cx + size, cy + size],
|
||||
],
|
||||
color,
|
||||
);
|
||||
} else if (shape === "diamond") {
|
||||
fillPolygon(image, [[cx, cy - size], [cx - size, cy], [cx, cy + size], [cx + size, cy]], color);
|
||||
fillPolygon(
|
||||
image,
|
||||
[
|
||||
[cx, cy - size],
|
||||
[cx - size, cy],
|
||||
[cx, cy + size],
|
||||
[cx + size, cy],
|
||||
],
|
||||
color,
|
||||
);
|
||||
} else {
|
||||
fillPolygon(image, starPoints(cx, cy, size, size * 0.45), color);
|
||||
}
|
||||
@@ -49,12 +74,12 @@ function shapeImage(shape: Shape, context: CaptchaGeneratorContext): string {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const dots = Math.round(35 + ratio * 150);
|
||||
for (let index = 0; index < dots; index++) {
|
||||
setPixel(
|
||||
image,
|
||||
context.randomInt(0, 95),
|
||||
context.randomInt(0, 71),
|
||||
[context.randomInt(105, 225), context.randomInt(105, 225), context.randomInt(105, 225), Math.round(55 + ratio * 65)],
|
||||
);
|
||||
setPixel(image, context.randomInt(0, 95), context.randomInt(0, 71), [
|
||||
context.randomInt(105, 225),
|
||||
context.randomInt(105, 225),
|
||||
context.randomInt(105, 225),
|
||||
Math.round(55 + ratio * 65),
|
||||
]);
|
||||
}
|
||||
|
||||
const lines = Math.round(1 + ratio * 4);
|
||||
@@ -65,7 +90,12 @@ function shapeImage(shape: Shape, context: CaptchaGeneratorContext): string {
|
||||
context.randomInt(0, 71),
|
||||
context.randomInt(0, 95),
|
||||
context.randomInt(0, 71),
|
||||
[context.randomInt(100, 210), context.randomInt(100, 210), context.randomInt(100, 210), Math.round(45 + ratio * 55)],
|
||||
[
|
||||
context.randomInt(100, 210),
|
||||
context.randomInt(100, 210),
|
||||
context.randomInt(100, 210),
|
||||
Math.round(45 + ratio * 55),
|
||||
],
|
||||
ratio > 0.75 ? 2 : 1,
|
||||
);
|
||||
}
|
||||
@@ -105,7 +135,11 @@ export class ImageCaptchaGenerator implements CaptchaChallengeGenerator {
|
||||
}
|
||||
|
||||
shuffle(entries, context);
|
||||
const answer = entries.filter((entry) => entry.shape === target).map((entry) => entry.id).sort().join(",");
|
||||
const answer = entries
|
||||
.filter((entry) => entry.shape === target)
|
||||
.map((entry) => entry.id)
|
||||
.sort()
|
||||
.join(",");
|
||||
return {
|
||||
type: "image",
|
||||
presentation: "visual",
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import type { CaptchaChallengeGenerator } from "../types.ts";
|
||||
import { alphaCaptchaGenerator, alphanumericCaptchaGenerator, numberCaptchaGenerator } from "./text.ts";
|
||||
import {
|
||||
alphaCaptchaGenerator,
|
||||
alphanumericCaptchaGenerator,
|
||||
numberCaptchaGenerator,
|
||||
} from "./text.ts";
|
||||
import { calculationCaptchaGenerator } from "./calculation.ts";
|
||||
import { imageCaptchaGenerator } from "./image.ts";
|
||||
import { honeypotCaptchaGenerator, notRobotCaptchaGenerator, timingCaptchaGenerator } from "./invisible.ts";
|
||||
import {
|
||||
honeypotCaptchaGenerator,
|
||||
notRobotCaptchaGenerator,
|
||||
timingCaptchaGenerator,
|
||||
} from "./invisible.ts";
|
||||
|
||||
export * from "./text.ts";
|
||||
export * from "./calculation.ts";
|
||||
@@ -13,7 +21,8 @@ export * from "./styles.ts";
|
||||
|
||||
export function defineCaptchaGenerator<T extends CaptchaChallengeGenerator>(generator: T): T {
|
||||
if (!generator.type) throw new TypeError("CAPTCHA generator requires a stable type");
|
||||
if (typeof generator.generate !== "function") throw new TypeError("CAPTCHA generator requires generate()");
|
||||
if (typeof generator.generate !== "function")
|
||||
throw new TypeError("CAPTCHA generator requires generate()");
|
||||
return generator;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@ export class InvisibleCaptchaGenerator implements CaptchaChallengeGenerator {
|
||||
return {
|
||||
type: this.type,
|
||||
presentation: "invisible",
|
||||
prompt: this.type === "not-robot" ? "Confirm that you are not a robot" : "Automated abuse check",
|
||||
prompt:
|
||||
this.type === "not-robot" ? "Confirm that you are not a robot" : "Automated abuse check",
|
||||
answer: JSON.stringify({ honeypot: "", timingToken }),
|
||||
answerKind: "invisible",
|
||||
inputMode: "none",
|
||||
|
||||
@@ -8,7 +8,11 @@ export interface RgbaImage {
|
||||
|
||||
export type Rgba = readonly [number, number, number, number?];
|
||||
|
||||
export function createImage(width: number, height: number, background: Rgba = [255, 255, 255, 255]): RgbaImage {
|
||||
export function createImage(
|
||||
width: number,
|
||||
height: number,
|
||||
background: Rgba = [255, 255, 255, 255],
|
||||
): RgbaImage {
|
||||
const data = new Uint8Array(width * height * 4);
|
||||
const alpha = background[3] ?? 255;
|
||||
for (let index = 0; index < data.length; index += 4) {
|
||||
@@ -33,13 +37,28 @@ export function setPixel(image: RgbaImage, x: number, y: number, color: Rgba): v
|
||||
image.data[index + 3] = 255;
|
||||
}
|
||||
|
||||
export function fillRect(image: RgbaImage, x: number, y: number, width: number, height: number, color: Rgba): void {
|
||||
export function fillRect(
|
||||
image: RgbaImage,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
color: Rgba,
|
||||
): void {
|
||||
for (let py = Math.floor(y); py < Math.ceil(y + height); py++) {
|
||||
for (let px = Math.floor(x); px < Math.ceil(x + width); px++) setPixel(image, px, py, color);
|
||||
}
|
||||
}
|
||||
|
||||
export function drawLine(image: RgbaImage, x0: number, y0: number, x1: number, y1: number, color: Rgba, thickness = 1): void {
|
||||
export function drawLine(
|
||||
image: RgbaImage,
|
||||
x0: number,
|
||||
y0: number,
|
||||
x1: number,
|
||||
y1: number,
|
||||
color: Rgba,
|
||||
thickness = 1,
|
||||
): void {
|
||||
let x = Math.round(x0);
|
||||
let y = Math.round(y0);
|
||||
const targetX = Math.round(x1);
|
||||
@@ -50,7 +69,14 @@ export function drawLine(image: RgbaImage, x0: number, y0: number, x1: number, y
|
||||
const sy = y < targetY ? 1 : -1;
|
||||
let error = dx + dy;
|
||||
while (true) {
|
||||
fillRect(image, x - Math.floor(thickness / 2), y - Math.floor(thickness / 2), thickness, thickness, color);
|
||||
fillRect(
|
||||
image,
|
||||
x - Math.floor(thickness / 2),
|
||||
y - Math.floor(thickness / 2),
|
||||
thickness,
|
||||
thickness,
|
||||
color,
|
||||
);
|
||||
if (x === targetX && y === targetY) break;
|
||||
const twice = 2 * error;
|
||||
if (twice >= dy) {
|
||||
@@ -64,7 +90,13 @@ export function drawLine(image: RgbaImage, x0: number, y0: number, x1: number, y
|
||||
}
|
||||
}
|
||||
|
||||
export function fillCircle(image: RgbaImage, centerX: number, centerY: number, radius: number, color: Rgba): void {
|
||||
export function fillCircle(
|
||||
image: RgbaImage,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
radius: number,
|
||||
color: Rgba,
|
||||
): void {
|
||||
const r2 = radius * radius;
|
||||
for (let y = Math.floor(centerY - radius); y <= Math.ceil(centerY + radius); y++) {
|
||||
for (let x = Math.floor(centerX - radius); x <= Math.ceil(centerX + radius); x++) {
|
||||
@@ -75,7 +107,11 @@ export function fillCircle(image: RgbaImage, centerX: number, centerY: number, r
|
||||
}
|
||||
}
|
||||
|
||||
function pointInPolygon(x: number, y: number, points: readonly (readonly [number, number])[]): boolean {
|
||||
function pointInPolygon(
|
||||
x: number,
|
||||
y: number,
|
||||
points: readonly (readonly [number, number])[],
|
||||
): boolean {
|
||||
let inside = false;
|
||||
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
|
||||
const xi = points[i]![0];
|
||||
@@ -88,13 +124,18 @@ function pointInPolygon(x: number, y: number, points: readonly (readonly [number
|
||||
return inside;
|
||||
}
|
||||
|
||||
export function fillPolygon(image: RgbaImage, points: readonly (readonly [number, number])[], color: Rgba): void {
|
||||
export function fillPolygon(
|
||||
image: RgbaImage,
|
||||
points: readonly (readonly [number, number])[],
|
||||
color: Rgba,
|
||||
): void {
|
||||
const minX = Math.floor(Math.min(...points.map((point) => point[0])));
|
||||
const maxX = Math.ceil(Math.max(...points.map((point) => point[0])));
|
||||
const minY = Math.floor(Math.min(...points.map((point) => point[1])));
|
||||
const maxY = Math.ceil(Math.max(...points.map((point) => point[1])));
|
||||
for (let y = minY; y <= maxY; y++) {
|
||||
for (let x = minX; x <= maxX; x++) if (pointInPolygon(x + 0.5, y + 0.5, points)) setPixel(image, x, y, color);
|
||||
for (let x = minX; x <= maxX; x++)
|
||||
if (pointInPolygon(x + 0.5, y + 0.5, points)) setPixel(image, x, y, color);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,7 +175,15 @@ export function drawText(
|
||||
let cursor = options.x;
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
const jitter = options.jitter?.(index) ?? { x: 0, y: 0, shear: 0 };
|
||||
drawGlyph(image, text[index]!, cursor + jitter.x, options.y + jitter.y, options.scale, options.color, jitter.shear);
|
||||
drawGlyph(
|
||||
image,
|
||||
text[index]!,
|
||||
cursor + jitter.x,
|
||||
options.y + jitter.y,
|
||||
options.scale,
|
||||
options.color,
|
||||
jitter.shear,
|
||||
);
|
||||
cursor += options.scale * 5 + spacing;
|
||||
}
|
||||
}
|
||||
@@ -170,7 +219,12 @@ function adler32(bytes: Uint8Array): number {
|
||||
}
|
||||
|
||||
function u32(value: number): Uint8Array {
|
||||
return Uint8Array.of((value >>> 24) & 255, (value >>> 16) & 255, (value >>> 8) & 255, value & 255);
|
||||
return Uint8Array.of(
|
||||
(value >>> 24) & 255,
|
||||
(value >>> 16) & 255,
|
||||
(value >>> 8) & 255,
|
||||
value & 255,
|
||||
);
|
||||
}
|
||||
|
||||
function concat(parts: readonly Uint8Array[]): Uint8Array {
|
||||
@@ -195,9 +249,15 @@ function deflateStored(data: Uint8Array): Uint8Array {
|
||||
const size = Math.min(65535, data.length - offset);
|
||||
const final = offset + size >= data.length;
|
||||
const length = size;
|
||||
const inverse = (~length) & 0xffff;
|
||||
const inverse = ~length & 0xffff;
|
||||
blocks.push(
|
||||
Uint8Array.of(final ? 1 : 0, length & 255, (length >>> 8) & 255, inverse & 255, (inverse >>> 8) & 255),
|
||||
Uint8Array.of(
|
||||
final ? 1 : 0,
|
||||
length & 255,
|
||||
(length >>> 8) & 255,
|
||||
inverse & 255,
|
||||
(inverse >>> 8) & 255,
|
||||
),
|
||||
data.slice(offset, offset + size),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import type {
|
||||
CaptchaConcreteImageStyle,
|
||||
CaptchaImageStyle,
|
||||
} from "../types.ts";
|
||||
import type { CaptchaConcreteImageStyle, CaptchaImageStyle } from "../types.ts";
|
||||
|
||||
export const CAPTCHA_CONCRETE_IMAGE_STYLES = [
|
||||
"classic",
|
||||
@@ -52,9 +49,7 @@ export function normalizeCaptchaImageStyle(
|
||||
if (value === undefined || value === null || value === "") return fallback;
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
if (!STYLE_SET.has(normalized)) {
|
||||
throw new RangeError(
|
||||
`imageStyle must be one of: ${CAPTCHA_IMAGE_STYLES.join(", ")}`,
|
||||
);
|
||||
throw new RangeError(`imageStyle must be one of: ${CAPTCHA_IMAGE_STYLES.join(", ")}`);
|
||||
}
|
||||
return normalized as CaptchaImageStyle;
|
||||
}
|
||||
@@ -67,9 +62,7 @@ export function normalizeCaptchaImageStyleList(
|
||||
for (const entry of styleValues(value)) {
|
||||
if (entry === "random") continue;
|
||||
if (!CONCRETE_STYLE_SET.has(entry)) {
|
||||
throw new RangeError(
|
||||
`${name} contains an unknown image style: ${entry}`,
|
||||
);
|
||||
throw new RangeError(`${name} contains an unknown image style: ${entry}`);
|
||||
}
|
||||
const style = entry as CaptchaConcreteImageStyle;
|
||||
if (!styles.includes(style)) styles.push(style);
|
||||
@@ -95,17 +88,12 @@ export function resolveCaptchaImageStyle(
|
||||
options: ResolveCaptchaImageStyleOptions,
|
||||
): ResolvedCaptchaImageStyle {
|
||||
const requested = normalizeCaptchaImageStyle(options.imageStyle, "random");
|
||||
const allowed = normalizeCaptchaImageStyleList(
|
||||
options.allowedStyles,
|
||||
"allowedStyles",
|
||||
);
|
||||
const allowed = normalizeCaptchaImageStyleList(options.allowedStyles, "allowedStyles");
|
||||
const excluded = new Set(
|
||||
normalizeCaptchaImageStyleList(options.excludedStyles, "excludedStyles"),
|
||||
);
|
||||
|
||||
const source = allowed.length
|
||||
? allowed
|
||||
: [...CAPTCHA_CONCRETE_IMAGE_STYLES];
|
||||
const source = allowed.length ? allowed : [...CAPTCHA_CONCRETE_IMAGE_STYLES];
|
||||
const pool = source.filter((style) => !excluded.has(style));
|
||||
|
||||
if (!pool.length) {
|
||||
@@ -116,9 +104,7 @@ export function resolveCaptchaImageStyle(
|
||||
|
||||
if (!options.randomizeStyle && requested !== "random") {
|
||||
if (!pool.includes(requested)) {
|
||||
throw new RangeError(
|
||||
`imageStyle ${requested} is not available in the configured style pool`,
|
||||
);
|
||||
throw new RangeError(`imageStyle ${requested} is not available in the configured style pool`);
|
||||
}
|
||||
return { requested, resolved: requested, pool };
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ const ALPHA = "ABCDEFGHJKMNPQRSTUVWXYZ";
|
||||
const ALPHANUMERIC = `${ALPHA}${NUMBERS}`;
|
||||
|
||||
function defaultLength(context: CaptchaGeneratorContext): number {
|
||||
return context.length ?? (context.difficulty === "easy" ? 4 : context.difficulty === "hard" ? 7 : 6);
|
||||
return (
|
||||
context.length ?? (context.difficulty === "easy" ? 4 : context.difficulty === "hard" ? 7 : 6)
|
||||
);
|
||||
}
|
||||
|
||||
function charset(type: CaptchaChallengeType): string {
|
||||
@@ -20,11 +22,15 @@ function charset(type: CaptchaChallengeType): string {
|
||||
return ALPHANUMERIC;
|
||||
}
|
||||
|
||||
function generateText(type: "number" | "alpha" | "alphanumeric", context: CaptchaGeneratorContext): GeneratedCaptchaChallenge {
|
||||
function generateText(
|
||||
type: "number" | "alpha" | "alphanumeric",
|
||||
context: CaptchaGeneratorContext,
|
||||
): GeneratedCaptchaChallenge {
|
||||
const source = charset(type);
|
||||
const length = Math.max(3, Math.min(10, defaultLength(context)));
|
||||
let answer = "";
|
||||
for (let index = 0; index < length; index++) answer += source[context.randomInt(0, source.length - 1)];
|
||||
for (let index = 0; index < length; index++)
|
||||
answer += source[context.randomInt(0, source.length - 1)];
|
||||
return {
|
||||
type,
|
||||
presentation: "visual",
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import type {
|
||||
CaptchaConcreteImageStyle,
|
||||
CaptchaGeneratorContext,
|
||||
} from "../types.ts";
|
||||
import type { CaptchaConcreteImageStyle, CaptchaGeneratorContext } from "../types.ts";
|
||||
import {
|
||||
createImage,
|
||||
drawGlyph,
|
||||
@@ -56,7 +53,11 @@ function randomColor(
|
||||
];
|
||||
}
|
||||
|
||||
function rawPixel(image: RgbaImage, x: number, y: number): readonly [number, number, number, number] {
|
||||
function rawPixel(
|
||||
image: RgbaImage,
|
||||
x: number,
|
||||
y: number,
|
||||
): readonly [number, number, number, number] {
|
||||
const px = clamp(Math.round(x), 0, image.width - 1);
|
||||
const py = clamp(Math.round(y), 0, image.height - 1);
|
||||
const index = (py * image.width + px) * 4;
|
||||
@@ -98,11 +99,12 @@ function layoutFor(text: string, style: CaptchaConcreteImageStyle): TextLayout {
|
||||
const scale = text.length > 9 ? 3 : text.length > 7 ? 4 : 5;
|
||||
const glyphWidth = scale * 5;
|
||||
const normalSpacing = scale + 3;
|
||||
const spacing = style === "collision"
|
||||
? Math.max(-Math.floor(scale * 0.45), -2)
|
||||
: style === "cross-shadow"
|
||||
? scale
|
||||
: normalSpacing;
|
||||
const spacing =
|
||||
style === "collision"
|
||||
? Math.max(-Math.floor(scale * 0.45), -2)
|
||||
: style === "cross-shadow"
|
||||
? scale
|
||||
: normalSpacing;
|
||||
const textWidth = text.length * glyphWidth + Math.max(0, text.length - 1) * spacing;
|
||||
const textHeight = scale * 7;
|
||||
return {
|
||||
@@ -218,9 +220,8 @@ function drawCharacters(
|
||||
const y = layout.startY + context.randomInt(-jitterY, jitterY);
|
||||
const glyphShear = (context.randomFloat() - 0.5) * shear;
|
||||
const accent = ACCENTS[context.randomInt(0, ACCENTS.length - 1)]!;
|
||||
const color = style === "collision" || style === "cross-shadow" || style === "pixel"
|
||||
? accent
|
||||
: INK;
|
||||
const color =
|
||||
style === "collision" || style === "cross-shadow" || style === "pixel" ? accent : INK;
|
||||
|
||||
if (style === "cross-shadow") {
|
||||
drawGlyph(image, character, x - 3, y + 3, layout.scale, [37, 99, 235, 95], glyphShear);
|
||||
@@ -228,7 +229,15 @@ function drawCharacters(
|
||||
} else if (style === "collision") {
|
||||
drawGlyph(image, character, x - 2, y + 2, layout.scale, [15, 23, 42, 75], glyphShear);
|
||||
if (index > 0 && context.randomFloat() < 0.65) {
|
||||
drawGlyph(image, character, x + context.randomInt(-4, 1), y, layout.scale, [2, 6, 23, 60], -glyphShear);
|
||||
drawGlyph(
|
||||
image,
|
||||
character,
|
||||
x + context.randomInt(-4, 1),
|
||||
y,
|
||||
layout.scale,
|
||||
[2, 6, 23, 60],
|
||||
-glyphShear,
|
||||
);
|
||||
}
|
||||
} else if (context.randomFloat() < 0.28 + ratio * 0.35) {
|
||||
drawGlyph(image, character, x + 1, y + 1, layout.scale, [15, 23, 42, 70], glyphShear);
|
||||
@@ -283,13 +292,25 @@ function shiftColumns(
|
||||
|
||||
function applyClassic(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
addCrossingLines(image, context, Math.round(3 + ratio * 5), Math.round(55 + ratio * 55), ratio > 0.7 ? 2 : 1);
|
||||
addCrossingLines(
|
||||
image,
|
||||
context,
|
||||
Math.round(3 + ratio * 5),
|
||||
Math.round(55 + ratio * 55),
|
||||
ratio > 0.7 ? 2 : 1,
|
||||
);
|
||||
addDots(image, context, Math.round(140 + ratio * 470), Math.round(45 + ratio * 50));
|
||||
}
|
||||
|
||||
function applyCollision(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
addCrossingLines(image, context, Math.round(5 + ratio * 7), Math.round(60 + ratio * 60), ratio > 0.55 ? 2 : 1);
|
||||
addCrossingLines(
|
||||
image,
|
||||
context,
|
||||
Math.round(5 + ratio * 7),
|
||||
Math.round(60 + ratio * 60),
|
||||
ratio > 0.55 ? 2 : 1,
|
||||
);
|
||||
addDots(image, context, Math.round(120 + ratio * 320), 70);
|
||||
const bars = Math.round(2 + ratio * 5);
|
||||
for (let index = 0; index < bars; index++) {
|
||||
@@ -306,7 +327,15 @@ function applyCollision(image: RgbaImage, context: CaptchaGeneratorContext): voi
|
||||
|
||||
function applySnow(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
addDots(image, context, Math.round(420 + ratio * 950), Math.round(85 + ratio * 75), 120, 245, ratio > 0.5 ? 2 : 1);
|
||||
addDots(
|
||||
image,
|
||||
context,
|
||||
Math.round(420 + ratio * 950),
|
||||
Math.round(85 + ratio * 75),
|
||||
120,
|
||||
245,
|
||||
ratio > 0.5 ? 2 : 1,
|
||||
);
|
||||
for (let index = 0; index < Math.round(35 + ratio * 90); index++) {
|
||||
const x = context.randomInt(0, image.width - 1);
|
||||
const y = context.randomInt(0, image.height - 1);
|
||||
@@ -327,7 +356,14 @@ function applyCorrosion(
|
||||
const x = context.randomInt(layout.startX - 5, layout.startX + layout.textWidth + 5);
|
||||
const y = context.randomInt(layout.startY - 5, layout.startY + layout.textHeight + 5);
|
||||
const size = context.randomInt(1, ratio > 0.65 ? 4 : 3);
|
||||
fillRect(image, x, y, size, size, context.randomFloat() < 0.6 ? LIGHT_BACKGROUND : [203, 213, 225, 210]);
|
||||
fillRect(
|
||||
image,
|
||||
x,
|
||||
y,
|
||||
size,
|
||||
size,
|
||||
context.randomFloat() < 0.6 ? LIGHT_BACKGROUND : [203, 213, 225, 210],
|
||||
);
|
||||
}
|
||||
addDots(image, context, Math.round(170 + ratio * 430), 85, 90, 190, 2);
|
||||
addCrossingLines(image, context, Math.round(2 + ratio * 4), 60, 1);
|
||||
@@ -341,12 +377,32 @@ function applySpiderweb(image: RgbaImage, context: CaptchaGeneratorContext): voi
|
||||
}));
|
||||
for (let index = 0; index < nodes.length; index++) {
|
||||
const current = nodes[index]!;
|
||||
const next = nodes[(index + context.randomInt(1, Math.max(1, nodes.length - 1))) % nodes.length]!;
|
||||
drawLine(image, current.x, current.y, next.x, next.y, [71, 85, 105, Math.round(65 + ratio * 65)], 1);
|
||||
if (index % 2 === 0) drawCircleOutline(image, current.x, current.y, context.randomInt(2, 5), [100, 116, 139, 70], 1);
|
||||
const next =
|
||||
nodes[(index + context.randomInt(1, Math.max(1, nodes.length - 1))) % nodes.length]!;
|
||||
drawLine(
|
||||
image,
|
||||
current.x,
|
||||
current.y,
|
||||
next.x,
|
||||
next.y,
|
||||
[71, 85, 105, Math.round(65 + ratio * 65)],
|
||||
1,
|
||||
);
|
||||
if (index % 2 === 0)
|
||||
drawCircleOutline(
|
||||
image,
|
||||
current.x,
|
||||
current.y,
|
||||
context.randomInt(2, 5),
|
||||
[100, 116, 139, 70],
|
||||
1,
|
||||
);
|
||||
}
|
||||
const anchorX = context.randomInt(Math.floor(image.width * 0.25), Math.floor(image.width * 0.75));
|
||||
const anchorY = context.randomInt(Math.floor(image.height * 0.25), Math.floor(image.height * 0.75));
|
||||
const anchorY = context.randomInt(
|
||||
Math.floor(image.height * 0.25),
|
||||
Math.floor(image.height * 0.75),
|
||||
);
|
||||
for (let index = 0; index < Math.round(7 + ratio * 7); index++) {
|
||||
const angle = (index / Math.round(7 + ratio * 7)) * Math.PI * 2;
|
||||
drawLine(
|
||||
@@ -365,8 +421,24 @@ function applySpiderweb(image: RgbaImage, context: CaptchaGeneratorContext): voi
|
||||
function applyCrossShadow(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const centerY = Math.floor(image.height / 2);
|
||||
drawLine(image, 0, centerY - 6, image.width - 1, centerY + 6, [37, 99, 235, 75], ratio > 0.55 ? 2 : 1);
|
||||
drawLine(image, 0, centerY + 7, image.width - 1, centerY - 8, [220, 38, 38, 65], ratio > 0.55 ? 2 : 1);
|
||||
drawLine(
|
||||
image,
|
||||
0,
|
||||
centerY - 6,
|
||||
image.width - 1,
|
||||
centerY + 6,
|
||||
[37, 99, 235, 75],
|
||||
ratio > 0.55 ? 2 : 1,
|
||||
);
|
||||
drawLine(
|
||||
image,
|
||||
0,
|
||||
centerY + 7,
|
||||
image.width - 1,
|
||||
centerY - 8,
|
||||
[220, 38, 38, 65],
|
||||
ratio > 0.55 ? 2 : 1,
|
||||
);
|
||||
addCrossingLines(image, context, Math.round(2 + ratio * 5), 55, 1);
|
||||
addDots(image, context, Math.round(120 + ratio * 300), 60);
|
||||
}
|
||||
@@ -399,11 +471,7 @@ function applySplit2(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
addCrossingLines(image, context, Math.round(2 + ratio * 4), 55, 1);
|
||||
}
|
||||
|
||||
function applyCut(
|
||||
image: RgbaImage,
|
||||
context: CaptchaGeneratorContext,
|
||||
layout: TextLayout,
|
||||
): void {
|
||||
function applyCut(image: RgbaImage, context: CaptchaGeneratorContext, layout: TextLayout): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const cuts = Math.round(4 + ratio * 8);
|
||||
for (let index = 0; index < cuts; index++) {
|
||||
@@ -459,12 +527,32 @@ function applyStitch(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const rows = Math.round(4 + ratio * 5);
|
||||
for (let index = 0; index < rows; index++) {
|
||||
const y = Math.round(((index + 1) / (rows + 1)) * image.height);
|
||||
drawDashedLine(image, 0, y, image.width - 1, y + context.randomInt(-3, 3), [71, 85, 105, 75], 4, 4, 1);
|
||||
drawDashedLine(
|
||||
image,
|
||||
0,
|
||||
y,
|
||||
image.width - 1,
|
||||
y + context.randomInt(-3, 3),
|
||||
[71, 85, 105, 75],
|
||||
4,
|
||||
4,
|
||||
1,
|
||||
);
|
||||
}
|
||||
const columns = Math.round(2 + ratio * 4);
|
||||
for (let index = 0; index < columns; index++) {
|
||||
const x = context.randomInt(10, image.width - 10);
|
||||
drawDashedLine(image, x, 0, x + context.randomInt(-5, 5), image.height - 1, [100, 116, 139, 65], 3, 5, 1);
|
||||
drawDashedLine(
|
||||
image,
|
||||
x,
|
||||
0,
|
||||
x + context.randomInt(-5, 5),
|
||||
image.height - 1,
|
||||
[100, 116, 139, 65],
|
||||
3,
|
||||
5,
|
||||
1,
|
||||
);
|
||||
}
|
||||
for (let index = 0; index < Math.round(18 + ratio * 35); index++) {
|
||||
const x = context.randomInt(0, image.width - 1);
|
||||
@@ -479,7 +567,15 @@ function applyStriped(image: RgbaImage, context: CaptchaGeneratorContext): void
|
||||
const spacing = Math.round(10 - ratio * 4);
|
||||
const offset = context.randomInt(-image.height, image.width);
|
||||
for (let x = offset; x < image.width + image.height; x += spacing) {
|
||||
drawLine(image, x, 0, x - image.height, image.height - 1, [71, 85, 105, Math.round(45 + ratio * 45)], ratio > 0.7 ? 2 : 1);
|
||||
drawLine(
|
||||
image,
|
||||
x,
|
||||
0,
|
||||
x - image.height,
|
||||
image.height - 1,
|
||||
[71, 85, 105, Math.round(45 + ratio * 45)],
|
||||
ratio > 0.7 ? 2 : 1,
|
||||
);
|
||||
}
|
||||
for (let y = context.randomInt(3, 9); y < image.height; y += context.randomInt(8, 14)) {
|
||||
drawLine(image, 0, y, image.width - 1, y, [148, 163, 184, 45], 1);
|
||||
@@ -595,16 +691,20 @@ function applyBrokenLines(
|
||||
for (let index = 0; index < Math.round(12 + ratio * 28); index++) {
|
||||
const x = context.randomInt(0, image.width - 12);
|
||||
const y = context.randomInt(0, image.height - 1);
|
||||
drawLine(image, x, y, x + context.randomInt(4, 18), y + context.randomInt(-2, 2), randomColor(context, 45, 175, 75), 1);
|
||||
drawLine(
|
||||
image,
|
||||
x,
|
||||
y,
|
||||
x + context.randomInt(4, 18),
|
||||
y + context.randomInt(-2, 2),
|
||||
randomColor(context, 45, 175, 75),
|
||||
1,
|
||||
);
|
||||
}
|
||||
addDots(image, context, Math.round(70 + ratio * 180), 60);
|
||||
}
|
||||
|
||||
function applyStyle(
|
||||
image: RgbaImage,
|
||||
context: CaptchaGeneratorContext,
|
||||
layout: TextLayout,
|
||||
): void {
|
||||
function applyStyle(image: RgbaImage, context: CaptchaGeneratorContext, layout: TextLayout): void {
|
||||
switch (context.imageStyle) {
|
||||
case "collision":
|
||||
applyCollision(image, context);
|
||||
@@ -667,10 +767,7 @@ function backgroundFor(style: CaptchaConcreteImageStyle): Rgba {
|
||||
return LIGHT_BACKGROUND;
|
||||
}
|
||||
|
||||
export function renderTextChallenge(
|
||||
text: string,
|
||||
context: CaptchaGeneratorContext,
|
||||
): string {
|
||||
export function renderTextChallenge(text: string, context: CaptchaGeneratorContext): string {
|
||||
const layout = layoutFor(text, context.imageStyle);
|
||||
const image = createImage(300, 104, backgroundFor(context.imageStyle));
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
export function defaultRandomBytes(length: number): Uint8Array {
|
||||
if (!Number.isInteger(length) || length < 1) throw new RangeError("random byte length must be positive");
|
||||
if (!Number.isInteger(length) || length < 1)
|
||||
throw new RangeError("random byte length must be positive");
|
||||
const bytes = new Uint8Array(length);
|
||||
crypto.getRandomValues(bytes);
|
||||
return bytes;
|
||||
|
||||
+160
-50
@@ -1,8 +1,19 @@
|
||||
import { defaultCaptchaGenerators } from "./challenges/index.ts";
|
||||
import { resolveCaptchaImageStyle } from "./challenges/styles.ts";
|
||||
import { AssetAudioRenderer } from "./audio/renderer.ts";
|
||||
import { bindingHash, constantTimeEqual, defaultRandomBytes, hmacSha256, randomId, sha256 } from "./crypto.ts";
|
||||
import { normalizeSelections, normalizeTextAnswer, normalizedSubmittedAnswer } from "./normalize.ts";
|
||||
import {
|
||||
bindingHash,
|
||||
constantTimeEqual,
|
||||
defaultRandomBytes,
|
||||
hmacSha256,
|
||||
randomId,
|
||||
sha256,
|
||||
} from "./crypto.ts";
|
||||
import {
|
||||
normalizeSelections,
|
||||
normalizeTextAnswer,
|
||||
normalizedSubmittedAnswer,
|
||||
} from "./normalize.ts";
|
||||
import { MemoryCaptchaStore } from "./stores/memory.ts";
|
||||
import type {
|
||||
CaptchaBinding,
|
||||
@@ -40,13 +51,16 @@ function failure(
|
||||
function assertAction(action: string): string {
|
||||
const value = action.trim();
|
||||
if (!value || value.length > 128 || !/^[a-z0-9][a-z0-9:._/-]*$/i.test(value)) {
|
||||
throw new TypeError("CAPTCHA action must be a non-empty stable identifier up to 128 characters");
|
||||
throw new TypeError(
|
||||
"CAPTCHA action must be a non-empty stable identifier up to 128 characters",
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertPositiveInteger(value: number, name: string): number {
|
||||
if (!Number.isInteger(value) || value < 1) throw new RangeError(`${name} must be a positive integer`);
|
||||
if (!Number.isInteger(value) || value < 1)
|
||||
throw new RangeError(`${name} must be a positive integer`);
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -54,7 +68,8 @@ function normalizeDisturbance(value: number | undefined, difficulty: CaptchaDiff
|
||||
const fallback = difficulty === "easy" ? 25 : difficulty === "hard" ? 75 : 50;
|
||||
if (value === undefined) return fallback;
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) throw new RangeError("disturbance must be a finite number between 25 and 75");
|
||||
if (!Number.isFinite(numeric))
|
||||
throw new RangeError("disturbance must be a finite number between 25 and 75");
|
||||
const normalized = Math.round(numeric);
|
||||
if (normalized < 25 || normalized > 75) {
|
||||
throw new RangeError("disturbance must be between 25 and 75");
|
||||
@@ -62,8 +77,13 @@ function normalizeDisturbance(value: number | undefined, difficulty: CaptchaDiff
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function randomInteger(randomBytes: (length: number) => Uint8Array, min: number, max: number): number {
|
||||
if (!Number.isInteger(min) || !Number.isInteger(max) || max < min) throw new RangeError("invalid random range");
|
||||
function randomInteger(
|
||||
randomBytes: (length: number) => Uint8Array,
|
||||
min: number,
|
||||
max: number,
|
||||
): number {
|
||||
if (!Number.isInteger(min) || !Number.isInteger(max) || max < min)
|
||||
throw new RangeError("invalid random range");
|
||||
const span = max - min + 1;
|
||||
if (span === 1) return min;
|
||||
const limit = Math.floor(0x1_0000_0000 / span) * span;
|
||||
@@ -74,10 +94,13 @@ function randomInteger(randomBytes: (length: number) => Uint8Array, min: number,
|
||||
}
|
||||
}
|
||||
|
||||
async function matchesHash(expected: string | undefined, raw: string | undefined): Promise<boolean> {
|
||||
async function matchesHash(
|
||||
expected: string | undefined,
|
||||
raw: string | undefined,
|
||||
): Promise<boolean> {
|
||||
if (!expected) return true;
|
||||
if (!raw) return false;
|
||||
return constantTimeEqual(expected, await bindingHash(raw) ?? "");
|
||||
return constantTimeEqual(expected, (await bindingHash(raw)) ?? "");
|
||||
}
|
||||
|
||||
export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
@@ -106,9 +129,18 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
this.store = options.store ?? new MemoryCaptchaStore();
|
||||
this.audioRenderer = options.audioRenderer ?? new AssetAudioRenderer();
|
||||
this.basePath = `/${(options.basePath ?? "/__wrnexus/captcha").replace(/^\/+|\/+$/g, "")}`;
|
||||
this.challengeTtlMs = assertPositiveInteger(options.challengeTtlMs ?? DEFAULT_CHALLENGE_TTL_MS, "challengeTtlMs");
|
||||
this.responseTokenTtlMs = assertPositiveInteger(options.responseTokenTtlMs ?? DEFAULT_TOKEN_TTL_MS, "responseTokenTtlMs");
|
||||
this.maxAttempts = assertPositiveInteger(options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS, "maxAttempts");
|
||||
this.challengeTtlMs = assertPositiveInteger(
|
||||
options.challengeTtlMs ?? DEFAULT_CHALLENGE_TTL_MS,
|
||||
"challengeTtlMs",
|
||||
);
|
||||
this.responseTokenTtlMs = assertPositiveInteger(
|
||||
options.responseTokenTtlMs ?? DEFAULT_TOKEN_TTL_MS,
|
||||
"responseTokenTtlMs",
|
||||
);
|
||||
this.maxAttempts = assertPositiveInteger(
|
||||
options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
|
||||
"maxAttempts",
|
||||
);
|
||||
this.minCompletionMs = Math.max(0, options.minCompletionMs ?? DEFAULT_MIN_COMPLETION_MS);
|
||||
this.responseField = options.responseField ?? DEFAULT_RESPONSE_FIELD;
|
||||
this.defaultType = options.defaultType ?? "alphanumeric";
|
||||
@@ -116,19 +148,22 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
this.bindIp = options.bindIp ?? false;
|
||||
this.now = options.now ?? Date.now;
|
||||
this.randomBytes = options.randomBytes ?? defaultRandomBytes;
|
||||
for (const generator of options.generators ?? defaultCaptchaGenerators()) this.generators.set(generator.type, generator);
|
||||
if (!this.generators.has(this.defaultType)) throw new Error(`No CAPTCHA generator registered for ${this.defaultType}`);
|
||||
for (const generator of options.generators ?? defaultCaptchaGenerators())
|
||||
this.generators.set(generator.type, generator);
|
||||
if (!this.generators.has(this.defaultType))
|
||||
throw new Error(`No CAPTCHA generator registered for ${this.defaultType}`);
|
||||
}
|
||||
|
||||
async create(options: CreateCaptchaOptions): Promise<CaptchaChallenge> {
|
||||
const action = assertAction(options.action);
|
||||
const requestedType = options.type ?? this.defaultType;
|
||||
const requestedPresentation = options.presentation ?? (
|
||||
requestedType === "honeypot" || requestedType === "timing" || requestedType === "not-robot"
|
||||
const requestedPresentation =
|
||||
options.presentation ??
|
||||
(requestedType === "honeypot" || requestedType === "timing" || requestedType === "not-robot"
|
||||
? "invisible"
|
||||
: "visual"
|
||||
);
|
||||
const actualType = requestedPresentation === "audio" && requestedType === "image" ? "number" : requestedType;
|
||||
: "visual");
|
||||
const actualType =
|
||||
requestedPresentation === "audio" && requestedType === "image" ? "number" : requestedType;
|
||||
const generator = this.generators.get(actualType);
|
||||
if (!generator) throw new Error(`No CAPTCHA generator registered for ${actualType}`);
|
||||
const now = this.now();
|
||||
@@ -161,19 +196,25 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
const id = randomId(this.randomBytes, 24);
|
||||
const answerSalt = randomId(this.randomBytes, 16);
|
||||
const caseSensitive = options.caseSensitive ?? false;
|
||||
const normalizedAnswer = generated.answerKind === "selections"
|
||||
? normalizeSelections(generated.answer.split(","))
|
||||
: generated.answerKind === "text"
|
||||
? normalizeTextAnswer(generated.answer, caseSensitive)
|
||||
: generated.answer;
|
||||
const normalizedAnswer =
|
||||
generated.answerKind === "selections"
|
||||
? normalizeSelections(generated.answer.split(","))
|
||||
: generated.answerKind === "text"
|
||||
? normalizeTextAnswer(generated.answer, caseSensitive)
|
||||
: generated.answer;
|
||||
const answerDigest = await hmacSha256(this.secret, `${id}:${answerSalt}:${normalizedAnswer}`);
|
||||
const expiresAt = now + assertPositiveInteger(options.expiresInMs ?? this.challengeTtlMs, "expiresInMs");
|
||||
const maxAttempts = assertPositiveInteger(options.maxAttempts ?? this.maxAttempts, "maxAttempts");
|
||||
const expiresAt =
|
||||
now + assertPositiveInteger(options.expiresInMs ?? this.challengeTtlMs, "expiresInMs");
|
||||
const maxAttempts = assertPositiveInteger(
|
||||
options.maxAttempts ?? this.maxAttempts,
|
||||
"maxAttempts",
|
||||
);
|
||||
const audioKey = generated.audioSequence?.length ? randomId(this.randomBytes, 18) : undefined;
|
||||
const responseField = options.responseField ?? this.responseField;
|
||||
const presentation = requestedPresentation === "audio" && generated.audioSequence?.length
|
||||
? "audio"
|
||||
: generated.presentation;
|
||||
const presentation =
|
||||
requestedPresentation === "audio" && generated.audioSequence?.length
|
||||
? "audio"
|
||||
: generated.presentation;
|
||||
|
||||
const publicChallenge: CaptchaChallenge = {
|
||||
id,
|
||||
@@ -190,7 +231,9 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
items: presentation === "audio" ? undefined : generated.items,
|
||||
minSelections: generated.minSelections,
|
||||
maxSelections: generated.maxSelections,
|
||||
audioUrl: audioKey ? `${this.basePath}/audio/${encodeURIComponent(id)}?key=${encodeURIComponent(audioKey)}` : undefined,
|
||||
audioUrl: audioKey
|
||||
? `${this.basePath}/audio/${encodeURIComponent(id)}?key=${encodeURIComponent(audioKey)}`
|
||||
: undefined,
|
||||
refreshUrl: `${this.basePath}/challenge`,
|
||||
verifyUrl: `${this.basePath}/verify`,
|
||||
honeypotField: String(generated.metadata?.honeypotField ?? "") || undefined,
|
||||
@@ -202,9 +245,13 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
requestedImageStyle: context.requestedImageStyle,
|
||||
imageStylePool: [...context.imageStylePool],
|
||||
locale: context.locale,
|
||||
...(generated.answerKind === "invisible" ? { minCompletionMs: context.minCompletionMs } : {}),
|
||||
...(generated.answerKind === "invisible"
|
||||
? { minCompletionMs: context.minCompletionMs }
|
||||
: {}),
|
||||
...(generated.metadata?.interaction ? { interaction: generated.metadata.interaction } : {}),
|
||||
...(requestedType === "image" && actualType !== requestedType ? { alternativeFor: requestedType } : {}),
|
||||
...(requestedType === "image" && actualType !== requestedType
|
||||
? { alternativeFor: requestedType }
|
||||
: {}),
|
||||
...options.metadata,
|
||||
},
|
||||
};
|
||||
@@ -248,14 +295,23 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
if (!input.challengeId) return failure(action, "missing-input", "Missing CAPTCHA challenge id");
|
||||
const now = this.now();
|
||||
const record = await this.store.getChallenge(input.challengeId);
|
||||
if (!record) return failure(action, "invalid-input", "Unknown CAPTCHA challenge", input.challengeId);
|
||||
if (record.expiresAt <= now) return failure(action, "expired", "The CAPTCHA challenge expired", record.id);
|
||||
if (record.consumedAt) return failure(action, "already-used", "The CAPTCHA challenge was already used", record.id);
|
||||
if (!record)
|
||||
return failure(action, "invalid-input", "Unknown CAPTCHA challenge", input.challengeId);
|
||||
if (record.expiresAt <= now)
|
||||
return failure(action, "expired", "The CAPTCHA challenge expired", record.id);
|
||||
if (record.consumedAt)
|
||||
return failure(action, "already-used", "The CAPTCHA challenge was already used", record.id);
|
||||
const bindingFailure = await this.checkChallengeBinding(record, input, action);
|
||||
if (bindingFailure) return bindingFailure;
|
||||
|
||||
const attempted = await this.store.incrementAttempts(record.id, now);
|
||||
if (!attempted) return failure(action, "already-used", "The CAPTCHA challenge is no longer available", record.id);
|
||||
if (!attempted)
|
||||
return failure(
|
||||
action,
|
||||
"already-used",
|
||||
"The CAPTCHA challenge is no longer available",
|
||||
record.id,
|
||||
);
|
||||
if (attempted.attempts > attempted.maxAttempts) {
|
||||
await this.store.consumeChallenge(attempted.id, now);
|
||||
return failure(action, "attempts-exhausted", "Too many CAPTCHA attempts", record.id);
|
||||
@@ -269,7 +325,10 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
}
|
||||
|
||||
const submitted = normalizedSubmittedAnswer(attempted, input);
|
||||
const digest = await hmacSha256(this.secret, `${attempted.id}:${attempted.answerSalt}:${submitted}`);
|
||||
const digest = await hmacSha256(
|
||||
this.secret,
|
||||
`${attempted.id}:${attempted.answerSalt}:${submitted}`,
|
||||
);
|
||||
if (!constantTimeEqual(attempted.answerDigest, digest)) {
|
||||
const exhausted = attempted.attempts >= attempted.maxAttempts;
|
||||
if (exhausted) await this.store.consumeChallenge(attempted.id, now);
|
||||
@@ -282,7 +341,13 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
}
|
||||
|
||||
const consumed = await this.store.consumeChallenge(attempted.id, now);
|
||||
if (!consumed) return failure(action, "already-used", "The CAPTCHA challenge was already used", attempted.id);
|
||||
if (!consumed)
|
||||
return failure(
|
||||
action,
|
||||
"already-used",
|
||||
"The CAPTCHA challenge was already used",
|
||||
attempted.id,
|
||||
);
|
||||
const plainToken = randomId(this.randomBytes, 32);
|
||||
const tokenHash = await sha256(plainToken);
|
||||
const expiresAt = now + this.responseTokenTtlMs;
|
||||
@@ -319,13 +384,33 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
const tokenHash = await sha256(token);
|
||||
const existing = await this.store.getToken(tokenHash);
|
||||
if (!existing) return failure(action, "invalid-input", "Unknown CAPTCHA response token");
|
||||
if (existing.expiresAt <= now) return failure(action, "expired", "The CAPTCHA response token expired", existing.challengeId);
|
||||
if (existing.consumedAt) return failure(action, "already-used", "The CAPTCHA response token was already used", existing.challengeId);
|
||||
if (existing.action !== action) return failure(action, "action-mismatch", "The CAPTCHA action does not match", existing.challengeId);
|
||||
if (existing.expiresAt <= now)
|
||||
return failure(action, "expired", "The CAPTCHA response token expired", existing.challengeId);
|
||||
if (existing.consumedAt)
|
||||
return failure(
|
||||
action,
|
||||
"already-used",
|
||||
"The CAPTCHA response token was already used",
|
||||
existing.challengeId,
|
||||
);
|
||||
if (existing.action !== action)
|
||||
return failure(
|
||||
action,
|
||||
"action-mismatch",
|
||||
"The CAPTCHA action does not match",
|
||||
existing.challengeId,
|
||||
);
|
||||
const bindingFailure = await this.checkTokenBinding(existing, input, action);
|
||||
if (bindingFailure) return bindingFailure;
|
||||
const record = input.consume === false ? existing : await this.store.consumeToken(tokenHash, now);
|
||||
if (!record) return failure(action, "already-used", "The CAPTCHA response token was already used", existing.challengeId);
|
||||
const record =
|
||||
input.consume === false ? existing : await this.store.consumeToken(tokenHash, now);
|
||||
if (!record)
|
||||
return failure(
|
||||
action,
|
||||
"already-used",
|
||||
"The CAPTCHA response token was already used",
|
||||
existing.challengeId,
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
provider: "self-hosted",
|
||||
@@ -338,13 +423,17 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
};
|
||||
}
|
||||
|
||||
async renderAudio(challengeId: string, key: string): Promise<{ bytes: Uint8Array; contentType: string } | undefined> {
|
||||
async renderAudio(
|
||||
challengeId: string,
|
||||
key: string,
|
||||
): Promise<{ bytes: Uint8Array; contentType: string } | undefined> {
|
||||
const record = await this.store.getChallenge(challengeId);
|
||||
if (!record || record.expiresAt <= this.now() || record.consumedAt) return undefined;
|
||||
const expectedKey = String(record.metadata.audioKey ?? "");
|
||||
if (!expectedKey || !constantTimeEqual(expectedKey, key)) return undefined;
|
||||
const sequence = record.metadata.audioSequence;
|
||||
if (!Array.isArray(sequence) || !sequence.every((value) => typeof value === "string")) return undefined;
|
||||
if (!Array.isArray(sequence) || !sequence.every((value) => typeof value === "string"))
|
||||
return undefined;
|
||||
const bytes = await this.audioRenderer.render(sequence, String(record.metadata.locale ?? "en"));
|
||||
return { bytes, contentType: this.audioRenderer.contentType ?? "audio/wav" };
|
||||
}
|
||||
@@ -358,7 +447,8 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
input: CaptchaBinding,
|
||||
action: string,
|
||||
): Promise<CaptchaVerificationResult | undefined> {
|
||||
if (record.action !== action) return failure(action, "action-mismatch", "The CAPTCHA action does not match", record.id);
|
||||
if (record.action !== action)
|
||||
return failure(action, "action-mismatch", "The CAPTCHA action does not match", record.id);
|
||||
if (!(await matchesHash(record.hostnameHash, input.hostname))) {
|
||||
return failure(action, "hostname-mismatch", "The CAPTCHA hostname does not match", record.id);
|
||||
}
|
||||
@@ -366,7 +456,12 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
return failure(action, "session-mismatch", "The CAPTCHA session does not match", record.id);
|
||||
}
|
||||
if (!(await matchesHash(record.ipHash, input.ip))) {
|
||||
return failure(action, "ip-mismatch", "The CAPTCHA network binding does not match", record.id);
|
||||
return failure(
|
||||
action,
|
||||
"ip-mismatch",
|
||||
"The CAPTCHA network binding does not match",
|
||||
record.id,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -377,13 +472,28 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
|
||||
action: string,
|
||||
): Promise<CaptchaVerificationResult | undefined> {
|
||||
if (!(await matchesHash(record.hostnameHash, input.hostname))) {
|
||||
return failure(action, "hostname-mismatch", "The CAPTCHA hostname does not match", record.challengeId);
|
||||
return failure(
|
||||
action,
|
||||
"hostname-mismatch",
|
||||
"The CAPTCHA hostname does not match",
|
||||
record.challengeId,
|
||||
);
|
||||
}
|
||||
if (!(await matchesHash(record.sessionHash, input.sessionId))) {
|
||||
return failure(action, "session-mismatch", "The CAPTCHA session does not match", record.challengeId);
|
||||
return failure(
|
||||
action,
|
||||
"session-mismatch",
|
||||
"The CAPTCHA session does not match",
|
||||
record.challengeId,
|
||||
);
|
||||
}
|
||||
if (!(await matchesHash(record.ipHash, input.ip))) {
|
||||
return failure(action, "ip-mismatch", "The CAPTCHA network binding does not match", record.challengeId);
|
||||
return failure(
|
||||
action,
|
||||
"ip-mismatch",
|
||||
"The CAPTCHA network binding does not match",
|
||||
record.challengeId,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,8 @@ function json(body: unknown, status = 200, headers: HeadersInit = {}): Response
|
||||
|
||||
async function readPayload(request: Request): Promise<Record<string, unknown>> {
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) return (await request.json()) as Record<string, unknown>;
|
||||
if (contentType.includes("application/json"))
|
||||
return (await request.json()) as Record<string, unknown>;
|
||||
if (contentType.includes("form")) {
|
||||
const form = await request.formData();
|
||||
const payload: Record<string, unknown> = {};
|
||||
@@ -88,15 +89,22 @@ export function createCaptchaHttpHandlers(
|
||||
counters.set(key, counter);
|
||||
}
|
||||
counter.count += 1;
|
||||
return { allowed: counter.count <= maximum, retryAfter: Math.max(1, Math.ceil((counter.resetAt - now) / 1000)) };
|
||||
return {
|
||||
allowed: counter.count <= maximum,
|
||||
retryAfter: Math.max(1, Math.ceil((counter.resetAt - now) / 1000)),
|
||||
};
|
||||
};
|
||||
|
||||
const create = async (request: Request, ctx?: Context): Promise<Response> => {
|
||||
if (!originAllowed(request)) return json({ success: false, code: "origin-rejected" }, 403);
|
||||
if (request.method !== "POST") return json({ success: false, code: "method-not-allowed" }, 405, { allow: "POST" });
|
||||
if (request.method !== "POST")
|
||||
return json({ success: false, code: "method-not-allowed" }, 405, { allow: "POST" });
|
||||
const ip = clientIp(request, ctx, options.trustProxy ?? false);
|
||||
const limit = withinLimit(`create:${ip}`, options.createLimit ?? 30);
|
||||
if (!limit.allowed) return json({ success: false, code: "rate-limited" }, 429, { "retry-after": String(limit.retryAfter) });
|
||||
if (!limit.allowed)
|
||||
return json({ success: false, code: "rate-limited" }, 429, {
|
||||
"retry-after": String(limit.retryAfter),
|
||||
});
|
||||
try {
|
||||
const payload = await readPayload(request);
|
||||
const challenge = await engine.create({
|
||||
@@ -118,10 +126,14 @@ export function createCaptchaHttpHandlers(
|
||||
|
||||
const verify = async (request: Request, ctx?: Context): Promise<Response> => {
|
||||
if (!originAllowed(request)) return json({ success: false, code: "origin-rejected" }, 403);
|
||||
if (request.method !== "POST") return json({ success: false, code: "method-not-allowed" }, 405, { allow: "POST" });
|
||||
if (request.method !== "POST")
|
||||
return json({ success: false, code: "method-not-allowed" }, 405, { allow: "POST" });
|
||||
const ip = clientIp(request, ctx, options.trustProxy ?? false);
|
||||
const limit = withinLimit(`verify:${ip}`, options.verifyLimit ?? 60);
|
||||
if (!limit.allowed) return json({ success: false, code: "rate-limited" }, 429, { "retry-after": String(limit.retryAfter) });
|
||||
if (!limit.allowed)
|
||||
return json({ success: false, code: "rate-limited" }, 429, {
|
||||
"retry-after": String(limit.retryAfter),
|
||||
});
|
||||
try {
|
||||
const payload = await readPayload(request);
|
||||
const result = await engine.verify({
|
||||
@@ -143,7 +155,8 @@ export function createCaptchaHttpHandlers(
|
||||
|
||||
const audio = async (request: Request): Promise<Response> => {
|
||||
if (!originAllowed(request)) return new Response("Forbidden", { status: 403 });
|
||||
if (request.method !== "GET" && request.method !== "HEAD") return new Response("Method Not Allowed", { status: 405 });
|
||||
if (request.method !== "GET" && request.method !== "HEAD")
|
||||
return new Response("Method Not Allowed", { status: 405 });
|
||||
const url = new URL(request.url);
|
||||
const prefix = `${engine.basePath}/audio/`;
|
||||
const id = decodeURIComponent(url.pathname.slice(prefix.length));
|
||||
|
||||
@@ -76,7 +76,8 @@ export function captchaGuard(options: CaptchaGuardOptions) {
|
||||
return async (ctx: Context, next: () => Promise<Response> | Response): Promise<Response> => {
|
||||
const result = await verifyRequest(ctx, options);
|
||||
ctx.locals.captcha = result;
|
||||
if (!result.success) return options.onFailure ? options.onFailure(ctx, result) : defaultFailure(options, result);
|
||||
if (!result.success)
|
||||
return options.onFailure ? options.onFailure(ctx, result) : defaultFailure(options, result);
|
||||
return next();
|
||||
};
|
||||
}
|
||||
@@ -93,7 +94,7 @@ export function captchaPageGate(options: CaptchaPageGateOptions) {
|
||||
const grants = ctx.session.get<CaptchaSessionGrant[]>(sessionKey) ?? [];
|
||||
if (validCaptchaGrant(grants, action, now, routeGroup)) return next();
|
||||
|
||||
const signals = await options.signals?.(ctx) ?? {};
|
||||
const signals = (await options.signals?.(ctx)) ?? {};
|
||||
const decision = shouldRequireCaptcha(action, policy, signals);
|
||||
ctx.locals.captchaRisk = decision;
|
||||
if (!decision.challenge) return next();
|
||||
|
||||
@@ -13,7 +13,10 @@ export function normalizeSelections(values: unknown): string {
|
||||
return [...new Set(values.map((value) => String(value).trim()).filter(Boolean))].sort().join(",");
|
||||
}
|
||||
|
||||
export function normalizedSubmittedAnswer(record: CaptchaChallengeRecord, input: VerifyCaptchaInput): string {
|
||||
export function normalizedSubmittedAnswer(
|
||||
record: CaptchaChallengeRecord,
|
||||
input: VerifyCaptchaInput,
|
||||
): string {
|
||||
if (record.answerKind === "selections") return normalizeSelections(input.selections);
|
||||
if (record.answerKind === "invisible") {
|
||||
return JSON.stringify({
|
||||
|
||||
+101
-40
@@ -1,4 +1,3 @@
|
||||
import { copyFile, mkdir } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { definePlugin } from "@wrnexus/plugin";
|
||||
@@ -22,17 +21,6 @@ export interface CaptchaAuditIssue {
|
||||
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const captchaClientRuntime = join(packageRoot, "assets", "client", "captcha.js");
|
||||
|
||||
async function copyCaptchaClientRuntime(destinationDir: string): Promise<void> {
|
||||
await mkdir(destinationDir, { recursive: true });
|
||||
await copyFile(captchaClientRuntime, join(destinationDir, "captcha.js"));
|
||||
}
|
||||
|
||||
async function installCaptchaClientRuntime(root: string, includeBuildOutput: boolean): Promise<void> {
|
||||
await copyCaptchaClientRuntime(join(root, "public", "assets", "wrnexus"));
|
||||
if (includeBuildOutput) {
|
||||
await copyCaptchaClientRuntime(join(root, "dist", "public", "assets", "wrnexus"));
|
||||
}
|
||||
}
|
||||
export function captchaComponentsDir(): string {
|
||||
return join(packageRoot, "components");
|
||||
}
|
||||
@@ -40,24 +28,54 @@ export function captchaComponentsDir(): string {
|
||||
function auditCaptchaSource(code: string, file: string, external: boolean): CaptchaAuditIssue[] {
|
||||
if (!code.includes("<Captcha") && !code.includes('data-component="Captcha"')) return [];
|
||||
const issues: CaptchaAuditIssue[] = [];
|
||||
const push = (id: string, severity: CaptchaAuditIssue["severity"], title: string, message: string) =>
|
||||
issues.push({ id: `${id}:${file}`, severity, title, message, file });
|
||||
const push = (
|
||||
id: string,
|
||||
severity: CaptchaAuditIssue["severity"],
|
||||
title: string,
|
||||
message: string,
|
||||
) => issues.push({ id: `${id}:${file}`, severity, title, message, file });
|
||||
|
||||
if (/secret(Key)?\s*=|providerSecret\s*=|captchaSecret\s*=/i.test(code)) {
|
||||
push("client-secret", "error", "CAPTCHA secret exposed", "Never pass a provider secret or verification secret to a .wrn component.");
|
||||
push(
|
||||
"client-secret",
|
||||
"error",
|
||||
"CAPTCHA secret exposed",
|
||||
"Never pass a provider secret or verification secret to a .wrn component.",
|
||||
);
|
||||
}
|
||||
if (!/action\s*=/.test(code)) {
|
||||
push("missing-action", "warning", "CAPTCHA action is missing", "Bind each challenge to a stable action such as signup, login, or contact-submit.");
|
||||
push(
|
||||
"missing-action",
|
||||
"warning",
|
||||
"CAPTCHA action is missing",
|
||||
"Bind each challenge to a stable action such as signup, login, or contact-submit.",
|
||||
);
|
||||
}
|
||||
if (/required\s*=\s*["']?false/i.test(code)) {
|
||||
push("optional-captcha", "warning", "CAPTCHA is optional", "Protected forms should require a CAPTCHA response and verify it on the server.");
|
||||
push(
|
||||
"optional-captcha",
|
||||
"warning",
|
||||
"CAPTCHA is optional",
|
||||
"Protected forms should require a CAPTCHA response and verify it on the server.",
|
||||
);
|
||||
}
|
||||
if (!/showAudio\s*=|presentation\s*=\s*["']audio/i.test(code)) {
|
||||
push("audio-alternative", "suggestion", "Confirm an accessible alternative", "Visual challenges should offer an audio or non-visual alternative.");
|
||||
push(
|
||||
"audio-alternative",
|
||||
"suggestion",
|
||||
"Confirm an accessible alternative",
|
||||
"Visual challenges should offer an audio or non-visual alternative.",
|
||||
);
|
||||
}
|
||||
|
||||
const imageStyle = code.match(/imageStyle\s*=\s*["']([^"']+)["']/i)?.[1]?.trim().toLowerCase();
|
||||
if (imageStyle && !CAPTCHA_IMAGE_STYLES.includes(imageStyle as (typeof CAPTCHA_IMAGE_STYLES)[number])) {
|
||||
const imageStyle = code
|
||||
.match(/imageStyle\s*=\s*["']([^"']+)["']/i)?.[1]
|
||||
?.trim()
|
||||
.toLowerCase();
|
||||
if (
|
||||
imageStyle &&
|
||||
!CAPTCHA_IMAGE_STYLES.includes(imageStyle as (typeof CAPTCHA_IMAGE_STYLES)[number])
|
||||
) {
|
||||
push(
|
||||
"unknown-image-style",
|
||||
"error",
|
||||
@@ -67,7 +85,11 @@ function auditCaptchaSource(code: string, file: string, external: boolean): Capt
|
||||
}
|
||||
|
||||
const disturbance = Number(code.match(/disturbance\s*=\s*["']?(\d+)/i)?.[1] ?? "");
|
||||
if (Number.isFinite(disturbance) && disturbance >= 65 && /showAudio\s*=\s*["']?false/i.test(code)) {
|
||||
if (
|
||||
Number.isFinite(disturbance) &&
|
||||
disturbance >= 65 &&
|
||||
/showAudio\s*=\s*["']?false/i.test(code)
|
||||
) {
|
||||
push(
|
||||
"hard-without-audio",
|
||||
"warning",
|
||||
@@ -75,10 +97,24 @@ function auditCaptchaSource(code: string, file: string, external: boolean): Capt
|
||||
"High disturbance should include audio or another non-visual challenge path.",
|
||||
);
|
||||
}
|
||||
if (external && /provider\s*=\s*["'](?:turnstile|recaptcha|hcaptcha)/i.test(code) && !/siteKey\s*=/.test(code)) {
|
||||
push("missing-site-key", "error", "Provider site key is missing", "External CAPTCHA providers require a public site key in the browser.");
|
||||
if (
|
||||
external &&
|
||||
/provider\s*=\s*["'](?:turnstile|recaptcha|hcaptcha)/i.test(code) &&
|
||||
!/siteKey\s*=/.test(code)
|
||||
) {
|
||||
push(
|
||||
"missing-site-key",
|
||||
"error",
|
||||
"Provider site key is missing",
|
||||
"External CAPTCHA providers require a public site key in the browser.",
|
||||
);
|
||||
}
|
||||
push("server-verification", "suggestion", "Server verification required", "Confirm the receiving API uses captchaGuard(), parseWithCaptcha(), or provider.verify().");
|
||||
push(
|
||||
"server-verification",
|
||||
"suggestion",
|
||||
"Server verification required",
|
||||
"Confirm the receiving API uses captchaGuard(), parseWithCaptcha(), or provider.verify().",
|
||||
);
|
||||
return issues;
|
||||
}
|
||||
|
||||
@@ -86,37 +122,62 @@ export function captchaPlugin(options: CaptchaPluginOptions = {}) {
|
||||
const metadataKey = "@wrnexus/captcha:audit";
|
||||
return definePlugin({
|
||||
name: "@wrnexus/captcha",
|
||||
version: "0.3.6",
|
||||
version: "0.4.0",
|
||||
enforce: "post",
|
||||
async configure(config, context) {
|
||||
componentDirs:
|
||||
options.exposeComponentDirectory === false
|
||||
? []
|
||||
: [options.componentDir ?? captchaComponentsDir()],
|
||||
clientRuntimes: [
|
||||
{
|
||||
id: "captcha",
|
||||
entry: captchaClientRuntime,
|
||||
type: "script",
|
||||
load: "defer",
|
||||
singleton: true,
|
||||
bundle: false,
|
||||
},
|
||||
],
|
||||
styleSources: [
|
||||
{
|
||||
id: "captcha-components",
|
||||
source: options.componentDir ?? captchaComponentsDir(),
|
||||
order: "normal",
|
||||
},
|
||||
],
|
||||
configure(config, context) {
|
||||
const current = (config.captcha ?? {}) as Record<string, unknown>;
|
||||
config.captcha = {
|
||||
componentDir: options.componentDir ?? captchaComponentsDir(),
|
||||
...current,
|
||||
};
|
||||
context.metadata.set("@wrnexus/captcha:component-dir", options.componentDir ?? captchaComponentsDir());
|
||||
await installCaptchaClientRuntime(context.root, context.command === "build");
|
||||
context.metadata.set(
|
||||
"@wrnexus/captcha:component-dir",
|
||||
options.componentDir ?? captchaComponentsDir(),
|
||||
);
|
||||
},
|
||||
transformCode(code, context) {
|
||||
if (context.mode !== "development") return;
|
||||
const previous = (context.metadata.get(metadataKey) as CaptchaAuditIssue[] | undefined) ?? [];
|
||||
const withoutFile = previous.filter((issue) => issue.file !== context.file);
|
||||
context.metadata.set(
|
||||
metadataKey,
|
||||
[...withoutFile, ...auditCaptchaSource(code, context.file, options.auditExternalProviders ?? true)],
|
||||
);
|
||||
context.metadata.set(metadataKey, [
|
||||
...withoutFile,
|
||||
...auditCaptchaSource(code, context.file, options.auditExternalProviders ?? true),
|
||||
]);
|
||||
},
|
||||
devToolbarPanels(context) {
|
||||
if (options.enableDevToolbar === false) return [];
|
||||
const issues = (context.metadata.get(metadataKey) as CaptchaAuditIssue[] | undefined) ?? [];
|
||||
return [{
|
||||
id: "wrnexus-captcha",
|
||||
title: "CAPTCHA",
|
||||
icon: "shield-check",
|
||||
badge: issues.length,
|
||||
description: "CAPTCHA security, accessibility, and integration checks",
|
||||
issues,
|
||||
}];
|
||||
return [
|
||||
{
|
||||
id: "wrnexus-captcha",
|
||||
title: "CAPTCHA",
|
||||
icon: "shield-check",
|
||||
badge: issues.length,
|
||||
description: "CAPTCHA security, accessibility, and integration checks",
|
||||
issues,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import type {
|
||||
CaptchaPolicyOptions,
|
||||
CaptchaRiskResult,
|
||||
CaptchaRiskSignals,
|
||||
} from "./types.ts";
|
||||
import type { CaptchaPolicyOptions, CaptchaRiskResult, CaptchaRiskSignals } from "./types.ts";
|
||||
|
||||
export function evaluateCaptchaRisk(
|
||||
signals: CaptchaRiskSignals,
|
||||
@@ -14,8 +10,10 @@ export function evaluateCaptchaRisk(
|
||||
score = Math.min(100, score + points);
|
||||
reasons.push(reason);
|
||||
};
|
||||
if ((signals.failedAttempts ?? 0) > 0) add(Math.min(35, (signals.failedAttempts ?? 0) * 10), "failed-attempts");
|
||||
if ((signals.requestsInWindow ?? 0) > 20) add(Math.min(35, ((signals.requestsInWindow ?? 0) - 20) * 2), "request-rate");
|
||||
if ((signals.failedAttempts ?? 0) > 0)
|
||||
add(Math.min(35, (signals.failedAttempts ?? 0) * 10), "failed-attempts");
|
||||
if ((signals.requestsInWindow ?? 0) > 20)
|
||||
add(Math.min(35, ((signals.requestsInWindow ?? 0) - 20) * 2), "request-rate");
|
||||
if (signals.completionMs !== undefined && signals.completionMs < 700) add(25, "too-fast");
|
||||
if (signals.missingBrowserSignals) add(20, "missing-browser-signals");
|
||||
if (signals.suspiciousHeaders) add(20, "suspicious-headers");
|
||||
|
||||
@@ -2,7 +2,9 @@ import type { CaptchaProvider } from "../types.ts";
|
||||
|
||||
export function defineCaptchaProvider<T extends CaptchaProvider>(provider: T): T {
|
||||
if (!provider.name) throw new TypeError("Custom CAPTCHA provider requires a stable name");
|
||||
if (!provider.client?.responseField) throw new TypeError("Custom CAPTCHA provider requires client.responseField");
|
||||
if (typeof provider.verify !== "function") throw new TypeError("Custom CAPTCHA provider requires verify()");
|
||||
if (!provider.client?.responseField)
|
||||
throw new TypeError("Custom CAPTCHA provider requires client.responseField");
|
||||
if (typeof provider.verify !== "function")
|
||||
throw new TypeError("Custom CAPTCHA provider requires verify()");
|
||||
return provider;
|
||||
}
|
||||
|
||||
@@ -32,10 +32,14 @@ export class ManagedCaptchaProvider implements CaptchaProvider {
|
||||
}
|
||||
|
||||
async createChallenge(options: CreateCaptchaOptions): Promise<CaptchaChallenge> {
|
||||
return this.request<CaptchaChallenge>("/v1/challenges", {
|
||||
siteKey: this.options.siteKey,
|
||||
...options,
|
||||
}, false);
|
||||
return this.request<CaptchaChallenge>(
|
||||
"/v1/challenges",
|
||||
{
|
||||
siteKey: this.options.siteKey,
|
||||
...options,
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
async verify(input: VerifyCaptchaInput): Promise<CaptchaVerificationResult> {
|
||||
@@ -64,6 +68,8 @@ export class ManagedCaptchaProvider implements CaptchaProvider {
|
||||
}
|
||||
}
|
||||
|
||||
export function managedCaptchaProvider(options: ManagedCaptchaProviderOptions): ManagedCaptchaProvider {
|
||||
export function managedCaptchaProvider(
|
||||
options: ManagedCaptchaProviderOptions,
|
||||
): ManagedCaptchaProvider {
|
||||
return new ManagedCaptchaProvider(options);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,10 @@ export class SelfHostedCaptchaProvider implements CaptchaProvider {
|
||||
|
||||
verify(input: VerifyCaptchaInput) {
|
||||
return input.responseToken || input.providerToken
|
||||
? this.engine.verifyResponseToken({ ...input, responseToken: input.responseToken ?? input.providerToken })
|
||||
? this.engine.verifyResponseToken({
|
||||
...input,
|
||||
responseToken: input.responseToken ?? input.providerToken,
|
||||
})
|
||||
: this.engine.verify(input);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,8 +61,10 @@ export class SiteverifyCaptchaProvider implements CaptchaProvider {
|
||||
async verify(input: VerifyCaptchaInput): Promise<CaptchaVerificationResult> {
|
||||
const token = input.providerToken ?? input.responseToken;
|
||||
const action = input.action;
|
||||
if (!token) return providerFailure(this.name, action, "missing-input", "Missing provider response token");
|
||||
if (token.length > 4096) return providerFailure(this.name, action, "invalid-input", "Provider token is too long");
|
||||
if (!token)
|
||||
return providerFailure(this.name, action, "missing-input", "Missing provider response token");
|
||||
if (token.length > 4096)
|
||||
return providerFailure(this.name, action, "invalid-input", "Provider token is too long");
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), this.options.timeoutMs ?? 10_000);
|
||||
const body = new URLSearchParams({ secret: this.options.secretKey, response: token });
|
||||
@@ -79,7 +81,12 @@ export class SiteverifyCaptchaProvider implements CaptchaProvider {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
return providerFailure(this.name, action, "provider-error", `${this.name} verification returned HTTP ${response.status}`);
|
||||
return providerFailure(
|
||||
this.name,
|
||||
action,
|
||||
"provider-error",
|
||||
`${this.name} verification returned HTTP ${response.status}`,
|
||||
);
|
||||
}
|
||||
const data = (await response.json()) as SiteverifyPayload;
|
||||
if (!data.success) {
|
||||
@@ -98,26 +105,45 @@ export class SiteverifyCaptchaProvider implements CaptchaProvider {
|
||||
}
|
||||
const expectedAction = this.options.expectedAction ?? action;
|
||||
if (data.action && expectedAction && data.action !== expectedAction) {
|
||||
return providerFailure(this.name, action, "action-mismatch", "Provider action does not match", {
|
||||
receivedAction: data.action,
|
||||
});
|
||||
return providerFailure(
|
||||
this.name,
|
||||
action,
|
||||
"action-mismatch",
|
||||
"Provider action does not match",
|
||||
{
|
||||
receivedAction: data.action,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (
|
||||
this.options.expectedHostnames?.length &&
|
||||
(!data.hostname || !this.options.expectedHostnames.includes(data.hostname))
|
||||
) {
|
||||
return providerFailure(this.name, action, "hostname-mismatch", "Provider hostname does not match", {
|
||||
hostname: data.hostname,
|
||||
});
|
||||
return providerFailure(
|
||||
this.name,
|
||||
action,
|
||||
"hostname-mismatch",
|
||||
"Provider hostname does not match",
|
||||
{
|
||||
hostname: data.hostname,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (this.options.minScore !== undefined && typeof data.score === "number") {
|
||||
const rejected = this.preset.scoreDirection === "higher-is-risk"
|
||||
? data.score >= this.options.minScore
|
||||
: data.score < this.options.minScore;
|
||||
const rejected =
|
||||
this.preset.scoreDirection === "higher-is-risk"
|
||||
? data.score >= this.options.minScore
|
||||
: data.score < this.options.minScore;
|
||||
if (rejected) {
|
||||
return providerFailure(this.name, action, "risk-rejected", "Provider risk score did not pass", {
|
||||
score: data.score,
|
||||
});
|
||||
return providerFailure(
|
||||
this.name,
|
||||
action,
|
||||
"risk-rejected",
|
||||
"Provider risk score did not pass",
|
||||
{
|
||||
score: data.score,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -135,7 +161,9 @@ export class SiteverifyCaptchaProvider implements CaptchaProvider {
|
||||
return providerFailure(
|
||||
this.name,
|
||||
action,
|
||||
error instanceof DOMException && error.name === "AbortError" ? "network-error" : "provider-error",
|
||||
error instanceof DOMException && error.name === "AbortError"
|
||||
? "network-error"
|
||||
: "provider-error",
|
||||
error instanceof DOMException && error.name === "AbortError"
|
||||
? `${this.name} verification timed out`
|
||||
: `${this.name} verification failed`,
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import type {
|
||||
CaptchaChallengeRecord,
|
||||
CaptchaResponseTokenRecord,
|
||||
CaptchaStore,
|
||||
} from "../types.ts";
|
||||
import type { CaptchaChallengeRecord, CaptchaResponseTokenRecord, CaptchaStore } from "../types.ts";
|
||||
|
||||
function cloneChallenge(record: CaptchaChallengeRecord): CaptchaChallengeRecord {
|
||||
return structuredClone(record);
|
||||
@@ -72,7 +68,10 @@ export class MemoryCaptchaStore implements CaptchaStore {
|
||||
return record ? cloneToken(record) : undefined;
|
||||
}
|
||||
|
||||
async consumeToken(tokenHash: string, now: number): Promise<CaptchaResponseTokenRecord | undefined> {
|
||||
async consumeToken(
|
||||
tokenHash: string,
|
||||
now: number,
|
||||
): Promise<CaptchaResponseTokenRecord | undefined> {
|
||||
const record = this.tokens.get(tokenHash);
|
||||
if (!record || record.expiresAt <= now || record.consumedAt) return undefined;
|
||||
record.consumedAt = now;
|
||||
@@ -96,7 +95,11 @@ export class MemoryCaptchaStore implements CaptchaStore {
|
||||
}
|
||||
}
|
||||
|
||||
private evict<T extends { expiresAt: number }>(map: Map<string, T>, max: number, now: number): void {
|
||||
private evict<T extends { expiresAt: number }>(
|
||||
map: Map<string, T>,
|
||||
max: number,
|
||||
now: number,
|
||||
): void {
|
||||
for (const [key, record] of map) if (record.expiresAt <= now) map.delete(key);
|
||||
while (map.size >= max) map.delete(map.keys().next().value!);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import type {
|
||||
CaptchaChallengeRecord,
|
||||
CaptchaResponseTokenRecord,
|
||||
CaptchaStore,
|
||||
} from "../types.ts";
|
||||
import type { CaptchaChallengeRecord, CaptchaResponseTokenRecord, CaptchaStore } from "../types.ts";
|
||||
|
||||
export interface RedisCaptchaClient {
|
||||
get(key: string): Promise<string | null> | string | null;
|
||||
@@ -94,7 +90,10 @@ export class RedisCaptchaStore implements CaptchaStore {
|
||||
return this.read<CaptchaResponseTokenRecord>(this.tokenKey(tokenHash));
|
||||
}
|
||||
|
||||
async consumeToken(tokenHash: string, now: number): Promise<CaptchaResponseTokenRecord | undefined> {
|
||||
async consumeToken(
|
||||
tokenHash: string,
|
||||
now: number,
|
||||
): Promise<CaptchaResponseTokenRecord | undefined> {
|
||||
const key = this.tokenKey(tokenHash);
|
||||
if (this.redis.eval) {
|
||||
const raw = await this.redis.eval(CONSUME_TOKEN, {
|
||||
@@ -107,7 +106,9 @@ export class RedisCaptchaStore implements CaptchaStore {
|
||||
const current = await this.read<CaptchaResponseTokenRecord>(key);
|
||||
if (!current || current.expiresAt <= now || current.consumedAt) return undefined;
|
||||
current.consumedAt = now;
|
||||
await this.redis.set(key, JSON.stringify(current), { px: Math.max(1, current.expiresAt - now) });
|
||||
await this.redis.set(key, JSON.stringify(current), {
|
||||
px: Math.max(1, current.expiresAt - now),
|
||||
});
|
||||
return current;
|
||||
});
|
||||
}
|
||||
@@ -138,7 +139,9 @@ export class RedisCaptchaStore implements CaptchaStore {
|
||||
if (!current || current.expiresAt <= now || current.consumedAt) return undefined;
|
||||
if (operation === "attempt") current.attempts += 1;
|
||||
else current.consumedAt = now;
|
||||
await this.redis.set(key, JSON.stringify(current), { px: Math.max(1, current.expiresAt - now) });
|
||||
await this.redis.set(key, JSON.stringify(current), {
|
||||
px: Math.max(1, current.expiresAt - now),
|
||||
});
|
||||
return current;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import type {
|
||||
CaptchaChallengeRecord,
|
||||
CaptchaResponseTokenRecord,
|
||||
CaptchaStore,
|
||||
} from "../types.ts";
|
||||
import type { CaptchaChallengeRecord, CaptchaResponseTokenRecord, CaptchaStore } from "../types.ts";
|
||||
|
||||
export interface SqliteStatementLike {
|
||||
run(...params: unknown[]): unknown;
|
||||
@@ -21,16 +17,21 @@ export interface SqliteCaptchaStoreOptions {
|
||||
}
|
||||
|
||||
function safeIdentifier(value: string): string {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) throw new TypeError(`Unsafe SQL identifier: ${value}`);
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value))
|
||||
throw new TypeError(`Unsafe SQL identifier: ${value}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseChallenge(row: Record<string, unknown> | undefined): CaptchaChallengeRecord | undefined {
|
||||
function parseChallenge(
|
||||
row: Record<string, unknown> | undefined,
|
||||
): CaptchaChallengeRecord | undefined {
|
||||
if (!row) return undefined;
|
||||
return JSON.parse(String(row.payload)) as CaptchaChallengeRecord;
|
||||
}
|
||||
|
||||
function parseToken(row: Record<string, unknown> | undefined): CaptchaResponseTokenRecord | undefined {
|
||||
function parseToken(
|
||||
row: Record<string, unknown> | undefined,
|
||||
): CaptchaResponseTokenRecord | undefined {
|
||||
if (!row) return undefined;
|
||||
return JSON.parse(String(row.payload)) as CaptchaResponseTokenRecord;
|
||||
}
|
||||
@@ -77,7 +78,13 @@ export class SqliteCaptchaStore implements CaptchaStore {
|
||||
(id, payload, expires_at, consumed_at, attempts)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(record.id, JSON.stringify(record), record.expiresAt, record.consumedAt ?? null, record.attempts);
|
||||
.run(
|
||||
record.id,
|
||||
JSON.stringify(record),
|
||||
record.expiresAt,
|
||||
record.consumedAt ?? null,
|
||||
record.attempts,
|
||||
);
|
||||
}
|
||||
|
||||
async getChallenge(id: string): Promise<CaptchaChallengeRecord | undefined> {
|
||||
@@ -96,7 +103,9 @@ export class SqliteCaptchaStore implements CaptchaStore {
|
||||
SET payload = ?, attempts = ?
|
||||
WHERE id = ? AND expires_at > ? AND consumed_at IS NULL AND attempts = ?`,
|
||||
)
|
||||
.run(JSON.stringify(current), current.attempts, id, now, current.attempts - 1) as { changes?: number };
|
||||
.run(JSON.stringify(current), current.attempts, id, now, current.attempts - 1) as {
|
||||
changes?: number;
|
||||
};
|
||||
return result?.changes === 0 ? undefined : current;
|
||||
}
|
||||
|
||||
@@ -134,7 +143,10 @@ export class SqliteCaptchaStore implements CaptchaStore {
|
||||
);
|
||||
}
|
||||
|
||||
async consumeToken(tokenHash: string, now: number): Promise<CaptchaResponseTokenRecord | undefined> {
|
||||
async consumeToken(
|
||||
tokenHash: string,
|
||||
now: number,
|
||||
): Promise<CaptchaResponseTokenRecord | undefined> {
|
||||
const current = await this.getToken(tokenHash);
|
||||
if (!current || current.expiresAt <= now || current.consumedAt) return undefined;
|
||||
current.consumedAt = now;
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import type { Context, Middleware } from "@wrnexus/core";
|
||||
|
||||
export type CaptchaProviderName =
|
||||
| "self-hosted"
|
||||
| "wrnexus-managed"
|
||||
| "turnstile"
|
||||
| "recaptcha"
|
||||
| "hcaptcha"
|
||||
| (string & {});
|
||||
"self-hosted" | "wrnexus-managed" | "turnstile" | "recaptcha" | "hcaptcha" | (string & {});
|
||||
|
||||
export type CaptchaChallengeType =
|
||||
| "number"
|
||||
@@ -231,7 +226,9 @@ export interface CaptchaGeneratorContext {
|
||||
|
||||
export interface CaptchaChallengeGenerator {
|
||||
readonly type: CaptchaChallengeType;
|
||||
generate(context: CaptchaGeneratorContext): GeneratedCaptchaChallenge | Promise<GeneratedCaptchaChallenge>;
|
||||
generate(
|
||||
context: CaptchaGeneratorContext,
|
||||
): GeneratedCaptchaChallenge | Promise<GeneratedCaptchaChallenge>;
|
||||
}
|
||||
|
||||
export interface CaptchaAudioRenderer {
|
||||
@@ -263,7 +260,10 @@ export interface CaptchaEngine {
|
||||
create(options: CreateCaptchaOptions): Promise<CaptchaChallenge>;
|
||||
verify(input: VerifyCaptchaInput): Promise<CaptchaVerificationResult>;
|
||||
verifyResponseToken(input: VerifyCaptchaInput): Promise<CaptchaVerificationResult>;
|
||||
renderAudio(challengeId: string, key: string): Promise<{ bytes: Uint8Array; contentType: string } | undefined>;
|
||||
renderAudio(
|
||||
challengeId: string,
|
||||
key: string,
|
||||
): Promise<{ bytes: Uint8Array; contentType: string } | undefined>;
|
||||
gc(): Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import type { Context } from "@wrnexus/core";
|
||||
import type { ObjectSchema, ParseResult } from "@wrnexus/validation";
|
||||
import type {
|
||||
CaptchaEngine,
|
||||
CaptchaProvider,
|
||||
CaptchaVerificationResult,
|
||||
} from "./types.ts";
|
||||
import type { CaptchaEngine, CaptchaProvider, CaptchaVerificationResult } from "./types.ts";
|
||||
import { selfHostedProvider } from "./providers/self-hosted.ts";
|
||||
|
||||
export interface ParseWithCaptchaOptions {
|
||||
@@ -28,7 +24,8 @@ export async function parseWithCaptcha<T = Record<string, unknown>>(
|
||||
options: ParseWithCaptchaOptions,
|
||||
): Promise<CaptchaParseResult<T>> {
|
||||
const parsed = schema.parse(input) as ParseResult<T>;
|
||||
const provider = options.provider ?? (options.engine ? selfHostedProvider(options.engine) : undefined);
|
||||
const provider =
|
||||
options.provider ?? (options.engine ? selfHostedProvider(options.engine) : undefined);
|
||||
if (!provider) throw new TypeError("parseWithCaptcha requires provider or engine");
|
||||
const field = options.responseField ?? provider.client.responseField;
|
||||
const token = input[field] ?? input.captchaToken ?? input.responseToken;
|
||||
|
||||
Reference in New Issue
Block a user