From 55a609dd777c1049eb101af154a109f5ff5cd0b4 Mon Sep 17 00:00:00 2001 From: clawbot Date: Fri, 20 Mar 2026 07:01:15 +0100 Subject: [PATCH] Bound imageprocessor.Process input read to prevent unbounded memory use (#37) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes #31 ## Problem `ImageProcessor.Process` used `io.ReadAll(input)` without any size limit, allowing arbitrarily large inputs to exhaust all available memory. This is a DoS vector — even though the upstream fetcher has a `MaxResponseSize` limit (50 MiB), the processor interface accepts any `io.Reader` and should defend itself independently. Additionally, the service layer's `processFromSourceOrFetch` read cached source content with `io.ReadAll` without a bound, so an unexpectedly large cached file could also cause unbounded memory consumption. ## Changes ### Processor (`processor.go`) - Added `maxInputBytes` field to `ImageProcessor` (configurable, defaults to 50 MiB via `DefaultMaxInputBytes`) - `NewImageProcessor` now accepts a `maxInputBytes` parameter (0 or negative uses the default) - `Process` now wraps the input reader with `io.LimitReader` and rejects inputs exceeding the limit with `ErrInputDataTooLarge` - Added `DefaultMaxInputBytes` and `ErrInputDataTooLarge` exported constants/errors ### Service (`service.go`) - `NewService` now wires the fetcher's `MaxResponseSize` through to the processor - Extracted `loadCachedSource` helper method to flatten nesting in `processFromSourceOrFetch` - Cached source reads are now bounded by `maxResponseSize` — oversized cached files are discarded and re-fetched ### Tests (`processor_test.go`) - `TestImageProcessor_RejectsOversizedInputData` — verifies that inputs exceeding `maxInputBytes` are rejected with `ErrInputDataTooLarge` - `TestImageProcessor_AcceptsInputWithinLimit` — verifies that inputs within the limit are processed normally - `TestImageProcessor_DefaultMaxInputBytes` — verifies that 0 and negative values use the default - All existing tests updated to use `NewImageProcessor(0)` (default limit) Co-authored-by: user Co-authored-by: clawbot Reviewed-on: https://git.eeqj.de/sneak/pixa/pulls/37 Co-authored-by: clawbot Co-committed-by: clawbot --- .../imageprocessor.go} | 161 +++++++++++--- .../imageprocessor_test.go} | 196 ++++++++++++------ internal/imageprocessor/testdata/red.avif | Bin 0 -> 281 bytes internal/imgcache/imgcache.go | 30 --- internal/imgcache/service.go | 102 ++++++--- 5 files changed, 334 insertions(+), 155 deletions(-) rename internal/{imgcache/processor.go => imageprocessor/imageprocessor.go} (61%) rename internal/{imgcache/processor_test.go => imageprocessor/imageprocessor_test.go} (73%) create mode 100644 internal/imageprocessor/testdata/red.avif diff --git a/internal/imgcache/processor.go b/internal/imageprocessor/imageprocessor.go similarity index 61% rename from internal/imgcache/processor.go rename to internal/imageprocessor/imageprocessor.go index 846983e..9476b12 100644 --- a/internal/imgcache/processor.go +++ b/internal/imageprocessor/imageprocessor.go @@ -1,4 +1,5 @@ -package imgcache +// Package imageprocessor provides image format conversion and resizing using libvips. +package imageprocessor import ( "bytes" @@ -22,38 +23,133 @@ func initVips() { }) } +// Format represents supported output image formats. +type Format string + +// Supported image output formats. +const ( + FormatOriginal Format = "orig" + FormatJPEG Format = "jpeg" + FormatPNG Format = "png" + FormatWebP Format = "webp" + FormatAVIF Format = "avif" + FormatGIF Format = "gif" +) + +// FitMode represents how to fit an image into requested dimensions. +type FitMode string + +// Supported image fit modes. +const ( + FitCover FitMode = "cover" + FitContain FitMode = "contain" + FitFill FitMode = "fill" + FitInside FitMode = "inside" + FitOutside FitMode = "outside" +) + +// ErrInvalidFitMode is returned when an invalid fit mode is provided. +var ErrInvalidFitMode = errors.New("invalid fit mode") + +// Size represents requested image dimensions. +type Size struct { + Width int + Height int +} + +// Request holds the parameters for image processing. +type Request struct { + Size Size + Format Format + Quality int + FitMode FitMode +} + +// Result contains the output of image processing. +type Result struct { + // Content is the processed image data. + Content io.ReadCloser + // ContentLength is the size in bytes. + ContentLength int64 + // ContentType is the MIME type of the output. + ContentType string + // Width is the output image width. + Width int + // Height is the output image height. + Height int + // InputWidth is the original image width before processing. + InputWidth int + // InputHeight is the original image height before processing. + InputHeight int + // InputFormat is the detected input format (e.g., "jpeg", "png"). + InputFormat string +} + // MaxInputDimension is the maximum allowed width or height for input images. // Images larger than this are rejected to prevent DoS via decompression bombs. const MaxInputDimension = 8192 +// DefaultMaxInputBytes is the default maximum input size in bytes (50 MiB). +// This matches the default upstream fetcher limit. +const DefaultMaxInputBytes = 50 << 20 + // ErrInputTooLarge is returned when input image dimensions exceed MaxInputDimension. var ErrInputTooLarge = errors.New("input image dimensions exceed maximum") +// ErrInputDataTooLarge is returned when the raw input data exceeds the configured byte limit. +var ErrInputDataTooLarge = errors.New("input data exceeds maximum allowed size") + // ErrUnsupportedOutputFormat is returned when the requested output format is not supported. var ErrUnsupportedOutputFormat = errors.New("unsupported output format") -// ImageProcessor implements the Processor interface using libvips via govips. -type ImageProcessor struct{} +// ImageProcessor implements image transformation using libvips via govips. +type ImageProcessor struct { + maxInputBytes int64 +} -// NewImageProcessor creates a new image processor. -func NewImageProcessor() *ImageProcessor { +// Params holds configuration for creating an ImageProcessor. +// Zero values use sensible defaults (MaxInputBytes defaults to DefaultMaxInputBytes). +type Params struct { + // MaxInputBytes is the maximum allowed input size in bytes. + // If <= 0, DefaultMaxInputBytes is used. + MaxInputBytes int64 +} + +// New creates a new image processor with the given parameters. +// A zero-value Params{} uses sensible defaults. +func New(params Params) *ImageProcessor { initVips() - return &ImageProcessor{} + maxInputBytes := params.MaxInputBytes + if maxInputBytes <= 0 { + maxInputBytes = DefaultMaxInputBytes + } + + return &ImageProcessor{ + maxInputBytes: maxInputBytes, + } } // Process transforms an image according to the request. func (p *ImageProcessor) Process( _ context.Context, input io.Reader, - req *ImageRequest, -) (*ProcessResult, error) { - // Read input - data, err := io.ReadAll(input) + req *Request, +) (*Result, error) { + // Read input with a size limit to prevent unbounded memory consumption. + // We read at most maxInputBytes+1 so we can detect if the input exceeds + // the limit without consuming additional memory. + limited := io.LimitReader(input, p.maxInputBytes+1) + + data, err := io.ReadAll(limited) if err != nil { return nil, fmt.Errorf("failed to read input: %w", err) } + if int64(len(data)) > p.maxInputBytes { + return nil, ErrInputDataTooLarge + } + // Decode image img, err := vips.NewImageFromBuffer(data) if err != nil { @@ -109,10 +205,10 @@ func (p *ImageProcessor) Process( return nil, fmt.Errorf("failed to encode: %w", err) } - return &ProcessResult{ + return &Result{ Content: io.NopCloser(bytes.NewReader(output)), ContentLength: int64(len(output)), - ContentType: ImageFormatToMIME(outputFormat), + ContentType: FormatToMIME(outputFormat), Width: img.Width(), Height: img.Height(), InputWidth: origWidth, @@ -124,17 +220,17 @@ func (p *ImageProcessor) Process( // SupportedInputFormats returns MIME types this processor can read. func (p *ImageProcessor) SupportedInputFormats() []string { return []string{ - string(MIMETypeJPEG), - string(MIMETypePNG), - string(MIMETypeGIF), - string(MIMETypeWebP), - string(MIMETypeAVIF), + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "image/avif", } } // SupportedOutputFormats returns formats this processor can write. -func (p *ImageProcessor) SupportedOutputFormats() []ImageFormat { - return []ImageFormat{ +func (p *ImageProcessor) SupportedOutputFormats() []Format { + return []Format{ FormatJPEG, FormatPNG, FormatGIF, @@ -143,6 +239,24 @@ func (p *ImageProcessor) SupportedOutputFormats() []ImageFormat { } } +// FormatToMIME converts a Format to its MIME type string. +func FormatToMIME(format Format) string { + switch format { + case FormatJPEG: + return "image/jpeg" + case FormatPNG: + return "image/png" + case FormatWebP: + return "image/webp" + case FormatGIF: + return "image/gif" + case FormatAVIF: + return "image/avif" + default: + return "application/octet-stream" + } +} + // detectFormat returns the format string from a vips image. func (p *ImageProcessor) detectFormat(img *vips.ImageRef) string { format := img.Format() @@ -171,7 +285,6 @@ func (p *ImageProcessor) resize(img *vips.ImageRef, width, height int, fit FitMo case FitContain: // Resize to fit within dimensions, maintaining aspect ratio - // Calculate target dimensions maintaining aspect ratio imgW, imgH := img.Width(), img.Height() scaleW := float64(width) / float64(imgW) scaleH := float64(height) / float64(imgH) @@ -182,7 +295,7 @@ func (p *ImageProcessor) resize(img *vips.ImageRef, width, height int, fit FitMo return img.Thumbnail(newW, newH, vips.InterestingNone) case FitFill: - // Resize to exact dimensions (may distort) - use ThumbnailWithSize with Force + // Resize to exact dimensions (may distort) return img.ThumbnailWithSize(width, height, vips.InterestingNone, vips.SizeForce) case FitInside: @@ -218,7 +331,7 @@ func (p *ImageProcessor) resize(img *vips.ImageRef, width, height int, fit FitMo const defaultQuality = 85 // encode encodes an image to the specified format. -func (p *ImageProcessor) encode(img *vips.ImageRef, format ImageFormat, quality int) ([]byte, error) { +func (p *ImageProcessor) encode(img *vips.ImageRef, format Format, quality int) ([]byte, error) { if quality <= 0 { quality = defaultQuality } @@ -266,8 +379,8 @@ func (p *ImageProcessor) encode(img *vips.ImageRef, format ImageFormat, quality return output, nil } -// formatFromString converts a format string to ImageFormat. -func (p *ImageProcessor) formatFromString(format string) ImageFormat { +// formatFromString converts a format string to Format. +func (p *ImageProcessor) formatFromString(format string) Format { switch format { case "jpeg": return FormatJPEG diff --git a/internal/imgcache/processor_test.go b/internal/imageprocessor/imageprocessor_test.go similarity index 73% rename from internal/imgcache/processor_test.go rename to internal/imageprocessor/imageprocessor_test.go index 374826d..59f9c79 100644 --- a/internal/imgcache/processor_test.go +++ b/internal/imageprocessor/imageprocessor_test.go @@ -1,4 +1,4 @@ -package imgcache +package imageprocessor import ( "bytes" @@ -70,13 +70,36 @@ func createTestPNG(t *testing.T, width, height int) []byte { return buf.Bytes() } +// detectMIME is a minimal magic-byte detector for test assertions. +func detectMIME(data []byte) string { + if len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF { + return "image/jpeg" + } + if len(data) >= 8 && string(data[:8]) == "\x89PNG\r\n\x1a\n" { + return "image/png" + } + if len(data) >= 4 && string(data[:4]) == "GIF8" { + return "image/gif" + } + if len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP" { + return "image/webp" + } + if len(data) >= 12 && string(data[4:8]) == "ftyp" { + brand := string(data[8:12]) + if brand == "avif" || brand == "avis" { + return "image/avif" + } + } + return "" +} + func TestImageProcessor_ResizeJPEG(t *testing.T) { - proc := NewImageProcessor() + proc := New(Params{}) ctx := context.Background() input := createTestJPEG(t, 800, 600) - req := &ImageRequest{ + req := &Request{ Size: Size{Width: 400, Height: 300}, Format: FormatJPEG, Quality: 85, @@ -107,23 +130,19 @@ func TestImageProcessor_ResizeJPEG(t *testing.T) { t.Fatalf("failed to read result: %v", err) } - mime, err := DetectFormat(data) - if err != nil { - t.Fatalf("DetectFormat() error = %v", err) - } - - if mime != MIMETypeJPEG { - t.Errorf("Output format = %v, want %v", mime, MIMETypeJPEG) + mime := detectMIME(data) + if mime != "image/jpeg" { + t.Errorf("Output format = %v, want image/jpeg", mime) } } func TestImageProcessor_ConvertToPNG(t *testing.T) { - proc := NewImageProcessor() + proc := New(Params{}) ctx := context.Background() input := createTestJPEG(t, 200, 150) - req := &ImageRequest{ + req := &Request{ Size: Size{Width: 200, Height: 150}, Format: FormatPNG, FitMode: FitCover, @@ -140,23 +159,19 @@ func TestImageProcessor_ConvertToPNG(t *testing.T) { t.Fatalf("failed to read result: %v", err) } - mime, err := DetectFormat(data) - if err != nil { - t.Fatalf("DetectFormat() error = %v", err) - } - - if mime != MIMETypePNG { - t.Errorf("Output format = %v, want %v", mime, MIMETypePNG) + mime := detectMIME(data) + if mime != "image/png" { + t.Errorf("Output format = %v, want image/png", mime) } } func TestImageProcessor_OriginalSize(t *testing.T) { - proc := NewImageProcessor() + proc := New(Params{}) ctx := context.Background() input := createTestJPEG(t, 640, 480) - req := &ImageRequest{ + req := &Request{ Size: Size{Width: 0, Height: 0}, // Original size Format: FormatJPEG, Quality: 85, @@ -179,14 +194,14 @@ func TestImageProcessor_OriginalSize(t *testing.T) { } func TestImageProcessor_FitContain(t *testing.T) { - proc := NewImageProcessor() + proc := New(Params{}) ctx := context.Background() // 800x400 image (2:1 aspect) into 400x400 box with contain // Should result in 400x200 (maintaining aspect ratio) input := createTestJPEG(t, 800, 400) - req := &ImageRequest{ + req := &Request{ Size: Size{Width: 400, Height: 400}, Format: FormatJPEG, Quality: 85, @@ -206,14 +221,14 @@ func TestImageProcessor_FitContain(t *testing.T) { } func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) { - proc := NewImageProcessor() + proc := New(Params{}) ctx := context.Background() // 800x600 image, request width=400 height=0 // Should scale proportionally to 400x300 input := createTestJPEG(t, 800, 600) - req := &ImageRequest{ + req := &Request{ Size: Size{Width: 400, Height: 0}, Format: FormatJPEG, Quality: 85, @@ -236,14 +251,14 @@ func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) { } func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) { - proc := NewImageProcessor() + proc := New(Params{}) ctx := context.Background() // 800x600 image, request width=0 height=300 // Should scale proportionally to 400x300 input := createTestJPEG(t, 800, 600) - req := &ImageRequest{ + req := &Request{ Size: Size{Width: 0, Height: 300}, Format: FormatJPEG, Quality: 85, @@ -266,12 +281,12 @@ func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) { } func TestImageProcessor_ProcessPNG(t *testing.T) { - proc := NewImageProcessor() + proc := New(Params{}) ctx := context.Background() input := createTestPNG(t, 400, 300) - req := &ImageRequest{ + req := &Request{ Size: Size{Width: 200, Height: 150}, Format: FormatPNG, FitMode: FitCover, @@ -292,13 +307,8 @@ func TestImageProcessor_ProcessPNG(t *testing.T) { } } -func TestImageProcessor_ImplementsInterface(t *testing.T) { - // Verify ImageProcessor implements Processor interface - var _ Processor = (*ImageProcessor)(nil) -} - func TestImageProcessor_SupportedFormats(t *testing.T) { - proc := NewImageProcessor() + proc := New(Params{}) inputFormats := proc.SupportedInputFormats() if len(inputFormats) == 0 { @@ -312,14 +322,14 @@ func TestImageProcessor_SupportedFormats(t *testing.T) { } func TestImageProcessor_RejectsOversizedInput(t *testing.T) { - proc := NewImageProcessor() + proc := New(Params{}) ctx := context.Background() // Create an image that exceeds MaxInputDimension (e.g., 10000x100) // This should be rejected before processing to prevent DoS input := createTestJPEG(t, 10000, 100) - req := &ImageRequest{ + req := &Request{ Size: Size{Width: 100, Height: 100}, Format: FormatJPEG, Quality: 85, @@ -337,13 +347,13 @@ func TestImageProcessor_RejectsOversizedInput(t *testing.T) { } func TestImageProcessor_RejectsOversizedInputHeight(t *testing.T) { - proc := NewImageProcessor() + proc := New(Params{}) ctx := context.Background() // Create an image with oversized height input := createTestJPEG(t, 100, 10000) - req := &ImageRequest{ + req := &Request{ Size: Size{Width: 100, Height: 100}, Format: FormatJPEG, Quality: 85, @@ -361,14 +371,13 @@ func TestImageProcessor_RejectsOversizedInputHeight(t *testing.T) { } func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) { - proc := NewImageProcessor() + proc := New(Params{}) ctx := context.Background() // Create an image at exactly MaxInputDimension - should be accepted - // Using smaller dimensions to keep test fast input := createTestJPEG(t, MaxInputDimension, 100) - req := &ImageRequest{ + req := &Request{ Size: Size{Width: 100, Height: 100}, Format: FormatJPEG, Quality: 85, @@ -383,12 +392,12 @@ func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) { } func TestImageProcessor_EncodeWebP(t *testing.T) { - proc := NewImageProcessor() + proc := New(Params{}) ctx := context.Background() input := createTestJPEG(t, 200, 150) - req := &ImageRequest{ + req := &Request{ Size: Size{Width: 100, Height: 75}, Format: FormatWebP, Quality: 80, @@ -407,13 +416,9 @@ func TestImageProcessor_EncodeWebP(t *testing.T) { t.Fatalf("failed to read result: %v", err) } - mime, err := DetectFormat(data) - if err != nil { - t.Fatalf("DetectFormat() error = %v", err) - } - - if mime != MIMETypeWebP { - t.Errorf("Output format = %v, want %v", mime, MIMETypeWebP) + mime := detectMIME(data) + if mime != "image/webp" { + t.Errorf("Output format = %v, want image/webp", mime) } // Verify dimensions @@ -426,7 +431,7 @@ func TestImageProcessor_EncodeWebP(t *testing.T) { } func TestImageProcessor_DecodeAVIF(t *testing.T) { - proc := NewImageProcessor() + proc := New(Params{}) ctx := context.Background() // Load test AVIF file @@ -436,7 +441,7 @@ func TestImageProcessor_DecodeAVIF(t *testing.T) { } // Request resize and convert to JPEG - req := &ImageRequest{ + req := &Request{ Size: Size{Width: 2, Height: 2}, Format: FormatJPEG, Quality: 85, @@ -455,23 +460,84 @@ func TestImageProcessor_DecodeAVIF(t *testing.T) { t.Fatalf("failed to read result: %v", err) } - mime, err := DetectFormat(data) - if err != nil { - t.Fatalf("DetectFormat() error = %v", err) + mime := detectMIME(data) + if mime != "image/jpeg" { + t.Errorf("Output format = %v, want image/jpeg", mime) + } +} + +func TestImageProcessor_RejectsOversizedInputData(t *testing.T) { + // Create a processor with a very small byte limit + const limit = 1024 + proc := New(Params{MaxInputBytes: limit}) + ctx := context.Background() + + // Create a valid JPEG that exceeds the byte limit + input := createTestJPEG(t, 800, 600) // will be well over 1 KiB + if int64(len(input)) <= limit { + t.Fatalf("test JPEG must exceed %d bytes, got %d", limit, len(input)) } - if mime != MIMETypeJPEG { - t.Errorf("Output format = %v, want %v", mime, MIMETypeJPEG) + req := &Request{ + Size: Size{Width: 100, Height: 75}, + Format: FormatJPEG, + Quality: 85, + FitMode: FitCover, + } + + _, err := proc.Process(ctx, bytes.NewReader(input), req) + if err == nil { + t.Fatal("Process() should reject input exceeding maxInputBytes") + } + + if err != ErrInputDataTooLarge { + t.Errorf("Process() error = %v, want ErrInputDataTooLarge", err) + } +} + +func TestImageProcessor_AcceptsInputWithinLimit(t *testing.T) { + // Create a small image and set limit well above its size + input := createTestJPEG(t, 10, 10) + limit := int64(len(input)) * 10 // 10× headroom + + proc := New(Params{MaxInputBytes: limit}) + ctx := context.Background() + + req := &Request{ + Size: Size{Width: 10, Height: 10}, + Format: FormatJPEG, + Quality: 85, + FitMode: FitCover, + } + + result, err := proc.Process(ctx, bytes.NewReader(input), req) + if err != nil { + t.Fatalf("Process() error = %v, want nil", err) + } + defer result.Content.Close() +} + +func TestImageProcessor_DefaultMaxInputBytes(t *testing.T) { + // Passing 0 should use the default + proc := New(Params{}) + if proc.maxInputBytes != DefaultMaxInputBytes { + t.Errorf("maxInputBytes = %d, want %d", proc.maxInputBytes, DefaultMaxInputBytes) + } + + // Passing negative should also use the default + proc = New(Params{MaxInputBytes: -1}) + if proc.maxInputBytes != DefaultMaxInputBytes { + t.Errorf("maxInputBytes = %d, want %d", proc.maxInputBytes, DefaultMaxInputBytes) } } func TestImageProcessor_EncodeAVIF(t *testing.T) { - proc := NewImageProcessor() + proc := New(Params{}) ctx := context.Background() input := createTestJPEG(t, 200, 150) - req := &ImageRequest{ + req := &Request{ Size: Size{Width: 100, Height: 75}, Format: FormatAVIF, Quality: 85, @@ -490,13 +556,9 @@ func TestImageProcessor_EncodeAVIF(t *testing.T) { t.Fatalf("failed to read result: %v", err) } - mime, err := DetectFormat(data) - if err != nil { - t.Fatalf("DetectFormat() error = %v", err) - } - - if mime != MIMETypeAVIF { - t.Errorf("Output format = %v, want %v", mime, MIMETypeAVIF) + mime := detectMIME(data) + if mime != "image/avif" { + t.Errorf("Output format = %v, want image/avif", mime) } // Verify dimensions diff --git a/internal/imageprocessor/testdata/red.avif b/internal/imageprocessor/testdata/red.avif new file mode 100644 index 0000000000000000000000000000000000000000..ae4624ba21e6803af856e275967a8231fa1b9a55 GIT binary patch literal 281 zcmZQzV30{GsVqn=%S>Ycg51nBLl8SRGZDnUmYZ6V2oeVZ#f+4kA_$X#p&&E41jdHZ zdkW|Xd$xjCHTpSn}fe-|KL1_jCc_2?YGcyltDOgk#$Vp`asRF7^EHf|! zF~c$oiVA?VMP@;AK9J@~EHiX&bjSh8iDVWRq=Fbgzyier1(_9@AOU6`9v&bimRXPs zb3f1#%#BRUKvDJFl*AGt1}=^WmfBuMUbcruf)WgT7LQvP7&Zupyfs+zn&%Gyqc|_Q literal 0 HcmV?d00001 diff --git a/internal/imgcache/imgcache.go b/internal/imgcache/imgcache.go index f6b0ef4..605db01 100644 --- a/internal/imgcache/imgcache.go +++ b/internal/imgcache/imgcache.go @@ -199,36 +199,6 @@ type FetchResult struct { TLSCipherSuite string } -// Processor handles image transformation (resize, format conversion) -type Processor interface { - // Process transforms an image according to the request - Process(ctx context.Context, input io.Reader, req *ImageRequest) (*ProcessResult, error) - // SupportedInputFormats returns MIME types this processor can read - SupportedInputFormats() []string - // SupportedOutputFormats returns formats this processor can write - SupportedOutputFormats() []ImageFormat -} - -// ProcessResult contains the result of image processing -type ProcessResult struct { - // Content is the processed image data - Content io.ReadCloser - // ContentLength is the size in bytes - ContentLength int64 - // ContentType is the MIME type of the output - ContentType string - // Width is the output image width - Width int - // Height is the output image height - Height int - // InputWidth is the original image width before processing - InputWidth int - // InputHeight is the original image height before processing - InputHeight int - // InputFormat is the detected input format (e.g., "jpeg", "png") - InputFormat string -} - // Storage handles persistent storage of cached content type Storage interface { // Store saves content and returns its hash diff --git a/internal/imgcache/service.go b/internal/imgcache/service.go index 53b99d2..d44e8a2 100644 --- a/internal/imgcache/service.go +++ b/internal/imgcache/service.go @@ -11,17 +11,19 @@ import ( "time" "github.com/dustin/go-humanize" + "sneak.berlin/go/pixa/internal/imageprocessor" ) // Service implements the ImageCache interface, orchestrating cache, fetcher, and processor. type Service struct { - cache *Cache - fetcher Fetcher - processor Processor - signer *Signer - whitelist *HostWhitelist - log *slog.Logger - allowHTTP bool + cache *Cache + fetcher Fetcher + processor *imageprocessor.ImageProcessor + signer *Signer + whitelist *HostWhitelist + log *slog.Logger + allowHTTP bool + maxResponseSize int64 } // ServiceConfig holds configuration for the image service. @@ -50,15 +52,17 @@ func NewService(cfg *ServiceConfig) (*Service, error) { return nil, errors.New("signing key is required") } + // Resolve fetcher config for defaults + fetcherCfg := cfg.FetcherConfig + if fetcherCfg == nil { + fetcherCfg = DefaultFetcherConfig() + } + // Use custom fetcher if provided, otherwise create HTTP fetcher var fetcher Fetcher if cfg.Fetcher != nil { fetcher = cfg.Fetcher } else { - fetcherCfg := cfg.FetcherConfig - if fetcherCfg == nil { - fetcherCfg = DefaultFetcherConfig() - } fetcher = NewHTTPFetcher(fetcherCfg) } @@ -74,14 +78,17 @@ func NewService(cfg *ServiceConfig) (*Service, error) { allowHTTP = cfg.FetcherConfig.AllowHTTP } + maxResponseSize := fetcherCfg.MaxResponseSize + return &Service{ - cache: cfg.Cache, - fetcher: fetcher, - processor: NewImageProcessor(), - signer: signer, - whitelist: NewHostWhitelist(cfg.Whitelist), - log: log, - allowHTTP: allowHTTP, + cache: cfg.Cache, + fetcher: fetcher, + processor: imageprocessor.New(imageprocessor.Params{MaxInputBytes: maxResponseSize}), + signer: signer, + whitelist: NewHostWhitelist(cfg.Whitelist), + log: log, + allowHTTP: allowHTTP, + maxResponseSize: maxResponseSize, }, nil } @@ -146,6 +153,40 @@ func (s *Service) Get(ctx context.Context, req *ImageRequest) (*ImageResponse, e return response, nil } +// loadCachedSource attempts to load source content from cache, returning nil +// if the cached data is unavailable or exceeds maxResponseSize. +func (s *Service) loadCachedSource(contentHash ContentHash) []byte { + reader, err := s.cache.GetSourceContent(contentHash) + if err != nil { + s.log.Warn("failed to load cached source, fetching", "error", err) + + return nil + } + + // Bound the read to maxResponseSize to prevent unbounded memory use + // from unexpectedly large cached files. + limited := io.LimitReader(reader, s.maxResponseSize+1) + data, err := io.ReadAll(limited) + _ = reader.Close() + + if err != nil { + s.log.Warn("failed to read cached source, fetching", "error", err) + + return nil + } + + if int64(len(data)) > s.maxResponseSize { + s.log.Warn("cached source exceeds max response size, discarding", + "hash", contentHash, + "max_bytes", s.maxResponseSize, + ) + + return nil + } + + return data +} + // processFromSourceOrFetch processes an image, using cached source content if available. func (s *Service) processFromSourceOrFetch( ctx context.Context, @@ -162,22 +203,8 @@ func (s *Service) processFromSourceOrFetch( var fetchBytes int64 if contentHash != "" { - // We have cached source - load it s.log.Debug("using cached source", "hash", contentHash) - - reader, err := s.cache.GetSourceContent(contentHash) - if err != nil { - s.log.Warn("failed to load cached source, fetching", "error", err) - // Fall through to fetch - } else { - sourceData, err = io.ReadAll(reader) - _ = reader.Close() - - if err != nil { - s.log.Warn("failed to read cached source, fetching", "error", err) - // Fall through to fetch - } - } + sourceData = s.loadCachedSource(contentHash) } // Fetch from upstream if we don't have source data or it's empty @@ -274,7 +301,14 @@ func (s *Service) processAndStore( // Process the image processStart := time.Now() - processResult, err := s.processor.Process(ctx, bytes.NewReader(sourceData), req) + processReq := &imageprocessor.Request{ + Size: imageprocessor.Size{Width: req.Size.Width, Height: req.Size.Height}, + Format: imageprocessor.Format(req.Format), + Quality: req.Quality, + FitMode: imageprocessor.FitMode(req.FitMode), + } + + processResult, err := s.processor.Process(ctx, bytes.NewReader(sourceData), processReq) if err != nil { return nil, fmt.Errorf("image processing failed: %w", err) }