mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-22 07:02:16 +03:00
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:
@@ -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) {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user