[v5] plumbing: transport/http, Contain credentials across redirects by hiddeco · Pull Request #2358 · go-git/go-git · GitHub
Skip to content

[v5] plumbing: transport/http, Contain credentials across redirects - #2358

Merged
pjbgf merged 4 commits into
releases/v5.xfrom
fix/v5-redirect-hardening
Sep 4, 2026
Merged

[v5] plumbing: transport/http, Contain credentials across redirects#2358
pjbgf merged 4 commits into
releases/v5.xfrom
fix/v5-redirect-hardening

Conversation

@hiddeco

@hiddeco hiddeco commented Sep 3, 2026

Copy link
Copy Markdown
Member

The v5 counterpart of #2355, plus two defects that are v5-only and are not visible in that diff.


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 called only from advertisedReferences, never from the pack POST, so under FollowRedirects a downgraded git-upload-pack sent 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 response NewErr does 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 call net'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, but net/http sends the host as written in Host, 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/net in go.mod while net/http uses 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 origins net/http still dials apart.

ModifyEndpointIfRedirect now 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 an origin -> elsewhere -> origin chain 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 AuthMethod are 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 a RoundTripper on the *http.Client passed to NewClientWithOptions, scoped to that origin. The transport keeps its own CheckRedirect on the client it copies, so the origin checks still apply. NewClientWithOptions now documents this, along with Client.Jar, which net/http consults after CheckRedirect and which is therefore outside the stripping too.

Redirect chains that pass through plaintext http now error where https -> http -> https and http -> https -> http previously completed silently. A cross-origin redirect to a private repository now fails at the discovery GET; on servers that hide private repositories this surfaces as repository not found, and the error does not mention the redirect.

Two things move the other way. Credentials now survive a same-host http to https upgrade, 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 credential authentication across a host, port or scheme change, and neither does this. But http.extraHeader, which is what an AuthMethod-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 what remote.Config().URLs round-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:

  • The downgrade guard reuses the wording ModifyEndpointIfRedirect already 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 from CheckRedirect it is wrapped by net/http in a *url.Error, so equality comparisons against the old string break while substring matches still work.
  • The header allowlist has five entries rather than nine. This transport never sends Git-Protocol, Cache-Control, Transfer-Encoding or Content-Encoding.
  • Error messages keep the existing http redirect: prefix.
  • ModifyEndpointIfRedirect is kept. Its scheme branch is now unreachable through CheckRedirect, but it is the only thing that clears the session credential, and it still covers a caller-supplied RoundTripper that follows redirects itself — which bypasses CheckRedirect, and therefore all of the stripping.
  • Redaction replaces the password and leaves a username-only URL alone, matching plumbing: transport/http, Compare origins when following redirects #2355.

Request URLs also reach an error before any of this, without being parsed: Endpoint.String() re-emits Endpoint.Path raw, so a path holding a stray percent builds a string url.Parse rejects and reports 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.

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 redirected git-upload-pack POST on both sides of the origin boundary under RedirectPolicy: "true", where a 307 preserves method and body.

The downgrade is covered end to end with split DialContext and DialTLSContext, so the assertion is that the plaintext listener was never dialled rather than that the URL looked wrong.

Copilot AI lite review requested due to automatic review settings September 3, 2026 13:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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 -> http downgrades early in CheckRedirect and strips credentials/caller headers once a redirect chain crosses origin.
  • Introduces URL redaction helpers and a newRequest wrapper 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
File Description
plumbing/transport/http/common.go Implements origin comparison, redirect-time stripping, scheme downgrade blocking, and URL redaction/newRequest wrapper.
plumbing/transport/http/upload_pack.go Switches request construction to newRequest to ensure redaction on parse errors.
plumbing/transport/http/receive_pack.go Switches request construction to newRequest to ensure redaction on parse errors.
plumbing/transport/http/common_test.go Adds comprehensive tests for origin equivalence, redirect stripping semantics, downgrade prevention, and redaction behavior.
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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

Comment thread plumbing/transport/http/common.go

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

Comment thread plumbing/transport/http/common.go
(*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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

Comment thread plumbing/transport/http/common.go

@pjbgf pjbgf left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@hiddeco thanks for working on this. 🙇

@pjbgf
pjbgf merged commit 093eb63 into releases/v5.x Sep 4, 2026
5 of 12 checks passed
@pjbgf
pjbgf deleted the fix/v5-redirect-hardening branch September 4, 2026 22:08
@pjbgf

pjbgf commented Sep 4, 2026

Copy link
Copy Markdown
Member

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants