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(); + }); +});