Keep a credential off a redirect¶
A request authenticated with a custom header — PRIVATE-TOKEN, X-Api-Key — will forward
that header to wherever a redirect points, because the standard library only strips the
six headers it knows about. This is how an API token ends up in a request to an
object-storage host, or to an attacker's host reached through an open redirect on a trusted
one.
Which mechanism protects you depends on how the credential is attached.
Strip a header you set yourself¶
Name the header with WithSensitiveHeaders:
client := httpclient.NewClient(
httpclient.WithSensitiveHeaders("PRIVATE-TOKEN"),
)
req, err := http.NewRequest(http.MethodGet, "https://gitlab.example.com/api/v4/…", nil)
req.Header.Set("PRIVATE-TOKEN", token)
resp, err := client.Do(req)
On any redirect that leaves the origin of the initial request, the named headers are
removed before the hop is followed, and the hop proceeds unauthenticated — which is exactly
what a pre-signed object-storage URL expects. Redirects that stay on the same scheme, host
and port keep the header, so an internal /download → /asset redirect still works.
Name every credential header the client might send; the option accumulates across calls:
Names are matched case-insensitively. You do not need to name Authorization,
Www-Authenticate, Cookie or Cookie2 — net/http already strips those on a cross-host
redirect.
Protect a credential injected by middleware¶
WithBearerToken and WithBasicAuth from go/transit set their header inside the
transport, below the redirect policy, so they are re-applied on every hop and
WithSensitiveHeaders cannot help. They pin to a host instead. Supply that host
explicitly:
httpclient.WithClientMiddleware(transithttp.NewClientChain(
transithttp.WithBearerToken(token, "api.example.com"),
))
A request whose host does not match the pin has its credential withheld and a warning
logged naming both hosts. Calling WithBearerToken(token) without a host is deprecated: it
pins to the first host the client happens to address, which is order-dependent and depends
on which request runs first.
Refuse redirects altogether¶
Where a downstream should never redirect, refusing is stronger than stripping:
Every redirect then fails the call with stopped after 0 redirects wrapped in a
*url.Error. The 3xx response is returned alongside the error with its body already
closed, so you can read its status and Location but not its body.
Check it in a test¶
The behaviour is worth asserting where a credential is involved. Point a test server at a second one on a different port and assert the second never sees the header:
var seen string
storage := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = r.Header.Get("PRIVATE-TOKEN")
}))
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, storage.URL+"/asset", http.StatusFound)
}))
client := httpclient.NewClient(httpclient.WithSensitiveHeaders("PRIVATE-TOKEN"))
// … issue the request with the header set …
assert.Empty(t, seen)
Two httptest servers always differ by port, which is enough: the origin comparison
includes the port, and no default-port normalisation is applied.
Why this is opt-in¶
Stripping is not on by default because a client that legitimately follows authenticated redirects inside one trust domain would break, and the module cannot tell the two cases apart. The reasoning, and what the origin comparison does and does not treat as the same place, is in hardened defaults.