Files
WRNexusJS/packages/captcha/src/providers/siteverify.ts
T
2026-07-27 12:42:18 +05:30

176 lines
5.3 KiB
TypeScript

import type {
CaptchaProvider,
CaptchaProviderClientConfig,
CaptchaProviderName,
CaptchaVerificationResult,
VerifyCaptchaInput,
} from "../types.ts";
export interface SiteverifyProviderOptions {
secretKey: string;
siteKey?: string;
expectedHostnames?: string[];
expectedAction?: string;
minScore?: number;
timeoutMs?: number;
fetch?: typeof fetch;
}
export interface SiteverifyPreset {
name: CaptchaProviderName;
endpoint: string;
client: CaptchaProviderClientConfig;
sendSiteKey?: boolean;
scoreDirection?: "higher-is-human" | "higher-is-risk";
}
interface SiteverifyPayload {
success?: boolean;
hostname?: string;
action?: string;
score?: number;
challenge_ts?: string;
"error-codes"?: string[];
error_codes?: string[];
[key: string]: unknown;
}
function providerFailure(
name: CaptchaProviderName,
action: string,
code: string,
message: string,
metadata?: Record<string, unknown>,
): CaptchaVerificationResult {
return { success: false, provider: name, action, code, message, metadata };
}
export class SiteverifyCaptchaProvider implements CaptchaProvider {
readonly name: CaptchaProviderName;
readonly client: CaptchaProviderClientConfig;
constructor(
private readonly preset: SiteverifyPreset,
private readonly options: SiteverifyProviderOptions,
) {
if (!options.secretKey) throw new TypeError(`${preset.name} secretKey is required`);
this.name = preset.name;
this.client = { ...preset.client, siteKey: options.siteKey };
}
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");
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.options.timeoutMs ?? 10_000);
const body = new URLSearchParams({ secret: this.options.secretKey, response: token });
if (input.ip) body.set("remoteip", input.ip);
if (this.preset.sendSiteKey && this.options.siteKey) body.set("sitekey", this.options.siteKey);
try {
const response = await (this.options.fetch ?? fetch)(this.preset.endpoint, {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
accept: "application/json",
},
body,
signal: controller.signal,
});
if (!response.ok) {
return providerFailure(
this.name,
action,
"provider-error",
`${this.name} verification returned HTTP ${response.status}`,
);
}
const data = (await response.json()) as SiteverifyPayload;
if (!data.success) {
const codes = data["error-codes"] ?? data.error_codes ?? [];
return providerFailure(
this.name,
action,
codes.includes("timeout-or-duplicate") || codes.includes("already-seen-response")
? "already-used"
: codes.includes("expired-input-response")
? "expired"
: "invalid-input",
"Provider verification failed",
{ errorCodes: codes },
);
}
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,
},
);
}
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,
},
);
}
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;
if (rejected) {
return providerFailure(
this.name,
action,
"risk-rejected",
"Provider risk score did not pass",
{
score: data.score,
},
);
}
}
return {
success: true,
provider: this.name,
action,
score: data.score,
hostname: data.hostname,
metadata: {
challengeTimestamp: data.challenge_ts,
raw: data,
},
};
} catch (error) {
return providerFailure(
this.name,
action,
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`,
);
} finally {
clearTimeout(timeout);
}
}
}