From 743afe6ab418380faac8d08b41bcc85f0576a457 Mon Sep 17 00:00:00 2001 From: shin-core <153108882+shin-core@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:50:49 +0900 Subject: [PATCH] fix(auth): normalize INTERNAL_JOB_TOKEN whitespace like the three private secrets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `authenticateInternalToken` compared the incoming bearer against the raw `env.INTERNAL_JOB_TOKEN`, while its sibling `authenticatePrivateToken` wraps each of its three secrets in `nonBlank()` (trim → undefined-if-empty) and early-exits on a falsy token. `extractBearerToken` already trims the incoming token, so an `INTERNAL_JOB_TOKEN` secret carrying a trailing newline — the shape a `wrangler secret put` piped from a file produces — could never match any caller and returned a blanket 401 for every `/v1/internal/*` route, while the same whitespace on `LOOPOVER_API_TOKEN` was tolerated. Apply `nonBlank()` and the `if (!token) return null` early exit, mirroring `authenticatePrivateToken` exactly. This is a normalization gap, not a fail-open one: `timingSafeEqual` already returns false for an empty/undefined expected value, and `nonBlank(" ")` is `undefined`, so a whitespace-only secret still authenticates nobody. Closes #9713 --- src/auth/security.ts | 7 ++++- test/unit/auth-security-helpers.test.ts | 34 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/auth/security.ts b/src/auth/security.ts index d1fc0f059c..709ddcfb2b 100644 --- a/src/auth/security.ts +++ b/src/auth/security.ts @@ -119,7 +119,12 @@ export async function authenticatePrivateToken(env: Env, token: string | undefin } export async function authenticateInternalToken(env: Env, token: string | undefined): Promise { - if (await timingSafeEqual(token, env.INTERNAL_JOB_TOKEN)) return { kind: "static", actor: "internal" }; + // Mirror authenticatePrivateToken's normalization (#9713): early-exit on a falsy bearer, and compare against the + // trimmed secret via nonBlank(). extractBearerToken already trims the incoming token, so an INTERNAL_JOB_TOKEN + // secret carrying a trailing newline (the shape a `wrangler secret put` piped from a file produces) would + // otherwise never match any caller and 401 every /v1/internal/* route, unlike the three secrets above. + if (!token) return null; + if (await timingSafeEqual(token, nonBlank(env.INTERNAL_JOB_TOKEN))) return { kind: "static", actor: "internal" }; return null; } diff --git a/test/unit/auth-security-helpers.test.ts b/test/unit/auth-security-helpers.test.ts index 50e7c8b88a..accc3a518b 100644 --- a/test/unit/auth-security-helpers.test.ts +++ b/test/unit/auth-security-helpers.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + authenticateInternalToken, buildBrowserSessionCookie, buildClearedBrowserSessionCookie, buildClearedGitHubOAuthStateCookie, @@ -136,3 +137,36 @@ describe("session/oauth cookie builders", () => { ); }); }); + +describe("authenticateInternalToken (#9713)", () => { + // authenticateInternalToken reads only env.INTERNAL_JOB_TOKEN, so a minimal cast keeps this pure-helper + // suite dependency-light (no D1) while still exercising both the nonBlank() and the early-exit branches. + const envWith = (internalJobToken: string): Env => ({ INTERNAL_JOB_TOKEN: internalJobToken }) as unknown as Env; + + it("trims surrounding whitespace on INTERNAL_JOB_TOKEN, matching the three private secrets (regression for #9713)", async () => { + const env = envWith(" secret "); + await expect(authenticateInternalToken(env, "secret")).resolves.toEqual({ kind: "static", actor: "internal" }); + }); + + it("authenticates a correct, untrimmed secret (bearer already trimmed by extractBearerToken)", async () => { + const env = envWith("secret"); + await expect(authenticateInternalToken(env, "secret")).resolves.toEqual({ kind: "static", actor: "internal" }); + }); + + it("authenticates nobody when INTERNAL_JOB_TOKEN is whitespace-only", async () => { + const env = envWith(" "); + // The blank secret trims to undefined via nonBlank(), so even the same whitespace never matches. + await expect(authenticateInternalToken(env, " ")).resolves.toBeNull(); + await expect(authenticateInternalToken(env, "")).resolves.toBeNull(); + }); + + it("early-returns null for an absent bearer without consulting the secret", async () => { + const env = envWith("secret"); + await expect(authenticateInternalToken(env, undefined)).resolves.toBeNull(); + }); + + it("rejects a token that does not match the configured secret", async () => { + const env = envWith("secret"); + await expect(authenticateInternalToken(env, "wrong")).resolves.toBeNull(); + }); +});