Skip to content

fix(auth): accept a valid x-api-key even when an unrelated Authorization bearer is present - #16483

Merged
diegosouzapw merged 3 commits into
release/v3.8.52from
fix/port-pr-2926-accept-any-valid-client-credential
Oct 15, 2026
Merged

diegosouzapw merged 3 commits into
release/v3.8.52from
fix/port-pr-2926-accept-any-valid-client-credential

Conversation

@diegosouzapw

Copy link
Copy Markdown
Collaborator

Summary

  • Claude Code with an active claude.ai session or ANTHROPIC_AUTH_TOKEN sends its own Authorization: Bearer alongside a valid OmniRoute x-api-key. The CLIENT_API gate only validated the Bearer, so these requests got a 401 when REQUIRE_API_KEY was on. When it was off, they were silently downgraded to anonymous and lost the key's policy and attribution.
  • Auth now accepts the request if any presented credential validates. Identity, policy and model gates bind to the credential that validated, not to the stale Bearer.
  • This is a conservative change. Nothing that fails validation can authenticate, revoked keys stay rejected, and the extractApiKey() contract is unchanged, including management auth with allowUrl: false.

Attribution

Thanks to @omar-nahhas for the original implementation.

Changes

  • src/sse/services/clientApiKeyResolver.ts (new): extractApiKeyCandidates() collects Bearer, x-api-key, x-goog-api-key and the /vscode/<key> path token, deduped and in precedence order, at most 4. resolveClientApiKey() returns the first candidate that passes isValidApiKey(), or the legacy extractApiKey() result when none does. It lives in its own module because auth.ts is frozen at its file-size ceiling. The only change in auth.ts is that readNonEmptyUrlToken is now exported.
  • src/server/authz/policies/clientApi.ts: validates each candidate and allows on the first valid one, with that credential masked as the subject. The [BUG] invalid api key in Codex Desktop auto config #2257 behavior is unchanged: with REQUIRE_API_KEY=false, all-invalid credentials still fall back to anonymous.
  • src/shared/utils/clientApiRouteAuth.ts, src/app/api/v1/embeddings, web/fetch, web/map, responses/input_tokens: the route-level re-checks use the same resolver, so they are never stricter than the gate.
  • src/shared/utils/apiKeyPolicy.ts (enforceApiKeyPolicy) and src/sse/handlers/chat.ts: the policy and model gates use the validated credential, so a stale Bearer cannot hide the real key's allowedModels or budget. The chat.ts edit adds no lines; the file is at its frozen size.
  • Out of scope, left unchanged: the Anthropic error envelope for /v1/messages 401s (optional in the plan, and it would change a public error shape), plus management auth, A2A, session-leases, video-bridge drilldown, combos/explain and the model catalog, which still use extractApiKey().

Test plan

  • node --import tsx/esm --test tests/unit/authz-client-api-multi-credential.test.ts (new, 16 tests). Before the fix: ℹ tests 16 ℹ pass 11 ℹ fail 5. The 5 behavior tests (stale Bearer + valid x-api-key / x-goog-api-key, REQUIRE off keeps identity, route guard, policy binding) failed. After the fix: ℹ tests 16 ℹ pass 16 ℹ fail 0. Negative cases covered: both invalid → 401 AUTH_002, revoked x-api-key next to a stale Bearer → 401, all-invalid with REQUIRE off → anonymous, and the extractApiKey Bearer-first contract is unchanged.
  • I ran all 101 node:test files that reference the touched symbols/files, plus the vitest file open-sse/mcp-server/__tests__/lazyApiKeyDb.test.ts: ℹ tests 1134 ℹ pass 1118 ℹ fail 12 under concurrency. Five of the failing files pass when re-run serially, so those failures came from load. The other 3 files (integration-wiring, chat-correlation-id-exhaustion, embeddings-route-apikeymeta-6929) fail the same way on a clean origin/release/v3.8.52 checkout, so they are inherited from the base. Vitest: 1 passed.
  • npm run typecheck:core → exit 0
  • npx eslint --suppressions-location config/quality/eslint-suppressions.json <changed files> → clean; npx prettier --check → clean
  • node scripts/check/check-pr-test-policy.mjs (PR context) → PASS; GITHUB_BASE_REF=release/v3.8.52 npm run check:test-masking → OK, no weakening
  • npm run check:cycles:ratchet → 14 within ceiling 14; node scripts/check/check-file-size.mjs → the only violation is open-sse/services/combo/executeTargetAttempt.ts, which comes from the base and is not touched here

