detached 92dab38

fix(security): scope auth headers to registry origin

Replace setAuthHeader with setScopedAuth that verifies the request URL
matches the registry origin before attaching credentials. Defense-in-depth
against credential leakage even if origin pinning on Location is bypassed.

Thread baseURL through uploadBlobChunks, uploadBlobChunk, and
finalizeBlobUpload so the origin check has access to the registry host.
commit
92dab3814b5eaf3f437fb92d1ab15a655ab87c26
author
brice · · 2026-03-14 22:12 UTC
parent
aa2bfce
signature
unsigned
2 files · +86 -14
M pkg/registrypush/push.go +25 -14
@@ -147,12 +147,12 @@ func (p *Pusher) UploadBlob(ctx context.Context, baseURL, repo, digest string, s
147147 }
148148 }
149149
150- uploadURL, err = p.uploadBlobChunks(ctx, uploadURL, content, auth)
150+ uploadURL, err = p.uploadBlobChunks(ctx, baseURL, uploadURL, content, auth)
151151 if err != nil {
152152 return err
153153 }
154154
155- return p.finalizeBlobUpload(ctx, uploadURL, digest, auth)
155+ return p.finalizeBlobUpload(ctx, baseURL, uploadURL, digest, auth)
156156 }
157157
158158 // defaultImageSource reads an image from the local Docker/Podman daemon.
@@ -283,7 +283,7 @@ func (p *Pusher) uploadManifest(ctx context.Context, baseURL, repo, tag string,
283283 if err != nil {
284284 return fmt.Errorf("failed to create manifest upload request: %w", err)
285285 }
286- setAuthHeader(req, auth)
286+ setScopedAuth(req, auth, baseURL)
287287 req.Header.Set("Content-Type", string(mediaType))
288288 req.ContentLength = int64(len(manifest))
289289
@@ -346,7 +346,7 @@ func (p *Pusher) checkBlobExists(ctx context.Context, baseURL, repo, digest, aut
346346 if err != nil {
347347 return fmt.Errorf("failed to create blob HEAD request: %w", err)
348348 }
349- setAuthHeader(req, auth)
349+ setScopedAuth(req, auth, baseURL)
350350
351351 resp, err := p.httpClient().Do(req)
352352 if err != nil {
@@ -370,7 +370,7 @@ func (p *Pusher) startBlobUpload(ctx context.Context, baseURL, repo, auth string
370370 if err != nil {
371371 return "", fmt.Errorf("failed to create blob upload start request: %w", err)
372372 }
373- setAuthHeader(req, auth)
373+ setScopedAuth(req, auth, baseURL)
374374
375375 resp, err := p.httpClient().Do(req)
376376 if err != nil {
@@ -391,7 +391,7 @@ func (p *Pusher) startBlobUpload(ctx context.Context, baseURL, repo, auth string
391391 return location, nil
392392 }
393393
394-func (p *Pusher) uploadBlobChunks(ctx context.Context, uploadURL string, content io.Reader, auth string) (string, error) {
394+func (p *Pusher) uploadBlobChunks(ctx context.Context, baseURL, uploadURL string, content io.Reader, auth string) (string, error) {
395395 buffer := make([]byte, p.chunkSize)
396396 offset := int64(0)
397397
@@ -404,7 +404,7 @@ func (p *Pusher) uploadBlobChunks(ctx context.Context, uploadURL string, content
404404 return "", fmt.Errorf("failed to read blob chunk: %w", err)
405405 }
406406
407- nextURL, patchErr := p.uploadBlobChunk(ctx, uploadURL, buffer[:readBytes], offset, auth)
407+ nextURL, patchErr := p.uploadBlobChunk(ctx, baseURL, uploadURL, buffer[:readBytes], offset, auth)
408408 if patchErr != nil {
409409 return "", patchErr
410410 }
@@ -418,12 +418,12 @@ func (p *Pusher) uploadBlobChunks(ctx context.Context, uploadURL string, content
418418 }
419419 }
420420
421-func (p *Pusher) uploadBlobChunk(ctx context.Context, uploadURL string, chunk []byte, offset int64, auth string) (string, error) {
421+func (p *Pusher) uploadBlobChunk(ctx context.Context, baseURL, uploadURL string, chunk []byte, offset int64, auth string) (string, error) {
422422 req, err := http.NewRequestWithContext(ctx, http.MethodPatch, uploadURL, bytesReader(chunk))
423423 if err != nil {
424424 return "", fmt.Errorf("failed to create blob chunk request: %w", err)
425425 }
426- setAuthHeader(req, auth)
426+ setScopedAuth(req, auth, baseURL)
427427 req.Header.Set("Content-Type", "application/octet-stream")
428428 req.Header.Set("Content-Range", fmt.Sprintf("%d-%d", offset, offset+int64(len(chunk))-1))
429429 req.ContentLength = int64(len(chunk))
@@ -447,7 +447,7 @@ func (p *Pusher) uploadBlobChunk(ctx context.Context, uploadURL string, chunk []
447447 return nextURL, nil
448448 }
449449
450-func (p *Pusher) finalizeBlobUpload(ctx context.Context, uploadURL, digest, auth string) error {
450+func (p *Pusher) finalizeBlobUpload(ctx context.Context, baseURL, uploadURL, digest, auth string) error {
451451 parsedURL, err := url.Parse(uploadURL)
452452 if err != nil {
453453 return fmt.Errorf("failed to parse blob upload location: %w", err)
@@ -461,7 +461,7 @@ func (p *Pusher) finalizeBlobUpload(ctx context.Context, uploadURL, digest, auth
461461 if err != nil {
462462 return fmt.Errorf("failed to create blob finalize request: %w", err)
463463 }
464- setAuthHeader(req, auth)
464+ setScopedAuth(req, auth, baseURL)
465465
466466 resp, err := p.httpClient().Do(req)
467467 if err != nil {
@@ -481,10 +481,21 @@ func (p *Pusher) httpClient() *http.Client {
481481 return &http.Client{Transport: p.transport}
482482 }
483483
484-func setAuthHeader(req *http.Request, auth string) {
485- if auth != "" {
486- req.Header.Set("Authorization", auth)
484+// setScopedAuth attaches the auth header only if the request URL matches the
485+// registry origin (scheme + host). This prevents credential leakage if a
486+// redirect somehow escapes origin pinning.
487+func setScopedAuth(req *http.Request, auth, registryOrigin string) {
488+ if auth == "" {
489+ return
487490 }
491+ origin, err := url.Parse(registryOrigin)
492+ if err != nil {
493+ return
494+ }
495+ if req.URL.Scheme != origin.Scheme || req.URL.Host != origin.Host {
496+ return
497+ }
498+ req.Header.Set("Authorization", auth)
488499 }
489500
490501 func resolveLocation(baseURL, location string) (string, error) {
@@ -147,12 +147,12 @@ func (p *Pusher) UploadBlob(ctx context.Context, baseURL, repo, digest string, s
147 }147 }
148 }148 }
149 149
150- uploadURL, err = p.uploadBlobChunks(ctx, uploadURL, content, auth)150+ uploadURL, err = p.uploadBlobChunks(ctx, baseURL, uploadURL, content, auth)
151 if err != nil {151 if err != nil {
152 return err152 return err
153 }153 }
154 154
155- return p.finalizeBlobUpload(ctx, uploadURL, digest, auth)155+ return p.finalizeBlobUpload(ctx, baseURL, uploadURL, digest, auth)
156 }156 }
157 157
158 // defaultImageSource reads an image from the local Docker/Podman daemon.158 // defaultImageSource reads an image from the local Docker/Podman daemon.
@@ -283,7 +283,7 @@ func (p *Pusher) uploadManifest(ctx context.Context, baseURL, repo, tag string,
283 if err != nil {283 if err != nil {
284 return fmt.Errorf("failed to create manifest upload request: %w", err)284 return fmt.Errorf("failed to create manifest upload request: %w", err)
285 }285 }
286- setAuthHeader(req, auth)286+ setScopedAuth(req, auth, baseURL)
287 req.Header.Set("Content-Type", string(mediaType))287 req.Header.Set("Content-Type", string(mediaType))
288 req.ContentLength = int64(len(manifest))288 req.ContentLength = int64(len(manifest))
289 289
@@ -346,7 +346,7 @@ func (p *Pusher) checkBlobExists(ctx context.Context, baseURL, repo, digest, aut
346 if err != nil {346 if err != nil {
347 return fmt.Errorf("failed to create blob HEAD request: %w", err)347 return fmt.Errorf("failed to create blob HEAD request: %w", err)
348 }348 }
349- setAuthHeader(req, auth)349+ setScopedAuth(req, auth, baseURL)
350 350
351 resp, err := p.httpClient().Do(req)351 resp, err := p.httpClient().Do(req)
352 if err != nil {352 if err != nil {
@@ -370,7 +370,7 @@ func (p *Pusher) startBlobUpload(ctx context.Context, baseURL, repo, auth string
370 if err != nil {370 if err != nil {
371 return "", fmt.Errorf("failed to create blob upload start request: %w", err)371 return "", fmt.Errorf("failed to create blob upload start request: %w", err)
372 }372 }
373- setAuthHeader(req, auth)373+ setScopedAuth(req, auth, baseURL)
374 374
375 resp, err := p.httpClient().Do(req)375 resp, err := p.httpClient().Do(req)
376 if err != nil {376 if err != nil {
@@ -391,7 +391,7 @@ func (p *Pusher) startBlobUpload(ctx context.Context, baseURL, repo, auth string
391 return location, nil391 return location, nil
392 }392 }
393 393
394-func (p *Pusher) uploadBlobChunks(ctx context.Context, uploadURL string, content io.Reader, auth string) (string, error) {394+func (p *Pusher) uploadBlobChunks(ctx context.Context, baseURL, uploadURL string, content io.Reader, auth string) (string, error) {
395 buffer := make([]byte, p.chunkSize)395 buffer := make([]byte, p.chunkSize)
396 offset := int64(0)396 offset := int64(0)
397 397
@@ -404,7 +404,7 @@ func (p *Pusher) uploadBlobChunks(ctx context.Context, uploadURL string, content
404 return "", fmt.Errorf("failed to read blob chunk: %w", err)404 return "", fmt.Errorf("failed to read blob chunk: %w", err)
405 }405 }
406 406
407- nextURL, patchErr := p.uploadBlobChunk(ctx, uploadURL, buffer[:readBytes], offset, auth)407+ nextURL, patchErr := p.uploadBlobChunk(ctx, baseURL, uploadURL, buffer[:readBytes], offset, auth)
408 if patchErr != nil {408 if patchErr != nil {
409 return "", patchErr409 return "", patchErr
410 }410 }
@@ -418,12 +418,12 @@ func (p *Pusher) uploadBlobChunks(ctx context.Context, uploadURL string, content
418 }418 }
419 }419 }
420 420
421-func (p *Pusher) uploadBlobChunk(ctx context.Context, uploadURL string, chunk []byte, offset int64, auth string) (string, error) {421+func (p *Pusher) uploadBlobChunk(ctx context.Context, baseURL, uploadURL string, chunk []byte, offset int64, auth string) (string, error) {
422 req, err := http.NewRequestWithContext(ctx, http.MethodPatch, uploadURL, bytesReader(chunk))422 req, err := http.NewRequestWithContext(ctx, http.MethodPatch, uploadURL, bytesReader(chunk))
423 if err != nil {423 if err != nil {
424 return "", fmt.Errorf("failed to create blob chunk request: %w", err)424 return "", fmt.Errorf("failed to create blob chunk request: %w", err)
425 }425 }
426- setAuthHeader(req, auth)426+ setScopedAuth(req, auth, baseURL)
427 req.Header.Set("Content-Type", "application/octet-stream")427 req.Header.Set("Content-Type", "application/octet-stream")
428 req.Header.Set("Content-Range", fmt.Sprintf("%d-%d", offset, offset+int64(len(chunk))-1))428 req.Header.Set("Content-Range", fmt.Sprintf("%d-%d", offset, offset+int64(len(chunk))-1))
429 req.ContentLength = int64(len(chunk))429 req.ContentLength = int64(len(chunk))
@@ -447,7 +447,7 @@ func (p *Pusher) uploadBlobChunk(ctx context.Context, uploadURL string, chunk []
447 return nextURL, nil447 return nextURL, nil
448 }448 }
449 449
450-func (p *Pusher) finalizeBlobUpload(ctx context.Context, uploadURL, digest, auth string) error {450+func (p *Pusher) finalizeBlobUpload(ctx context.Context, baseURL, uploadURL, digest, auth string) error {
451 parsedURL, err := url.Parse(uploadURL)451 parsedURL, err := url.Parse(uploadURL)
452 if err != nil {452 if err != nil {
453 return fmt.Errorf("failed to parse blob upload location: %w", err)453 return fmt.Errorf("failed to parse blob upload location: %w", err)
@@ -461,7 +461,7 @@ func (p *Pusher) finalizeBlobUpload(ctx context.Context, uploadURL, digest, auth
461 if err != nil {461 if err != nil {
462 return fmt.Errorf("failed to create blob finalize request: %w", err)462 return fmt.Errorf("failed to create blob finalize request: %w", err)
463 }463 }
464- setAuthHeader(req, auth)464+ setScopedAuth(req, auth, baseURL)
465 465
466 resp, err := p.httpClient().Do(req)466 resp, err := p.httpClient().Do(req)
467 if err != nil {467 if err != nil {
@@ -481,10 +481,21 @@ func (p *Pusher) httpClient() *http.Client {
481 return &http.Client{Transport: p.transport}481 return &http.Client{Transport: p.transport}
482 }482 }
483 483
484-func setAuthHeader(req *http.Request, auth string) {484+// setScopedAuth attaches the auth header only if the request URL matches the
485- if auth != "" {485+// registry origin (scheme + host). This prevents credential leakage if a
486- req.Header.Set("Authorization", auth)486+// redirect somehow escapes origin pinning.
487+func setScopedAuth(req *http.Request, auth, registryOrigin string) {
488+ if auth == "" {
489+ return
487 }490 }
491+ origin, err := url.Parse(registryOrigin)
492+ if err != nil {
493+ return
494+ }
495+ if req.URL.Scheme != origin.Scheme || req.URL.Host != origin.Host {
496+ return
497+ }
498+ req.Header.Set("Authorization", auth)
488 }499 }
489 500
490 func resolveLocation(baseURL, location string) (string, error) {501 func resolveLocation(baseURL, location string) (string, error) {
M pkg/registrypush/push_test.go +61 -0
@@ -284,6 +284,67 @@ func TestUploadBlob_RejectsCrossOriginRedirect(t *testing.T) {
284284 assert.Contains(t, err.Error(), "cross-origin")
285285 }
286286
287+// authRecordingTransport records Authorization headers per request URL.
288+type authRecordingTransport struct {
289+ inner http.RoundTripper
290+ records []authRecord
291+}
292+
293+type authRecord struct {
294+ URL string
295+ Auth string
296+}
297+
298+func (t *authRecordingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
299+ t.records = append(t.records, authRecord{
300+ URL: req.URL.String(),
301+ Auth: req.Header.Get("Authorization"),
302+ })
303+ return t.inner.RoundTrip(req)
304+}
305+
306+func TestUploadBlob_AuthHeaderOnlySentToSameOrigin(t *testing.T) {
307+ registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
308+ switch {
309+ case r.Method == http.MethodHead:
310+ w.WriteHeader(http.StatusNotFound)
311+ case r.Method == http.MethodPost:
312+ w.Header().Set("Location", "/v2/repo/blobs/uploads/abc?chunk=0")
313+ w.WriteHeader(http.StatusAccepted)
314+ case r.Method == http.MethodPatch:
315+ w.Header().Set("Location", "/v2/repo/blobs/uploads/abc?chunk=1")
316+ w.WriteHeader(http.StatusAccepted)
317+ case r.Method == http.MethodPut:
318+ w.WriteHeader(http.StatusCreated)
319+ default:
320+ w.WriteHeader(http.StatusInternalServerError)
321+ }
322+ }))
323+ defer registry.Close()
324+
325+ recorder := &authRecordingTransport{inner: http.DefaultTransport}
326+ p := registrypush.New(
327+ registrypush.WithChunkSize(1024),
328+ registrypush.WithTransport(recorder),
329+ )
330+ ctx := context.Background()
331+ secret := "Basic dXNlcjpwYXNz"
332+ err := p.UploadBlob(ctx, registry.URL, "test/repo", "sha256:abc", 5, strings.NewReader("hello"), secret)
333+
334+ require.NoError(t, err)
335+ require.NotEmpty(t, recorder.records)
336+
337+ registryURL, _ := url.Parse(registry.URL)
338+ for _, rec := range recorder.records {
339+ reqURL, _ := url.Parse(rec.URL)
340+ if reqURL.Host == registryURL.Host {
341+ assert.Equal(t, secret, rec.Auth, "same-origin request to %s should have auth", rec.URL)
342+ } else {
343+ assert.Empty(t, rec.Auth, "cross-origin request to %s should NOT have auth", rec.URL)
344+ }
345+ }
346+}
347+
287348 type fakeBlobUploadRegistry struct {
288349 t *testing.T
289350 expectedAuth string
@@ -284,6 +284,67 @@ func TestUploadBlob_RejectsCrossOriginRedirect(t *testing.T) {
284 assert.Contains(t, err.Error(), "cross-origin")284 assert.Contains(t, err.Error(), "cross-origin")
285 }285 }
286 286
287+// authRecordingTransport records Authorization headers per request URL.
288+type authRecordingTransport struct {
289+ inner http.RoundTripper
290+ records []authRecord
291+}
292+
293+type authRecord struct {
294+ URL string
295+ Auth string
296+}
297+
298+func (t *authRecordingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
299+ t.records = append(t.records, authRecord{
300+ URL: req.URL.String(),
301+ Auth: req.Header.Get("Authorization"),
302+ })
303+ return t.inner.RoundTrip(req)
304+}
305+
306+func TestUploadBlob_AuthHeaderOnlySentToSameOrigin(t *testing.T) {
307+ registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
308+ switch {
309+ case r.Method == http.MethodHead:
310+ w.WriteHeader(http.StatusNotFound)
311+ case r.Method == http.MethodPost:
312+ w.Header().Set("Location", "/v2/repo/blobs/uploads/abc?chunk=0")
313+ w.WriteHeader(http.StatusAccepted)
314+ case r.Method == http.MethodPatch:
315+ w.Header().Set("Location", "/v2/repo/blobs/uploads/abc?chunk=1")
316+ w.WriteHeader(http.StatusAccepted)
317+ case r.Method == http.MethodPut:
318+ w.WriteHeader(http.StatusCreated)
319+ default:
320+ w.WriteHeader(http.StatusInternalServerError)
321+ }
322+ }))
323+ defer registry.Close()
324+
325+ recorder := &authRecordingTransport{inner: http.DefaultTransport}
326+ p := registrypush.New(
327+ registrypush.WithChunkSize(1024),
328+ registrypush.WithTransport(recorder),
329+ )
330+ ctx := context.Background()
331+ secret := "Basic dXNlcjpwYXNz"
332+ err := p.UploadBlob(ctx, registry.URL, "test/repo", "sha256:abc", 5, strings.NewReader("hello"), secret)
333+
334+ require.NoError(t, err)
335+ require.NotEmpty(t, recorder.records)
336+
337+ registryURL, _ := url.Parse(registry.URL)
338+ for _, rec := range recorder.records {
339+ reqURL, _ := url.Parse(rec.URL)
340+ if reqURL.Host == registryURL.Host {
341+ assert.Equal(t, secret, rec.Auth, "same-origin request to %s should have auth", rec.URL)
342+ } else {
343+ assert.Empty(t, rec.Auth, "cross-origin request to %s should NOT have auth", rec.URL)
344+ }
345+ }
346+}
347+
287 type fakeBlobUploadRegistry struct {348 type fakeBlobUploadRegistry struct {
288 t *testing.T349 t *testing.T
289 expectedAuth string350 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 7

Tags 106

↑↓ moveenter openesc close