Skip to content

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:

httpclient.WithSensitiveHeaders("PRIVATE-TOKEN", "X-Api-Key", "X-Auth-Token")

Names are matched case-insensitively. You do not need to name Authorization, Www-Authenticate, Cookie or Cookie2net/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:

client := httpclient.NewClient(httpclient.WithMaxRedirects(0))

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.