File
Blob: src/worker/services/turnstile.ts
| 1 | export const TURNSTILE_REQUIRED_MESSAGE = "Human verification failed. Please try again."; |
| 2 | export 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. |
| 8 | const TURNSTILE_TEST_SECRET_KEYS = new Set([ |
| 9 | "1x0000000000000000000000000000000AA", |
| 10 | "2x0000000000000000000000000000000AA", |
| 11 | "3x0000000000000000000000000000000AA", |
| 12 | ]); |
| 13 | |
| 14 | interface SiteverifyResponse { |
| 15 | success: boolean; |
| 16 | action?: string; |
| 17 | hostname?: string; |
| 18 | "error-codes"?: string[]; |
| 19 | metadata?: { result_with_testing_key?: boolean }; |
| 20 | } |
| 21 | |
| 22 | type Status = 400 | 403 | 503; |
| 23 | |
| 24 | export type TurnstileVerification = |
| 25 | { ok: true } | { ok: false; status: Status; reason: string; message: string; errorCodes: string[] }; |
| 26 | |
| 27 | interface VerifyOptions { |
| 28 | expectedAction: string; |
| 29 | remoteIp?: string | null; |
| 30 | requestUrl: string; |
| 31 | token: string; |
| 32 | } |
| 33 | |
| 34 | export 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 | }; |