Skip to content

fix(proxy): select one credential per header name, independent of order#41

Merged
andybons merged 1 commit into
mainfrom
fix/inject-credentials-placeholder-selection
Jul 9, 2026
Merged

fix(proxy): select one credential per header name, independent of order#41
andybons merged 1 commit into
mainfrom
fix/inject-credentials-placeholder-selection

Conversation

@andybons

@andybons andybons commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #40.

The bug

injectCredentials' placeholder pass tested req.Header.Get(c.Name) after earlier iterations had already written that header:

for _, c := range creds {
    if req.Header.Get(c.Name) != "" {   // re-reads what we just wrote
        req.Header.Set(c.Name, c.Value)

So when several credentials for a host share a header name and the client sends a placeholder for it, every same-named credential passes the "client sent this header" check. Reproduced before fixing, with [{claude, Authorization}, {github-user, Authorization}] and a client placeholder:

on the wire: "Bearer github"
Grants:      [claude github-user]

Three consequences

  1. The wrong credential reached the destination. The last credential in config order won, regardless of which grant the placeholder meant to select — contradicting the function's own contract ("This lets the client choose which credential to use"). Config order is implicit; no YAML author knows it is significant.
  2. Grants over-reported. The canonical log line named every same-named credential as injected. That is an audit record asserting a credential was used that never left the proxy.
  3. Injected over-reported. Since v0.15.0 it drives cache invalidation on a 401/403, so a rejected request evicted cache entries for credentials the destination never saw. Because eviction is cooldown-gated per key, that could also suppress a bystander's own legitimate eviction later.

The fix

Sample the client's headers once, before anything is written, then choose exactly one credential per header name. The winner is decided by grant, not by position, so config order no longer matters.

Behavior change. The two selection paths break the tie in opposite directions:

path tie-break rationale
auto-injection (no placeholder) prefer non-claude unchanged; claude is OAuth and must not be injected transparently
placeholder selection prefer claude a client that explicitly sends the header is asking for exactly it

That asymmetry is what the original doc comment already described — "claude ... should only be injected when Claude Code explicitly sends a placeholder" — but the code could not honor it: with a shared header name, a placeholder previously selected whichever credential happened to be last.

In practice hosts.go registers claude and anthropic for api.anthropic.com with different header names (Authorization vs x-api-key), which is why this stayed latent.

Testing

Test-first; every test below was confirmed failing before the fix. The red state showed both bugs directly — Grants = [claude other third], want [claude] and wire Authorization = "Bearer other", want "Bearer claude".

New proxy/inject_credentials_test.go covers: placeholder prefers claude; the winner is independent of slice order; a credential whose header the client never sent is not selected by a same-named injection (the feedback loop); auto-inject still prefers non-claude; distinct header names all auto-inject; a placeholder suppresses auto-inject; Grants never over-reports; empty creds.

TestIntercept_AuthFailureInvalidatesOnlyWireCredentialWithPlaceholder previously pinned the last-writer behavior and now asserts the tie-break — that expectation change is deliberate, and is the only existing test affected.

go vet ./... clean, go test -race ./... green.

🤖 Generated with Claude Code

injectCredentials' placeholder pass tested req.Header.Get(c.Name) after
earlier iterations had already written that header. When several
credentials for a host shared a header name and the client sent a
placeholder for it, every same-named credential passed the "client sent
this header" check.

Three consequences:

  - The last credential in config order silently won the wire, regardless
    of which grant the client's placeholder meant to select.
  - Grants recorded every same-named credential as injected, so the
    canonical log line named credentials that never left the proxy.
  - Injected, which drives cache invalidation on a 401/403, listed
    credentials the destination never saw.

Sample the client's headers once, before any injection, and choose
exactly one credential per header name. Both selection paths now break
ties by grant rather than by position: auto-injection prefers a
non-"claude" grant, and placeholder selection prefers "claude". The
claude grant is Claude Code's OAuth flow, so it must not be injected
transparently, but a client that explicitly sends the header is asking
for exactly it -- which the old code could not reliably honor.

TestIntercept_AuthFailureInvalidatesOnlyWireCredentialWithPlaceholder
previously pinned the last-writer behavior; it now asserts the tie-break.

Fixes #40.
@claude

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Claude finished @andybons's task in 2m 41s —— View job


Claude's Review

Tasks

  • Gather context (PR description, changed files)
  • Fetch diff against origin/main
  • Review proxy/proxy.go credential injection logic
  • Review proxy/inject_credentials_test.go
  • Review proxy/intercept_test.go changes
  • Review CHANGELOG.md
  • Trace all call sites (proxy.go:1927, proxy.go:2396, relay.go:114)
  • Post final summary

Summary

This is a solid, well-scoped fix. I traced the core logic in injectCredentials (proxy/proxy.go:1109-1184) and all three call sites (proxy.go:1927, proxy.go:2396, relay.go:114).

Correctness verified:

  • clientSent is sampled once from req.Header before any write, closing the re-read bug described in the PR. At every call site, injectCredentials runs immediately after headers are copied from the client and before mergeExtraHeaders/header stripping, so clientSent reflects only what the client actually sent — no false positives from proxy-added headers.
  • selectWinners's tie-break is genuinely order-independent: I traced both the 2-credential and 3-credential (claude/other/third) cases by hand against TestInjectCredentials_GrantsNeverOverReport and TestInjectCredentials_PlaceholderWinnerIndependentOfOrder — the incumbent/challenger swap logic only fires when it improves the preferred direction (toward claude for placeholder, away from claude for auto-inject), so a 3-way tie among non-claude grants deterministically keeps the first one, matching pre-existing behavior for that case.
  • Grants/Injected are now built strictly from the winners map, so they can never name a credential that lost a tie — the audit/eviction over-reporting bug is fixed.
  • The TestIntercept_AuthFailureInvalidatesOnlyWireCredentialWithPlaceholder rewrite correctly flips the expected invalidated grant from github-user (last writer) to claude (tie-break winner), and the surrounding comment explains why via the cooldown-gated eviction risk.
  • Confirmed TestInjectCredentials_PlaceholderSuppressesAutoInject documents genuinely pre-existing behavior (auto-inject pass only runs when zero placeholders matched at all) — this PR doesn't touch that, it's orthogonal to the header-selection bug being fixed.
  • No credential values are logged anywhere in the new code — slog.Debug calls only pass grant, host, header, method, path, consistent with AGENTS.md's logging rule.

No bugs found. I don't have inline findings to raise. The doc comments on injectCredentials accurately describe the implementation, and the CHANGELOG entry accurately describes the bug, the three consequences, and the deliberate behavior change.

Note: I was unable to execute go build ./... / go vet ./... / go test -race ./... in this environment — the tool permission mode blocked those commands (only read-only git/file operations were allowed). The PR description states these were run and passed; you may want to confirm the CI check run turned green.
· Branch: fix/inject-credentials-placeholder-selection

@andybons andybons merged commit 1ba4429 into main Jul 9, 2026
2 checks passed
andybons added a commit that referenced this pull request Jul 9, 2026
Fix-only release: the injectCredentials placeholder-selection and
grant-over-reporting fix from #41.
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.

injectCredentials: placeholder re-read lets the wrong credential win the wire and over-reports grants

1 participant