Refuse SVG at the fetcher and never save an unknown input as JPEG (part of #68)
check / check (push) Failing after 8m23s

The fetcher no longer accepts image/svg+xml, so an SVG source gets the
refusal every unaccepted upstream type gets, which both image routes now
answer with 415 where they answered 500. orig of an input the processor
cannot name is ErrUnsupportedInputFormat, also answered 415, where it
was saved as JPEG. README.md drops SVG from the source formats and lists
the 415.

Model: opus-5-5
This commit is contained in:
2026-10-13 01:35:35 +00:00
parent 5bd6582b24
commit badc3ddb3b
5 changed files with 43 additions and 19 deletions
+10 -7
View File
@@ -190,9 +190,11 @@ path under `/v1/` answers 200, in maintenance mode too.
the cache and the upstream fetch; 403 when the upstream host, or a host it the cache and the upstream fetch; 403 when the upstream host, or a host it
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); 502 when the upstream answered with an `image_requests_per_minute` (below); 415 when the upstream answers with a
error status, and for 5 minutes after that for the same source URL; 503 when `Content-Type` that is not one of the source formats (below), or for `orig` of
pixa is busy or in maintenance mode; 500 for any other failure. 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.
- `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
@@ -200,7 +202,7 @@ path under `/v1/` answers 200, in maintenance mode too.
expired; 504 when the upstream has not sent its response headers within expired; 504 when the upstream has not sent its response headers within
`upstream_fetch_timeout`, but 500 when that time runs out while the image `upstream_fetch_timeout`, but 500 when that time runs out while the image
itself is still arriving; 400 for an `Accept` header that is not valid, and itself is still arriving; 400 for an `Accept` header that is not valid, and
406, 403, 429, 502, 503 and 500, as for `/v1/image/`. 406, 403, 415, 429, 502, 503 and 500, as for `/v1/image/`.
- `GET /robots.txt` — asks every crawler to stay away (`Disallow: /`). Needs: - `GET /robots.txt` — asks every crawler to stay away (`Disallow: /`). Needs:
nothing. Answers: 200. nothing. Answers: 200.
- `GET /.well-known/healthcheck.json` — JSON with `status` (`ok`), `now`, - `GET /.well-known/healthcheck.json` — JSON with `status` (`ok`), `now`,
@@ -260,9 +262,10 @@ once, is refused with 400.
both optional (values under Signature Specification). Both are part of what is both optional (values under Signature Specification). Both are part of what is
cached, so each value of either is a separate cached image. cached, so each value of either is a separate cached image.
The source image may be JPEG, PNG, GIF, WebP, AVIF, JPEG XL or SVG. The The source image may be JPEG, PNG, GIF, WebP, AVIF or JPEG XL. The upstream's
upstream's `Content-Type` must name its format, and the image's first bytes must `Content-Type` must name its format, and the image's first bytes must match it.
match it. An upstream answering with any other `Content-Type`, SVG included, is refused
with 415.
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:
+8
View File
@@ -247,6 +247,14 @@ func (s *Handlers) respondImageError(
return return
} }
if errors.Is(err, httpfetcher.ErrInvalidContentType) ||
errors.Is(err, imageprocessor.ErrUnsupportedInputFormat) {
s.respondError(w, "unsupported source format",
http.StatusUnsupportedMediaType)
return
}
s.respondError(w, "internal error", http.StatusInternalServerError) s.respondError(w, "internal error", http.StatusInternalServerError)
} }
+4
View File
@@ -161,6 +161,10 @@ func (s *Handlers) handleImageError(w http.ResponseWriter, err error) {
errors.Is(err, imageprocessor.ErrTooManyImages): errors.Is(err, imageprocessor.ErrTooManyImages):
s.respondError(w, "server busy, try again later", s.respondError(w, "server busy, try again later",
http.StatusServiceUnavailable) http.StatusServiceUnavailable)
case errors.Is(err, httpfetcher.ErrInvalidContentType),
errors.Is(err, imageprocessor.ErrUnsupportedInputFormat):
s.respondError(w, "unsupported source format",
http.StatusUnsupportedMediaType)
default: default:
s.log.Error("image request failed", "error", err) s.log.Error("image request failed", "error", err)
s.respondError(w, "internal error", http.StatusInternalServerError) s.respondError(w, "internal error", http.StatusInternalServerError)
-1
View File
@@ -158,7 +158,6 @@ func DefaultConfig() *Config {
contentTypeWebP, contentTypeWebP,
contentTypeAVIF, contentTypeAVIF,
contentTypeJXL, contentTypeJXL,
contentTypeSVG,
}, },
AllowHTTP: false, AllowHTTP: false,
MaxConnectionsPerHost: DefaultMaxConnectionsPerHost, MaxConnectionsPerHost: DefaultMaxConnectionsPerHost,
+21 -11
View File
@@ -137,6 +137,11 @@ 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
// the input is in a format the processor cannot name, so it cannot be saved
// in that format.
var ErrUnsupportedInputFormat = errors.New("unsupported input format")
// ErrTooManyImages is returned when MaxConcurrentProcessing images are being // ErrTooManyImages is returned when MaxConcurrentProcessing images are being
// processed and none finishes within ProcessingWaitTimeout. // processed and none finishes within ProcessingWaitTimeout.
var ErrTooManyImages = errors.New("too many images being processed at once") var ErrTooManyImages = errors.New("too many images being processed at once")
@@ -256,10 +261,14 @@ func (p *ImageProcessor) Process(
} }
} }
// orig is the source's own format; encode refuses an empty format // orig is the source's own format, and an error when the processor
// cannot name it; encode refuses an empty format
outputFormat := req.Format outputFormat := req.Format
if outputFormat == FormatOriginal { if outputFormat == FormatOriginal {
outputFormat = p.formatFromString(inputFormat) outputFormat, err = p.formatFromString(inputFormat)
if err != nil {
return nil, err
}
} }
// Encode to target format // Encode to target format
@@ -687,22 +696,23 @@ func jxlDistance(quality int) float64 {
return 53.0/3000.0*q*q - 23.0/20.0*q + 25.0 return 53.0/3000.0*q*q - 23.0/20.0*q + 25.0
} }
// formatFromString converts a format string to Format. // formatFromString converts a format string from detectFormat to Format. A
func (p *ImageProcessor) formatFromString(format string) Format { // format detectFormat could not name is ErrUnsupportedInputFormat.
func (p *ImageProcessor) formatFromString(format string) (Format, error) {
switch format { switch format {
case "jpeg": case "jpeg":
return FormatJPEG return FormatJPEG, nil
case "png": case "png":
return FormatPNG return FormatPNG, nil
case "gif": case "gif":
return FormatGIF return FormatGIF, nil
case "webp": case "webp":
return FormatWebP return FormatWebP, nil
case string(FormatAVIF): case string(FormatAVIF):
return FormatAVIF return FormatAVIF, nil
case string(FormatJXL): case string(FormatJXL):
return FormatJXL return FormatJXL, nil
default: default:
return FormatJPEG return "", fmt.Errorf("%w: %s", ErrUnsupportedInputFormat, format)
} }
} }