From 94205426e75405a2f2cb09a3277ecd8e917f4eb5 Mon Sep 17 00:00:00 2001 From: Evan Date: Mon, 4 May 2026 12:53:02 -0600 Subject: [PATCH] Move turnstile check to api (#3845) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Re-enables Turnstile verification (was temporarily disabled in v31 to diagnose intermittent `invalid-input-response` rejections) and moves the verification call off the game servers. Game servers no longer hold `TURNSTILE_SECRET_KEY` or hit `challenges.cloudflare.com` directly. Instead they POST to `${jwtIssuer}/turnstile` on the api-worker (authenticated with the existing `apiKey`), which holds the secret and proxies to Cloudflare. Shrinks blast radius and removes the secret from every game host + GH Actions workflow. Response from the api-worker is Zod-validated; null tokens short-circuit to `rejected` locally. ## Please complete the following: - [x] I have added screenshots for all UI updates (n/a — server only) - [x] I process any text displayed to the user through translateText() (n/a — no user-visible text) - [ ] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Discord: evanpelle --- .github/workflows/deploy.yml | 1 - .github/workflows/release.yml | 4 -- deploy.sh | 1 - src/core/configuration/Config.ts | 1 - src/core/configuration/DefaultConfig.ts | 3 -- src/core/configuration/DevConfig.ts | 4 -- src/core/configuration/Env.ts | 3 -- src/server/Turnstile.ts | 68 +++++++++++-------------- src/server/Worker.ts | 11 +--- tests/util/TestServerConfig.ts | 3 -- 10 files changed, 33 insertions(+), 66 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b0882f2dd..f1284dcbf 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -138,7 +138,6 @@ jobs: CDN_BASE: ${{ vars.CDN_BASE }} OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }} OTEL_AUTH_HEADER: ${{ secrets.OTEL_AUTH_HEADER }} - TURNSTILE_SECRET_KEY: ${{ secrets.TURNSTILE_SECRET_KEY }} API_KEY: ${{ secrets.API_KEY }} SERVER_HOST_MASTERS: ${{ secrets.SERVER_HOST_MASTERS }} SERVER_HOST_FALK2: ${{ secrets.SERVER_HOST_FALK2 }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 71cbfe364..cca6a1828 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -71,7 +71,6 @@ jobs: IMAGE_ID: ${{ needs.build.outputs.IMAGE_ID }} OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }} OTEL_AUTH_HEADER: ${{ secrets.OTEL_AUTH_HEADER }} - TURNSTILE_SECRET_KEY: ${{ secrets.TURNSTILE_SECRET_KEY }} API_KEY: ${{ secrets.API_KEY }} SERVER_HOST_STAGING: ${{ secrets.SERVER_HOST_STAGING }} SSH_KEY: ~/.ssh/id_rsa @@ -122,7 +121,6 @@ jobs: IMAGE_ID: ${{ needs.build.outputs.IMAGE_ID }} OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }} OTEL_AUTH_HEADER: ${{ secrets.OTEL_AUTH_HEADER }} - TURNSTILE_SECRET_KEY: ${{ secrets.TURNSTILE_SECRET_KEY }} API_KEY: ${{ secrets.API_KEY }} SERVER_HOST_FALK2: ${{ secrets.SERVER_HOST_FALK2 }} SSH_KEY: ~/.ssh/id_rsa @@ -173,7 +171,6 @@ jobs: IMAGE_ID: ${{ needs.build.outputs.IMAGE_ID }} OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }} OTEL_AUTH_HEADER: ${{ secrets.OTEL_AUTH_HEADER }} - TURNSTILE_SECRET_KEY: ${{ secrets.TURNSTILE_SECRET_KEY }} API_KEY: ${{ secrets.API_KEY }} SERVER_HOST_FALK2: ${{ secrets.SERVER_HOST_FALK2 }} SSH_KEY: ~/.ssh/id_rsa @@ -224,7 +221,6 @@ jobs: IMAGE_ID: ${{ needs.build.outputs.IMAGE_ID }} OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }} OTEL_AUTH_HEADER: ${{ secrets.OTEL_AUTH_HEADER }} - TURNSTILE_SECRET_KEY: ${{ secrets.TURNSTILE_SECRET_KEY }} API_KEY: ${{ secrets.API_KEY }} SERVER_HOST_FALK2: ${{ secrets.SERVER_HOST_FALK2 }} SSH_KEY: ~/.ssh/id_rsa diff --git a/deploy.sh b/deploy.sh index 3797d1554..53c90df21 100755 --- a/deploy.sh +++ b/deploy.sh @@ -134,7 +134,6 @@ ENV=$ENV HOST=$HOST GHCR_IMAGE=$GHCR_IMAGE GHCR_TOKEN=$GHCR_TOKEN -TURNSTILE_SECRET_KEY=$TURNSTILE_SECRET_KEY API_KEY=$API_KEY DOMAIN=$DOMAIN SUBDOMAIN=$SUBDOMAIN diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 751ccdb85..e0cacf57b 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -25,7 +25,6 @@ export enum GameEnv { export interface ServerConfig { turnstileSiteKey(): string; - turnstileSecretKey(): string; turnIntervalMs(): number; gameCreationRate(): number; numWorkers(): number; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 281e46024..e59653365 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -43,9 +43,6 @@ const JwksSchema = z.object({ }); export abstract class DefaultServerConfig implements ServerConfig { - turnstileSecretKey(): string { - return Env.TURNSTILE_SECRET_KEY ?? ""; - } abstract turnstileSiteKey(): string; allowedFlares(): string[] | undefined { return; diff --git a/src/core/configuration/DevConfig.ts b/src/core/configuration/DevConfig.ts index 09c0adaa6..8ead8c8e5 100644 --- a/src/core/configuration/DevConfig.ts +++ b/src/core/configuration/DevConfig.ts @@ -8,10 +8,6 @@ export class DevServerConfig extends DefaultServerConfig { return "1x00000000000000000000AA"; } - turnstileSecretKey(): string { - return "1x0000000000000000000000000000000AA"; - } - adminToken(): string { return "WARNING_DEV_ADMIN_KEY_DO_NOT_USE_IN_PRODUCTION"; } diff --git a/src/core/configuration/Env.ts b/src/core/configuration/Env.ts index 655202b34..b5dfe2111 100644 --- a/src/core/configuration/Env.ts +++ b/src/core/configuration/Env.ts @@ -62,9 +62,6 @@ export const Env = { return getEnv("GAME_ENV") ?? "dev"; }, - get TURNSTILE_SECRET_KEY() { - return getEnv("TURNSTILE_SECRET_KEY"); - }, get STRIPE_PUBLISHABLE_KEY() { return getEnv("STRIPE_PUBLISHABLE_KEY"); }, diff --git a/src/server/Turnstile.ts b/src/server/Turnstile.ts index 5362672b0..4ab5f1d1c 100644 --- a/src/server/Turnstile.ts +++ b/src/server/Turnstile.ts @@ -1,12 +1,22 @@ +import { z } from "zod"; +import { ServerConfig } from "../core/configuration/Config"; + +const TurnstileVerdictSchema = z.discriminatedUnion("status", [ + z.object({ status: z.literal("approved") }), + z.object({ status: z.literal("rejected"), reason: z.string() }), +]); + +type TurnstileVerdict = z.infer; + +export type TurnstileResponse = + | TurnstileVerdict + | { status: "error"; reason: string }; + export async function verifyTurnstileToken( ip: string, turnstileToken: string | null, - turnstileSecret: string, -): Promise< - | { status: "approved" } - | { status: "rejected"; reason: string } - | { status: "error"; reason: string } -> { + config: ServerConfig, +): Promise { if (!turnstileToken) { return { status: "rejected", reason: "No turnstile token provided" }; } @@ -15,54 +25,38 @@ export async function verifyTurnstileToken( const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 3000); - const response = await fetch( - "https://challenges.cloudflare.com/turnstile/v0/siteverify", - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - secret: turnstileSecret, - response: turnstileToken, - remoteip: ip, - }), - signal: controller.signal, + const response = await fetch(`${config.jwtIssuer()}/turnstile`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": config.apiKey(), }, - ); + body: JSON.stringify({ ip, token: turnstileToken }), + signal: controller.signal, + }); clearTimeout(timeoutId); if (!response.ok) { return { status: "error", - reason: `Turnstile API returned ${response.status}`, + reason: `api-worker returned ${response.status}`, }; } - const result = (await response.json()) as { - success: boolean; - challenge_ts?: string; - hostname?: string; - "error-codes"?: string[]; - action?: string; - cdata?: string; - }; - - if (!result.success) { - const codes = result["error-codes"]?.join(", ") ?? "unknown"; + const parsed = TurnstileVerdictSchema.safeParse(await response.json()); + if (!parsed.success) { return { - status: "rejected", - reason: `Turnstile token validation failed: ${codes}`, + status: "error", + reason: `api-worker returned malformed response: ${parsed.error.message}`, }; } - - return { status: "approved" }; + return parsed.data; } catch (e) { if (e instanceof Error && e.name === "AbortError") { return { status: "error", - reason: "Turnstile token validation timed out after 3 seconds", + reason: "Turnstile token validation timed out after 5 seconds", }; } return { diff --git a/src/server/Worker.ts b/src/server/Worker.ts index d8d43433b..13cf1f32c 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -40,10 +40,6 @@ const workerId = parseInt(process.env.WORKER_ID ?? "0"); const log = logger.child({ comp: `w_${workerId}` }); const playlist = new MapPlaylist(); -// TEMPORARY: Turnstile validation disabled while we diagnose intermittent -// invalid-input-response rejections in v31. Flip back to true to re-enable. -const TURNSTILE_ENABLED = false; - // Worker setup export async function startWorker() { log.info(`Worker starting...`); @@ -432,14 +428,11 @@ export async function startWorker() { return; } - // TEMPORARY: Turnstile validation disabled while we diagnose - // intermittent invalid-input-response rejections in v31. - // Re-enable by flipping TURNSTILE_ENABLED back to true. - if (TURNSTILE_ENABLED && config.env() !== GameEnv.Dev) { + if (config.env() !== GameEnv.Dev) { const turnstileResult = await verifyTurnstileToken( ip, clientMsg.turnstileToken, - config.turnstileSecretKey(), + config, ); switch (turnstileResult.status) { case "approved": diff --git a/tests/util/TestServerConfig.ts b/tests/util/TestServerConfig.ts index 4b34f133f..c213b1709 100644 --- a/tests/util/TestServerConfig.ts +++ b/tests/util/TestServerConfig.ts @@ -7,9 +7,6 @@ export class TestServerConfig implements ServerConfig { turnstileSiteKey(): string { throw new Error("Method not implemented."); } - turnstileSecretKey(): string { - throw new Error("Method not implemented."); - } apiKey(): string { throw new Error("Method not implemented."); }