Refuse SVG sources and never save an unknown input as JPEG (closes #68) #240

Merged
clawbot merged 5 commits from issue-68-refuse-svg into next 2026-10-13 07:13:18 +02:00
6 changed files with 53 additions and 19 deletions
Showing only changes of commit 06e537c458 - Show all commits
+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
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); 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.
`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.
- `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
@@ -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
`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
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:
nothing. Answers: 200.
- `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
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
upstream's `Content-Type` must name its format, and the image's first bytes must
match it.
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 the format `auto`, pixa chooses the format for each request from its
`Accept` header, in this order:
+10
View File
@@ -30,6 +30,16 @@ P2: security: per-origin rate limiting
# Completed Steps
- 2026-10-13 SVG sources are refused, and `orig` is never saved as JPEG in place
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
`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.
- 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
+8
View File
@@ -247,6 +247,14 @@ func (s *Handlers) respondImageError(
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)
}
+4
View File
@@ -161,6 +161,10 @@ func (s *Handlers) handleImageError(w http.ResponseWriter, err error) {
errors.Is(err, imageprocessor.ErrTooManyImages):
s.respondError(w, "server busy, try again later",
http.StatusServiceUnavailable)
case errors.Is(err, httpfetcher.ErrInvalidContentType),
errors.Is(err, imageprocessor.ErrUnsupportedInputFormat):
s.respondError(w, "unsupported source format",
http.StatusUnsupportedMediaType)
default:
s.log.Error("image request failed", "error", err)
s.respondError(w, "internal error", http.StatusInternalServerError)
-1
View File
@@ -158,7 +158,6 @@ func DefaultConfig() *Config {
contentTypeWebP,
contentTypeAVIF,
contentTypeJXL,
contentTypeSVG,
},
AllowHTTP: false,
MaxConnectionsPerHost: DefaultMaxConnectionsPerHost,
+21 -11
View File
@@ -137,6 +137,11 @@ 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.
var ErrUnsupportedInputFormat = errors.New("unsupported input format")
// ErrTooManyImages is returned when MaxConcurrentProcessing images are being
// processed and none finishes within ProcessingWaitTimeout.
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
if outputFormat == FormatOriginal {
outputFormat = p.formatFromString(inputFormat)
outputFormat, err = p.formatFromString(inputFormat)
if err != nil {
return nil, err
}
}
// 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
}
// formatFromString converts a format string to Format.
func (p *ImageProcessor) formatFromString(format string) Format {
// formatFromString converts a format string from detectFormat to Format. A
// format detectFormat could not name is ErrUnsupportedInputFormat.
func (p *ImageProcessor) formatFromString(format string) (Format, error) {
switch format {
case "jpeg":
return FormatJPEG
return FormatJPEG, nil
case "png":
return FormatPNG
return FormatPNG, nil
case "gif":
return FormatGIF
return FormatGIF, nil
case "webp":
return FormatWebP
return FormatWebP, nil
case string(FormatAVIF):
return FormatAVIF
return FormatAVIF, nil
case string(FormatJXL):
return FormatJXL
return FormatJXL, nil
default:
return FormatJPEG
return "", fmt.Errorf("%w: %s", ErrUnsupportedInputFormat, format)
}
}