[v5] plumbing: transport/http, Contain credentials across redirects - #2358
Conversation
There was a problem hiding this comment.
🔵 Needs a closer look
The changes modify security-sensitive redirect and credential-handling behavior in the HTTP transport and warrant final human review despite strong tests.
Pull request overview
This PR hardens the v5 HTTP transport’s redirect handling to prevent credentials (and other caller-provided headers) from leaking across origin changes and to ensure URL userinfo passwords are redacted from error messages.
Changes:
- Adds origin-aware redirect processing: blocks
https -> httpdowngrades early inCheckRedirectand strips credentials/caller headers once a redirect chain crosses origin. - Introduces URL redaction helpers and a
newRequestwrapper so unparseable request-URL errors don’t echo plaintext passwords. - Expands test coverage for origin comparison, downgrade blocking-before-dial, header allowlisting/stripping behavior, and error-message redaction.
File summaries
Review details
- Files reviewed: 4/4 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
a67f60f to
1ec31ea
Compare
There was a problem hiding this comment.
🟡 Changes recommended
The new downgrade guard in checkRedirect performs case-sensitive scheme comparisons and doesn’t fail-closed when the prior URL has an empty scheme, which can undermine the intended redirect downgrade protection.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 4/4 changed files
- Comments generated: 1
- Review effort level: Lite
1ec31ea to
c4beddb
Compare
There was a problem hiding this comment.
🟡 Changes recommended
checkRedirect still validates supported schemes with a case-sensitive comparison, which can incorrectly reject valid caller-supplied URLs (e.g., Scheme "HTTPS") despite earlier logic correctly treating schemes as case-insensitive.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 4/4 changed files
- Comments generated: 1
- Review effort level: Lite
(*url.URL).String() renders the userinfo password verbatim, and it is
also what %q formats. Request URLs are built from the endpoint, so they
carry whatever credentials the caller passed in the clone URL.
Three error messages formatted a request URL raw:
- checkRedirect, under the "false" redirect policy;
- checkRedirect, on a non-initial request under the default policy,
which is reached on the upload-pack and receive-pack POST as well as
on the discovery GET;
- (*Err).Error(), reached on any response NewErr does not map to a
sentinel error, with no redirect involved.
Add redactedURL and use it at all three. It matches the helper on the
v6 branch: the password is replaced, a URL carrying only a username is
returned unchanged.
The request URL also reaches an error before any of those, without
being parsed. Endpoint.String() re-emits Endpoint.Path raw, so a path
holding a stray percent builds a string url.Parse rejects, and it
reports that string verbatim. Only http.Client redacts, and only errors
it raises itself, so the three http.NewRequest calls go through a
wrapper that redacts what url.Parse hands back. A redirect Location can
install such a path on the session, and a clone URL can carry one
directly.
Only the text of the messages changes.
Signed-off-by: Hidde Beydals <hidde@hhh.computer>
checkRedirect had no scheme comparison. The only rejection lived in ModifyEndpointIfRedirect, which runs after client.Do has followed the whole chain, so an https to http hop had already carried the request headers to a plaintext server by the time the error was built. That check also compares only the final URL against the endpoint protocol, so https -> http -> https passed through it and returned no error at all. It is never called on the pack POST, which had no scheme check of any kind. Compare the previous hop's scheme against the next one inside checkRedirect, which is the only hook that runs before a hop is sent. An upgrade from http to https is still allowed. A via entry may carry no URL. Such a hop cannot be shown not to have been https, so it is assumed to have been and a cleartext target is rejected; skipping the comparison would let an undeterminable hop turn the check off. Two tests built via from bare requests and relied on that, and now use an https target so they still reach what they cover. The message reuses the wording ModifyEndpointIfRedirect produces for the same hop, which this check now reaches first, so the text a caller sees for a direct downgrade is unchanged. It gains the target URL, redacted. Errors from CheckRedirect are wrapped by client.Do, so the error is now a *url.Error rather than a bare one. The check sits before the policy switch, as it does on the v6 branch, so a downgrade under the "false" policy reports the scheme change rather than "redirects disabled". Both block the redirect. ModifyEndpointIfRedirect is left alone. CheckRedirect is not consulted when a caller supplies a RoundTripper that follows redirects itself. Signed-off-by: Hidde Beydals <hidde@hhh.computer>
Add credentialsMayFollow, which reports whether credentials issued for one URL may be sent to another. Scheme, host and effective port must all match, except that a plain http origin may upgrade to https on the same host. A subdomain is a different origin, matching canonical git and libcurl rather than net/http, which forwards to any subdomain. An address literal is compared as netip parses it, so the spellings of one address are one origin, while a scope zone stays verbatim because net resolves it to an interface by exact name. A registered name is ASCII-lowercased and nothing else: a trailing root dot and an IPv4-mapped literal both reach the same peer, but net/http sends the host as written, so either spelling can be routed elsewhere. Ports are compared after resolving the scheme's well-known port and trimming leading zeroes. ModifyEndpointIfRedirect now clears session credentials through this predicate rather than its own host and port comparison, so the session and the per-hop stripping cannot drift apart on what an origin is. Two changes follow. A same-host http to https redirect keeps its credentials; it previously dropped them, because the endpoint's effective port moved from 80 to 443 even though the redirect improved confidentiality. Hosts differing only in case are no longer treated as separate origins. The Endpoint-shaped effectivePort is replaced rather than kept beside the URL-shaped one: its only caller was the comparison above. Signed-off-by: Hidde Beydals <hidde@hhh.computer>
net/http decides what a redirect may carry by comparing hostnames and matching a fixed list of header names, so it forwards credentials to a subdomain, ignores the port and the scheme, and does not recognise a credential an AuthMethod put in a name of its own. A PRIVATE-TOKEN or X-Api-Key header therefore reached an unrelated host. Strip credentials in CheckRedirect once the chain has left the origin of the original request. That is the only hook that runs while a redirected request's headers are still mutable; ModifyEndpointIfRedirect sees the chain after client.Do has walked all of it. What survives is an allowlist of the headers go-git sets itself, rather than a list of credential names, because caller credentials arrive under names that cannot be enumerated. It also drops the URL userinfo, which the request URL carries whenever the clone URL did. The decision is recomputed per hop, since net/http rebuilds each redirect from the original request's headers, and it is sticky: once the chain has left the origin, credentials stay gone even if a later hop returns to it. The strip runs both before and after any CheckRedirect hook the caller installed, so a hook that copies headers from the original request cannot reinstate them. The allowlist covers the five headers this branch sets. It omits Git-Protocol, Cache-Control, Transfer-Encoding and Content-Encoding, which the v6 list carries and this transport never sends. None of this reaches credentials the transport cannot see. A RoundTripper injects after the hop is decided and Client.Jar is consulted after CheckRedirect, so both are documented on NewClientWithOptions as outside the stripping, along with the RoundTripper being the supported way to authenticate to a new origin. Signed-off-by: Hidde Beydals <hidde@hhh.computer>
c4beddb to
19020e6
Compare
There was a problem hiding this comment.
🟡 Changes recommended
canonicalHost() currently cannot preserve IPv6 scope zones as intended, causing zone case to be folded and breaking the documented origin comparison behavior (and corresponding tests).
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 4/4 changed files
- Comments generated: 1
- Review effort level: Lite