⚠️ base-red inherited: #15306 — unit/vitest/integration/package-artifact gates on release/v3.8.52

diegosouzapw and others added 2 commits October 13, 2026 09:00
…ion bearer is present

Clients such as Claude Code with an active claude.ai session or
ANTHROPIC_AUTH_TOKEN send their own `Authorization: Bearer` next to a valid
OmniRoute `x-api-key`. The CLIENT_API gate validated only the Bearer, so the
request got a 401 with REQUIRE_API_KEY on, or was downgraded to anonymous
(losing the key's policy and attribution) with it off.

- add clientApiKeyResolver: extractApiKeyCandidates() (Bearer, x-api-key,
  x-goog-api-key, /vscode path token; deduped, precedence order) and
  resolveClientApiKey() returning the first candidate that validates, or
  the legacy extractApiKey() result when none does
- clientApiPolicy accepts the request when any presented credential
  validates and masks THAT credential as the subject
- route-level guard (enforceClientApiRouteAuth), embeddings, web fetch/map
  and responses input_tokens re-checks use the same resolver so they are
  never stricter than the gate
- enforceApiKeyPolicy and the chat handler bind policy/model gates to the
  validated credential, so a stale Bearer cannot hide the real key's
  restrictions
- extractApiKey() is unchanged (management auth keeps allowUrl:false)

Co-authored-by: omar-nahhas <omar@innovativerealities.com>
Inspired-by: decolua/9router#2926
@github-actions

Copy link
Copy Markdown
Contributor

CI Coverage Report

  • Coverage job: skipped
  • PR test policy: success

Coverage artifact was not available for this run.

# Conflicts:
#	src/shared/utils/apiKeyPolicy.ts
@diegosouzapw
diegosouzapw merged commit eafe72e into release/v3.8.52 Oct 15, 2026
39 of 51 checks passed
diegosouzapw added a commit that referenced this pull request Oct 17, 2026
…dding routes (#16499)

GHSA-3w2r: apply the API-key connection scope (allowedConnections + quota pools, combo per-target model gate, keyless-provider #9057 rule) to the direct /v1 media, embedding, retrieval and voice-proxy routes.

Reconciled with #16492 (already on release/v3.8.52): the audio/video bridge parts were identical and merged clean; transcriptions/route.ts keeps #16492's scoped credential lookups but uses the shared keyConnectionScope helpers (the route-local isKeyScopeBlocked/keyScopeBlockedResponse duplicates are dropped) plus this PR's additions (quota-scope rejection, combo target key gate, keyless-provider gate). imageCombo keeps the release's runImageRequestWithAccounting dispatch (#14619) with this PR's scoped credential lookup. web/fetch keeps resolveClientApiKey (#16483) with this PR's scoped policy. Inventory test keeps the release's adobeFireflyEdit entry plus this PR's counts.

Boundary: the 3 new open-sse to src edges (image/speech/video combo importing @/sse/services/keyConnectionScope) are gone. The pure helpers moved to open-sse/services/comboKeyScope.ts. src/sse/services/keyConnectionScope.ts re-exports them and keeps the DB-dependent parts (resolveKeyConnectionScope, createComboTargetKeyGate, narrowConnectionScope); the route still injects the model gate as ComboKeyScope.isTargetAllowed. allowlistPermitsSyntheticNoAuth moved next to SYNTHETIC_NOAUTH_CONNECTION_ID (noAuthAllowlist.ts re-exports it). measure-boundary --ratchet: 774/774, the same count as the tip.

Validation: focused node tests 245/245 (PR tests, #16492 audio-bridge test, inventory, image/speech/video combo, noauth/allowlist, image/transcription/web-fetch route suites, new combo-key-scope-engine-module test) plus 112/112 re-run after the final base merge. Vitest autoCombo 56/56. Static checks: typecheck:core, api-typecheck and open-sse-typecheck OK; complexity, cognitive complexity, file-size and dead-code OK; changelog-integrity OK; skills generator reported Generated: 0; eslint on changed files OK; cycles ratchet 14/14.

Remaining CI reds are inherited from the base: #15306.
@diegosouzapw
diegosouzapw deleted the fix/port-pr-2926-accept-any-valid-client-credential branch October 18, 2026 23:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant