mirror of
https://github.com/project-zot/zot.git
synced 2026-06-17 12:58:02 +08:00
55b68228da
* 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>
205 lines
4.7 KiB
Go
205 lines
4.7 KiB
Go
package api
|
|
|
|
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) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
header string
|
|
size int64
|
|
want []httpRange
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "open ended range",
|
|
header: "bytes=0-",
|
|
size: 10,
|
|
want: []httpRange{{start: 0, end: 9}},
|
|
},
|
|
{
|
|
name: "range end is capped to size",
|
|
header: "bytes=0-100",
|
|
size: 10,
|
|
want: []httpRange{{start: 0, end: 9}},
|
|
},
|
|
{
|
|
name: "suffix range",
|
|
header: "bytes=-3",
|
|
size: 10,
|
|
want: []httpRange{{start: 7, end: 9}},
|
|
},
|
|
{
|
|
name: "oversized suffix range returns whole blob",
|
|
header: "bytes=-100",
|
|
size: 10,
|
|
want: []httpRange{{start: 0, end: 9}},
|
|
},
|
|
{
|
|
name: "ranges are sorted",
|
|
header: "bytes=7-8, 0-1",
|
|
size: 10,
|
|
want: []httpRange{
|
|
{start: 0, end: 1},
|
|
{start: 7, end: 8},
|
|
},
|
|
},
|
|
{
|
|
name: "overlapping and adjacent ranges are coalesced",
|
|
header: "bytes=0-2,3-4,6-8,7-9",
|
|
size: 10,
|
|
want: []httpRange{
|
|
{start: 0, end: 4},
|
|
{start: 6, end: 9},
|
|
},
|
|
},
|
|
{name: "zero size", header: "bytes=0-", wantErr: true},
|
|
{name: "wrong unit", header: "byte=0-1", size: 10, wantErr: true},
|
|
{name: "empty range set", header: "bytes=", size: 10, wantErr: true},
|
|
{name: "empty range spec", header: "bytes=0-1,", size: 10, wantErr: true},
|
|
{name: "zero suffix", header: "bytes=-0", size: 10, wantErr: true},
|
|
{name: "bad suffix", header: "bytes=-x", size: 10, wantErr: true},
|
|
{name: "bad start", header: "bytes=x-1", size: 10, wantErr: true},
|
|
{name: "bad end", header: "bytes=1-x", size: 10, wantErr: true},
|
|
{name: "inverted range", header: "bytes=2-1", size: 10, wantErr: true},
|
|
{name: "range starts at size", header: "bytes=10-", size: 10, wantErr: true},
|
|
{name: "range without dash", header: "bytes=0", size: 10, wantErr: true},
|
|
{
|
|
name: "too many ranges",
|
|
header: "bytes=" + strings.TrimSuffix(strings.Repeat("0-0,", maxRangeSpecCount+1), ","),
|
|
size: 10,
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
test := test
|
|
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got, err := parseRangeHeader(test.header, test.size)
|
|
if test.wantErr {
|
|
if err == nil {
|
|
t.Fatal("expected parse error")
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected parse error: %v", err)
|
|
}
|
|
|
|
if !reflect.DeepEqual(got, test.want) {
|
|
t.Fatalf("expected ranges %v, got %v", test.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|