Compare commits
2
Commits
69dd0143e8
...
6241c6a425
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6241c6a425 | ||
|
|
d67b6d5051 |
@@ -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
|
redirects to, is `localhost`, ends in `.localhost` or `.local`, or has an
|
||||||
address in a blocked network (see `blocked_networks`); 429 over
|
address in a blocked network (see `blocked_networks`); 429 over
|
||||||
`image_requests_per_minute` (below); 415 when the upstream answers with a
|
`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
|
`Content-Type` that is not one of the source formats (below), or with an image
|
||||||
a source pixa cannot save in its own format; 502 when the upstream answered
|
libvips would not load as one of them; 502 when the upstream answered with an
|
||||||
with an error status, and for 5 minutes after that for the same source URL;
|
error status, and for 5 minutes after that for the same source URL; 503 when
|
||||||
503 when pixa is busy or in maintenance mode; 500 for any other failure.
|
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
|
- `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
|
(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
|
`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
|
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.
|
`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
|
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
|
With the format `auto`, pixa chooses the format for each request from its
|
||||||
`Accept` header, in this order:
|
`Accept` header, in this order:
|
||||||
|
|||||||
@@ -34,12 +34,16 @@ P2: security: per-origin rate limiting
|
|||||||
of the source's own format (closes #68): the upstream fetch no longer accepts
|
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
|
`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
|
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
|
included, where they answered 500. Before decoding, the image processor
|
||||||
processor cannot name is `ErrUnsupportedInputFormat`, also answered 415, where
|
refuses with `ErrUnsupportedInputFormat`, also answered 415, any input that
|
||||||
it was saved as JPEG. `README.md` no longer lists SVG. The SVG detection in
|
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
|
`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
|
the unused code of https://git.eeqj.de/sneak/pixa/issues/168.
|
||||||
under another image type still fails the first-bytes check and answers 500.
|
|
||||||
- 2026-10-12 per-client limit on the image routes (closes #236): the new setting
|
- 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,
|
`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
|
is how many `GET` and `HEAD` requests one client may send to `/v1/image/` and
|
||||||
|
|||||||
@@ -137,9 +137,8 @@ var ErrInputDataTooLarge = errors.New("input data exceeds maximum allowed size")
|
|||||||
// not supported.
|
// not supported.
|
||||||
var ErrUnsupportedOutputFormat = errors.New("unsupported output format")
|
var ErrUnsupportedOutputFormat = errors.New("unsupported output format")
|
||||||
|
|
||||||
// ErrUnsupportedInputFormat is returned when the requested format is orig and
|
// ErrUnsupportedInputFormat is returned, before the input is decoded, when
|
||||||
// the input is in a format the processor cannot name, so it cannot be saved
|
// libvips would not load it as one of the source formats.
|
||||||
// in that format.
|
|
||||||
var ErrUnsupportedInputFormat = errors.New("unsupported input format")
|
var ErrUnsupportedInputFormat = errors.New("unsupported input format")
|
||||||
|
|
||||||
// ErrTooManyImages is returned when MaxConcurrentProcessing images are being
|
// ErrTooManyImages is returned when MaxConcurrentProcessing images are being
|
||||||
@@ -224,6 +223,16 @@ func (p *ImageProcessor) Process(
|
|||||||
return nil, ErrInputDataTooLarge
|
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
|
// Decode image
|
||||||
img, err := vips.NewImageFromBuffer(data)
|
img, err := vips.NewImageFromBuffer(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -242,9 +251,6 @@ func (p *ImageProcessor) Process(
|
|||||||
origWidth := img.Width()
|
origWidth := img.Width()
|
||||||
origHeight := img.Height()
|
origHeight := img.Height()
|
||||||
|
|
||||||
// Detect input format
|
|
||||||
inputFormat := p.detectFormat(img)
|
|
||||||
|
|
||||||
// Validate input dimensions to prevent DoS via decompression bombs
|
// Validate input dimensions to prevent DoS via decompression bombs
|
||||||
if origWidth > MaxInputDimension || origHeight > MaxInputDimension {
|
if origWidth > MaxInputDimension || origHeight > MaxInputDimension {
|
||||||
return nil, ErrInputTooLarge
|
return nil, ErrInputTooLarge
|
||||||
@@ -261,14 +267,10 @@ func (p *ImageProcessor) Process(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// orig is the source's own format, and an error when the processor
|
// orig is the source's own format; encode refuses an empty format
|
||||||
// cannot name it; encode refuses an empty format
|
|
||||||
outputFormat := req.Format
|
outputFormat := req.Format
|
||||||
if outputFormat == FormatOriginal {
|
if outputFormat == FormatOriginal {
|
||||||
outputFormat, err = p.formatFromString(inputFormat)
|
outputFormat = sourceFormat
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encode to target format
|
// 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.
|
// detectFormat returns the format string for a govips image type.
|
||||||
func (p *ImageProcessor) detectFormat(img *vips.ImageRef) string {
|
func (p *ImageProcessor) detectFormat(imageType vips.ImageType) string {
|
||||||
format := img.Format()
|
switch imageType {
|
||||||
switch format {
|
|
||||||
case vips.ImageTypeJPEG:
|
case vips.ImageTypeJPEG:
|
||||||
return "jpeg"
|
return "jpeg"
|
||||||
case vips.ImageTypePNG:
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user