Skip to content
File

Blob: src/worker/services/turnstile.ts

typescript183 lines
1import { eg, type TypeFromCodec } from "@cloudflare/util-en-garde";
2import { isLoopbackHostname } from "@/worker/security";
3 
4export const TURNSTILE_REQUIRED_MESSAGE = "Human verification failed. Please try again.";
5export const TURNSTILE_UNAVAILABLE_MESSAGE = "Human verification is temporarily unavailable.";
6 
7const TURNSTILE_TEST_SECRET_KEYS = new Set([
8 "1x0000000000000000000000000000000AA",
9 "2x0000000000000000000000000000000AA",
10 "3x0000000000000000000000000000000AA",
11]);
12 
13const TurnstileSiteverifyResponse = eg.object({
14 success: eg.boolean,
15 action: eg.string.optional,
16 hostname: eg.string.optional,
17 "error-codes": eg.array(eg.string).optional,
18 metadata: eg.object({
19 result_with_testing_key: eg.boolean.optional,
20 }).optional,
21});
22type TurnstileSiteverifyResponse = TypeFromCodec<typeof TurnstileSiteverifyResponse>;
23 
24type TurnstileFailureStatus = 400 | 403 | 503;
25 
26type TurnstileVerificationResult =
27 | {
28 ok: true;
29 response: TurnstileSiteverifyResponse;
30 }
31 | {
32 ok: false;
33 errorCodes: string[];
34 message: string;
35 reason: string;
36 status: TurnstileFailureStatus;
37 };
38 
39interface VerifyTurnstileTokenOptions {
40 expectedAction: string;
41 remoteIp?: string | null;
42 requestUrl: string;
43 token: string;
44}
45 
46export async function verifyTurnstileToken(
47 env: Env,
48 options: VerifyTurnstileTokenOptions,
49): Promise<TurnstileVerificationResult> {
50 const secret = env.TURNSTILE_SECRET_KEY?.trim();
51 const token = options.token.trim();
52 
53 if (!secret) {
54 return {
55 ok: false,
56 errorCodes: [],
57 message: TURNSTILE_UNAVAILABLE_MESSAGE,
58 reason: "missing_secret",
59 status: 503,
60 };
61 }
62 
63 if (!token) {
64 return {
65 ok: false,
66 errorCodes: [],
67 message: TURNSTILE_REQUIRED_MESSAGE,
68 reason: "missing_token",
69 status: 400,
70 };
71 }
72 
73 const requestHostname = new URL(options.requestUrl).hostname;
74 if (TURNSTILE_TEST_SECRET_KEYS.has(secret) && isLoopbackHostname(requestHostname)) {
75 return {
76 ok: true,
77 response: {
78 success: true,
79 action: "test",
80 hostname: requestHostname,
81 metadata: {
82 result_with_testing_key: true,
83 },
84 },
85 };
86 }
87 
88 let response: Response;
89 
90 try {
91 response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
92 method: "POST",
93 headers: {
94 "content-type": "application/json",
95 },
96 body: JSON.stringify({
97 secret,
98 response: token,
99 ...(options.remoteIp ? { remoteip: options.remoteIp } : {}),
100 }),
101 });
102 } catch {
103 return {
104 ok: false,
105 errorCodes: [],
106 message: TURNSTILE_UNAVAILABLE_MESSAGE,
107 reason: "siteverify_request_failed",
108 status: 503,
109 };
110 }
111 
112 let payload: unknown;
113 
114 try {
115 payload = await response.json();
116 } catch {
117 return {
118 ok: false,
119 errorCodes: [],
120 message: TURNSTILE_UNAVAILABLE_MESSAGE,
121 reason: "siteverify_invalid_json",
122 status: 503,
123 };
124 }
125 
126 let verification: TurnstileSiteverifyResponse;
127 
128 try {
129 verification = TurnstileSiteverifyResponse.assertDecode(payload);
130 } catch {
131 return {
132 ok: false,
133 errorCodes: [],
134 message: TURNSTILE_UNAVAILABLE_MESSAGE,
135 reason: "siteverify_invalid_payload",
136 status: 503,
137 };
138 }
139 
140 const isTestingKeyResponse =
141 TURNSTILE_TEST_SECRET_KEYS.has(secret) &&
142 (verification.action === "test" ||
143 (verification.metadata?.result_with_testing_key === true && !verification.action));
144 
145 if (!verification.success) {
146 return {
147 ok: false,
148 errorCodes: verification["error-codes"] ?? [],
149 message: TURNSTILE_REQUIRED_MESSAGE,
150 reason: "verification_failed",
151 status: 403,
152 };
153 }
154 
155 const actionMatches = verification.action === options.expectedAction || isTestingKeyResponse;
156 
157 if (!actionMatches) {
158 return {
159 ok: false,
160 errorCodes: verification["error-codes"] ?? [],
161 message: TURNSTILE_REQUIRED_MESSAGE,
162 reason: "action_mismatch",
163 status: 403,
164 };
165 }
166 
167 const expectedHostname = new URL(options.requestUrl).hostname;
168 if (!isTestingKeyResponse && verification.hostname && verification.hostname !== expectedHostname) {
169 return {
170 ok: false,
171 errorCodes: verification["error-codes"] ?? [],
172 message: TURNSTILE_REQUIRED_MESSAGE,
173 reason: "hostname_mismatch",
174 status: 403,
175 };
176 }
177 
178 return {
179 ok: true,
180 response: verification,
181 };
182}