check / check (push) Canceled after 0s
Service.Warm, the ImageCache interface, both GenerateSignedURL methods, signature.ParseParams, imagerequest.ParseImagePath, four symbols of internal/magic, the image processor's lists of supported formats, IsEmpty and Count of the allowlist, ssrfSafeDialer, CacheStale, ErrCacheMiss, ErrNegativeCache, ImageResponse.LastModified and the Load methods of ContentStorage and VariantStorage had no caller outside tests. They are gone, with the tests that tested only them. The handlers tests sign their image URLs with generateSignedURL, a test helper that builds the URL as the removed generator did. The SSRF dialer tests call dialSSRFSafe with no operator blocklist, which is all ssrfSafeDialer did. The tests of ParseImagePath and ContentStorage.Load call ParseImageURL and LoadWithSize instead. MIMEToImageFormat, ContentStorage.Store and CleanExpired stay, as pixa calls them. Model: opus-5-5
397 lines
10 KiB
Go
397 lines
10 KiB
Go
package imagerequest
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
)
|
|
|
|
// Shared test fixture strings.
|
|
const (
|
|
testHostCDN = "cdn.example.com"
|
|
testHostExample = "example.com"
|
|
testPathCat = "/photos/cat.jpg"
|
|
)
|
|
|
|
// assertRequest compares the source host, path and query and the size and
|
|
// format of got against want.
|
|
func assertRequest(t *testing.T, got, want *Request) {
|
|
t.Helper()
|
|
|
|
if got.Source.Host != want.Source.Host {
|
|
t.Errorf("Host = %q, want %q", got.Source.Host, want.Source.Host)
|
|
}
|
|
|
|
if got.Source.Path != want.Source.Path {
|
|
t.Errorf("Path = %q, want %q", got.Source.Path, want.Source.Path)
|
|
}
|
|
|
|
if got.Source.Query != want.Source.Query {
|
|
t.Errorf("Query = %q, want %q", got.Source.Query, want.Source.Query)
|
|
}
|
|
|
|
if got.Transform.Size != want.Transform.Size {
|
|
t.Errorf("Size = %v, want %v", got.Transform.Size, want.Transform.Size)
|
|
}
|
|
|
|
if got.Transform.Format != want.Transform.Format {
|
|
t.Errorf("Format = %q, want %q",
|
|
got.Transform.Format, want.Transform.Format)
|
|
}
|
|
}
|
|
|
|
func TestParseImageURL(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want *Request
|
|
}{
|
|
{
|
|
name: "basic path with size",
|
|
input: "/v1/image/cdn.example.com/photos/cat.jpg/800x600.webp",
|
|
want: &Request{
|
|
Source: Source{Host: testHostCDN, Path: testPathCat},
|
|
Transform: Transform{Size: Size{Width: 800, Height: 600}, Format: FormatWebP},
|
|
},
|
|
},
|
|
{
|
|
name: "original size with 0x0",
|
|
input: "/v1/image/cdn.example.com/photos/cat.jpg/0x0.jpeg",
|
|
want: &Request{
|
|
Source: Source{Host: testHostCDN, Path: testPathCat},
|
|
Transform: Transform{Size: Size{Width: 0, Height: 0}, Format: FormatJPEG},
|
|
},
|
|
},
|
|
{
|
|
name: "original size with orig keyword",
|
|
input: "/v1/image/cdn.example.com/photos/cat.jpg/orig.png",
|
|
want: &Request{
|
|
Source: Source{Host: testHostCDN, Path: testPathCat},
|
|
Transform: Transform{Size: Size{Width: 0, Height: 0}, Format: FormatPNG},
|
|
},
|
|
},
|
|
{
|
|
name: "path with query string",
|
|
input: "/v1/image/cdn.example.com/photos/cat.jpg?arg1=val1&arg2=val2/800x600.webp",
|
|
want: &Request{
|
|
Source: Source{
|
|
Host: testHostCDN, Path: testPathCat, Query: "arg1=val1&arg2=val2",
|
|
},
|
|
Transform: Transform{Size: Size{Width: 800, Height: 600}, Format: FormatWebP},
|
|
},
|
|
},
|
|
{
|
|
name: "deep nested path",
|
|
input: "/v1/image/cdn.example.com/a/b/c/d/image.jpg/1920x1080.avif",
|
|
want: &Request{
|
|
Source: Source{Host: testHostCDN, Path: "/a/b/c/d/image.jpg"},
|
|
Transform: Transform{Size: Size{Width: 1920, Height: 1080}, Format: FormatAVIF},
|
|
},
|
|
},
|
|
{
|
|
name: "jpg alias for jpeg",
|
|
input: "/v1/image/example.com/img.png/100x100.jpg",
|
|
want: &Request{
|
|
Source: Source{Host: testHostExample, Path: "/img.png"},
|
|
Transform: Transform{Size: Size{Width: 100, Height: 100}, Format: FormatJPEG},
|
|
},
|
|
},
|
|
{
|
|
name: "gif format",
|
|
input: "/v1/image/example.com/animated.gif/200x200.gif",
|
|
want: &Request{
|
|
Source: Source{Host: testHostExample, Path: "/animated.gif"},
|
|
Transform: Transform{Size: Size{Width: 200, Height: 200}, Format: FormatGIF},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got, err := ParseImageURL(tt.input)
|
|
if err != nil {
|
|
t.Fatalf("ParseImageURL() unexpected error = %v", err)
|
|
}
|
|
|
|
assertRequest(t, got, tt.want)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestParseImageURL_AutoFormat verifies that the format auto in an image URL
|
|
// parses as FormatAuto.
|
|
func TestParseImageURL_AutoFormat(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got, err := ParseImageURL("/v1/image/example.com/photo.jpg/200x200.auto")
|
|
if err != nil {
|
|
t.Fatalf("ParseImageURL() error = %v", err)
|
|
}
|
|
|
|
if got.Transform.Format != FormatAuto {
|
|
t.Errorf("Format = %q, want %q", got.Transform.Format, FormatAuto)
|
|
}
|
|
}
|
|
|
|
func TestParseImageURL_Errors(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
wantErr error
|
|
}{
|
|
{
|
|
name: "missing prefix",
|
|
input: "/image/cdn.example.com/photo.jpg/800x600.webp",
|
|
wantErr: ErrInvalidPath,
|
|
},
|
|
{
|
|
name: "empty path after prefix",
|
|
input: "/v1/image/",
|
|
wantErr: ErrMissingHost,
|
|
},
|
|
{
|
|
name: "host only, no path or size",
|
|
input: "/v1/image/cdn.example.com",
|
|
wantErr: ErrMissingSize,
|
|
},
|
|
{
|
|
name: "invalid size format",
|
|
input: "/v1/image/cdn.example.com/photo.jpg/invalid.webp",
|
|
wantErr: ErrInvalidSize,
|
|
},
|
|
{
|
|
name: "unsupported format",
|
|
input: "/v1/image/cdn.example.com/photo.jpg/800x600.bmp",
|
|
wantErr: ErrInvalidFormat,
|
|
},
|
|
{
|
|
name: "dimension too large",
|
|
input: "/v1/image/cdn.example.com/photo.jpg/10000x600.webp",
|
|
wantErr: ErrDimensionTooLarge,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := ParseImageURL(tt.input)
|
|
if err == nil {
|
|
t.Fatalf("ParseImageURL() error = nil, wantErr %v", tt.wantErr)
|
|
}
|
|
|
|
if !errorIs(err, tt.wantErr) {
|
|
t.Errorf("ParseImageURL() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestParseImageURL_LowerCasesHost verifies that the host is lower-cased, so
|
|
// that every spelling of its letter case is the same host to the allowlist,
|
|
// the upstream connection limits and the cache, and that the path keeps its
|
|
// case.
|
|
func TestParseImageURL_LowerCasesHost(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got, err := ParseImageURL("/v1/image/CDN.Example.COM/Photos/Cat.jpg/800x600.webp")
|
|
if err != nil {
|
|
t.Fatalf("ParseImageURL() error = %v", err)
|
|
}
|
|
|
|
t.Logf("parsed host %q, path %q", got.Source.Host, got.Source.Path)
|
|
|
|
if got.Source.Host != testHostCDN {
|
|
t.Errorf("Host = %q, want %q", got.Source.Host, testHostCDN)
|
|
}
|
|
|
|
if got.Source.Path != "/Photos/Cat.jpg" {
|
|
t.Errorf("Path = %q, want %q", got.Source.Path, "/Photos/Cat.jpg")
|
|
}
|
|
}
|
|
|
|
// TestParseImageURL_RefusesUserinfo verifies that a host holding "@" is
|
|
// refused: a URL reads what comes before the "@" as a user name and password
|
|
// and the rest as the host.
|
|
func TestParseImageURL_RefusesUserinfo(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, input := range []string{
|
|
"/v1/image/user@cdn.example.com/photos/cat.jpg/800x600.webp",
|
|
"/v1/image/user:password@cdn.example.com/photos/cat.jpg/800x600.webp",
|
|
} {
|
|
got, err := ParseImageURL(input)
|
|
t.Logf("ParseImageURL(%q) = %+v, error = %v", input, got, err)
|
|
|
|
if !errors.Is(err, ErrInvalidHost) {
|
|
t.Errorf("ParseImageURL(%q) error = %v, want %v",
|
|
input, err, ErrInvalidHost)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseImageURL_PathTraversal(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// All path traversal attempts should be rejected
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
}{
|
|
{
|
|
name: "simple parent directory",
|
|
input: "/v1/image/cdn.example.com/../etc/passwd/800x600.jpeg",
|
|
},
|
|
{
|
|
name: "double parent directory",
|
|
input: "/v1/image/cdn.example.com/../../etc/passwd/800x600.jpeg",
|
|
},
|
|
{
|
|
name: "parent in middle of path",
|
|
input: "/v1/image/cdn.example.com/photos/../../../etc/passwd/800x600.jpeg",
|
|
},
|
|
{
|
|
name: "parent directory in path",
|
|
input: "/v1/image/cdn.example.com/photos/../secret/800x600.jpeg",
|
|
},
|
|
{
|
|
name: "encoded parent directory",
|
|
input: "/v1/image/cdn.example.com/photos/%2e%2e/secret/800x600.jpeg",
|
|
},
|
|
{
|
|
name: "double encoded parent",
|
|
input: "/v1/image/cdn.example.com/photos/%252e%252e/secret/800x600.jpeg",
|
|
},
|
|
{
|
|
name: "backslash traversal",
|
|
input: "/v1/image/cdn.example.com/photos/..\\secret/800x600.jpeg",
|
|
},
|
|
{
|
|
name: "mixed slashes",
|
|
input: "/v1/image/cdn.example.com/photos/../\\../secret/800x600.jpeg",
|
|
},
|
|
{
|
|
name: "null byte injection",
|
|
input: "/v1/image/cdn.example.com/photos/image.jpg%00.png/800x600.jpeg",
|
|
},
|
|
{
|
|
name: "parent at start of path",
|
|
input: "/v1/image/cdn.example.com/../800x600.jpeg",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := ParseImageURL(tt.input)
|
|
if err == nil {
|
|
t.Error("ParseImageURL() should reject path traversal attempts")
|
|
}
|
|
|
|
if !errors.Is(err, ErrPathTraversal) {
|
|
t.Errorf("ParseImageURL() error = %v, want ErrPathTraversal", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestParseImageURL_SourceURLHoldingPercent25 verifies that a source path and
|
|
// a source query holding %25, the percent-escape of a literal "%", are
|
|
// accepted as they are, although decoding them once more leaves a "%" that
|
|
// does not start a percent-escape.
|
|
func TestParseImageURL_SourceURLHoldingPercent25(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want *Request
|
|
}{
|
|
{
|
|
name: "path",
|
|
input: "/v1/image/cdn.example.com/sale/50%25off.jpg/800x600.webp",
|
|
want: &Request{
|
|
Source: Source{Host: testHostCDN, Path: "/sale/50%25off.jpg"},
|
|
Transform: Transform{Size: Size{Width: 800, Height: 600}, Format: FormatWebP},
|
|
},
|
|
},
|
|
{
|
|
name: "query",
|
|
input: "/v1/image/cdn.example.com/photos/cat.jpg?discount=50%25/800x600.webp",
|
|
want: &Request{
|
|
Source: Source{
|
|
Host: testHostCDN, Path: testPathCat, Query: "discount=50%25",
|
|
},
|
|
Transform: Transform{Size: Size{Width: 800, Height: 600}, Format: FormatWebP},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got, err := ParseImageURL(tt.input)
|
|
if err != nil {
|
|
t.Fatalf("ParseImageURL() unexpected error = %v", err)
|
|
}
|
|
|
|
assertRequest(t, got, tt.want)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestParseImageURL_DoubleEncodedTraversalNextToPercent verifies that a
|
|
// double-encoded ".." is still refused when the path also holds a "%" that
|
|
// does not start a percent-escape, either as written or after one more
|
|
// decoding.
|
|
func TestParseImageURL_DoubleEncodedTraversalNextToPercent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, input := range []string{
|
|
"/v1/image/cdn.example.com/photos/%252e%252e/secret/50%25off.jpg/800x600.jpeg",
|
|
"/v1/image/cdn.example.com/photos/%252e%252e/secret/100%/800x600.jpeg",
|
|
} {
|
|
_, err := ParseImageURL(input)
|
|
t.Logf("ParseImageURL(%q) error = %v", input, err)
|
|
|
|
if !errors.Is(err, ErrPathTraversal) {
|
|
t.Errorf("ParseImageURL(%q) error = %v, want ErrPathTraversal",
|
|
input, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// errorIs checks if err matches target (handles wrapped errors).
|
|
func errorIs(err, target error) bool {
|
|
if errors.Is(err, target) {
|
|
return true
|
|
}
|
|
// Check if error message contains target message for wrapped errors
|
|
if err != nil && target != nil {
|
|
return contains(err.Error(), target.Error())
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func contains(s, substr string) bool {
|
|
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsAt(s, substr))
|
|
}
|
|
|
|
func containsAt(s, substr string) bool {
|
|
for i := 0; i <= len(s)-len(substr); i++ {
|
|
if s[i:i+len(substr)] == substr {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|