The fetcher accepts image/svg+xml; internal/magic detects SVG. orig of an SVG at the orig size is the SVG unchanged, cached, with a Content-Security-Policy allowing only its styles and data: images; at another size, ErrSVGNotOriginalSize (400). Any other format renders it at the least size covering the output, at most 8192 on a side. Rendering refuses an SVG with a side over 8192 times the other (ErrSVGTooThin, 415), and the orig size of one above 8192 on a side (ErrSVGTooLargeForOrigSize, 400). Child processes of pixad read an SVG's size and render it, killed after 20 seconds and limited to 1 GiB; any failure there is ErrSVGRenderFailed (415). Tests pinning the SVG refusal now expect SVG. Model: opus-5-5
This commit is contained in:
1 parent
20874ba2af
commit
2b19774518
22 files changed
+1525
-131
No files matched your search
@@ -1,4 +1,7 @@
|
||||
// Package imageprocessor provides image format conversion and resizing using libvips.
|
||||
// It reads the size of an SVG source and renders it in child processes, each
|
||||
// the running program started again (see RunAsSVGChildProcess). pixa kills
|
||||
// them after SVGRenderTimeout, and each may use at most MaxSVGRenderMemory.
|
||||
package imageprocessor
|
||||
|
||||
import (
|
||||
@@ -7,6 +10,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -113,10 +118,43 @@ var ErrUnsupportedOutputFormat = errors.New("unsupported output format")
|
||||
// ErrUnsupportedInputFormat is returned, before the input is decoded, when
|
||||
// libvips reads the input's first bytes as another format than its source
|
||||
// format, or as none. An SVG whose first bytes pass the first-bytes check for
|
||||
// AVIF is one example. A source format other than the six pixa reads is also
|
||||
// refused with it.
|
||||
// AVIF is one example. A source format other than the seven pixa reads is
|
||||
// also refused with it.
|
||||
var ErrUnsupportedInputFormat = errors.New("unsupported input format")
|
||||
|
||||
// ErrSVGNotOriginalSize is returned when orig is asked of an SVG source at a
|
||||
// size other than orig. pixa returns an SVG only as it is; a raster format
|
||||
// gives it a size.
|
||||
var ErrSVGNotOriginalSize = errors.New(
|
||||
"an SVG is returned as SVG only at the orig size")
|
||||
|
||||
// ErrSVGTooThin is returned, before rendering, when an SVG source has one
|
||||
// side more than MaxInputDimension times the other. Rendered with its longer
|
||||
// side at most MaxInputDimension, its shorter side would be under 1 pixel.
|
||||
var ErrSVGTooThin = errors.New("SVG source too thin to render")
|
||||
|
||||
// ErrSVGTooLargeForOrigSize is returned, before rendering, when a format other
|
||||
// than orig asks for an SVG source at the orig size and the SVG has a side
|
||||
// above MaxOutputDimension.
|
||||
var ErrSVGTooLargeForOrigSize = errors.New(
|
||||
"SVG source too large to render at its own size")
|
||||
|
||||
// SVGRenderTimeout is how long the child processes that read the size of one
|
||||
// SVG source and render it may run, counted from the start of the first,
|
||||
// before pixa kills them.
|
||||
const SVGRenderTimeout = 20 * time.Second
|
||||
|
||||
// MaxSVGRenderMemory is the most memory, in bytes, that each child process
|
||||
// reading the size of an SVG source or rendering it may use. It is the
|
||||
// child's RLIMIT_DATA, which limits its heap and its other private writable
|
||||
// memory.
|
||||
const MaxSVGRenderMemory = 1 << 30 // 1 GiB
|
||||
|
||||
// ErrSVGRenderFailed is returned when a child process fails to read the size
|
||||
// of an SVG source or to render it: librsvg cannot read the SVG, or the
|
||||
// child takes more than SVGRenderTimeout or MaxSVGRenderMemory.
|
||||
var ErrSVGRenderFailed = errors.New("SVG source could not be rendered")
|
||||
|
||||
// ErrTooManyImages is returned when MaxConcurrentProcessing images are being
|
||||
// processed and none finishes within ProcessingWaitTimeout.
|
||||
var ErrTooManyImages = errors.New("too many images being processed at once")
|
||||
@@ -134,6 +172,10 @@ type ImageProcessor struct {
|
||||
processingSemaphore chan struct{}
|
||||
// processingWaitTimeout is ProcessingWaitTimeout; tests shorten it.
|
||||
processingWaitTimeout time.Duration
|
||||
// svgRenderTimeout is SVGRenderTimeout; tests shorten it.
|
||||
svgRenderTimeout time.Duration
|
||||
// maxSVGRenderMemory is MaxSVGRenderMemory; tests lower it.
|
||||
maxSVGRenderMemory int
|
||||
}
|
||||
|
||||
// Params holds configuration for creating an ImageProcessor. Both fields
|
||||
@@ -153,6 +195,8 @@ func New(params Params) *ImageProcessor {
|
||||
maxInputBytes: params.MaxInputBytes,
|
||||
processingSemaphore: make(chan struct{}, params.MaxConcurrentProcessing),
|
||||
processingWaitTimeout: ProcessingWaitTimeout,
|
||||
svgRenderTimeout: SVGRenderTimeout,
|
||||
maxSVGRenderMemory: MaxSVGRenderMemory,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +208,10 @@ func New(params Params) *ImageProcessor {
|
||||
// encodes, it returns ctx's error and gives the slot back. govips gives pixa
|
||||
// no way to stop libvips once an encode has begun, so an encode keeps its
|
||||
// slot until it finishes. An AVIF output of more than MaxAVIFOutputPixels is
|
||||
// refused with ErrAVIFOutputTooLarge before anything is decoded.
|
||||
// refused with ErrAVIFOutputTooLarge before anything is decoded. orig of an
|
||||
// SVG source is the SVG as it is, since libvips cannot save SVG; any other
|
||||
// format renders it at the output size. Child processes read an SVG's size
|
||||
// and render it, as processSVG describes.
|
||||
func (p *ImageProcessor) Process(
|
||||
ctx context.Context,
|
||||
input io.Reader,
|
||||
@@ -183,35 +230,18 @@ func (p *ImageProcessor) Process(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Input libvips would not load as sourceFormat is refused before it is
|
||||
// decoded
|
||||
err = checkInputFormat(data, sourceFormat)
|
||||
if sourceFormat == imagerequest.FormatSVG {
|
||||
return p.processSVG(ctx, data, transform)
|
||||
}
|
||||
|
||||
img, err := loadImage(data, sourceFormat)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Decode image
|
||||
img, err := vips.NewImageFromBuffer(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode image: %w", err)
|
||||
}
|
||||
defer img.Close()
|
||||
|
||||
// Turn the image upright now: encode strips the EXIF orientation tag,
|
||||
// and sizes below must be worked out on the upright image.
|
||||
err = img.AutoRotate()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to auto-rotate: %w", err)
|
||||
}
|
||||
|
||||
// Get original dimensions
|
||||
origWidth := img.Width()
|
||||
origHeight := img.Height()
|
||||
|
||||
// Validate input dimensions to prevent DoS via decompression bombs
|
||||
if origWidth > MaxInputDimension || origHeight > MaxInputDimension {
|
||||
return nil, ErrInputTooLarge
|
||||
}
|
||||
origWidth, origHeight := img.Width(), img.Height()
|
||||
outputFormat := resolveOutputFormat(transform.Format, sourceFormat)
|
||||
|
||||
// Determine target dimensions; too large ones are refused before resizing
|
||||
targetWidth, targetHeight, err := targetDimensions(transform.Size,
|
||||
@@ -220,12 +250,6 @@ func (p *ImageProcessor) Process(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// orig is the source's own format; encode refuses an empty format
|
||||
outputFormat := transform.Format
|
||||
if outputFormat == imagerequest.FormatOriginal {
|
||||
outputFormat = sourceFormat
|
||||
}
|
||||
|
||||
// Determine output dimensions; too large ones are refused before
|
||||
// resizing, which with FitCover already decodes the input
|
||||
outputWidth, outputHeight, err := outputDimensions(origWidth, origHeight,
|
||||
@@ -242,8 +266,104 @@ func (p *ImageProcessor) Process(
|
||||
}
|
||||
}
|
||||
|
||||
// Encode to target format
|
||||
output, err := p.encode(ctx, img, outputFormat, transform.Quality)
|
||||
return p.encodeResult(ctx, img, outputFormat, transform.Quality,
|
||||
origWidth, origHeight)
|
||||
}
|
||||
|
||||
// WaitForProcessing waits until no image is being processed, or until ctx
|
||||
// ends, and returns how many images were still being processed then. It
|
||||
// waits by taking each slot in processingSemaphore as it frees up until it
|
||||
// holds them all, or until ctx ends, then gives back the slots it took.
|
||||
func (p *ImageProcessor) WaitForProcessing(ctx context.Context) int {
|
||||
taken := 0
|
||||
|
||||
defer func() {
|
||||
for range taken {
|
||||
<-p.processingSemaphore
|
||||
}
|
||||
}()
|
||||
|
||||
for taken < cap(p.processingSemaphore) {
|
||||
select {
|
||||
case p.processingSemaphore <- struct{}{}:
|
||||
taken++
|
||||
case <-ctx.Done():
|
||||
return len(p.processingSemaphore) - taken
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// processSVG is Process for an SVG source, data. Apart from the first-bytes
|
||||
// check, pixad hands the SVG to libvips only in child processes (see
|
||||
// runSVGChildProcess). One reads the SVG's size. For a raster format a second
|
||||
// renders it as PNG, which is then resized and encoded as any other image.
|
||||
// pixa kills them once svgRenderTimeout has passed since the first one
|
||||
// started.
|
||||
func (p *ImageProcessor) processSVG(
|
||||
ctx context.Context, data []byte, transform *imagerequest.Transform,
|
||||
) (*Result, error) {
|
||||
err := checkInputFormat(data, imagerequest.FormatSVG)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(p.svgRenderTimeout)
|
||||
|
||||
origWidth, origHeight, err := p.readSVGSize(ctx, deadline, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
outputFormat := resolveOutputFormat(transform.Format, imagerequest.FormatSVG)
|
||||
if outputFormat == imagerequest.FormatSVG {
|
||||
return svgAsItIs(data, transform.Size, origWidth, origHeight)
|
||||
}
|
||||
|
||||
err = checkSVGOrigSize(transform.Size, origWidth, origHeight)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
targetWidth, targetHeight, err := targetDimensions(transform.Size,
|
||||
origWidth, origHeight)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
outputWidth, outputHeight, err := outputDimensions(origWidth, origHeight,
|
||||
targetWidth, targetHeight, transform.FitMode, outputFormat)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The SVG is rendered at about the output size, to stay sharp
|
||||
img, err := p.renderSVG(ctx, deadline, data, origWidth, origHeight,
|
||||
outputWidth, outputHeight)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer img.Close()
|
||||
|
||||
if targetWidth != origWidth || targetHeight != origHeight {
|
||||
err := p.resize(img, outputWidth, outputHeight, transform.FitMode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resize: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return p.encodeResult(ctx, img, outputFormat, transform.Quality,
|
||||
origWidth, origHeight)
|
||||
}
|
||||
|
||||
// encodeResult encodes img as format at quality and returns it as the result
|
||||
// for a source of origWidth x origHeight.
|
||||
func (p *ImageProcessor) encodeResult(
|
||||
ctx context.Context, img *vips.ImageRef, format imagerequest.Format,
|
||||
quality, origWidth, origHeight int,
|
||||
) (*Result, error) {
|
||||
output, err := p.encode(ctx, img, format, quality)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode: %w", err)
|
||||
}
|
||||
@@ -251,7 +371,7 @@ func (p *ImageProcessor) Process(
|
||||
return &Result{
|
||||
Content: io.NopCloser(bytes.NewReader(output)),
|
||||
ContentLength: int64(len(output)),
|
||||
ContentType: outputFormat.MIMEType(),
|
||||
ContentType: format.MIMEType(),
|
||||
Width: img.Width(),
|
||||
Height: img.Height(),
|
||||
InputWidth: origWidth,
|
||||
@@ -290,29 +410,125 @@ func targetDimensions(
|
||||
return width, height, nil
|
||||
}
|
||||
|
||||
// WaitForProcessing waits until no image is being processed, or until ctx
|
||||
// ends, and returns how many images were still being processed then. It
|
||||
// waits by taking each slot in processingSemaphore as it frees up until it
|
||||
// holds them all, or until ctx ends, then gives back the slots it took.
|
||||
func (p *ImageProcessor) WaitForProcessing(ctx context.Context) int {
|
||||
taken := 0
|
||||
|
||||
defer func() {
|
||||
for range taken {
|
||||
<-p.processingSemaphore
|
||||
}
|
||||
}()
|
||||
|
||||
for taken < cap(p.processingSemaphore) {
|
||||
select {
|
||||
case p.processingSemaphore <- struct{}{}:
|
||||
taken++
|
||||
case <-ctx.Done():
|
||||
return len(p.processingSemaphore) - taken
|
||||
}
|
||||
// resolveOutputFormat returns the format of the output for the requested
|
||||
// format: sourceFormat for orig, and format itself for any other. encode
|
||||
// refuses an empty format.
|
||||
func resolveOutputFormat(
|
||||
format, sourceFormat imagerequest.Format,
|
||||
) imagerequest.Format {
|
||||
if format == imagerequest.FormatOriginal {
|
||||
return sourceFormat
|
||||
}
|
||||
|
||||
return 0
|
||||
return format
|
||||
}
|
||||
|
||||
// loadImage refuses data that libvips would not load as sourceFormat, then
|
||||
// decodes it and turns the image upright: encode strips the EXIF orientation
|
||||
// tag, and sizes must be worked out on the upright image. An image with a
|
||||
// side above MaxInputDimension is ErrInputTooLarge, which guards against
|
||||
// decompression bombs.
|
||||
func loadImage(
|
||||
data []byte, sourceFormat imagerequest.Format,
|
||||
) (*vips.ImageRef, error) {
|
||||
err := checkInputFormat(data, sourceFormat)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
img, err := vips.NewImageFromBuffer(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode image: %w", err)
|
||||
}
|
||||
|
||||
err = img.AutoRotate()
|
||||
if err != nil {
|
||||
img.Close()
|
||||
|
||||
return nil, fmt.Errorf("failed to auto-rotate: %w", err)
|
||||
}
|
||||
|
||||
if img.Width() > MaxInputDimension || img.Height() > MaxInputDimension {
|
||||
img.Close()
|
||||
|
||||
return nil, ErrInputTooLarge
|
||||
}
|
||||
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// svgAsItIs returns the SVG data unchanged, which is orig of an SVG source
|
||||
// at the orig size. width x height is the SVG's own size. Any other size is
|
||||
// ErrSVGNotOriginalSize.
|
||||
func svgAsItIs(
|
||||
data []byte, size imagerequest.Size, width, height int,
|
||||
) (*Result, error) {
|
||||
if !size.OriginalSize() {
|
||||
return nil, fmt.Errorf("%w: asked for %dx%d", ErrSVGNotOriginalSize,
|
||||
size.Width, size.Height)
|
||||
}
|
||||
|
||||
return &Result{
|
||||
Content: io.NopCloser(bytes.NewReader(data)),
|
||||
ContentLength: int64(len(data)),
|
||||
ContentType: imagerequest.FormatSVG.MIMEType(),
|
||||
Width: width,
|
||||
Height: height,
|
||||
InputWidth: width,
|
||||
InputHeight: height,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// checkSVGOrigSize returns ErrSVGTooLargeForOrigSize when size is the orig
|
||||
// size and the SVG source, of width x height, has a side above
|
||||
// MaxOutputDimension. processSVG calls it after returning orig of the SVG, so
|
||||
// the SVG would be rendered at its own size.
|
||||
func checkSVGOrigSize(size imagerequest.Size, width, height int) error {
|
||||
if size.OriginalSize() && max(width, height) > MaxOutputDimension {
|
||||
return fmt.Errorf("%w: %dx%d is above %d", ErrSVGTooLargeForOrigSize,
|
||||
width, height, MaxOutputDimension)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// svgRenderSize returns the least size in the proportions of width x height,
|
||||
// an SVG's own size, that covers outputWidth x outputHeight, so that resize
|
||||
// hardly changes it. Neither side is above MaxInputDimension.
|
||||
func svgRenderSize(width, height, outputWidth, outputHeight int) (int, int) {
|
||||
scale := min(
|
||||
max(float64(outputWidth)/float64(width),
|
||||
float64(outputHeight)/float64(height)),
|
||||
float64(MaxInputDimension)/float64(max(width, height)))
|
||||
|
||||
return int(math.Round(float64(width) * scale)),
|
||||
int(math.Round(float64(height) * scale))
|
||||
}
|
||||
|
||||
// renderSVG renders the SVG data, whose own size is width x height, at
|
||||
// svgRenderSize for outputWidth x outputHeight, in a child process that pixa
|
||||
// kills at deadline (see runSVGChildProcess). It returns the rendered image,
|
||||
// read from the child's PNG. An SVG with one side more than
|
||||
// MaxInputDimension times the other is ErrSVGTooThin.
|
||||
func (p *ImageProcessor) renderSVG(
|
||||
ctx context.Context, deadline time.Time, data []byte,
|
||||
width, height, outputWidth, outputHeight int,
|
||||
) (*vips.ImageRef, error) {
|
||||
if max(width, height) > MaxInputDimension*min(width, height) {
|
||||
return nil, fmt.Errorf("%w: %dx%d", ErrSVGTooThin, width, height)
|
||||
}
|
||||
|
||||
renderWidth, renderHeight := svgRenderSize(width, height,
|
||||
outputWidth, outputHeight)
|
||||
|
||||
png, err := p.runSVGChildProcess(ctx, deadline, data,
|
||||
renderSVGAsPNGArgument, strconv.Itoa(renderWidth),
|
||||
strconv.Itoa(renderHeight))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return loadImage(png, imagerequest.FormatPNG)
|
||||
}
|
||||
|
||||
// acquireSlot takes a slot in processingSemaphore, waiting at most
|
||||
@@ -397,6 +613,8 @@ func vipsImageType(format imagerequest.Format) vips.ImageType {
|
||||
return vips.ImageTypeAVIF
|
||||
case imagerequest.FormatJXL:
|
||||
return vips.ImageTypeJXL
|
||||
case imagerequest.FormatSVG:
|
||||
return vips.ImageTypeSVG
|
||||
case imagerequest.FormatOriginal, imagerequest.FormatAuto:
|
||||
return vips.ImageTypeUnknown
|
||||
default:
|
||||
@@ -575,7 +793,9 @@ func (p *ImageProcessor) encode(
|
||||
case imagerequest.FormatJXL:
|
||||
return exportJXL(img, quality)
|
||||
|
||||
case imagerequest.FormatOriginal, imagerequest.FormatAuto:
|
||||
// libvips cannot save SVG; Process returns an SVG source as it is
|
||||
case imagerequest.FormatSVG, imagerequest.FormatOriginal,
|
||||
imagerequest.FormatAuto:
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
|
||||
|
||||
default:
|
||||
|
||||
Reference in new issue
Block a user