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"],