Skip to content
File

Blob: src/worker/services/turnstile.ts

typescript156 lines
1export const TURNSTILE_REQUIRED_MESSAGE = "Human verification failed. Please try again.";
2export const TURNSTILE_UNAVAILABLE_MESSAGE = "Human verification is temporarily unavailable.";
3 
4// Cloudflare's documented "always passes / fails / interactive" testing
5// secrets. Used only to recognize the test-mode siteverify response shape
6// (action="test" or metadata.result_with_testing_key=true with no action) โ€”
7// never to skip the siteverify call itself.
8const TURNSTILE_TEST_SECRET_KEYS = new Set([
9 "1x0000000000000000000000000000000AA",
10 "2x0000000000000000000000000000000AA",
11 "3x0000000000000000000000000000000AA",
12]);
13 
14interface SiteverifyResponse {
15 success: boolean;
16 action?: string;
17 hostname?: string;
18 "error-codes"?: string[];
19 metadata?: { result_with_testing_key?: boolean };
20}
21 
22type Status = 400 | 403 | 503;
23 
24export type TurnstileVerification =
25 { ok: true } | { ok: false; status: Status; reason: string; message: string; errorCodes: string[] };
26 
27interface VerifyOptions {
28 expectedAction: string;
29 remoteIp?: string | null;
30 requestUrl: string;
31 token: string;
32}
33 
34export const verifyTurnstileToken = async (env: Env, options: VerifyOptions): Promise<TurnstileVerification> => {
35 const secret = env.TURNSTILE_SECRET_KEY?.trim();
36 const siteKey = env.TURNSTILE_SITE_KEY?.trim();
37 
38 // Both keys are deployment invariants. Secret is checked first so the
39 // both-missing case lands on `missing_secret` and existing operator
40 // runbooks key off that reason. Site key is not sent to siteverify
41 // (Cloudflare's API doesn't accept it), but requiring it here keeps
42 // direct API POSTs from bypassing the deployment contract that
43 // /api/config already enforces for the client config path.
44 if (!secret) {
45 return {
46 ok: false,
47 status: 503,
48 reason: "missing_secret",
49 message: TURNSTILE_UNAVAILABLE_MESSAGE,
50 errorCodes: [],
51 };
52 }
53 if (!siteKey) {
54 return {
55 ok: false,
56 status: 503,
57 reason: "missing_site_key",
58 message: TURNSTILE_UNAVAILABLE_MESSAGE,
59 errorCodes: [],
60 };
61 }
62 
63 const token = options.token.trim();
64 if (!token) {
65 return {
66 ok: false,
67 status: 400,
68 reason: "missing_token",
69 message: TURNSTILE_REQUIRED_MESSAGE,
70 errorCodes: [],
71 };
72 }
73 
74 const requestHostname = new URL(options.requestUrl).hostname;
75 
76 let response: Response;
77 try {
78 response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
79 method: "POST",
80 headers: { "content-type": "application/json" },
81 body: JSON.stringify({
82 secret,
83 response: token,
84 ...(options.remoteIp ? { remoteip: options.remoteIp } : {}),
85 }),
86 });
87 } catch {
88 return {
89 ok: false,
90 status: 503,
91 reason: "siteverify_request_failed",
92 message: TURNSTILE_UNAVAILABLE_MESSAGE,
93 errorCodes: [],
94 };
95 }
96 
97 let verification: SiteverifyResponse;
98 try {
99 verification = (await response.json()) as SiteverifyResponse;
100 } catch {
101 return {
102 ok: false,
103 status: 503,
104 reason: "siteverify_invalid_payload",
105 message: TURNSTILE_UNAVAILABLE_MESSAGE,
106 errorCodes: [],
107 };
108 }
109 
110 if (!verification.success) {
111 return {
112 ok: false,
113 status: 403,
114 reason: "verification_failed",
115 message: TURNSTILE_REQUIRED_MESSAGE,
116 errorCodes: verification["error-codes"] ?? [],
117 };
118 }
119 
120 // Cloudflare's testing secrets return `action: "test"` (or omit the action
121 // entirely with metadata.result_with_testing_key=true) instead of echoing
122 // the widget's action โ€” recognize that response shape and skip the
123 // action/hostname comparisons that would otherwise fail. siteverify still
124 // had to succeed first.
125 const isTestingKeyResponse =
126 TURNSTILE_TEST_SECRET_KEYS.has(secret) &&
127 (verification.action === "test" ||
128 (verification.metadata?.result_with_testing_key === true && !verification.action));
129 
130 if (!isTestingKeyResponse && verification.action !== options.expectedAction) {
131 return {
132 ok: false,
133 status: 403,
134 reason: "action_mismatch",
135 message: TURNSTILE_REQUIRED_MESSAGE,
136 errorCodes: verification["error-codes"] ?? [],
137 };
138 }
139 
140 // Cloudflare's documented siteverify response always includes hostname
141 // for non-test keys. Treat absence as a verification failure rather
142 // than a no-op โ€” a stripped/proxied response could otherwise disable
143 // hostname binding silently.
144 if (!isTestingKeyResponse && (!verification.hostname || verification.hostname !== requestHostname)) {
145 return {
146 ok: false,
147 status: 403,
148 reason: "hostname_mismatch",
149 message: TURNSTILE_REQUIRED_MESSAGE,
150 errorCodes: verification["error-codes"] ?? [],
151 };
152 }
153 
154 return { ok: true };
155};