diff --git a/README.md b/README.md index 49c74bb..9525dcc 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,10 @@ once, is refused with 400. both optional (values under Signature Specification). Both are part of what is cached, so each value of either is a separate cached image. +A size with one side `0` has that side scaled to keep the source's proportions, +and is refused with 400 when that side would come out above 8192, the most +either side may be. + The source image may be JPEG, PNG, GIF, WebP, AVIF or JPEG XL. The upstream's `Content-Type` must name its format, and the image's first bytes must match it. An upstream answering with any other `Content-Type`, SVG included, is refused @@ -345,7 +349,8 @@ nor change what it asks for. (`POST /generate`). The format is JPEG XL unless another is chosen; a form sent with an empty format, or none, also makes a JPEG XL URL. Width and height both empty or `0` keep the original size; if only one of them is empty - or `0`, that side is scaled to keep the image's proportions. + or `0`, that side is scaled to keep the image's proportions. A URL whose + scaled side would come out above 8192 answers 400. 3. The page shows the URL, `https:///v1/e//img.`, and when it expires. `` is the host the page was opened on, and the URL starts with `http` instead while `debug` is on. The name after the token is ignored diff --git a/TODO.md b/TODO.md index 5a5cf5d..2ec0e84 100644 --- a/TODO.md +++ b/TODO.md @@ -30,6 +30,15 @@ P2: security: per-origin rate limiting # Completed Steps +- 2026-10-13 a size with one side `0` no longer makes an output above 8192 on a + side (closes #241): that side is computed from the source's proportions, and + nothing held it to the 8192 a requested side is held to, so `0x8192` of a + 3000x600 source was resized to 40960x8192 and encoded. The image processor now + refuses such a size with `ErrOutputTooLarge` once it knows the source's + dimensions, before resizing, and both image routes answer it 400 with the JSON + error body. The refusal is not cached as a failed fetch, so the same source is + still served at other sizes. `imgcache.MaxDimension` is now the image + processor's `MaxOutputDimension`, so the two cannot differ. - 2026-10-13 the unused cache lifetime is removed (closes #69): `CacheConfig.CacheTTL`, which pixad set to `DefaultCacheTTL` (24 hours), was never read, so cached images never expired. The field and the constant are diff --git a/internal/handlers/image.go b/internal/handlers/image.go index 9978565..aefde8e 100644 --- a/internal/handlers/image.go +++ b/internal/handlers/image.go @@ -255,6 +255,14 @@ func (s *Handlers) respondImageError( return } + if errors.Is(err, imageprocessor.ErrOutputTooLarge) { + s.respondError(w, fmt.Sprintf("size too large for the source: the side "+ + "computed from its proportions is above %d", + imageprocessor.MaxOutputDimension), http.StatusBadRequest) + + return + } + s.respondError(w, "internal error", http.StatusInternalServerError) } diff --git a/internal/handlers/imageenc.go b/internal/handlers/imageenc.go index 816e83f..8da6d47 100644 --- a/internal/handlers/imageenc.go +++ b/internal/handlers/imageenc.go @@ -165,6 +165,10 @@ func (s *Handlers) handleImageError(w http.ResponseWriter, err error) { errors.Is(err, imageprocessor.ErrUnsupportedInputFormat): s.respondError(w, "unsupported source format", http.StatusUnsupportedMediaType) + case errors.Is(err, imageprocessor.ErrOutputTooLarge): + s.respondError(w, fmt.Sprintf("size too large for the source: the side "+ + "computed from its proportions is above %d", + imageprocessor.MaxOutputDimension), http.StatusBadRequest) default: s.log.Error("image request failed", "error", err) s.respondError(w, "internal error", http.StatusInternalServerError) diff --git a/internal/imageprocessor/imageprocessor.go b/internal/imageprocessor/imageprocessor.go index 9f08387..2bac86d 100644 --- a/internal/imageprocessor/imageprocessor.go +++ b/internal/imageprocessor/imageprocessor.go @@ -133,6 +133,14 @@ var ErrInputTooLarge = errors.New("input image dimensions exceed maximum") // configured byte limit. var ErrInputDataTooLarge = errors.New("input data exceeds maximum allowed size") +// MaxOutputDimension is the maximum width or height of an output image, and +// so of a requested size (imgcache.MaxDimension). +const MaxOutputDimension = 8192 + +// ErrOutputTooLarge is returned when a size with one side 0 would give that +// side, computed from the input's proportions, more than MaxOutputDimension. +var ErrOutputTooLarge = errors.New("output image dimensions exceed maximum") + // ErrUnsupportedOutputFormat is returned when the requested output format is // not supported. var ErrUnsupportedOutputFormat = errors.New("unsupported output format") @@ -262,6 +270,13 @@ func (p *ImageProcessor) Process( // Determine target dimensions targetWidth, targetHeight := targetDimensions(req.Size, origWidth, origHeight) + // A side given as 0 is computed from the input's proportions, so it can + // come out above MaxOutputDimension; refuse it before resizing + if targetWidth > MaxOutputDimension || targetHeight > MaxOutputDimension { + return nil, fmt.Errorf("%w: %dx%d is above %d", ErrOutputTooLarge, + targetWidth, targetHeight, MaxOutputDimension) + } + // Resize if needed if targetWidth != origWidth || targetHeight != origHeight { err := p.resize(img, targetWidth, targetHeight, req.FitMode) diff --git a/internal/imgcache/urlparser.go b/internal/imgcache/urlparser.go index b11d80e..0d5e6c5 100644 --- a/internal/imgcache/urlparser.go +++ b/internal/imgcache/urlparser.go @@ -7,6 +7,8 @@ import ( "regexp" "strconv" "strings" + + "sneak.berlin/go/pixa/internal/imageprocessor" ) // URL parsing errors. @@ -20,8 +22,9 @@ var ( ErrPathTraversal = errors.New("path traversal detected") ) -// MaxDimension is the maximum allowed width or height. -const MaxDimension = 8192 +// MaxDimension is the maximum allowed width or height, the same as the image +// processor's maximum for an output image. +const MaxDimension = imageprocessor.MaxOutputDimension // ValidateDimension checks one requested width or height; name ("width" or // "height") appears in the error. 0 means "original size" and is valid.