Support SVG sources (closes #68)
check / check (push) Waiting to run

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 was merged in pull request #351.
This commit is contained in:
clawbot committed 2026-11-12 04:41:25 +01:00
1 parent 20874ba2af
commit c2e32d987b
22 files changed
+1525 -131

No files matched your search

+53 -6
View File
@@ -234,12 +234,8 @@ func (s *Handlers) HandleTooManyRequests() http.HandlerFunc {
func (s *Handlers) respondImageError(
w http.ResponseWriter, req *imagerequest.Request, err error,
) {
// An upstream body whose first bytes are not those of the format its
// Content-Type names is the upstream's error, not pixa's: the image
// service has logged it as a refused source.
if errors.Is(err, magic.ErrMagicByteMismatch) ||
errors.Is(err, magic.ErrUnknownFormat) ||
errors.Is(err, magic.ErrNotEnoughData) {
// The image service has logged this as a refused source
if isFirstBytesMismatch(err) {
s.respondError(w, "unsupported source format",
http.StatusUnsupportedMediaType)
@@ -269,11 +265,29 @@ func (s *Handlers) respondImageError(
errors.Is(err, imageprocessor.ErrUnsupportedInputFormat):
status = http.StatusUnsupportedMediaType
message = "unsupported source format"
case errors.Is(err, imageprocessor.ErrSVGTooThin):
status = http.StatusUnsupportedMediaType
message = fmt.Sprintf("SVG source too thin to render: one side is "+
"more than %d times the other", imageprocessor.MaxInputDimension)
case errors.Is(err, imageprocessor.ErrSVGRenderFailed):
status = http.StatusUnsupportedMediaType
message = fmt.Sprintf("SVG source could not be rendered: it is not "+
"valid, or it takes more than %d seconds or %d GiB of memory to "+
"render", int(imageprocessor.SVGRenderTimeout.Seconds()),
imageprocessor.MaxSVGRenderMemory/bytesPerGiB)
case errors.Is(err, imageprocessor.ErrOutputTooLarge):
status = http.StatusBadRequest
message = fmt.Sprintf("size too large for the source: the side "+
"computed from its proportions is above %d",
imageprocessor.MaxOutputDimension)
case errors.Is(err, imageprocessor.ErrSVGNotOriginalSize):
status = http.StatusBadRequest
message = "an SVG source is returned as SVG only at the orig size; " +
"ask for another format to resize it"
case errors.Is(err, imageprocessor.ErrSVGTooLargeForOrigSize):
status = http.StatusBadRequest
message = fmt.Sprintf("SVG source above %d on a side: give a size "+
"to render it at", imageprocessor.MaxOutputDimension)
case errors.Is(err, imageprocessor.ErrAVIFOutputTooLarge):
status = http.StatusBadRequest
message = fmt.Sprintf("size too large for AVIF: the output "+
@@ -297,6 +311,18 @@ func (s *Handlers) respondImageError(
s.respondError(w, message, status)
}
// bytesPerGiB is the number of bytes in a GiB.
const bytesPerGiB = 1 << 30
// isFirstBytesMismatch reports whether err refuses an upstream body whose
// first bytes are not those of the format its Content-Type names. That is the
// upstream's error, not pixa's.
func isFirstBytesMismatch(err error) bool {
return errors.Is(err, magic.ErrMagicByteMismatch) ||
errors.Is(err, magic.ErrUnknownFormat) ||
errors.Is(err, magic.ErrNotEnoughData)
}
// cacheControl returns the Cache-Control header for an image served through a
// URL that expires at expires, or never when expires is the zero time. A cache
// may keep the image for a year, but not past the URL's expiry, after which
@@ -315,6 +341,26 @@ func cacheControl(expires time.Time) string {
return fmt.Sprintf("public, max-age=%d, immutable", int64(maxAge/time.Second))
}
// svgContentSecurityPolicyValue is the Content-Security-Policy header value
// of an SVG response, in place of the middleware's. A browser that opens the
// SVG applies its styles and draws its embedded data: images, but runs none
// of its scripts and loads nothing else. The styles need unsafe-inline, as an
// SVG keeps them inline, in style elements and attributes.
const svgContentSecurityPolicyValue = "default-src 'none'; " +
"style-src 'unsafe-inline'; " +
"img-src data:; " +
"frame-ancestors 'none'; " +
"sandbox"
// setSVGContentSecurityPolicy sets the Content-Security-Policy in header to
// svgContentSecurityPolicyValue when contentType is that of SVG. Any other
// response keeps the middleware's policy.
func setSVGContentSecurityPolicy(header http.Header, contentType string) {
if contentType == imagerequest.FormatSVG.MIMEType() {
header.Set("Content-Security-Policy", svgContentSecurityPolicyValue)
}
}
// refuseBlockedReferer answers 403 with a JSON error when the request's Referer
// names a host on referer_blocklist, and reports whether it answered. A request
// with no Referer, or one that does not parse as a URL with a host, is not
@@ -397,6 +443,7 @@ func (s *Handlers) serveImage(
// Set response headers
w.Header().Set("Content-Type", resp.ContentType)
setSVGContentSecurityPolicy(w.Header(), resp.ContentType)
if resp.ContentLength > 0 {
w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
@@ -475,7 +475,7 @@ func TestImageRoutes_SameErrorAnswers(t *testing.T) {
{"no free upstream connection", httpfetcher.ErrTooManyConnections,
http.StatusServiceUnavailable, slog.LevelWarn},
{"Content-Type not a source format",
fmt.Errorf("%w: image/svg+xml", httpfetcher.ErrInvalidContentType),
fmt.Errorf("%w: image/tiff", httpfetcher.ErrInvalidContentType),
http.StatusUnsupportedMediaType, slog.LevelWarn},
{"any other failure", io.ErrUnexpectedEOF,
http.StatusInternalServerError, slog.LevelError},
@@ -15,7 +15,7 @@ import (
// TestUnsupportedSourceFormatAnswers415 checks that respondImageError, which
// answers the errors of both image routes, answers 415 with a clear error when
// the upstream answers with a content type pixa does not accept, such as SVG,
// the upstream answers with a content type pixa does not accept, such as TIFF,
// and when libvips does not read a source as the format its first bytes
// show, wrapped as the service wraps them.
func TestUnsupportedSourceFormatAnswers415(t *testing.T) {
@@ -23,11 +23,11 @@ func TestUnsupportedSourceFormatAnswers415(t *testing.T) {
h := &Handlers{log: slog.New(slog.DiscardHandler)}
req := &imagerequest.Request{
Source: imagerequest.Source{Host: "img.example.com", Path: "/a.svg"},
Source: imagerequest.Source{Host: "img.example.com", Path: "/a.tiff"},
}
for _, err := range []error{
fmt.Errorf("upstream fetch failed: %w: image/svg+xml",
fmt.Errorf("upstream fetch failed: %w: image/tiff",
httpfetcher.ErrInvalidContentType),
fmt.Errorf("image processing failed: %w: libvips does not read the input as avif",
imageprocessor.ErrUnsupportedInputFormat),
@@ -47,3 +47,67 @@ func TestUnsupportedSourceFormatAnswers415(t *testing.T) {
}
}
}
// TestSVGTooThinAnswers415 checks that respondImageError answers 415 with a
// clear error for an SVG source too thin to render, wrapped as the service
// wraps it.
func TestSVGTooThinAnswers415(t *testing.T) {
t.Parallel()
h := &Handlers{log: slog.New(slog.DiscardHandler)}
req := &imagerequest.Request{
Source: imagerequest.Source{Host: "svg.example.com", Path: "/a.svg"},
}
err := fmt.Errorf("image processing failed: %w: 81930x10",
imageprocessor.ErrSVGTooThin)
rec := httptest.NewRecorder()
h.respondImageError(rec, req, err)
t.Logf("for %v: %d %s", err, rec.Code, rec.Body.String())
if rec.Code != http.StatusUnsupportedMediaType {
t.Errorf("status = %d, want %d", rec.Code,
http.StatusUnsupportedMediaType)
}
if !strings.Contains(rec.Body.String(), "SVG source too thin to render") {
t.Errorf("body = %q, want the SVG too thin error", rec.Body.String())
}
}
// TestSVGRenderFailedAnswers415 checks that respondImageError answers 415,
// naming the limits, for an SVG source that a child process could not read
// or render, wrapped as the service wraps it.
func TestSVGRenderFailedAnswers415(t *testing.T) {
t.Parallel()
h := &Handlers{log: slog.New(slog.DiscardHandler)}
err := fmt.Errorf("image processing failed: %w: it took more than 20s",
imageprocessor.ErrSVGRenderFailed)
rec := httptest.NewRecorder()
h.respondImageError(rec, &imagerequest.Request{}, err)
t.Logf("for %v: %d %s", err, rec.Code, rec.Body.String())
checkErrorBody(t, rec, http.StatusUnsupportedMediaType,
"SVG source could not be rendered: it is not valid, or it takes more "+
"than 20 seconds or 1 GiB of memory to render")
}
// TestSVGTooLargeForOrigSizeAnswers400 checks that respondImageError answers
// 400, asking for a size, for an SVG source above 8192 on a side asked for at
// the orig size in a raster format, wrapped as the service wraps it.
func TestSVGTooLargeForOrigSizeAnswers400(t *testing.T) {
t.Parallel()
h := &Handlers{log: slog.New(slog.DiscardHandler)}
err := fmt.Errorf("image processing failed: %w: 10000x10 is above 8192",
imageprocessor.ErrSVGTooLargeForOrigSize)
rec := httptest.NewRecorder()
h.respondImageError(rec, &imagerequest.Request{}, err)
t.Logf("for %v: %d %s", err, rec.Code, rec.Body.String())
checkErrorBody(t, rec, http.StatusBadRequest,
"SVG source above 8192 on a side: give a size to render it at")
}