Move turnstile check to api (#3845)

## 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
This commit is contained in:
Evan
2026-05-04 12:53:02 -06:00
committed by GitHub
parent 257fb9b38e
commit 94205426e7
10 changed files with 33 additions and 66 deletions
-1
View File
@@ -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 }}
-4
View File
@@ -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
-1
View File
@@ -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
-1
View File
@@ -25,7 +25,6 @@ export enum GameEnv {
export interface ServerConfig {
turnstileSiteKey(): string;
turnstileSecretKey(): string;
turnIntervalMs(): number;
gameCreationRate(): number;
numWorkers(): number;
-3
View File
@@ -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;
-4
View File
@@ -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";
}
-3
View File
@@ -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");
},
+31 -37
View File
@@ -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<typeof TurnstileVerdictSchema>;
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<TurnstileResponse> {
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 {
+2 -9
View File
@@ -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":
-3
View File
@@ -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.");
}