fix(security): enforce per-key policy for a bare x-api-key (allowedModels bypass)

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.
This commit is contained in:
Xiangzhe
2026-08-21 13:53:24 -03:00
parent f1019ebf23
commit ef78596876
2 changed files with 61 additions and 3 deletions

View File

@@ -645,13 +645,37 @@ async function validateRateLimitAndThrottle(context: PolicyContext): Promise<Res
return null;
}
/**
* A bare `x-api-key` / `x-goog-api-key` (no anthropic-version, no claude
* user-agent) is accepted by the CLIENT_API auth layer (clientApi.ts
* `extractBearer`) but ignored by the Issue-#2225-gated `extractApiKey()` used
* for policy resolution — so a genuine key sent that way passed auth while
* skipping its own allowedModels / budget / rate-limit policy
* (GHSA-2phc-xp22-9f56). Resolve those headers here so the policy layer sees the
* same key auth accepted. Bearer, URL-token and anthropic-gated paths are already
* covered by `extractApiKey()`; unknown keys still fail open downstream, so this
* only tightens enforcement for real keys.
*/
function extractUngatedClientApiKey(request: Request): string | null {
const xApiKey = request.headers.get("x-api-key") ?? request.headers.get("X-Api-Key");
if (xApiKey && xApiKey.trim()) return xApiKey.trim();
const xGoog = request.headers.get("x-goog-api-key") ?? request.headers.get("X-Goog-Api-Key");
if (xGoog && xGoog.trim()) return xGoog.trim();
return null;
}
export async function enforceApiKeyPolicy(
request: Request,
modelStr: string | null
): Promise<ApiKeyPolicyResult> {
// 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) {

View File

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