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, so it stays sharp. 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). The cache reads up to 1024 first bytes to find a file's format. Tests pinning the SVG refusal now expect SVG. Render time and memory are unbounded: P1 in TODO.md until the owner decides on #68. Model: opus-5-5
This commit is contained in:
1 parent
092176d917
commit
d47aea67b8
20 files changed
+945
-97
No files matched your search
@@ -267,11 +267,23 @@ 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.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 "+
|
||||
@@ -313,6 +325,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
|
||||
@@ -395,6 +427,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))
|
||||
|
||||
@@ -467,7 +467,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,48 @@ 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())
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
Reference in new issue
Block a user