Operator-set target headers: credential headers survive a cross-host redirect, Trailer is not reserved, and an invalid header name error can quote a secret #233
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Three findings from the review of #229, which shipped the first way to configure per-target request headers. None blocked that merge; all three are small and belong to the feature it introduced.
Milestoned to
1.0.0because the first item can leak an operator credential to a third party, and the feature that creates the surface is itself part of 1.0.1. Credential headers survive a cross-host redirect.
internal/delivery/engine.go:169builds the delivery client with noCheckRedirect. Go's default stripsAuthorizationandCookieon a cross-host redirect but forwards everything else — soX-Api-Key,PRIVATE-TOKEN,X-Auth-Tokenand friends follow a 302 to a host the operator never configured. The client predates #229, but before it there was no way to set such a header at all, so the surface is new.Exploitability is low: it needs an open redirect at a destination the operator already trusted, and the dial-time SSRF guard still blocks private hosts. It is a real credential egress path nonetheless.
This one needs a DECISION, not a reflex.
CheckRedirect: http.ErrUseLastResponsestops following redirects entirely, which is a behaviour change for any destination that legitimately redirects. The alternative is to follow but strip configured headers on a cross-host hop. Pick one, say why in the PR, and document it.2.
Traileris missing fromisReservedTargetHeader(internal/delivery/target_headers.go:59-71). Go'sreqWriteExcludeHeader(net/http/request.go:99-105) dropsTrailerfrom the written request, so an operator can set it, it is accepted and stored, and it provably never reaches the wire — exactly the silently-ignored-header failure the rejection list exists to prevent. The other four names plusUser-Agentwere verified correct againstapplyRequestHeaders, andContent-Typeis correctly NOT reserved becausecfg.Headersis applied after it and wins. One-line fix.3. A stated safety property does not hold.
internal/delivery/target_headers.go:113-124— the invalid-header-name error quotesrawName, which is the text before the FIRST colon. For inputX-Auth-Token abc:def,rawNameisX-Auth-Token abcand the 400 body rendersheader name must be a valid HTTP token: "X-Auth-Token abc".TestParseTargetHeaders_ErrorsNeverQuoteAValuemisses it because it puts the secret AFTER the colon.Exposure is negligible — the operator's own submission, in their own browser, not logged, stored or sent to Sentry. But "no error quotes a header value" is an asserted invariant with a test named after it, and the invariant is false. Fix the error or fix the claim.
Definition of done:
Trailerrejected, with a test asserting aTrailer:line is refusedTestParseTargetHeaders_ErrorsNeverQuoteAValueis extended to the before-the-colon case so it would have caught this