detached aa2bfce

fix(security): pin registry Location headers to original origin

Reject cross-origin Location redirects in the native registry push
client. Previously, resolveLocation() blindly followed any Location
header, allowing a malicious registry to redirect blob uploads to an
attacker-controlled host while credentials were still attached.
commit
aa2bfce5557600d4537cbd0e513c9a7c3407c0f5
author
brice · · 2026-03-14 22:09 UTC
parent
103b727
signature
unsigned
2 files · +35 -1
M pkg/registrypush/push.go +7 -1
@@ -502,7 +502,13 @@ func resolveLocation(baseURL, location string) (string, error) {
502502 return "", err
503503 }
504504
505- return base.ResolveReference(ref).String(), nil
505+ resolved := base.ResolveReference(ref)
506+
507+ if resolved.Scheme != base.Scheme || resolved.Host != base.Host {
508+ return "", fmt.Errorf("registry returned cross-origin Location %q (expected %s://%s)", location, base.Scheme, base.Host)
509+ }
510+
511+ return resolved.String(), nil
506512 }
507513
508514 func chunkCount(size, chunkSize int64) int64 {
@@ -502,7 +502,13 @@ func resolveLocation(baseURL, location string) (string, error) {
502 return "", err502 return "", err
503 }503 }
504 504
505- return base.ResolveReference(ref).String(), nil505+ resolved := base.ResolveReference(ref)
506+
507+ if resolved.Scheme != base.Scheme || resolved.Host != base.Host {
508+ return "", fmt.Errorf("registry returned cross-origin Location %q (expected %s://%s)", location, base.Scheme, base.Host)
509+ }
510+
511+ return resolved.String(), nil
506 }512 }
507 513
508 func chunkCount(size, chunkSize int64) int64 {514 func chunkCount(size, chunkSize int64) int64 {
M pkg/registrypush/push_test.go +28 -0
@@ -256,6 +256,34 @@ func TestPusher_WithInsecureTLS_AllowsSelfSignedCertificate(t *testing.T) {
256256 require.NoError(t, err)
257257 }
258258
259+func TestUploadBlob_RejectsCrossOriginRedirect(t *testing.T) {
260+ attacker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
261+ t.Errorf("request leaked to attacker: %s %s (Authorization: %s)", r.Method, r.URL.Path, r.Header.Get("Authorization"))
262+ w.WriteHeader(http.StatusOK)
263+ }))
264+ defer attacker.Close()
265+
266+ registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
267+ switch {
268+ case r.Method == http.MethodHead:
269+ w.WriteHeader(http.StatusNotFound)
270+ case r.Method == http.MethodPost:
271+ w.Header().Set("Location", attacker.URL+"/v2/repo/blobs/uploads/evil")
272+ w.WriteHeader(http.StatusAccepted)
273+ default:
274+ w.WriteHeader(http.StatusInternalServerError)
275+ }
276+ }))
277+ defer registry.Close()
278+
279+ p := registrypush.New(registrypush.WithChunkSize(1024))
280+ ctx := context.Background()
281+ err := p.UploadBlob(ctx, registry.URL, "test/repo", "sha256:abc", 5, strings.NewReader("hello"), "Basic secret")
282+
283+ require.Error(t, err)
284+ assert.Contains(t, err.Error(), "cross-origin")
285+}
286+
259287 type fakeBlobUploadRegistry struct {
260288 t *testing.T
261289 expectedAuth string
@@ -256,6 +256,34 @@ func TestPusher_WithInsecureTLS_AllowsSelfSignedCertificate(t *testing.T) {
256 require.NoError(t, err)256 require.NoError(t, err)
257 }257 }
258 258
259+func TestUploadBlob_RejectsCrossOriginRedirect(t *testing.T) {
260+ attacker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
261+ t.Errorf("request leaked to attacker: %s %s (Authorization: %s)", r.Method, r.URL.Path, r.Header.Get("Authorization"))
262+ w.WriteHeader(http.StatusOK)
263+ }))
264+ defer attacker.Close()
265+
266+ registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
267+ switch {
268+ case r.Method == http.MethodHead:
269+ w.WriteHeader(http.StatusNotFound)
270+ case r.Method == http.MethodPost:
271+ w.Header().Set("Location", attacker.URL+"/v2/repo/blobs/uploads/evil")
272+ w.WriteHeader(http.StatusAccepted)
273+ default:
274+ w.WriteHeader(http.StatusInternalServerError)
275+ }
276+ }))
277+ defer registry.Close()
278+
279+ p := registrypush.New(registrypush.WithChunkSize(1024))
280+ ctx := context.Background()
281+ err := p.UploadBlob(ctx, registry.URL, "test/repo", "sha256:abc", 5, strings.NewReader("hello"), "Basic secret")
282+
283+ require.Error(t, err)
284+ assert.Contains(t, err.Error(), "cross-origin")
285+}
286+
259 type fakeBlobUploadRegistry struct {287 type fakeBlobUploadRegistry struct {
260 t *testing.T288 t *testing.T
261 expectedAuth string289 expectedAuth string

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 106

↑↓ moveenter openesc close