fix(proxy): select one credential per header name, independent of order#41
Conversation
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 finished @andybons's task in 2m 41s —— View job Claude's ReviewTasks
SummaryThis is a solid, well-scoped fix. I traced the core logic in Correctness verified:
No bugs found. I don't have inline findings to raise. The doc comments on Note: I was unable to execute |
Fix-only release: the injectCredentials placeholder-selection and grant-over-reporting fix from #41.
Fixes #40.
The bug
injectCredentials' placeholder pass testedreq.Header.Get(c.Name)after earlier iterations had already written that header: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:Three consequences
Grantsover-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.Injectedover-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:
claudeclaudeThat 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.goregistersclaudeandanthropicforapi.anthropic.comwith different header names (Authorizationvsx-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]andwire Authorization = "Bearer other", want "Bearer claude".New
proxy/inject_credentials_test.gocovers: 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;Grantsnever over-reports; empty creds.TestIntercept_AuthFailureInvalidatesOnlyWireCredentialWithPlaceholderpreviously 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