From ef78596876c1d4e3fad20107268e4498ff894b98 Mon Sep 17 00:00:00 2001 From: Xiangzhe Date: Fri, 21 Aug 2026 13:53:24 -0300 Subject: [PATCH] fix(security): enforce per-key policy for a bare x-api-key (allowedModels bypass) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLIENT_API auth layer accepts a plain `x-api-key` (no anthropic-version), but enforceApiKeyPolicy resolved the key via the Issue-#2225-gated extractApiKey(), which ignores that header — so a valid restricted key sent as a bare x-api-key passed auth while skipping its allowedModels / budget / rate-limit policy entirely. Resolve the ungated x-api-key / x-goog-api-key in the policy layer too; unknown keys still fail open, so only real keys are affected. extractApiKey() (used by MANAGEMENT routes) keeps its local-mode gating. Reported by @Benson-mk via GHSA-2phc-xp22-9f56 and GHSA-m3cj-q455-6wfr. --- src/shared/utils/apiKeyPolicy.ts | 30 ++++++++++++++++++++++++--- tests/unit/api-key-policy.test.ts | 34 +++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/shared/utils/apiKeyPolicy.ts b/src/shared/utils/apiKeyPolicy.ts index 7df8f1cd1d..ebd23c9ead 100644 --- a/src/shared/utils/apiKeyPolicy.ts +++ b/src/shared/utils/apiKeyPolicy.ts @@ -645,13 +645,37 @@ async function validateRateLimitAndThrottle(context: PolicyContext): Promise { - // A real bearer key wins; otherwise an authenticated dashboard playground may - // test a specific key's policy by id (resolved server-side, secret never sent). - const apiKey = extractApiKey(request) || (await resolvePlaygroundTestKey(request)); + // A real bearer key wins; then a bare x-api-key/x-goog-api-key that auth + // accepted but extractApiKey() gates out; otherwise an authenticated dashboard + // playground may test a specific key's policy by id (resolved server-side, + // secret never sent). + const apiKey = + extractApiKey(request) || + extractUngatedClientApiKey(request) || + (await resolvePlaygroundTestKey(request)); // No API key = local/session mode, skip policy checks if (!apiKey) { diff --git a/tests/unit/api-key-policy.test.ts b/tests/unit/api-key-policy.test.ts index ceda6e7348..63cf63be7b 100644 --- a/tests/unit/api-key-policy.test.ts +++ b/tests/unit/api-key-policy.test.ts @@ -96,6 +96,17 @@ function makeAnthropicPolicyRequest(apiKey) { }); } +// A bare `x-api-key` with NO anthropic-version header and no claude user-agent: +// the CLIENT_API auth layer accepts it, but the gated extractApiKey() used by +// the policy layer used to ignore it, so the key's per-key policy was skipped +// entirely (GHSA-2phc-xp22-9f56). +function makeBareXApiKeyPolicyRequest(apiKey) { + return new Request("http://localhost/v1/responses", { + method: "POST", + headers: apiKey ? { "x-api-key": apiKey } : {}, + }); +} + async function readErrorMessage(response) { const body = (await response.json()) as { error?: { message?: unknown } }; return typeof body.error?.message === "string" ? body.error.message : ""; @@ -457,6 +468,29 @@ test("enforceApiKeyPolicy rejects disabled keys and blocked schedules", async () assert.match(await readErrorMessage(blocked.rejection), /Access denied outside allowed hours/); }); +test("enforceApiKeyPolicy enforces allowedModels for a bare x-api-key (GHSA-2phc-xp22-9f56)", async () => { + const restrictedKey = await createKeyWithPolicy({ + allowedModels: ["openai/gpt-4.1"], + }); + const policy = await loadPolicy("bare-x-api-key"); + + // Disallowed model via a bare x-api-key must be rejected, exactly as it is for + // a Bearer token — the header used to carry the key must not weaken the policy. + const disallowed = await policy.enforceApiKeyPolicy( + makeBareXApiKeyPolicyRequest(restrictedKey.key), + "anthropic/claude-3-7-sonnet" + ); + assert.equal(disallowed.rejection.status, 403); + assert.match(await readErrorMessage(disallowed.rejection), /not allowed/); + + // The allowed model still passes through the same header. + const allowed = await policy.enforceApiKeyPolicy( + makeBareXApiKeyPolicyRequest(restrictedKey.key), + "openai/gpt-4.1" + ); + assert.equal(allowed.rejection, null); +}); + test("enforceApiKeyPolicy rejects disallowed models and exhausted budgets", async () => { const restrictedKey = await createKeyWithPolicy({ allowedModels: ["openai/gpt-4.1"],