fix(proxy): keep static credentials a resolver did not answer for - #64
Conversation
A host may carry both a credential resolver and a static credential on different headers, each serving a different client. On api.anthropic.com a token exchange resolves Claude Code's subscription onto Authorization while an application's API key sits on x-api-key. getCredentialsForRequest returned the resolver's credentials alone whenever it resolved anything, so the static credential never reached injectCredentials. The application's placeholder went upstream unreplaced and Anthropic rejected it as an invalid x-api-key, while the proxy logged a successful injection of the other header. Static credentials whose header the resolver did not answer for are now appended to its result. One sharing the resolver's header stays dropped, so api.github.com keeps its behaviour: the token exchange and the GitHub App key both target Authorization, and the exchange must keep winning it. injectCredentials then selects per header, which is what it already documents. Verified end to end against a running proxy: with a resolver on Authorization and a static key on x-api-key, a client sending an x-api-key placeholder now receives the static key and no bearer, and a client sending an Authorization placeholder receives the bearer and no key. Before the change the first client got the bearer plus its own untouched placeholder.
📦 Previous Review (superseded by newer review)Claude finished @andybons's task in 6m 30s —— View job Code review — PR #64
SummaryThe fix itself is correct and well-targeted: Three inline comments posted:
Nothing here blocks merging; #1 is a quick fix, #2 is worth a maintainer decision (and possibly a test either way), #3 is optional. |
Merging every unanswered static credential into a resolver's result fed them to injectCredentials' auto-inject fallback as well, not just its placeholder selection. A request carrying none of their headers would then collect every distinct-header credential for the host, attaching a resolver's per-user token — a Claude Code subscription, say — to a request that never asked for it. The merge now covers only headers the client actually sent, so a request with no placeholder still sees the resolver's credential alone, exactly as before. Placeholder selection is unchanged, which is the case this whole change exists to serve. getCredentialsForRequest's own docstring described the pre-fix exclusive win and now describes what it does. Reported by claude[bot] on the pull request.
📦 Previous Review (superseded by newer review)Claude finished @andybons's task in 9m 11s —— View job Code review: PR #64
Summary
The third comment (double Two new findings from this pass, posted inline:
Neither finding is a security leak or a regression on the PR's stated goal — both are about the blast radius/precision of side effects introduced by allowing resolver and static credentials to coexist in one request. Core logic (placeholder selection, header-scoped merge, |
A resolver may strip headers as a side effect, so reading the client's headers afterwards can drop a static credential whose header collides with a resolver's subject_header. Reading them before the resolver instead was measured and is worse: the stripped header leaves injectCredentials with no client header to select on, so it takes the auto-inject path and attaches both credentials — handing out the resolver's per-user token on a request that asked for neither. Losing a credential to a config collision beats that. Reported by claude[bot] on the pull request.
📦 Previous Review (superseded by newer review)Claude finished @andybons's task in 10m 14s —— View job Code review — PR #64 (re-review after push)
Status of previously open threads
New findings from this pass
Nothing here blocks merging on its own; both are edge cases in an extension point (custom resolvers) and an underused path (named relay), not the primary CONNECT/plain-HTTP flow this PR is fixing. The fix itself — |
A request carrying several placeholders injects one credential per header, so a single 401 evicts all of them. Merging static credentials into a resolver's result lets that pair a resolver-backed token with a static key, where before a successful resolver excluded statics entirely. Recorded as a deliberate tradeoff rather than narrowed: upstream does not say which credential it rejected, so evicting only some risks leaving a revoked one cached and failing every later request, against a cost of one extra resolve. Reported by claude[bot] on the pull request.
📦 Previous Review (superseded by newer review)Claude finished @andybons's task in 1m 28s —— View job Code review — PR #64 (re-review after push)
SummaryBoth findings still open from the last round were resolved by documenting them as deliberate, measured tradeoffs rather than by changing behavior — and in both cases the reasoning holds up:
Both are doc-only changes ( One cosmetic nit, not blocking: proxy/proxy.go:1658 — // than one extra resolve.
//
// A 401 or 403 is the
// only signal gatekeeper gets that a credential resolved from a cache has gone
// stale — ...
No new functional issues found. This PR looks ready to merge from a correctness/security standpoint. |
A resolver is handed the request and may mutate it, and the two directions of mutation break the merge in opposite ways. Reading the client's headers only after the resolver ran let a resolver that SETS a header make a static credential look requested, so the merge attached a credential the client never asked for. Reading them only before let a resolver that STRIPS one leave injectCredentials no client header to select on, taking the auto-inject path and attaching every credential for the host. A header now counts as asked for when the client sent it and the resolver left it in place, which is safe under both mutations. The built-in token-exchange resolver only strips, but CredentialResolver is an exported extension point and nothing constrains an implementation to that. Reported by claude[bot] on the pull request.
|
Claude finished @andybons's task in 2m 13s —— View job Re-reviewing PR #64 after latest push
SummaryCommit The fix:
I traced this against all three production call sites ( The two remaining threads (auto-inject fan-out scoping, and eviction blast radius on No new issues found in this pass. |
Release entry for #64, which shipped without one. The v0.22.0 tag moves to this commit so the tagged tree carries its own entry, matching every prior release.
The problem
A host may carry both a credential resolver and a static credential on different headers, each serving a different client. On
api.anthropic.coma token exchange resolves Claude Code's subscription ontoAuthorizationwhile an application's API key sits onx-api-key.getCredentialsForRequestreturned the resolver's credentials alone whenever it resolved anything, so the static credential never reachedinjectCredentials. The application's placeholder went upstream unreplaced, and Anthropic rejected it asinvalid x-api-keywhile the proxy's own log recorded a successful injection — of the other header:The change
When a resolver returns credentials, static credentials for the host are merged in — but only those the client asked for, meaning the client sent the credential's header and the resolver left it in place.
injectCredentialsthen selects per header, which is what it already documents.Each part of that condition earns its keep, and the review shaped all three (thanks @claude):
api.github.comkeeps its behaviour — the token exchange and the GitHub App key both targetAuthorization, and the exchange must keep winning it.injectCredentials' auto-inject fallback: a request carrying neither header would collect every distinct-header credential, attaching the resolver's per-user token to a request that never asked for it.injectCredentialsno client header to select on (auto-inject attaches both), while one that sets a header makes a static look requested (attached unasked). Requiring the header both before and after the resolver is safe under either mutation.The eviction path is unchanged: a rejected request that carried several placeholders still evicts every injected credential, since upstream does not say which one it rejected — now recorded as a deliberate tradeoff beside the existing scoping comment.
Verification
End to end against the running binary — a fake upstream echoing the auth headers it received, a fake STS for the token exchange, patched and unpatched instances on the same config:
x-api-key: <placeholder>authorization: Bearer SUBSCRIPTION-BEARERx-api-key: <placeholder>x-api-key: real-api-keyonlyAuthorization: <placeholder>authorization: Bearer SUBSCRIPTION-BEARERauthorization: Bearer SUBSCRIPTION-BEARERThe first unpatched row reproduces the production failure exactly.
Tests in
proxy/resolver_static_merge_test.go, each red-verified against the code it guards:TestProxy_ResolverAndStaticOnDifferentHeaders— fails on unpatched code withx-api-key = "placeholder".TestProxy_ResolverStillWinsItsOwnHeader— passes on unpatched code, deliberately: it guards against this fix going too far.TestProxy_NoPlaceholderDoesNotFanOutCredentials— fails against the unconditional merge.TestProxy_ResolverMutationDoesNotChangeWhatWasAskedFor— strip and set subtests; the set case fails against the post-resolve-only check.go test ./...passes across all packages.Noted while working, not addressed here
isValidHostrejects any credential host containing:, and config load never checks it — sohost: api.example.com:8443loads clean, deploys, and injects nothing, with only a Debug log. Happy to send a config-load guard as a separate change.