Compare commits

..
1 Commits
Author SHA1 Message Date
sneak 53e9dc6eff Test that both image routes fetch an IPv6 source host without a port
check / check (push) Canceled after 0s
A router test against a test upstream that records each request: an
IPv6 source host written with and without a port, through a /v1/image/
URL with a signature and through an encrypted URL. A public address must
be fetched, a private one refused with 403 without reaching the
upstream. The cases without a port fail: each answers 500.

Model: opus-5-5
2026-10-16 02:11:19 +00:00
8 changed files with 41 additions and 192 deletions

No files matched your search

+3 -11
View File
@@ -536,13 +536,8 @@ Where:
- `query` — source query string as under Routes, empty string if none, with `%` - `query` — source query string as under Routes, empty string if none, with `%`
and `:` written as in `path`; neither then holds a `:`, so path `/a:b` with no and `:` written as in `path`; neither then holds a `:`, so path `/a:b` with no
query and path `/a` with query `b:` are signed differently query and path `/a` with query `b:` are signed differently
- `width` — requested width in pixels, or `0`: the width is then worked out from - `width` — requested width in pixels, `0` for original
the source's proportions and the requested height, rounded down but at least - `height` — requested height in pixels, `0` for original
1, or is the source's own width when the height is `0` too (the `orig` size)
- `height` — requested height in pixels, or `0`: the height is then worked out
from the source's proportions and the requested width, rounded down but at
least 1, or is the source's own height when the width is `0` too (the `orig`
size)
- `format` — output format, one of those listed under Routes, with `original` - `format` — output format, one of those listed under Routes, with `original`
signed as `orig`, `jpg` as `jpeg`, and no format as `jxl`, so a URL with no signed as `orig`, `jpg` as `jpeg`, and no format as `jxl`, so a URL with no
format has the signature of the same URL ending in `.jxl`; `auto` is signed as format has the signature of the same URL ending in `.jxl`; `auto` is signed as
@@ -555,10 +550,7 @@ Where:
with 400 with 400
- `fit` — the URL's `fit` query parameter (cover, contain, fill, inside, - `fit` — the URL's `fit` query parameter (cover, contain, fill, inside,
outside), or `cover` when the URL has no `fit`; a request whose `fit` is outside), or `cover` when the URL has no `fit`; a request whose `fit` is
anything else, an empty `fit=` included, is refused with 400. `outside` scales anything else, an empty `fit=` included, is refused with 400
the image to cover the requested size, keeping its proportions, so it can come
out larger than that size on one side: an 8192x1 source at `100x0`, which is
100x1 by its proportions, comes out at 8192x1
The URL's `sig` is the HMAC-SHA256 result in base64url (the URL-safe alphabet of The URL's `sig` is the HMAC-SHA256 result in base64url (the URL-safe alphabet of
RFC 4648) with the trailing `=` padding kept, 44 characters in all. pixa RFC 4648) with the trailing `=` padding kept, 44 characters in all. pixa
-20
View File
@@ -30,26 +30,6 @@ P2: security: per-origin rate limiting
# Completed Steps # Completed Steps
- 2026-10-16 a source host that is an IPv6 address in brackets with no port,
such as `[2001:db8::a]`, is fetched like one with a port, through `/v1/image/`
and through an encrypted URL (closes #295). The fetcher's check of the source
URL kept the brackets when no port followed them, could not look the host up,
and the request answered 500. It now checks the address without brackets, so a
private one is refused with 403, as with a port.
- 2026-10-16 a size with one side `0` keeps the side computed from the source's
proportions at 1 or more (closes #283): it was rounded down, so for a thin
source it came out at 0, and libvips then made the size square. An 8192x1
source at `100x0` came out at 100x100, and at `1300x0` with `fit=cover` made
libvips ask for about 39 GB at once; an 8192x2 source at `100x0` answered 500.
`fit=contain` and `fit=inside` work both sides out again from the smaller of
the two scales and `fit=outside` from the larger, and each keeps both sides at
1 or more too, so a 1x8192 source at `0x100` with `fit=contain`, and a 49x8192
source at `0x100` or a 49x49 source at `1x1` with `fit=outside`, no longer
answer 500. An 8192x1 source at `1300x0` now comes out at 1300x1, and the
process running it alone peaked at about 34 MiB resident. With `fit=outside`
the image is scaled to cover the size, so that source at `100x0` comes out at
8192x1. `README.md` now says this under Signature Specification, and says how
a `0` side is worked out where it said `0` meant the original.
- 2026-10-16 the generator page lower-cases the source host and writes an empty - 2026-10-16 the generator page lower-cases the source host and writes an empty
source path as `/` before it makes the encrypted URL, as `/v1/image/` does source path as `/` before it makes the encrypted URL, as `/v1/image/` does
(closes #293). A host written in upper and in lower case, or a source URL with (closes #293). A host written in upper and in lower case, or a source URL with
+8 -8
View File
@@ -473,18 +473,18 @@ func validateURL(ctx context.Context, rawURL string, allowHTTP bool) error {
return ErrUnsupportedScheme return ErrUnsupportedScheme
} }
parsedURL, err := neturl.Parse(rawURL) // Parse to extract host
if err != nil { host := extractHost(rawURL)
return fmt.Errorf("%w: %w", ErrInvalidHost, err)
}
// Hostname drops any port, and the brackets of an IPv6 address whether
// or not a port follows them
host := parsedURL.Hostname()
if host == "" { if host == "" {
return ErrInvalidHost return ErrInvalidHost
} }
// Remove port if present
h, _, err := net.SplitHostPort(host)
if err == nil {
host = h
}
// Block obvious localhost patterns // Block obvious localhost patterns
if isLocalhost(host) { if isLocalhost(host) {
return ErrSSRFBlocked return ErrSSRFBlocked
@@ -50,69 +50,3 @@ func TestImageProcessor_RefusesComputedSideAboveMaximum(t *testing.T) {
}) })
} }
} }
// TestImageProcessor_ComputedSideIsAtLeastOne verifies that, for a size with
// one side 0, the side computed from the input's proportions is 1 where it
// would round down to 0, and that Process makes that size in every fit mode
// but FitOutside, which scales the input to cover the size and so can make
// it longer. Each case checks the size targetDimensions gives before it
// calls Process: with that side at 0, an 8192x1 input at 1300x0 makes
// libvips ask for about 39 GB with FitCover.
func TestImageProcessor_ComputedSideIsAtLeastOne(t *testing.T) {
t.Parallel()
tests := []struct {
name string
inputWidth, inputHeight int
size Size
wantWidth, wantHeight int
}{
{"8192x1 at 100x0", 8192, 1, Size{Width: 100}, 100, 1},
{"8192x1 at 1300x0", 8192, 1, Size{Width: 1300}, 1300, 1},
{"8192x2 at 100x0", 8192, 2, Size{Width: 100}, 100, 1},
{"1x8192 at 0x100", 1, 8192, Size{Height: 100}, 1, 100},
{"1x8192 at 0x1300", 1, 8192, Size{Height: 1300}, 1, 1300},
{"2x8192 at 0x100", 2, 8192, Size{Height: 100}, 1, 100},
}
fits := []FitMode{FitCover, FitContain, FitFill, FitInside, FitOutside}
for _, tt := range tests {
for _, fit := range fits {
t.Run(tt.name+" "+string(fit), func(t *testing.T) {
t.Parallel()
width, height, err := targetDimensions(tt.size,
tt.inputWidth, tt.inputHeight)
if err != nil || width != tt.wantWidth || height != tt.wantHeight {
t.Fatalf("targetDimensions() = %dx%d, %v, want %dx%d",
width, height, err, tt.wantWidth, tt.wantHeight)
}
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
result, err := New(Params{}).Process(t.Context(),
bytes.NewReader(input),
&Request{Size: tt.size, Format: FormatJPEG, FitMode: fit})
if err != nil {
t.Fatalf("Process() error = %v", err)
}
defer func() { _ = result.Content.Close() }()
t.Logf("Process() made %dx%d", result.Width, result.Height)
ok := result.Width == tt.wantWidth && result.Height == tt.wantHeight
if fit == FitOutside {
ok = result.Width >= tt.wantWidth && result.Height >= tt.wantHeight
}
if !ok {
t.Errorf("Process() made %dx%d, want %dx%d (at least, for %s)",
result.Width, result.Height, tt.wantWidth, tt.wantHeight,
FitOutside)
}
})
}
}
}
@@ -60,45 +60,3 @@ func TestImageProcessor_RefusesFitOutsideAboveMaximum(t *testing.T) {
}) })
} }
} }
// TestImageProcessor_FitOutsideSideIsAtLeastOne verifies that FitOutside
// keeps each side at 1 or more when it works both sides out again from the
// larger scale. A side of 49 scaled to 1 is 49 times 1/49, which in floating
// point is just under 1 and so rounded down to 0, a width libvips refuses.
func TestImageProcessor_FitOutsideSideIsAtLeastOne(t *testing.T) {
t.Parallel()
tests := []struct {
name string
inputWidth, inputHeight int
size Size
wantWidth, wantHeight int
}{
{"49x8192 at 0x100", 49, 8192, Size{Height: 100}, 1, 167},
{"49x49 at 1x1", 49, 49, Size{Width: 1, Height: 1}, 1, 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
result, err := New(Params{}).Process(t.Context(),
bytes.NewReader(input),
&Request{Size: tt.size, Format: FormatJPEG, FitMode: FitOutside})
if err != nil {
t.Fatalf("Process() error = %v", err)
}
defer func() { _ = result.Content.Close() }()
t.Logf("Process() made %dx%d", result.Width, result.Height)
if result.Width != tt.wantWidth || result.Height != tt.wantHeight {
t.Errorf("Process() made %dx%d, want %dx%d",
result.Width, result.Height, tt.wantWidth, tt.wantHeight)
}
})
}
}
+12 -20
View File
@@ -311,10 +311,8 @@ func (p *ImageProcessor) Process(
// targetDimensions calculates the output dimensions for a requested size, // targetDimensions calculates the output dimensions for a requested size,
// scaling proportionally when only one dimension is given and keeping the // scaling proportionally when only one dimension is given and keeping the
// original dimensions when both are zero. A side scaled proportionally is // original dimensions when both are zero. A side scaled proportionally can
// rounded down but kept at 1 or more, as for a thin image it would round // come out above MaxOutputDimension, which is ErrOutputTooLarge.
// down to 0, a side of no pixels. It can come out above MaxOutputDimension,
// which is ErrOutputTooLarge.
func targetDimensions(size Size, origWidth, origHeight int) (int, int, error) { func targetDimensions(size Size, origWidth, origHeight int) (int, int, error) {
width, height := size.Width, size.Height width, height := size.Width, size.Height
@@ -324,10 +322,10 @@ func targetDimensions(size Size, origWidth, origHeight int) (int, int, error) {
return origWidth, origHeight, nil return origWidth, origHeight, nil
case width == 0: case width == 0:
// Only height specified: calculate width proportionally // Only height specified: calculate width proportionally
width = max(1, origWidth*height/origHeight) width = origWidth * height / origHeight
case height == 0: case height == 0:
// Only width specified: calculate height proportionally // Only width specified: calculate height proportionally
height = max(1, origHeight*width/origWidth) height = origHeight * width / origWidth
} }
if width > MaxOutputDimension || height > MaxOutputDimension { if width > MaxOutputDimension || height > MaxOutputDimension {
@@ -481,16 +479,13 @@ func (p *ImageProcessor) resize(
return cropCentreAndResize(img, width, height) return cropCentreAndResize(img, width, height)
case FitContain: case FitContain:
// Resize to fit within dimensions, maintaining aspect ratio. Scaled // Resize to fit within dimensions, maintaining aspect ratio
// by the smaller scale, the short side of a thin image can round
// down to 0, which libvips refuses as a width, so each side is at
// least 1.
imgW, imgH := img.Width(), img.Height() imgW, imgH := img.Width(), img.Height()
scaleW := float64(width) / float64(imgW) scaleW := float64(width) / float64(imgW)
scaleH := float64(height) / float64(imgH) scaleH := float64(height) / float64(imgH)
scale := min(scaleW, scaleH) scale := min(scaleW, scaleH)
newW := max(1, int(float64(imgW)*scale)) newW := int(float64(imgW) * scale)
newH := max(1, int(float64(imgH)*scale)) newH := int(float64(imgH) * scale)
return img.Thumbnail(newW, newH, vips.InterestingNone) return img.Thumbnail(newW, newH, vips.InterestingNone)
@@ -508,22 +503,19 @@ func (p *ImageProcessor) resize(
scaleW := float64(width) / float64(imgW) scaleW := float64(width) / float64(imgW)
scaleH := float64(height) / float64(imgH) scaleH := float64(height) / float64(imgH)
scale := min(scaleW, scaleH) scale := min(scaleW, scaleH)
newW := max(1, int(float64(imgW)*scale)) newW := int(float64(imgW) * scale)
newH := max(1, int(float64(imgH)*scale)) newH := int(float64(imgH) * scale)
return img.Thumbnail(newW, newH, vips.InterestingNone) return img.Thumbnail(newW, newH, vips.InterestingNone)
case FitOutside: case FitOutside:
// Resize so smallest dimension fits, may exceed target on other // Resize so smallest dimension fits, may exceed target on other dimension
// dimension. A side scaled to 1 can come out just under 1 in
// floating point, as 49 times 1/49 does, and round down to 0, which
// libvips refuses as a width, so each side is at least 1.
imgW, imgH := img.Width(), img.Height() imgW, imgH := img.Width(), img.Height()
scaleW := float64(width) / float64(imgW) scaleW := float64(width) / float64(imgW)
scaleH := float64(height) / float64(imgH) scaleH := float64(height) / float64(imgH)
scale := max(scaleW, scaleH) scale := max(scaleW, scaleH)
newW := max(1, int(float64(imgW)*scale)) newW := int(float64(imgW) * scale)
newH := max(1, int(float64(imgH)*scale)) newH := int(float64(imgH) * scale)
// The dimension that exceeds the target is held to the maximum too // The dimension that exceeds the target is held to the maximum too
if newW > MaxOutputDimension || newH > MaxOutputDimension { if newW > MaxOutputDimension || newH > MaxOutputDimension {
+1 -3
View File
@@ -29,9 +29,7 @@ var (
const MaxDimension = imageprocessor.MaxOutputDimension const MaxDimension = imageprocessor.MaxOutputDimension
// ValidateDimension checks one requested width or height; name ("width" or // ValidateDimension checks one requested width or height; name ("width" or
// "height") appears in the error. 0 is valid: that side is then worked out from // "height") appears in the error. 0 means "original size" and is valid.
// the source's proportions and the other requested side, rounded down but at
// least 1, or is the source's own side when the other side is 0 too.
func ValidateDimension(name string, value int) error { func ValidateDimension(name string, value int) error {
if value < 0 { if value < 0 {
return fmt.Errorf("%w: %s is negative", ErrInvalidSize, name) return fmt.Errorf("%w: %s is negative", ErrInvalidSize, name)
+17 -22
View File
@@ -438,18 +438,14 @@ func TestGeneratorRefusesSourceURLItCannotFetchAsWritten(t *testing.T) {
} }
} }
// ipv6SourcePath is the source path of the image URLs signedImageURL and // TestImageRoutesFetchIPv6SourceHostWithOrWithoutPort requests the source
// encryptedImageURL make. // path /a.png on an IPv6 source host, written with and without a port, through
const ipv6SourcePath = "/ipv6.png" // a /v1/image/ URL with a signature and through an encrypted URL. The public
// TestImageRoutesFetchIPv6SourceHostWithOrWithoutPort requests ipv6SourcePath
// on an IPv6 source host, written with and without a port, through a
// /v1/image/ URL with a signature and through an encrypted URL. The public
// address 2001:db8::a, a documentation address (RFC 3849) the fetcher accepts // address 2001:db8::a, a documentation address (RFC 3849) the fetcher accepts
// as public, answers 200, and the upstream receives the request for // as public, answers 200, and the upstream receives the request for /a.png.
// ipv6SourcePath. The private address fd00::1 answers 403, and the upstream // The private address fd00::1 answers 403, and the upstream receives no
// receives no request: the test's fetcher connects every address to the test // request: the test's fetcher connects every address to the test upstream, so
// upstream, so only the fetcher's check of the source URL can refuse it. // only the fetcher's check of the source URL can refuse it.
func TestImageRoutesFetchIPv6SourceHostWithOrWithoutPort(t *testing.T) { func TestImageRoutesFetchIPv6SourceHostWithOrWithoutPort(t *testing.T) {
t.Parallel() t.Parallel()
@@ -461,9 +457,9 @@ func TestImageRoutesFetchIPv6SourceHostWithOrWithoutPort(t *testing.T) {
wantRequests []string wantRequests []string
}{ }{
{"public address without a port", "[2001:db8::a]", {"public address without a port", "[2001:db8::a]",
http.StatusOK, []string{ipv6SourcePath}}, http.StatusOK, []string{"/a.png"}},
{"public address with a port", "[2001:db8::a]:8443", {"public address with a port", "[2001:db8::a]:8443",
http.StatusOK, []string{ipv6SourcePath}}, http.StatusOK, []string{"/a.png"}},
{"private address without a port", "[fd00::1]", {"private address without a port", "[fd00::1]",
http.StatusForbidden, nil}, http.StatusForbidden, nil},
{"private address with a port", "[fd00::1]:8443", {"private address with a port", "[fd00::1]:8443",
@@ -504,17 +500,17 @@ func TestImageRoutesFetchIPv6SourceHostWithOrWithoutPort(t *testing.T) {
} }
} }
// signedImageURL returns the /v1/image/ URL of ipv6SourcePath on sourceHost at // signedImageURL returns the /v1/image/ URL of the source path /a.png on
// its original size as PNG, with a signature made with the test signing key, // sourceHost at its original size as PNG, with a signature made with the test
// which a host not on the allowlist needs. The URL has no q or fit, so it is // signing key, which a host not on the allowlist needs. The URL has no q or
// signed with the quality 85 and the fit cover. // fit, so it is signed with the quality 85 and the fit cover.
func signedImageURL(t *testing.T, sourceHost string) string { func signedImageURL(t *testing.T, sourceHost string) string {
t.Helper() t.Helper()
path, sig, exp := signature.New(testSigningKey).GenerateSignedURL( path, sig, exp := signature.New(testSigningKey).GenerateSignedURL(
&signature.Request{ &signature.Request{
SourceHost: sourceHost, SourceHost: sourceHost,
SourcePath: ipv6SourcePath, SourcePath: "/a.png",
Format: string(imgcache.FormatPNG), Format: string(imgcache.FormatPNG),
Quality: 85, Quality: 85,
FitMode: string(imgcache.FitCover), FitMode: string(imgcache.FitCover),
@@ -523,8 +519,8 @@ func signedImageURL(t *testing.T, sourceHost string) string {
return path + "?sig=" + sig + "&exp=" + strconv.FormatInt(exp, 10) return path + "?sig=" + sig + "&exp=" + strconv.FormatInt(exp, 10)
} }
// encryptedImageURL returns the encrypted URL of ipv6SourcePath on sourceHost // encryptedImageURL returns the encrypted URL of the source path /a.png on
// at its original size as PNG, made with the test signing key. // sourceHost at its original size as PNG, made with the test signing key.
func encryptedImageURL(t *testing.T, sourceHost string) string { func encryptedImageURL(t *testing.T, sourceHost string) string {
t.Helper() t.Helper()
@@ -534,8 +530,7 @@ func encryptedImageURL(t *testing.T, sourceHost string) string {
} }
token, err := generator.Generate(&encurl.Payload{ token, err := generator.Generate(&encurl.Payload{
SourceHost: sourceHost, SourcePath: ipv6SourcePath, SourceHost: sourceHost, SourcePath: "/a.png", Format: imgcache.FormatPNG,
Format: imgcache.FormatPNG,
}) })
if err != nil { if err != nil {
t.Fatalf("encurl Generate: %v", err) t.Fatalf("encurl Generate: %v", err)