feat(storage): redirect blob pulls to backend URLs (#4092)

* feat(storage): redirect blob pulls to backend URLs

* fix: rebase conflicts

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>

* refactor: rename redirect field

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>

* test: relax brittle TestPeriodicGC substore log assertion

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>

* feat(storage): improve blob redirect config handling and validation

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>

* fix(storage): address PR review feedback for blob redirect

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>

* feat(storage): apply latest PR review fixes for blob redirect

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>

* test: fix blob redirect and verify test regressions

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>

* fix(storage): enforce redirectBlobURL validation and add redirect tests

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>

* fix(storage): fix err113/noctx lint errors in storage driver tests

- Replace httptest.NewRequest with httptest.NewRequestWithContext in
  s3, gcs, and imagestore driver tests (noctx)
- Replace dynamic errors.New in s3 driver test with a package-level
  static sentinel error (err113)

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>

* test(storage): use temp dirs in imagestore redirect tests

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>

* fix: handle ranged blob redirects and add regression tests

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>

* fix: validate blob digest consistently in GetBlob

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>

* test: fix GetBlobPartialFn mock return values for range requests

The test 'does not redirect ranged blob requests' was failing because the mock
was returning incorrect length values. For a range request 'bytes=0-0' (1 byte),
it was returning 4 bytes, which caused a length mismatch check in GetBlob to
return HTTP 500.

Fix the mock to dynamically calculate the correct length: to - from + 1

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>

* fix(storage): preserve signed URL bytes in normalizeBlobRedirectURL

Preserve the original URL bytes from backend storage drivers (important
for signed/presigned URLs) while only lowercasing the scheme prefix.
URL re-serialization via net/url can invalidate signatures through path
escaping or canonicalization.

Add regression tests covering signed URL query parameters and mixed-case
scheme handling.

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>

* fix(storage): address PR review comments for blob redirect

- Return signed redirect URLs unchanged; validate scheme/CRLF/host only,
  no URL normalization that would corrupt signed URL bytes
- Add inline comments for all non-obvious decisions: range bypass, soft
  fallback on invalid URL, local driver empty return, subpath resolution,
  redirectBlobURL config constraint on local/empty driver
- Expand TestNormalizeBlobRedirectURL to cover allowed schemes (http/https),
  parse failure, missing host, and CRLF injection cases
- Add TestIsBlobRedirectEnabled covering subpath-only enablement with
  default store disabled

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>

* test(storage): address remaining blob redirect review comments

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>

* fix: gofumpt formatting in routes_test.go

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>

---------

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>
Co-authored-by: Akash Kumar <meakash7902@gmail.com>
This commit is contained in:
Ramkumar Chinchani
2026-06-15 14:36:07 -07:00
committed by GitHub
parent 95ce68ccb0
commit 55b68228da
19 changed files with 904 additions and 33 deletions
+35 -12
View File
@@ -27,17 +27,18 @@ var (
)
type StorageConfig struct {
RootDirectory string
MaxRepos int
Dedupe bool
RemoteCache bool
GC bool
Commit bool
GCDelay time.Duration // applied for blobs
GCInterval time.Duration
Retention ImageRetention
StorageDriver map[string]any `mapstructure:",omitempty"`
CacheDriver map[string]any `mapstructure:",omitempty"`
RootDirectory string
MaxRepos int
Dedupe bool
RemoteCache bool
RedirectBlobURL bool
GC bool
Commit bool
GCDelay time.Duration // applied for blobs
GCInterval time.Duration
Retention ImageRetention
StorageDriver map[string]any `mapstructure:",omitempty"`
CacheDriver map[string]any `mapstructure:",omitempty"`
// GCMaxSchedulerDelay is the maximum random delay for GC task scheduling
// This field is not configurable by the end user
@@ -803,7 +804,8 @@ func New() *Config {
func (expConfig StorageConfig) ParamsEqual(actConfig StorageConfig) bool {
return expConfig.GC == actConfig.GC && expConfig.Dedupe == actConfig.Dedupe &&
expConfig.GCDelay == actConfig.GCDelay && expConfig.GCInterval == actConfig.GCInterval
expConfig.RedirectBlobURL == actConfig.RedirectBlobURL && expConfig.GCDelay == actConfig.GCDelay &&
expConfig.GCInterval == actConfig.GCInterval
}
// isRetentionEnabledInternal checks if retention is enabled without acquiring a lock (internal use only).
@@ -1009,6 +1011,7 @@ func (c *Config) UpdateReloadableConfig(newConfig *Config) {
// Update storage configuration
c.Storage.GC = newConfig.Storage.GC
c.Storage.Dedupe = newConfig.Storage.Dedupe
c.Storage.RedirectBlobURL = newConfig.Storage.RedirectBlobURL
c.Storage.GCDelay = newConfig.Storage.GCDelay
c.Storage.GCInterval = newConfig.Storage.GCInterval
@@ -1026,6 +1029,7 @@ func (c *Config) UpdateReloadableConfig(newConfig *Config) {
subPathConfig.GC = storageConfig.GC
subPathConfig.Dedupe = storageConfig.Dedupe
subPathConfig.RedirectBlobURL = storageConfig.RedirectBlobURL
subPathConfig.GCDelay = storageConfig.GCDelay
subPathConfig.GCInterval = storageConfig.GCInterval
@@ -1156,6 +1160,25 @@ func (c *Config) CopyStorageConfig() GlobalStorageConfig {
return storageCopy
}
// IsBlobRedirectEnabled returns whether blob redirect is enabled for a store path.
// If a matching subpath exists, its setting takes precedence over the global one.
func (c *Config) IsBlobRedirectEnabled(storePath string) bool {
if c == nil {
return false
}
c.mu.RLock()
defer c.mu.RUnlock()
if storePath != "/" {
if subPathConfig, ok := c.Storage.SubPaths[storePath]; ok {
return subPathConfig.RedirectBlobURL
}
}
return c.Storage.RedirectBlobURL
}
// CopyExtensionsConfig returns a copy of the extensions config if it exists.
func (c *Config) CopyExtensionsConfig() *extconf.ExtensionConfig {
if c == nil {
+101 -20
View File
@@ -118,6 +118,14 @@ func TestConfig(t *testing.T) {
So(firstStorageConfig.ParamsEqual(secondStorageConfig), ShouldBeTrue)
firstStorageConfig.RedirectBlobURL = true
So(firstStorageConfig.ParamsEqual(secondStorageConfig), ShouldBeFalse)
firstStorageConfig.RedirectBlobURL = false
So(firstStorageConfig.ParamsEqual(secondStorageConfig), ShouldBeTrue)
isSame, err := config.SameFile("test-config", "test")
So(err, ShouldNotBeNil)
So(isSame, ShouldBeFalse)
@@ -1593,6 +1601,38 @@ func TestConfig(t *testing.T) {
})
})
Convey("Test IsBlobRedirectEnabled()", func() {
Convey("returns global setting for default store path", func() {
cfg := &config.Config{
Storage: config.GlobalStorageConfig{
StorageConfig: config.StorageConfig{RedirectBlobURL: true},
},
}
So(cfg.IsBlobRedirectEnabled("/"), ShouldBeTrue)
})
Convey("returns subpath setting when subpath exists", func() {
cfg := &config.Config{
Storage: config.GlobalStorageConfig{
StorageConfig: config.StorageConfig{RedirectBlobURL: false},
SubPaths: map[string]config.StorageConfig{
"/a": {RedirectBlobURL: true},
},
},
}
So(cfg.IsBlobRedirectEnabled("/a"), ShouldBeTrue)
So(cfg.IsBlobRedirectEnabled("/b"), ShouldBeFalse)
})
Convey("nil config returns false", func() {
var nilCfg *config.Config
So(nilCfg.IsBlobRedirectEnabled("/"), ShouldBeFalse)
})
})
Convey("Test CopyLogConfig()", func() {
Convey("Test with non-nil Log", func() {
cfg := &config.Config{
@@ -2300,6 +2340,39 @@ func TestConfig(t *testing.T) {
So(cfg.CopyExtensionsConfig().IsSearchEnabled(), ShouldBeTrue)
})
Convey("Test with Storage update", func() {
cfg := &config.Config{
Storage: config.GlobalStorageConfig{
StorageConfig: config.StorageConfig{
GC: true,
Dedupe: false,
RedirectBlobURL: false,
GCDelay: time.Hour,
GCInterval: 2 * time.Hour,
},
},
}
newConfig := &config.Config{
Storage: config.GlobalStorageConfig{
StorageConfig: config.StorageConfig{
GC: false,
Dedupe: true,
RedirectBlobURL: true,
GCDelay: 3 * time.Hour,
GCInterval: 4 * time.Hour,
},
},
}
cfg.UpdateReloadableConfig(newConfig)
So(cfg.Storage.GC, ShouldBeFalse)
So(cfg.Storage.Dedupe, ShouldBeTrue)
So(cfg.Storage.RedirectBlobURL, ShouldBeTrue)
So(cfg.Storage.GCDelay, ShouldEqual, 3*time.Hour)
So(cfg.Storage.GCInterval, ShouldEqual, 4*time.Hour)
})
Convey("Test search CVE config removal when new config has nil Search.CVE", func() {
// First set up a config with search enabled and CVE config
enabled := true
@@ -3223,21 +3296,24 @@ func TestConfig(t *testing.T) {
cfg := &config.Config{
Storage: config.GlobalStorageConfig{
StorageConfig: config.StorageConfig{
GC: true,
Dedupe: false,
GC: true,
Dedupe: false,
RedirectBlobURL: false,
},
SubPaths: map[string]config.StorageConfig{
"/path1": {
GC: true,
Dedupe: false,
GCDelay: time.Hour,
GCInterval: time.Hour * 24,
GC: true,
Dedupe: false,
RedirectBlobURL: false,
GCDelay: time.Hour,
GCInterval: time.Hour * 24,
},
"/path2": {
GC: false,
Dedupe: true,
GCDelay: time.Hour * 2,
GCInterval: time.Hour * 48,
GC: false,
Dedupe: true,
RedirectBlobURL: true,
GCDelay: time.Hour * 2,
GCInterval: time.Hour * 48,
},
},
},
@@ -3247,21 +3323,24 @@ func TestConfig(t *testing.T) {
newConfig := &config.Config{
Storage: config.GlobalStorageConfig{
StorageConfig: config.StorageConfig{
GC: true,
Dedupe: false,
GC: true,
Dedupe: false,
RedirectBlobURL: true,
},
SubPaths: map[string]config.StorageConfig{
"/path1": {
GC: false, // Changed
Dedupe: true, // Changed
GCDelay: time.Hour * 2, // Changed
GCInterval: time.Hour * 12, // Changed
GC: false, // Changed
Dedupe: true, // Changed
RedirectBlobURL: true, // Changed
GCDelay: time.Hour * 2, // Changed
GCInterval: time.Hour * 12, // Changed
},
"/path2": {
GC: true, // Changed
Dedupe: false, // Changed
GCDelay: time.Hour * 3, // Changed
GCInterval: time.Hour * 36, // Changed
GC: true, // Changed
Dedupe: false, // Changed
RedirectBlobURL: false, // Changed
GCDelay: time.Hour * 3, // Changed
GCInterval: time.Hour * 36, // Changed
},
},
},
@@ -3277,6 +3356,7 @@ func TestConfig(t *testing.T) {
path1Config := cfg.Storage.SubPaths["/path1"]
So(path1Config.GC, ShouldBeFalse)
So(path1Config.Dedupe, ShouldBeTrue)
So(path1Config.RedirectBlobURL, ShouldBeTrue)
So(path1Config.GCDelay, ShouldEqual, time.Hour*2)
So(path1Config.GCInterval, ShouldEqual, time.Hour*12)
@@ -3284,6 +3364,7 @@ func TestConfig(t *testing.T) {
path2Config := cfg.Storage.SubPaths["/path2"]
So(path2Config.GC, ShouldBeTrue)
So(path2Config.Dedupe, ShouldBeFalse)
So(path2Config.RedirectBlobURL, ShouldBeFalse)
So(path2Config.GCDelay, ShouldEqual, time.Hour*3)
So(path2Config.GCInterval, ShouldEqual, time.Hour*36)
})
+1 -1
View File
@@ -12194,7 +12194,7 @@ func TestPeriodicGC(t *testing.T) {
// periodic GC is enabled for sub store
So(string(data), ShouldContainSubstring,
fmt.Sprintf("\"SubPaths\":{\"/a\":{\"RootDirectory\":\"%s\",\"MaxRepos\":0,\"Dedupe\":false,\"RemoteCache\":false,\"GC\":true,\"Commit\":false,\"GCDelay\":1000000000,\"GCInterval\":86400000000000", subDir)) //nolint:lll // gofumpt conflicts with lll
fmt.Sprintf("\"SubPaths\":{\"/a\":{\"RootDirectory\":\"%s\",\"MaxRepos\":0,\"Dedupe\":false,\"RemoteCache\":false,\"RedirectBlobURL\":false,\"GC\":true,\"Commit\":false,\"GCDelay\":1000000000,\"GCInterval\":86400000000000", subDir)) //nolint:lll // gofumpt conflicts with lll
})
Convey("Periodic gc error", t, func() {
+57
View File
@@ -1418,6 +1418,35 @@ func writeMultipartRanges(
}
}
func (rh *RouteHandler) isBlobRedirectEnabled(repo string) bool {
storePath := rh.c.StoreController.GetStorePath(repo)
return rh.c.Config.IsBlobRedirectEnabled(storePath)
}
func normalizeBlobRedirectURL(rawURL string) (string, bool) {
if strings.ContainsAny(rawURL, "\r\n") {
return "", false
}
parsedURL, err := url.Parse(rawURL)
if err != nil {
return "", false
}
if parsedURL.Scheme == "" || parsedURL.Host == "" {
return "", false
}
scheme := strings.ToLower(parsedURL.Scheme)
if scheme != constants.SchemeHTTP && scheme != constants.SchemeHTTPS {
return "", false
}
// Keep the exact signed URL returned by the backend; only validate safety here.
return rawURL, true
}
// GetBlob godoc
// @Summary Get image blob/layer
// @Description Get an image's blob/layer given a digest
@@ -1473,6 +1502,34 @@ func (rh *RouteHandler) GetBlob(response http.ResponseWriter, request *http.Requ
}
}
if err := digest.Validate(); err != nil {
writeBlobError(zerr.ErrBadBlobDigest)
return
}
// Keep ranged pulls on the proxy path so zot can preserve 206 semantics.
if !rangeHeaderPresent && rh.isBlobRedirectEnabled(name) {
redirectURL, err := imgStore.GetBlobRedirectURL(request, name, digest)
if err != nil {
writeBlobError(err)
return
} else if redirectURL != "" {
if normalizedURL, ok := normalizeBlobRedirectURL(redirectURL); ok {
response.Header().Set(constants.DistContentDigestKey, digest.String())
response.Header().Set("Location", normalizedURL)
response.WriteHeader(http.StatusTemporaryRedirect)
return
}
// Invalid redirect URLs are treated as a soft failure to keep pulls working.
rh.c.Log.Warn().Str("repo", name).Str("digest", digest.String()).
Msg("ignoring invalid blob redirect URL and falling back to proxy")
}
}
if rangeHeaderPresent {
ok, bsize, err := imgStore.CheckBlob(name, digest)
if err != nil {
+100
View File
@@ -4,6 +4,10 @@ import (
"reflect"
"strings"
"testing"
"zotregistry.dev/zot/v2/pkg/api/config"
"zotregistry.dev/zot/v2/pkg/storage"
storageTypes "zotregistry.dev/zot/v2/pkg/storage/types"
)
func TestParseRangeHeader(t *testing.T) {
@@ -102,3 +106,99 @@ func TestParseRangeHeader(t *testing.T) {
})
}
}
func TestNormalizeBlobRedirectURL(t *testing.T) {
t.Parallel()
tests := []struct {
name string
rawURL string
wantURL string
wantOK bool
}{
{
name: "preserves signed url bytes unchanged",
rawURL: "HTTPS://storage.example.com/blob?X-Amz-Signature=a%2Fb%2Bc",
wantURL: "HTTPS://storage.example.com/blob?X-Amz-Signature=a%2Fb%2Bc",
wantOK: true,
},
{
name: "allows http scheme",
rawURL: "http://storage.example.com/blob",
wantURL: "http://storage.example.com/blob",
wantOK: true,
},
{
name: "rejects disallowed scheme",
rawURL: "javascript:alert(1)",
wantOK: false,
},
{
name: "rejects parse failure",
rawURL: "https://storage.example.com/%zz",
wantOK: false,
},
{
name: "rejects missing host",
rawURL: "https:///blob",
wantOK: false,
},
{
name: "rejects crlf injection",
rawURL: "https://storage.example.com/blob?sig=abc\r\nX-Test: y",
wantOK: false,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
t.Parallel()
gotURL, gotOK := normalizeBlobRedirectURL(test.rawURL)
if gotOK != test.wantOK {
t.Fatalf("expected ok=%v, got %v", test.wantOK, gotOK)
}
if gotURL != test.wantURL {
t.Fatalf("expected url %q, got %q", test.wantURL, gotURL)
}
})
}
}
func TestIsBlobRedirectEnabled(t *testing.T) {
t.Parallel()
routeHandler := &RouteHandler{
c: &Controller{
Config: &config.Config{
Storage: config.GlobalStorageConfig{
StorageConfig: config.StorageConfig{
RedirectBlobURL: false,
},
SubPaths: map[string]config.StorageConfig{
"/a": {
RedirectBlobURL: true,
},
},
},
},
StoreController: storage.StoreController{
SubStore: map[string]storageTypes.ImageStore{
"/a": nil,
},
},
},
}
if !routeHandler.isBlobRedirectEnabled("a/repo") {
t.Fatal("expected redirect to be enabled for /a subpath repo")
}
// Default storage remains disabled even when a specific subpath enables redirect.
if routeHandler.isBlobRedirectEnabled("b/repo") {
t.Fatal("expected redirect to be disabled for default storage")
}
}
+277
View File
@@ -933,6 +933,283 @@ func TestRoutes(t *testing.T) {
},
})
So(statusCode, ShouldEqual, http.StatusBadRequest)
Convey("redirects blob pulls when storage redirect is enabled", func() {
blobDigest := "sha256:7b8437f04f83f084b7ed68ad8c4a4947e12fc4e1b006b38129bac89114ec3621"
redirectURL := "https://storage.example.com/zot/repo/blobs/sha256/layer"
getBlobCalled := false
ctlr.Config.Storage.RedirectBlobURL = true
defer func() {
ctlr.Config.Storage.RedirectBlobURL = false
}()
ctlr.StoreController.DefaultStore = &mocks.MockedImageStore{
GetBlobRedirectURLFn: func(r *http.Request, repo string, digest godigest.Digest) (string, error) {
So(r.Method, ShouldEqual, http.MethodGet)
So(repo, ShouldEqual, "repo")
So(digest.String(), ShouldEqual, blobDigest)
return redirectURL, nil
},
GetBlobFn: func(repo string, digest godigest.Digest, mediaType string) (io.ReadCloser, int64, error) {
getBlobCalled = true
return io.NopCloser(bytes.NewBufferString("")), 0, nil
},
}
request, _ := http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil)
request = mux.SetURLVars(request, map[string]string{
"name": "repo",
"digest": blobDigest,
})
response := httptest.NewRecorder()
rthdlr.GetBlob(response, request)
resp := response.Result()
defer resp.Body.Close()
So(resp.StatusCode, ShouldEqual, http.StatusTemporaryRedirect)
So(resp.Header.Get(constants.DistContentDigestKey), ShouldEqual, blobDigest)
So(resp.Header.Get("Location"), ShouldEqual, redirectURL)
So(getBlobCalled, ShouldBeFalse)
})
Convey("rejects invalid blob digests before redirect or proxy execution", func() {
invalidDigest := "sha256:bad"
Convey("with redirect enabled", func() {
ctlr.Config.Storage.RedirectBlobURL = true
defer func() {
ctlr.Config.Storage.RedirectBlobURL = false
}()
ctlr.StoreController.DefaultStore = &mocks.MockedImageStore{
GetBlobRedirectURLFn: func(r *http.Request, repo string, digest godigest.Digest) (string, error) {
t.Fatal("GetBlobRedirectURL should not run for an invalid digest")
return "", nil
},
}
request, _ := http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil)
request = mux.SetURLVars(request, map[string]string{
"name": "repo",
"digest": invalidDigest,
})
response := httptest.NewRecorder()
rthdlr.GetBlob(response, request)
resp := response.Result()
defer resp.Body.Close()
So(resp.StatusCode, ShouldEqual, http.StatusBadRequest)
})
})
Convey("does not redirect ranged blob requests", func() {
blobDigest := "sha256:7b8437f04f83f084b7ed68ad8c4a4947e12fc4e1b006b38129bac89114ec3621"
redirectCalled := false
proxyCalled := false
ctlr.Config.Storage.RedirectBlobURL = true
defer func() {
ctlr.Config.Storage.RedirectBlobURL = false
}()
ctlr.StoreController.DefaultStore = &mocks.MockedImageStore{
GetBlobRedirectURLFn: func(r *http.Request, repo string, digest godigest.Digest) (string, error) {
redirectCalled = true
return "https://storage.example.com/zot/repo/blobs/sha256/layer", nil
},
CheckBlobFn: func(repo string, digest godigest.Digest) (bool, int64, error) {
return true, 4, nil
},
GetBlobPartialFn: func(repo string, digest godigest.Digest, mediaType string,
from, to int64,
) (io.ReadCloser, int64, int64, error) {
proxyCalled = true
// Return the correct length: to - from + 1
length := to - from + 1
return io.NopCloser(strings.NewReader("b")), length, length, nil
},
}
// Range requests must stay on proxy path to preserve partial-content behavior.
request, _ := http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil)
request.Header.Set("Range", "bytes=0-0")
request = mux.SetURLVars(request, map[string]string{
"name": "repo",
"digest": blobDigest,
})
response := httptest.NewRecorder()
rthdlr.GetBlob(response, request)
resp := response.Result()
defer resp.Body.Close()
So(resp.StatusCode, ShouldEqual, http.StatusPartialContent)
So(redirectCalled, ShouldBeFalse)
So(proxyCalled, ShouldBeTrue)
})
Convey("falls back to proxying when redirect URL is unavailable", func() {
blobDigest := "sha256:7b8437f04f83f084b7ed68ad8c4a4947e12fc4e1b006b38129bac89114ec3621"
getBlobCalled := false
ctlr.Config.Storage.RedirectBlobURL = true
defer func() {
ctlr.Config.Storage.RedirectBlobURL = false
}()
ctlr.StoreController.DefaultStore = &mocks.MockedImageStore{
GetBlobRedirectURLFn: func(r *http.Request, repo string, digest godigest.Digest) (string, error) {
return "", nil
},
GetBlobFn: func(repo string, digest godigest.Digest, mediaType string) (io.ReadCloser, int64, error) {
getBlobCalled = true
return io.NopCloser(bytes.NewBufferString("blob")), 4, nil
},
}
request, _ := http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil)
request = mux.SetURLVars(request, map[string]string{
"name": "repo",
"digest": blobDigest,
})
response := httptest.NewRecorder()
rthdlr.GetBlob(response, request)
resp := response.Result()
defer resp.Body.Close()
So(resp.StatusCode, ShouldEqual, http.StatusOK)
So(getBlobCalled, ShouldBeTrue)
})
Convey("falls back to proxying when redirect URL is invalid", func() {
blobDigest := "sha256:7b8437f04f83f084b7ed68ad8c4a4947e12fc4e1b006b38129bac89114ec3621"
getBlobCalled := false
ctlr.Config.Storage.RedirectBlobURL = true
defer func() {
ctlr.Config.Storage.RedirectBlobURL = false
}()
ctlr.StoreController.DefaultStore = &mocks.MockedImageStore{
GetBlobRedirectURLFn: func(r *http.Request, repo string, digest godigest.Digest) (string, error) {
return "javascript:alert(1)", nil
},
GetBlobFn: func(repo string, digest godigest.Digest, mediaType string) (io.ReadCloser, int64, error) {
getBlobCalled = true
return io.NopCloser(bytes.NewBufferString("blob")), 4, nil
},
}
request, _ := http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil)
request = mux.SetURLVars(request, map[string]string{
"name": "repo",
"digest": blobDigest,
})
response := httptest.NewRecorder()
rthdlr.GetBlob(response, request)
resp := response.Result()
defer resp.Body.Close()
So(resp.StatusCode, ShouldEqual, http.StatusOK)
So(resp.Header.Get("Location"), ShouldEqual, "")
So(getBlobCalled, ShouldBeTrue)
})
Convey("uses subpath redirect config", func() {
blobDigest := "sha256:7b8437f04f83f084b7ed68ad8c4a4947e12fc4e1b006b38129bac89114ec3621"
redirectURL := "https://storage.example.com/zot-a/repo/blobs/sha256/layer"
getBlobCalled := false
subStore := &mocks.MockedImageStore{
GetBlobRedirectURLFn: func(r *http.Request, repo string, digest godigest.Digest) (string, error) {
So(repo, ShouldEqual, "a/repo")
return redirectURL, nil
},
GetBlobFn: func(repo string, digest godigest.Digest, mediaType string) (io.ReadCloser, int64, error) {
getBlobCalled = true
return io.NopCloser(bytes.NewBufferString("")), 0, nil
},
}
ctlr.Config.Storage.RedirectBlobURL = false
// Redirect enablement is resolved from matched store path, not only global storage.
ctlr.Config.Storage.SubPaths = map[string]config.StorageConfig{
"/a": {RedirectBlobURL: true},
}
ctlr.StoreController.SubStore = map[string]storageTypes.ImageStore{
"/a": subStore,
}
defer func() {
ctlr.Config.Storage.SubPaths = nil
ctlr.StoreController.SubStore = nil
}()
request, _ := http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil)
request = mux.SetURLVars(request, map[string]string{
"name": "a/repo",
"digest": blobDigest,
})
response := httptest.NewRecorder()
rthdlr.GetBlob(response, request)
resp := response.Result()
defer resp.Body.Close()
So(resp.StatusCode, ShouldEqual, http.StatusTemporaryRedirect)
So(resp.Header.Get(constants.DistContentDigestKey), ShouldEqual, blobDigest)
So(resp.Header.Get("Location"), ShouldEqual, redirectURL)
So(getBlobCalled, ShouldBeFalse)
})
Convey("returns registry errors from redirect lookup", func() {
blobDigest := "sha256:7b8437f04f83f084b7ed68ad8c4a4947e12fc4e1b006b38129bac89114ec3621"
getBlobCalled := false
ctlr.Config.Storage.RedirectBlobURL = true
defer func() {
ctlr.Config.Storage.RedirectBlobURL = false
}()
ctlr.StoreController.DefaultStore = &mocks.MockedImageStore{
GetBlobRedirectURLFn: func(r *http.Request, repo string, digest godigest.Digest) (string, error) {
return "", zerr.ErrBlobNotFound
},
GetBlobFn: func(repo string, digest godigest.Digest, mediaType string) (io.ReadCloser, int64, error) {
getBlobCalled = true
return io.NopCloser(bytes.NewBufferString("")), 0, nil
},
}
request, _ := http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil)
request = mux.SetURLVars(request, map[string]string{
"name": "repo",
"digest": blobDigest,
})
response := httptest.NewRecorder()
rthdlr.GetBlob(response, request)
resp := response.Result()
defer resp.Body.Close()
So(resp.StatusCode, ShouldEqual, http.StatusNotFound)
So(getBlobCalled, ShouldBeFalse)
})
})
Convey("CreateBlobUpload", func() {