176 lines
5.3 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|