62 lines
2.2 KiB
TypeScript
62 lines
2.2 KiB
TypeScript
import type { CaptchaPolicyOptions, CaptchaRiskResult, CaptchaRiskSignals } from "./types.ts";
|
|
|
|
export function evaluateCaptchaRisk(
|
|
signals: CaptchaRiskSignals,
|
|
threshold = 50,
|
|
): CaptchaRiskResult {
|
|
let score = Math.max(0, Math.min(100, signals.customScore ?? 0));
|
|
const reasons: string[] = [];
|
|
const add = (points: number, reason: string): void => {
|
|
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.completionMs !== undefined && signals.completionMs < 700) add(25, "too-fast");
|
|
if (signals.missingBrowserSignals) add(20, "missing-browser-signals");
|
|
if (signals.suspiciousHeaders) add(20, "suspicious-headers");
|
|
if (signals.tokenReuse) add(70, "token-reuse");
|
|
if (signals.knownBadIp) add(60, "known-bad-ip");
|
|
return { score, challenge: score >= threshold, reasons };
|
|
}
|
|
|
|
export function shouldRequireCaptcha(
|
|
action: string,
|
|
options: CaptchaPolicyOptions = {},
|
|
signals: CaptchaRiskSignals = {},
|
|
): CaptchaRiskResult {
|
|
if (options.neverForActions?.includes(action) || options.mode === "never") {
|
|
return { score: 0, challenge: false, reasons: ["policy-never"] };
|
|
}
|
|
if (options.alwaysForActions?.includes(action) || options.mode === "always") {
|
|
return { score: 100, challenge: true, reasons: ["policy-always"] };
|
|
}
|
|
if ((options.mode ?? "adaptive") === "session") {
|
|
return { score: 100, challenge: true, reasons: ["session-unverified"] };
|
|
}
|
|
return evaluateCaptchaRisk(signals, options.threshold ?? 50);
|
|
}
|
|
|
|
export interface CaptchaSessionGrant {
|
|
action: string;
|
|
routeGroup?: string;
|
|
expiresAt: number;
|
|
provider: string;
|
|
}
|
|
|
|
export function validCaptchaGrant(
|
|
grants: CaptchaSessionGrant[] | undefined,
|
|
action: string,
|
|
now: number,
|
|
routeGroup?: string,
|
|
): CaptchaSessionGrant | undefined {
|
|
return grants?.find(
|
|
(grant) =>
|
|
grant.expiresAt > now &&
|
|
(grant.action === action || grant.action === "*") &&
|
|
(!routeGroup || !grant.routeGroup || grant.routeGroup === routeGroup),
|
|
);
|
|
}
|