The v5 counterpart of #2355, plus two defects that are v5-only and are not visible in that diff.
checkRedirecthad no scheme comparison. The only rejection lived inModifyEndpointIfRedirect, which runs afterclient.Dohas followed the whole chain, so anhttpstohttphop had already carried the request headers to a plaintext server by the time the error was built. That check also compares only the final URL against the endpoint protocol, sohttps -> http -> httpspassed through it and returned no error at all. It is called only fromadvertisedReferences, never from the pack POST, so underFollowRedirectsa downgradedgit-upload-packsent the credential and the request body in cleartext and also returned no error.Separately, three error messages formatted a request URL raw, and
(*url.URL).String()renders the userinfo password verbatim. One of them,(*Err).Error(), needs no redirect at all: it fires on any responseNewErrdoes not map to a sentinel, and request URLs are built from the endpoint, which carries whatever credentials the clone URL did.How hosts compare
Registered names fold on ASCII case only. IP literals go through
netip.ParseAddr, the same callnet's resolver uses to tell a literal from a name, so[::1]and[0:0:0:0:0:0:0:1]are one origin. Everything else is a different origin, including a trailing root dot and an IPv4-mapped literal against the IPv4 it dials. Both of those reach the same peer, butnet/httpsends the host as written inHost, so a server can route the two spellings to different virtual hosts. Same peer is not same authority.No IDNA mapping is applied. It would only widen the equality, and go-git pins
x/netingo.modwhilenet/httpuses the copy vendored into the toolchain. The two are versioned separately, so a release that moved the Unicode tables under one and not the other would have the comparison merge originsnet/httpstill dials apart.ModifyEndpointIfRedirectnow clears session credentials through the same predicate rather than its own host and port comparison, so the per-hop stripping and the session cannot drift apart on what an origin is. The two stay deliberately asymmetric in one respect, documented at the call site: the strip is sticky over the whole chain, while the session compares only the final URL, so anorigin -> elsewhere -> originchain can leave the discovery GET anonymous while the session's POSTs are authenticated.Behaviour change
When a redirect leaves the repository's origin, only the headers the transport sets itself are carried over. Headers added by an
AuthMethodare not, including non-credential ones such as a trace or tenant header. There is deliberately no opt-out: a caller who needs to authenticate to the origin a redirect moved the repository to can inject the credential from aRoundTripperon the*http.Clientpassed toNewClientWithOptions, scoped to that origin. The transport keeps its ownCheckRedirecton the client it copies, so the origin checks still apply.NewClientWithOptionsnow documents this, along withClient.Jar, whichnet/httpconsults afterCheckRedirectand which is therefore outside the stripping too.Redirect chains that pass through plaintext
httpnow error wherehttps -> http -> httpsandhttp -> https -> httppreviously completed silently. A cross-origin redirect to a private repository now fails at the discovery GET; on servers that hide private repositories this surfaces asrepository not found, and the error does not mention the redirect.Two things move the other way. Credentials now survive a same-host
httptohttpsupgrade, which previously dropped them because the endpoint's effective port moved from 80 to 443. Hosts differing only in ASCII case are no longer separate origins. Neither is a security fix; the credential was already spent on the first plaintext request in the upgrade case.No exported identifier is added, removed, renamed or re-signatured, and
AuthMethod's method set is untouched, so this is patch-release safe.Deliberate deviations
Git does not carry
credentialauthentication across a host, port or scheme change, and neither does this. Buthttp.extraHeader, which is what anAuthMethod-supplied header amounts to, is forwarded by Git to arbitrary hosts across a redirect. go-git now drops it.A unicode hostname and its punycode are different origins here, so a redirect that only respells the host loses the credential. Git and curl keep it, because they convert to punycode when parsing the URL rather than when comparing. Normalising at parse time is the better fix, but it changes
Endpoint.Host, error text and whatremote.Config().URLsround-trips, so it does not belong here.Allowing credentials across an http to https upgrade goes the other way, and departs from curl, Git and Fetch, which all count scheme as part of host identity. Auth is sent pre-emptively, so an http origin has already spent its credential in cleartext before any redirect exists.
Where this branch differs from #2355, in each case deliberately:
ModifyEndpointIfRedirectalready produced for the same hop, so the text a caller sees for a direct downgrade is unchanged. It gains the target URL, redacted. Because the error now comes fromCheckRedirectit is wrapped bynet/httpin a*url.Error, so equality comparisons against the old string break while substring matches still work.Git-Protocol,Cache-Control,Transfer-EncodingorContent-Encoding.http redirect:prefix.ModifyEndpointIfRedirectis kept. Its scheme branch is now unreachable throughCheckRedirect, but it is the only thing that clears the session credential, and it still covers a caller-suppliedRoundTripperthat follows redirects itself — which bypassesCheckRedirect, and therefore all of the stripping.Request URLs also reach an error before any of this, without being parsed:
Endpoint.String()re-emitsEndpoint.Pathraw, so a path holding a stray percent builds a stringurl.Parserejects and reports verbatim. Onlyhttp.Clientredacts, and only errors it raises itself, so the threehttp.NewRequestcalls go through a wrapper that redacts whaturl.Parsehands back. A redirectLocationcan install such a path on the session, and a clone URL can carry one directly.Tests
The origin comparison, including hosts where Unicode case-folding would merge two names that resolve to different servers; the redaction helpers and all six sites that reach them; redirects within and across an origin; multi-hop chains and the sticky decision; interaction with a caller-supplied
CheckRedirect; the header allowlist pinned by membership so widening it has to be deliberate; and a redirectedgit-upload-packPOST on both sides of the origin boundary underRedirectPolicy: "true", where a 307 preserves method and body.The downgrade is covered end to end with split
DialContextandDialTLSContext, so the assertion is that the plaintext listener was never dialled rather than that the URL looked wrong.