Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a2aa94691 |
@@ -36,15 +36,16 @@ P2: security: per-origin rate limiting
|
||||
- `Service.Warm` and the `ImageCache` interface
|
||||
- `Service.GenerateSignedURL` and `Signer.GenerateSignedURL`
|
||||
- `signature.ParseParams`
|
||||
- `PeekAndValidate`, `IsSupportedMIMEType` and `ImageFormatToMIME` of
|
||||
`internal/magic`
|
||||
- `imagerequest.ParseImagePath`
|
||||
- `PeekAndValidate`, `IsSupportedMIMEType`, `ImageFormatToMIME` and
|
||||
`FormatOriginal` of `internal/magic`
|
||||
- `SupportedInputFormats` and `SupportedOutputFormats` of the image
|
||||
processor
|
||||
- `IsEmpty` and `Count` of the allowlist
|
||||
- `ssrfSafeDialer`
|
||||
- `CacheStale`, `ErrCacheMiss`, `ErrNegativeCache` and
|
||||
`ImageResponse.LastModified`
|
||||
- `VariantStorage.Load`
|
||||
- `ContentStorage.Load` and `VariantStorage.Load`
|
||||
- 2026-10-26 cache hits, misses, upstream fetches, the bytes those fetches read
|
||||
and transforms are counted as Prometheus counters in memory (closes #311).
|
||||
They are `pixa_cache_hits_total`, `pixa_cache_misses_total`,
|
||||
|
||||
@@ -66,23 +66,6 @@ var queryEscapeRegex = regexp.MustCompile(`%[0-9A-Fa-f]{2}|[^!-~]|["#'<>%]`)
|
||||
var hostRegex = regexp.MustCompile(
|
||||
`^(?:[A-Za-z0-9_.-]+|\[([0-9A-Fa-f:.]+)\])(?::[0-9]*)?$`)
|
||||
|
||||
// ParseImagePath parses an image URL's path after /v1/image/, decoded once:
|
||||
// <host>/<path>/<size>.<format>, or <host>/<path>/<size> for JPEG XL.
|
||||
// Examples:
|
||||
// - cdn.example.com/photos/cat.jpg/800x600.webp
|
||||
// - cdn.example.com/photos/cat.jpg/0x0.jpeg
|
||||
// - cdn.example.com/photos/cat.jpg/orig.png
|
||||
// - cdn.example.com/photos/cat.jpg/800x600
|
||||
func ParseImagePath(path string) (*Request, error) {
|
||||
// Strip leading slash if present (chi may include it)
|
||||
path = strings.TrimPrefix(path, "/")
|
||||
if path == "" {
|
||||
return nil, ErrMissingHost
|
||||
}
|
||||
|
||||
return parseImageComponents(path)
|
||||
}
|
||||
|
||||
// ParseImageURL parses an image URL's path, decoded once as r.URL.Path holds
|
||||
// it: /v1/image/<host>/<path>/<size>.<format>, or /v1/image/<host>/<path>/<size>
|
||||
// for JPEG XL. A source query follows the source path after a "?", which the
|
||||
|
||||
@@ -192,76 +192,16 @@ func TestParseImageURL_Errors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImagePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// ParseImagePath is for chi wildcard capture (no /v1/image/ prefix)
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want *Request
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "chi wildcard capture",
|
||||
input: "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: "with leading slash from chi",
|
||||
input: "/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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := ParseImagePath(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ParseImagePath() error = %v, wantErr %v", err, tt.wantErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
assertRequest(t, got, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseImagePath_LowerCasesHost verifies that the host is lower-cased, so
|
||||
// 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 TestParseImagePath_LowerCasesHost(t *testing.T) {
|
||||
func TestParseImageURL_LowerCasesHost(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := ParseImagePath("CDN.Example.COM/Photos/Cat.jpg/800x600.webp")
|
||||
got, err := ParseImageURL("/v1/image/CDN.Example.COM/Photos/Cat.jpg/800x600.webp")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseImagePath() error = %v", err)
|
||||
t.Fatalf("ParseImageURL() error = %v", err)
|
||||
}
|
||||
|
||||
t.Logf("parsed host %q, path %q", got.Source.Host, got.Source.Path)
|
||||
@@ -275,21 +215,21 @@ func TestParseImagePath_LowerCasesHost(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseImagePath_RefusesUserinfo verifies that a host holding "@" is
|
||||
// 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 TestParseImagePath_RefusesUserinfo(t *testing.T) {
|
||||
func TestParseImageURL_RefusesUserinfo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, input := range []string{
|
||||
"user@cdn.example.com/photos/cat.jpg/800x600.webp",
|
||||
"user:password@cdn.example.com/photos/cat.jpg/800x600.webp",
|
||||
"/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 := ParseImagePath(input)
|
||||
t.Logf("ParseImagePath(%q) = %+v, error = %v", input, got, err)
|
||||
got, err := ParseImageURL(input)
|
||||
t.Logf("ParseImageURL(%q) = %+v, error = %v", input, got, err)
|
||||
|
||||
if !errors.Is(err, ErrInvalidHost) {
|
||||
t.Errorf("ParseImagePath(%q) error = %v, want %v",
|
||||
t.Errorf("ParseImageURL(%q) error = %v, want %v",
|
||||
input, err, ErrInvalidHost)
|
||||
}
|
||||
}
|
||||
@@ -315,6 +255,10 @@ func TestParseImageURL_PathTraversal(t *testing.T) {
|
||||
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",
|
||||
@@ -357,40 +301,6 @@ func TestParseImageURL_PathTraversal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImagePath_PathTraversal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test path traversal via ParseImagePath (chi wildcard)
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{
|
||||
name: "parent directory in path",
|
||||
input: "cdn.example.com/photos/../secret/800x600.jpeg",
|
||||
},
|
||||
{
|
||||
name: "encoded traversal",
|
||||
input: "cdn.example.com/photos/%2e%2e/secret/800x600.jpeg",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := ParseImagePath(tt.input)
|
||||
if err == nil {
|
||||
t.Error("ParseImagePath() should reject path traversal attempts")
|
||||
}
|
||||
|
||||
if !errors.Is(err, ErrPathTraversal) {
|
||||
t.Errorf("ParseImagePath() 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
|
||||
|
||||
@@ -290,9 +290,9 @@ func TestContentStorage_StoreReplacesFileOfAnotherSize(t *testing.T) {
|
||||
t.Fatalf("Store() error = %v", err)
|
||||
}
|
||||
|
||||
stored, err := storage.Load(key)
|
||||
stored, _, err := storage.LoadWithSize(key)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
t.Fatalf("LoadWithSize() error = %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = stored.Close() }()
|
||||
|
||||
@@ -62,22 +62,6 @@ func (s *ContentStorage) Store(
|
||||
return int64(len(data)), nil
|
||||
}
|
||||
|
||||
// Load returns a reader for the content with the given key.
|
||||
func (s *ContentStorage) Load(key imagerequest.SourceKey) (io.ReadCloser, error) {
|
||||
path := s.keyToPath(key)
|
||||
|
||||
f, err := os.Open(path) //nolint:gosec // path derived from source key
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to open content: %w", err)
|
||||
}
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// LoadWithSize returns a reader and file size for the content with the given key.
|
||||
func (s *ContentStorage) LoadWithSize(
|
||||
key imagerequest.SourceKey,
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||
)
|
||||
|
||||
func TestContentStorage_StoreAndLoad(t *testing.T) {
|
||||
func TestContentStorage_StoreAndLoadWithSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
@@ -44,9 +44,9 @@ func TestContentStorage_StoreAndLoad(t *testing.T) {
|
||||
}
|
||||
|
||||
// Load and verify content
|
||||
r, err := storage.Load(key)
|
||||
r, _, err := storage.LoadWithSize(key)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
t.Fatalf("LoadWithSize() error = %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = r.Close() }()
|
||||
@@ -57,11 +57,11 @@ func TestContentStorage_StoreAndLoad(t *testing.T) {
|
||||
}
|
||||
|
||||
if !bytes.Equal(loaded, content) {
|
||||
t.Errorf("Load() content = %q, want %q", loaded, content)
|
||||
t.Errorf("LoadWithSize() content = %q, want %q", loaded, content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentStorage_LoadNotFound(t *testing.T) {
|
||||
func TestContentStorage_LoadWithSizeNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
@@ -71,9 +71,9 @@ func TestContentStorage_LoadNotFound(t *testing.T) {
|
||||
t.Fatalf("NewContentStorage() error = %v", err)
|
||||
}
|
||||
|
||||
_, err = storage.Load(imagerequest.SourceKey("nonexistent"))
|
||||
_, _, err = storage.LoadWithSize(imagerequest.SourceKey("nonexistent"))
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("Load() error = %v, want ErrNotFound", err)
|
||||
t.Errorf("LoadWithSize() error = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,13 +35,12 @@ type ImageFormat string
|
||||
|
||||
// Supported image output formats.
|
||||
const (
|
||||
FormatOriginal ImageFormat = "orig"
|
||||
FormatJPEG ImageFormat = "jpeg"
|
||||
FormatPNG ImageFormat = "png"
|
||||
FormatWebP ImageFormat = "webp"
|
||||
FormatAVIF ImageFormat = "avif"
|
||||
FormatJXL ImageFormat = "jxl"
|
||||
FormatGIF ImageFormat = "gif"
|
||||
FormatJPEG ImageFormat = "jpeg"
|
||||
FormatPNG ImageFormat = "png"
|
||||
FormatWebP ImageFormat = "webp"
|
||||
FormatAVIF ImageFormat = "avif"
|
||||
FormatJXL ImageFormat = "jxl"
|
||||
FormatGIF ImageFormat = "gif"
|
||||
)
|
||||
|
||||
// MinMagicBytes is the minimum number of bytes needed to detect format.
|
||||
|
||||
Reference in New Issue
Block a user