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.Warm` and the `ImageCache` interface
|
||||||
- `Service.GenerateSignedURL` and `Signer.GenerateSignedURL`
|
- `Service.GenerateSignedURL` and `Signer.GenerateSignedURL`
|
||||||
- `signature.ParseParams`
|
- `signature.ParseParams`
|
||||||
- `PeekAndValidate`, `IsSupportedMIMEType` and `ImageFormatToMIME` of
|
- `imagerequest.ParseImagePath`
|
||||||
`internal/magic`
|
- `PeekAndValidate`, `IsSupportedMIMEType`, `ImageFormatToMIME` and
|
||||||
|
`FormatOriginal` of `internal/magic`
|
||||||
- `SupportedInputFormats` and `SupportedOutputFormats` of the image
|
- `SupportedInputFormats` and `SupportedOutputFormats` of the image
|
||||||
processor
|
processor
|
||||||
- `IsEmpty` and `Count` of the allowlist
|
- `IsEmpty` and `Count` of the allowlist
|
||||||
- `ssrfSafeDialer`
|
- `ssrfSafeDialer`
|
||||||
- `CacheStale`, `ErrCacheMiss`, `ErrNegativeCache` and
|
- `CacheStale`, `ErrCacheMiss`, `ErrNegativeCache` and
|
||||||
`ImageResponse.LastModified`
|
`ImageResponse.LastModified`
|
||||||
- `VariantStorage.Load`
|
- `ContentStorage.Load` and `VariantStorage.Load`
|
||||||
- 2026-10-26 cache hits, misses, upstream fetches, the bytes those fetches read
|
- 2026-10-26 cache hits, misses, upstream fetches, the bytes those fetches read
|
||||||
and transforms are counted as Prometheus counters in memory (closes #311).
|
and transforms are counted as Prometheus counters in memory (closes #311).
|
||||||
They are `pixa_cache_hits_total`, `pixa_cache_misses_total`,
|
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(
|
var hostRegex = regexp.MustCompile(
|
||||||
`^(?:[A-Za-z0-9_.-]+|\[([0-9A-Fa-f:.]+)\])(?::[0-9]*)?$`)
|
`^(?:[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
|
// 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>
|
// 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
|
// 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) {
|
// TestParseImageURL_LowerCasesHost verifies that the host is lower-cased, so
|
||||||
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
|
|
||||||
// that every spelling of its letter case is the same host to the allowlist,
|
// 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
|
// the upstream connection limits and the cache, and that the path keeps its
|
||||||
// case.
|
// case.
|
||||||
func TestParseImagePath_LowerCasesHost(t *testing.T) {
|
func TestParseImageURL_LowerCasesHost(t *testing.T) {
|
||||||
t.Parallel()
|
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 {
|
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)
|
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
|
// refused: a URL reads what comes before the "@" as a user name and password
|
||||||
// and the rest as the host.
|
// and the rest as the host.
|
||||||
func TestParseImagePath_RefusesUserinfo(t *testing.T) {
|
func TestParseImageURL_RefusesUserinfo(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
for _, input := range []string{
|
for _, input := range []string{
|
||||||
"user@cdn.example.com/photos/cat.jpg/800x600.webp",
|
"/v1/image/user@cdn.example.com/photos/cat.jpg/800x600.webp",
|
||||||
"user:password@cdn.example.com/photos/cat.jpg/800x600.webp",
|
"/v1/image/user:password@cdn.example.com/photos/cat.jpg/800x600.webp",
|
||||||
} {
|
} {
|
||||||
got, err := ParseImagePath(input)
|
got, err := ParseImageURL(input)
|
||||||
t.Logf("ParseImagePath(%q) = %+v, error = %v", input, got, err)
|
t.Logf("ParseImageURL(%q) = %+v, error = %v", input, got, err)
|
||||||
|
|
||||||
if !errors.Is(err, ErrInvalidHost) {
|
if !errors.Is(err, ErrInvalidHost) {
|
||||||
t.Errorf("ParseImagePath(%q) error = %v, want %v",
|
t.Errorf("ParseImageURL(%q) error = %v, want %v",
|
||||||
input, err, ErrInvalidHost)
|
input, err, ErrInvalidHost)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,6 +255,10 @@ func TestParseImageURL_PathTraversal(t *testing.T) {
|
|||||||
name: "parent in middle of path",
|
name: "parent in middle of path",
|
||||||
input: "/v1/image/cdn.example.com/photos/../../../etc/passwd/800x600.jpeg",
|
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",
|
name: "encoded parent directory",
|
||||||
input: "/v1/image/cdn.example.com/photos/%2e%2e/secret/800x600.jpeg",
|
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
|
// TestParseImageURL_SourceURLHoldingPercent25 verifies that a source path and
|
||||||
// a source query holding %25, the percent-escape of a literal "%", are
|
// a source query holding %25, the percent-escape of a literal "%", are
|
||||||
// accepted as they are, although decoding them once more leaves a "%" that
|
// 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)
|
t.Fatalf("Store() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stored, err := storage.Load(key)
|
stored, _, err := storage.LoadWithSize(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load() error = %v", err)
|
t.Fatalf("LoadWithSize() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = stored.Close() }()
|
defer func() { _ = stored.Close() }()
|
||||||
|
|||||||
@@ -62,22 +62,6 @@ func (s *ContentStorage) Store(
|
|||||||
return int64(len(data)), nil
|
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.
|
// LoadWithSize returns a reader and file size for the content with the given key.
|
||||||
func (s *ContentStorage) LoadWithSize(
|
func (s *ContentStorage) LoadWithSize(
|
||||||
key imagerequest.SourceKey,
|
key imagerequest.SourceKey,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestContentStorage_StoreAndLoad(t *testing.T) {
|
func TestContentStorage_StoreAndLoadWithSize(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
@@ -44,9 +44,9 @@ func TestContentStorage_StoreAndLoad(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load and verify content
|
// Load and verify content
|
||||||
r, err := storage.Load(key)
|
r, _, err := storage.LoadWithSize(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load() error = %v", err)
|
t.Fatalf("LoadWithSize() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() { _ = r.Close() }()
|
defer func() { _ = r.Close() }()
|
||||||
@@ -57,11 +57,11 @@ func TestContentStorage_StoreAndLoad(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !bytes.Equal(loaded, content) {
|
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()
|
t.Parallel()
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
@@ -71,9 +71,9 @@ func TestContentStorage_LoadNotFound(t *testing.T) {
|
|||||||
t.Fatalf("NewContentStorage() error = %v", err)
|
t.Fatalf("NewContentStorage() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = storage.Load(imagerequest.SourceKey("nonexistent"))
|
_, _, err = storage.LoadWithSize(imagerequest.SourceKey("nonexistent"))
|
||||||
if !errors.Is(err, ErrNotFound) {
|
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.
|
// Supported image output formats.
|
||||||
const (
|
const (
|
||||||
FormatOriginal ImageFormat = "orig"
|
FormatJPEG ImageFormat = "jpeg"
|
||||||
FormatJPEG ImageFormat = "jpeg"
|
FormatPNG ImageFormat = "png"
|
||||||
FormatPNG ImageFormat = "png"
|
FormatWebP ImageFormat = "webp"
|
||||||
FormatWebP ImageFormat = "webp"
|
FormatAVIF ImageFormat = "avif"
|
||||||
FormatAVIF ImageFormat = "avif"
|
FormatJXL ImageFormat = "jxl"
|
||||||
FormatJXL ImageFormat = "jxl"
|
FormatGIF ImageFormat = "gif"
|
||||||
FormatGIF ImageFormat = "gif"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// MinMagicBytes is the minimum number of bytes needed to detect format.
|
// MinMagicBytes is the minimum number of bytes needed to detect format.
|
||||||
|
|||||||
Reference in New Issue
Block a user