From 0070f031fa8564d7b6ab77a312be9f236277ba44 Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Tue, 20 Oct 2026 17:37:58 +0200 Subject: [PATCH] fix(thumbnails): give the thumbnail size its own ceiling The source ceiling had to stand in for the box a request may ask for, two limits with different reasons under one name. webdav decides the preview size and never asks for more than its largest resolution; this is the net for everything else. --- services/thumbnails/README.md | 6 ++++ services/thumbnails/pkg/config/config.go | 6 ++-- .../pkg/config/defaults/defaultconfig.go | 2 ++ .../thumbnails/pkg/service/http/v0/push.go | 9 ++++++ .../pkg/service/http/v0/push_helpers_test.go | 12 +++++++ .../pkg/service/http/v0/push_test.go | 14 ++++++++ .../thumbnails/pkg/service/http/v0/service.go | 32 +++++++++++-------- 7 files changed, 65 insertions(+), 16 deletions(-) diff --git a/services/thumbnails/README.md b/services/thumbnails/README.md index dbf0403dc4..50fb2345a7 100644 --- a/services/thumbnails/README.md +++ b/services/thumbnails/README.md @@ -94,9 +94,15 @@ limit; `429` when the concurrency limit is reached; `413` for a body above | `THUMBNAILS_MAX_CONCURRENT_REQUESTS` | Maximum number of thumbnail requests decoded and resized in parallel. Default is 0 (unlimited). Requests arriving while the limit is reached get HTTP 429 with a `Retry-After` header | | `THUMBNAILS_MAX_INPUT_WIDTH` | Maximum width of a source image, 7680 by default. A wider one is refused with HTTP 422, which webdav turns into its legacy 403 | | `THUMBNAILS_MAX_INPUT_HEIGHT` | Maximum height of a source image, 7680 by default | +| `THUMBNAILS_MAX_OUTPUT_WIDTH` | Maximum width of a thumbnail that may be asked for, 2048 by default. A larger box is refused with HTTP 422 | +| `THUMBNAILS_MAX_OUTPUT_HEIGHT` | Maximum height of a thumbnail that may be asked for, 2048 by default | | `THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE` | Maximum size of a posted image, `50MB` by default, empty for unlimited. A larger body is refused with HTTP 413. webdav sends no more than its own limit of the same name, this bounds everything else that reaches the endpoint | | `THUMBNAILS_MAX_ANIMATION_FRAMES` | Maximum number of frames an animated image may have, 100 by default, 0 for unlimited. A file with more frames is refused with HTTP 422 before any frame is decoded. The memory a request can ask for is this limit times the maximum dimensions, so lower it when raising those | +The source and the thumbnail have ceilings of their own, for different reasons. How large a preview may be is webdav's decision, and its largest configured resolution is what it ever asks for; `THUMBNAILS_MAX_OUTPUT_*` is the net under that for anything else reaching this endpoint, because `fill` and `stretch` enlarge a small source to fill the box. How large a source may be is this service's decision, since it is the one that sees the pixels: webdav knows a file's type, size and checksum, not its dimensions. + +A real imagor has one pair of knobs for both, `-vips-max-width` and `-vips-max-height`, which bound every image in its pipeline, the loaded source as well as the result. Set them to the source ceiling. They are also more forgiving than this service for formats libvips can shrink while loading: a 9000 pixel wide JPEG passes a 2048 limit there, where this service refuses it on the header. + ## Using libvips for Image Processing To improve performance and to support a wider range of image formats, the diff --git a/services/thumbnails/pkg/config/config.go b/services/thumbnails/pkg/config/config.go index 2c7258dd33..e40bfbfca6 100644 --- a/services/thumbnails/pkg/config/config.go +++ b/services/thumbnails/pkg/config/config.go @@ -32,8 +32,10 @@ type Config struct { // still rejected, but the enforcement now lives at decode time in this service // (which owns the image) rather than in webdav. type Thumbnail struct { - MaxInputWidth int `yaml:"max_input_width" env:"THUMBNAILS_MAX_INPUT_WIDTH" desc:"The maximum width of an input image which is being processed." introductionVersion:"1.0.0"` - MaxInputHeight int `yaml:"max_input_height" env:"THUMBNAILS_MAX_INPUT_HEIGHT" desc:"The maximum height of an input image which is being processed." introductionVersion:"1.0.0"` + MaxInputWidth int `yaml:"max_input_width" env:"THUMBNAILS_MAX_INPUT_WIDTH" desc:"The maximum width of an input image which is being processed." introductionVersion:"1.0.0"` + MaxOutputWidth int `yaml:"max_output_width" env:"THUMBNAILS_MAX_OUTPUT_WIDTH" desc:"The maximum width of a thumbnail that may be asked for. A larger box is refused with HTTP 422. webdav never asks for more than its largest configured resolution, so this bounds everything else that reaches the endpoint." introductionVersion:"%%NEXT%%"` + MaxOutputHeight int `yaml:"max_output_height" env:"THUMBNAILS_MAX_OUTPUT_HEIGHT" desc:"The maximum height of a thumbnail that may be asked for." introductionVersion:"%%NEXT%%"` + MaxInputHeight int `yaml:"max_input_height" env:"THUMBNAILS_MAX_INPUT_HEIGHT" desc:"The maximum height of an input image which is being processed." introductionVersion:"1.0.0"` // 0 (the default) means unlimited; a request arriving while the limit is // reached gets HTTP 429 with a Retry-After. MaxConcurrentRequests int `yaml:"max_concurrent_requests" env:"THUMBNAILS_MAX_CONCURRENT_REQUESTS" desc:"Maximum number of concurrent thumbnail generation requests. Default is 0 which is unlimited." introductionVersion:"%%NEXT%%"` diff --git a/services/thumbnails/pkg/config/defaults/defaultconfig.go b/services/thumbnails/pkg/config/defaults/defaultconfig.go index 0a21d08f8c..090a3c86dc 100644 --- a/services/thumbnails/pkg/config/defaults/defaultconfig.go +++ b/services/thumbnails/pkg/config/defaults/defaultconfig.go @@ -37,6 +37,8 @@ func DefaultConfig() *config.Config { Thumbnail: config.Thumbnail{ MaxInputWidth: 7680, MaxInputHeight: 7680, + MaxOutputWidth: 2048, + MaxOutputHeight: 2048, MaxInputImageFileSize: "50MB", MaxAnimationFrames: 100, }, diff --git a/services/thumbnails/pkg/service/http/v0/push.go b/services/thumbnails/pkg/service/http/v0/push.go index 0c013f41a5..6da9324bd3 100644 --- a/services/thumbnails/pkg/service/http/v0/push.go +++ b/services/thumbnails/pkg/service/http/v0/push.go @@ -56,6 +56,15 @@ func (s Thumbnails) pushHandler(w http.ResponseWriter, r *http.Request) { return } + // The box is the one thing a caller chooses freely, and fill and stretch + // enlarge a small source to fill it. webdav never asks for more than its + // largest resolution; this bounds whoever else reaches the endpoint. + if (s.maxOutWidth > 0 && width > s.maxOutWidth) || (s.maxOutHeight > 0 && height > s.maxOutHeight) { + writeMaxResolution(w) + + return + } + operation := chi.URLParam(r, "operation") if operation == "" { operation = OpFill diff --git a/services/thumbnails/pkg/service/http/v0/push_helpers_test.go b/services/thumbnails/pkg/service/http/v0/push_helpers_test.go index 7e3e0d8f44..678b254427 100644 --- a/services/thumbnails/pkg/service/http/v0/push_helpers_test.go +++ b/services/thumbnails/pkg/service/http/v0/push_helpers_test.go @@ -135,6 +135,18 @@ func testServiceFrames(maxFrames int) Service { } // testServiceBody is the real service with a maximum posted image size. +// testServiceOutput is the real service with the configured ceiling on the box a +// request may ask for. +func testServiceOutput(maxWidth, maxHeight int) Service { + GinkgoHelper() + + cfg := defaults.DefaultConfig() + defaults.EnsureDefaults(cfg) + cfg.Thumbnail.MaxOutputWidth, cfg.Thumbnail.MaxOutputHeight = maxWidth, maxHeight + + return NewService(Logger(log.NopLogger()), Config(cfg)) +} + func testServiceBody(maxSize string) Service { GinkgoHelper() diff --git a/services/thumbnails/pkg/service/http/v0/push_test.go b/services/thumbnails/pkg/service/http/v0/push_test.go index b2975f12bf..0a22f035f2 100644 --- a/services/thumbnails/pkg/service/http/v0/push_test.go +++ b/services/thumbnails/pkg/service/http/v0/push_test.go @@ -30,6 +30,20 @@ var _ = Describe("the push endpoint", func() { }) + Describe("the configured maximum thumbnail size", func() { + It("refuses a box above it", func() { + rec := postTo(testServiceOutput(2048, 2048), "/unsafe/4096x4096/", pngSource(10, 10), "image/png") + + Expect(rec.Code).To(Equal(http.StatusUnprocessableEntity), rec.Body.String()) + }) + + It("lets a box at it through", func() { + rec := postTo(testServiceOutput(2048, 2048), "/unsafe/2048x2048/", pngSource(10, 10), "image/png") + + Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String()) + }) + }) + Describe("the configured maximum posted image size", func() { It("refuses a body above it", func() { rec := postTo(testServiceBody("1KB"), "/unsafe/50x50/", jpegSource(300, 300), "image/jpeg") diff --git a/services/thumbnails/pkg/service/http/v0/service.go b/services/thumbnails/pkg/service/http/v0/service.go index 58432068b4..b2899fe586 100644 --- a/services/thumbnails/pkg/service/http/v0/service.go +++ b/services/thumbnails/pkg/service/http/v0/service.go @@ -41,13 +41,15 @@ func NewService(opts ...Option) Service { } svc := Thumbnails{ - logger: options.Logger, - mux: m, - maxWidth: limits.MaxInputWidth, - maxHeight: limits.MaxInputHeight, - maxFrames: limits.MaxAnimationFrames, - maxBody: maxBody, - limiter: newConcurrencyLimiter(limits.MaxConcurrentRequests), + logger: options.Logger, + mux: m, + maxWidth: limits.MaxInputWidth, + maxHeight: limits.MaxInputHeight, + maxOutWidth: limits.MaxOutputWidth, + maxOutHeight: limits.MaxOutputHeight, + maxFrames: limits.MaxAnimationFrames, + maxBody: maxBody, + limiter: newConcurrencyLimiter(limits.MaxConcurrentRequests), } // Push-based thumbnail generation endpoint (imagor-compatible). The optional @@ -73,13 +75,15 @@ func NewService(opts ...Option) Service { // Thumbnails implements the business logic for Service. type Thumbnails struct { - logger log.Logger - mux *chi.Mux - maxWidth int - maxHeight int - maxFrames int - maxBody uint64 - limiter *concurrencyLimiter + logger log.Logger + mux *chi.Mux + maxWidth int + maxHeight int + maxOutWidth int + maxOutHeight int + maxFrames int + maxBody uint64 + limiter *concurrencyLimiter } // ServeHTTP implements the Service interface.