Bound imageprocessor.Process input read to prevent unbounded memory use #37

Merged
sneak merged 3 commits from fix/bounded-processor-read into main 2026-03-20 07:01:15 +01:00
5 changed files with 163 additions and 108 deletions
Showing only changes of commit d7e1cfaa24 - Show all commits

View File

@@ -1,4 +1,5 @@
package imgcache
// Package imageprocessor provides image format conversion and resizing using libvips.
package imageprocessor
import (
"bytes"
@@ -22,6 +23,68 @@ 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
@@ -39,7 +102,7 @@ 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.
// ImageProcessor implements image transformation using libvips via govips.
type ImageProcessor struct {
maxInputBytes int64
}
@@ -71,8 +134,8 @@ func New(params Params) *ImageProcessor {
func (p *ImageProcessor) Process(
_ context.Context,
input io.Reader,
req *ImageRequest,
) (*ProcessResult, error) {
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.
@@ -142,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,
@@ -157,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,
@@ -176,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()
@@ -204,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)
@@ -215,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:
@@ -251,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
}
@@ -299,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

View File

@@ -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 := 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,13 +130,9 @@ 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)
}
}
@@ -123,7 +142,7 @@ func TestImageProcessor_ConvertToPNG(t *testing.T) {
input := createTestJPEG(t, 200, 150)
req := &ImageRequest{
req := &Request{
Size: Size{Width: 200, Height: 150},
Format: FormatPNG,
FitMode: FitCover,
@@ -140,13 +159,9 @@ 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)
}
}
@@ -156,7 +171,7 @@ func TestImageProcessor_OriginalSize(t *testing.T) {
input := createTestJPEG(t, 640, 480)
req := &ImageRequest{
req := &Request{
Size: Size{Width: 0, Height: 0}, // Original size
Format: FormatJPEG,
Quality: 85,
@@ -186,7 +201,7 @@ func TestImageProcessor_FitContain(t *testing.T) {
// 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,
@@ -213,7 +228,7 @@ func TestImageProcessor_ProportionalScale_WidthOnly(t *testing.T) {
// Should scale proportionally to 400x300
input := createTestJPEG(t, 800, 600)
req := &ImageRequest{
req := &Request{
Size: Size{Width: 400, Height: 0},
Format: FormatJPEG,
Quality: 85,
@@ -243,7 +258,7 @@ func TestImageProcessor_ProportionalScale_HeightOnly(t *testing.T) {
// Should scale proportionally to 400x300
input := createTestJPEG(t, 800, 600)
req := &ImageRequest{
req := &Request{
Size: Size{Width: 0, Height: 300},
Format: FormatJPEG,
Quality: 85,
@@ -271,7 +286,7 @@ func TestImageProcessor_ProcessPNG(t *testing.T) {
input := createTestPNG(t, 400, 300)
req := &ImageRequest{
req := &Request{
Size: Size{Width: 200, Height: 150},
Format: FormatPNG,
FitMode: FitCover,
@@ -292,11 +307,6 @@ 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 := New(Params{})
@@ -319,7 +329,7 @@ func TestImageProcessor_RejectsOversizedInput(t *testing.T) {
// 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,
@@ -343,7 +353,7 @@ func TestImageProcessor_RejectsOversizedInputHeight(t *testing.T) {
// 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,
@@ -365,10 +375,9 @@ func TestImageProcessor_AcceptsMaxDimensionInput(t *testing.T) {
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,
@@ -388,7 +397,7 @@ func TestImageProcessor_EncodeWebP(t *testing.T) {
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
@@ -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,13 +460,9 @@ 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)
}
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)
}
}
@@ -477,7 +478,7 @@ func TestImageProcessor_RejectsOversizedInputData(t *testing.T) {
t.Fatalf("test JPEG must exceed %d bytes, got %d", limit, len(input))
}
req := &ImageRequest{
req := &Request{
Size: Size{Width: 100, Height: 75},
Format: FormatJPEG,
Quality: 85,
@@ -502,7 +503,7 @@ func TestImageProcessor_AcceptsInputWithinLimit(t *testing.T) {
proc := New(Params{MaxInputBytes: limit})
ctx := context.Background()
req := &ImageRequest{
req := &Request{
Size: Size{Width: 10, Height: 10},
Format: FormatJPEG,
Quality: 85,
@@ -536,7 +537,7 @@ func TestImageProcessor_EncodeAVIF(t *testing.T) {
input := createTestJPEG(t, 200, 150)
req := &ImageRequest{
req := &Request{
Size: Size{Width: 100, Height: 75},
Format: FormatAVIF,
Quality: 85,
@@ -555,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

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 B

View File

@@ -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

View File

@@ -11,13 +11,14 @@ 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
processor *imageprocessor.ImageProcessor
signer *Signer
whitelist *HostWhitelist
log *slog.Logger
@@ -82,7 +83,7 @@ func NewService(cfg *ServiceConfig) (*Service, error) {
return &Service{
cache: cfg.Cache,
fetcher: fetcher,
processor: New(Params{MaxInputBytes: maxResponseSize}),
processor: imageprocessor.New(imageprocessor.Params{MaxInputBytes: maxResponseSize}),
signer: signer,
whitelist: NewHostWhitelist(cfg.Whitelist),
log: log,
@@ -300,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)
}