Compare commits

2 Commits
Author SHA1 Message Date
clawbot 6241c6a425 Refuse, before decoding, input libvips would not load as a source format
check / check (push) Waiting to run
govips picks the libvips loader for the input with DetermineImageType.
The image processor now names the format from that type before decoding,
and refuses with ErrUnsupportedInputFormat, answered 415, any input it
cannot name as one of the six source formats. An SVG whose first bytes
pass the first-bytes check for AVIF therefore never reaches librsvg.
orig is the format named there. README.md and TODO.md say so.

Model: opus-5-5
2026-10-13 03:20:52 +00:00
clawbot d67b6d5051 Test that an SVG served as AVIF is refused, and that each source format loads
An SVG starting with an XML comment holding "ftypavis" passes the
first-bytes check for AVIF; resized as PNG, it is answered 200 with the
SVG rendered. The new server test asks for it through both image routes,
resized and as orig, and wants 415 for each. The new processor test
checks that an image in each of the six source formats loads and is saved
again in its own format.

Model: opus-5-5
2026-10-13 03:20:48 +00:00
5 changed files with 149 additions and 26 deletions
+6 -5
View File
@@ -191,10 +191,10 @@ path under `/v1/` answers 200, in maintenance mode too.
redirects to, is `localhost`, ends in `.localhost` or `.local`, or has an
address in a blocked network (see `blocked_networks`); 429 over
`image_requests_per_minute` (below); 415 when the upstream answers with a
`Content-Type` that is not one of the source formats (below), or for `orig` of
a source pixa cannot save in its own format; 502 when the upstream answered
with an error status, and for 5 minutes after that for the same source URL;
503 when pixa is busy or in maintenance mode; 500 for any other failure.
`Content-Type` that is not one of the source formats (below), or with an image
libvips would not load as one of them; 502 when the upstream answered with an
error status, and for 5 minutes after that for the same source URL; 503 when
pixa is busy or in maintenance mode; 500 for any other failure.
- `GET` or `HEAD` `/v1/e/<token>/<name>` — an image through an encrypted URL
(see Encrypted URLs). Needs: nothing but the URL. Answers: 200; 304 when
`If-None-Match` matches the image's `ETag`; 400 for a token that does not
@@ -265,7 +265,8 @@ once, is refused with 400.
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
with 415.
with 415, and so is an image that libvips would not load as one of these
formats, such as an SVG whose first bytes pass the check for AVIF.
With the format `auto`, pixa chooses the format for each request from its
`Accept` header, in this order:
+9 -5
View File
@@ -34,12 +34,16 @@ P2: security: per-origin rate limiting
of the source's own format (closes #68): the upstream fetch no longer accepts
`image/svg+xml`, which libvips would render. Both image routes answer 415 for
an upstream `Content-Type` that is not one of the source formats, SVG
included, where they answered 500. `orig` of a source in a format the image
processor cannot name is `ErrUnsupportedInputFormat`, also answered 415, where
it was saved as JPEG. `README.md` no longer lists SVG. The SVG detection in
included, where they answered 500. Before decoding, the image processor
refuses with `ErrUnsupportedInputFormat`, also answered 415, any input that
govips' `DetermineImageType`, which picks the libvips loader, does not name as
a source format. That catches an SVG whose first bytes pass the first-bytes
check for another type, as an XML comment can make them do for AVIF; an SVG
whose first bytes do not pass that check answers 500, as before. `orig` is
always the source's own format, where a source the processor could not name
was saved as JPEG. `README.md` no longer lists SVG. The SVG detection in
`internal/magic` stays: the request path does not call it, and it goes with
the unused code of https://git.eeqj.de/sneak/pixa/issues/168. An SVG sent
under another image type still fails the first-bytes check and answers 500.
the unused code of https://git.eeqj.de/sneak/pixa/issues/168.
- 2026-10-12 per-client limit on the image routes (closes #236): the new setting
`image_requests_per_minute` (`PIXA_IMAGE_REQUESTS_PER_MINUTE`), default 600,
is how many `GET` and `HEAD` requests one client may send to `/v1/image/` and
+17 -16
View File
@@ -137,9 +137,8 @@ var ErrInputDataTooLarge = errors.New("input data exceeds maximum allowed size")
// not supported.
var ErrUnsupportedOutputFormat = errors.New("unsupported output format")
// ErrUnsupportedInputFormat is returned when the requested format is orig and
// the input is in a format the processor cannot name, so it cannot be saved
// in that format.
// ErrUnsupportedInputFormat is returned, before the input is decoded, when
// libvips would not load it as one of the source formats.
var ErrUnsupportedInputFormat = errors.New("unsupported input format")
// ErrTooManyImages is returned when MaxConcurrentProcessing images are being
@@ -224,6 +223,16 @@ func (p *ImageProcessor) Process(
return nil, ErrInputDataTooLarge
}
// govips picks the libvips loader with DetermineImageType. Input it
// would not load as a source format, such as SVG, which librsvg would
// render, is refused before libvips reads it.
inputFormat := p.detectFormat(vips.DetermineImageType(data))
sourceFormat, err := p.formatFromString(inputFormat)
if err != nil {
return nil, err
}
// Decode image
img, err := vips.NewImageFromBuffer(data)
if err != nil {
@@ -242,9 +251,6 @@ func (p *ImageProcessor) Process(
origWidth := img.Width()
origHeight := img.Height()
// Detect input format
inputFormat := p.detectFormat(img)
// Validate input dimensions to prevent DoS via decompression bombs
if origWidth > MaxInputDimension || origHeight > MaxInputDimension {
return nil, ErrInputTooLarge
@@ -261,14 +267,10 @@ func (p *ImageProcessor) Process(
}
}
// orig is the source's own format, and an error when the processor
// cannot name it; encode refuses an empty format
// orig is the source's own format; encode refuses an empty format
outputFormat := req.Format
if outputFormat == FormatOriginal {
outputFormat, err = p.formatFromString(inputFormat)
if err != nil {
return nil, err
}
outputFormat = sourceFormat
}
// Encode to target format
@@ -412,10 +414,9 @@ func (p *ImageProcessor) acquireSlot(ctx context.Context) (func(), error) {
}
}
// detectFormat returns the format string from a vips image.
func (p *ImageProcessor) detectFormat(img *vips.ImageRef) string {
format := img.Format()
switch format {
// detectFormat returns the format string for a govips image type.
func (p *ImageProcessor) detectFormat(imageType vips.ImageType) string {
switch imageType {
case vips.ImageTypeJPEG:
return "jpeg"
case vips.ImageTypePNG:
@@ -0,0 +1,46 @@
package imageprocessor
import (
"bytes"
"io"
"testing"
)
// TestImageProcessor_LoadsEachSourceFormat verifies that an image in each of
// the six source formats, saved by the processor itself, is loaded and saved
// again in its own format (orig).
func TestImageProcessor_LoadsEachSourceFormat(t *testing.T) {
t.Parallel()
proc := New(Params{})
for _, format := range []Format{
FormatJPEG, FormatPNG, FormatGIF, FormatWebP, FormatAVIF, FormatJXL,
} {
saved, err := proc.Process(t.Context(),
bytes.NewReader(createTestPNG(t, 20, 20)), &Request{Format: format})
if err != nil {
t.Fatalf("saving the test image as %s: %v", format, err)
}
source, err := io.ReadAll(saved.Content)
if err != nil {
t.Fatalf("reading the %s image: %v", format, err)
}
result, err := proc.Process(t.Context(), bytes.NewReader(source),
&Request{Format: FormatOriginal})
if err != nil {
t.Errorf("orig of the %s image: %v", format, err)
continue
}
t.Logf("orig of the %s image: %s", format, result.ContentType)
if result.ContentType != FormatToMIME(format) {
t.Errorf("orig of the %s image is %s, want %s",
format, result.ContentType, FormatToMIME(format))
}
}
}
@@ -0,0 +1,71 @@
package server
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"sneak.berlin/go/pixa/internal/encurl"
"sneak.berlin/go/pixa/internal/imgcache"
)
// svgServedAsAVIF is an SVG whose first bytes pass the first-bytes check for
// AVIF: its XML comment puts "ftypavis" at bytes 4 to 11, where an AVIF file
// has its ftyp box and brand. libvips would load it as SVG.
const svgServedAsAVIF = `<!--ftypavis--><svg xmlns="http://www.w3.org/2000/svg" ` +
`width="64" height="48"><rect width="64" height="48"/></svg>`
// TestImageProxyRefusesSVGServedAsAVIF requests svgServedAsAVIF, which the
// upstream answers as image/avif, through both image routes, resized as PNG
// and in its own format (orig). Each request is answered 415, as for an SVG
// source, never with an image.
func TestImageProxyRefusesSVGServedAsAVIF(t *testing.T) {
t.Parallel()
upstream := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "image/avif")
_, _ = io.WriteString(w, svgServedAsAVIF)
}))
t.Cleanup(upstream.Close)
s, _, _ := startImageProxy(t, upstream)
generator, err := encurl.NewGenerator(testSigningKey)
if err != nil {
t.Fatalf("encurl.NewGenerator: %v", err)
}
encryptedURL := func(format imgcache.ImageFormat) string {
token, err := generator.Generate(&encurl.Payload{
SourceHost: upstreamHost, SourcePath: "/drawing.avif",
Width: 32, Height: 24, Format: format,
})
if err != nil {
t.Fatalf("encurl Generate: %v", err)
}
return "/v1/e/" + token + "/drawing." + string(format)
}
for _, target := range []string{
"/v1/image/" + upstreamHost + "/drawing.avif/32x24.png",
"/v1/image/" + upstreamHost + "/drawing.avif/64x48.orig",
encryptedURL(imgcache.FormatPNG),
encryptedURL(imgcache.FormatOriginal),
} {
rec := httptest.NewRecorder()
s.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, target, nil))
t.Logf("GET %s: %d, Content-Type %s, body %q",
target, rec.Code, rec.Header().Get("Content-Type"), rec.Body.String())
if rec.Code != http.StatusUnsupportedMediaType ||
!strings.Contains(rec.Body.String(), "unsupported source format") {
t.Errorf("GET %s: %d %q, want %d unsupported source format",
target, rec.Code, rec.Body.String(), http.StatusUnsupportedMediaType)
}
}
}