Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions registry/remote/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"io"
"mime"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
Expand Down Expand Up @@ -978,6 +979,25 @@ func (s *blobStore) Push(ctx context.Context, expected ocispec.Descriptor, conte
return s.completePushAfterInitialPost(ctx, req, resp, expected, content)
}

// sameUploadHost reports whether location and reqURL refer to the same host,
// normalizing implicit default ports (80 for http, 443 for https) so that
// e.g. "example.com" and "example.com:443" compare equal over HTTPS.
func sameUploadHost(location, reqURL *url.URL) bool {
if location.Hostname() != reqURL.Hostname() {
return false
}
canonicalPort := func(u *url.URL) string {
if p := u.Port(); p != "" {
return p
}
if u.Scheme == "https" {
return "443"
}
return "80"
}
return canonicalPort(location) == canonicalPort(reqURL)
}

// completePushAfterInitialPost implements step 2 of the push protocol. This can be invoked either by
// Push or by Mount when the receiving repository does not implement the
// mount endpoint.
Expand All @@ -1000,6 +1020,15 @@ func (s *blobStore) completePushAfterInitialPost(ctx context.Context, req *http.
if reqPort == "443" && locationHostname == reqHostname && locationPort == "" {
location.Host = locationHostname + ":" + reqPort
}
// Validate the Location stays on the same host to prevent credentials from
// being forwarded to an attacker-controlled endpoint.
// Reference: https://github.com/oras-project/oras-go/security/advisories/GHSA-jxpm-75mh-9fp7
if !sameUploadHost(location, req.URL) {
return fmt.Errorf("blob upload Location %q is on a different host than the registry %q", location.Host, req.URL.Host)
}
if req.URL.Scheme == "https" && location.Scheme != "https" {
return fmt.Errorf("blob upload Location %q downgrades scheme from https", location.Host)
}
url := location.String()
req, err = http.NewRequestWithContext(ctx, http.MethodPut, url, content)
if err != nil {
Expand Down
52 changes: 52 additions & 0 deletions registry/remote/repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3111,6 +3111,58 @@ func Test_BlobStore_Push(t *testing.T) {
}
}

func Test_BlobStore_Push_CrossHostRedirect(t *testing.T) {
blob := []byte("hello world")
blobDesc := ocispec.Descriptor{
MediaType: "test",
Digest: digest.FromBytes(blob),
Size: int64(len(blob)),
}

// attacker server: records whether it receives an Authorization header
credentialLeaked := false
attacker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "" {
credentialLeaked = true
}
w.WriteHeader(http.StatusCreated)
}))
defer attacker.Close()

// malicious registry: returns a cross-host Location on POST
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && r.URL.Path == "/v2/test/blobs/uploads/" {
w.Header().Set("Location", attacker.URL+"/v2/test/blobs/uploads/evil-uuid")
w.WriteHeader(http.StatusAccepted)
return
}
w.WriteHeader(http.StatusForbidden)
t.Errorf("unexpected access: %s %s", r.Method, r.URL)
}))
defer ts.Close()

uri, err := url.Parse(ts.URL)
if err != nil {
t.Fatalf("invalid test http server: %v", err)
}

repo, err := NewRepository(uri.Host + "/test")
if err != nil {
t.Fatalf("NewRepository() error = %v", err)
}
repo.PlainHTTP = true
store := repo.Blobs()
ctx := context.Background()

err = store.Push(ctx, blobDesc, bytes.NewReader(blob))
if err == nil {
t.Error("Blobs.Push() expected error on cross-host Location, got nil")
}
if credentialLeaked {
t.Error("Blobs.Push() forwarded Authorization header to attacker-controlled host")
}
}

func Test_BlobStore_Exists(t *testing.T) {
blob := []byte("hello world")
blobDesc := ocispec.Descriptor{
Expand Down
Loading