slurp-image is a small standalone HTTP service that resizes and re-encodes
images on demand. The <Image> component in a template emits URLs that point at
it; the service does not read templates and has no knowledge of Slurp beyond the
URL shape.
It performs one transform: a width-only resize that preserves aspect ratio and
never upscales, re-encoded to AVIF, WebP or JPEG. There is no cropping, no
rotation, no quality parameter and no height parameter.
Running the service
/_slurp/image. It has no authentication. Expose it only if you mean to.
The URLs a template emits
<picture> with three variants:
/_slurp/image on the site’s own origin. height is used for the <img>
attribute only and is never sent to the service, because the only transform
is width-driven. It reserves layout space and avoids a shift.
width defaults to 800 and height to 600 when not given. Add priority to
drop loading="lazy" for an above-the-fold image. A missing src is a
MissingImageSrc render error.
The endpoint
string
required
An absolute
http or https URL. At most 4,096 bytes, no control characters
(anything below 0x20, or 0x7f), no userinfo component, and it must have a
host.integer
required
Target width in pixels, 1 to 8,192. Absent or
0 is a 400.string
default:"webp"
One of
avif, webp, jpeg or jpg. Anything else is a 400.There is no
h and no q, and the format parameter is f, not fmt.
Quality is fixed: AVIF is quality 80 at speed 6, and JPEG and WebP use the
image crate defaults. Resampling is Lanczos3.Formats
PNG and GIF are decoded and are never emitted. AVIF and HEIC are recognised only
so such a file is reported as an unsupported source format rather than as corrupt
data; there is no ISO base media decoder linked in, and AVIF is an output
format only.
Responses
A success is the image bytes with the rightContent-Type and
Cache-Control: public, max-age=<cache-ttl>.
An error is JSON, {"error": "..."}:
Only 429 and 503 carry a
Retry-After (60 and 5 seconds). A 4xx caused by the
request itself will fail again at any time.
Error messages for security failures are vague, so the service is not an oracle
for what is reachable from inside the network.
Configuration
Command-line flags
Not exposed as flags
Several limits live onConfig and can only be set by a Rust caller using
slurp-image as a library. An unexpected 413 or 422 comes from here:
Caching
Two levels, both keyed bysha256(src, width, format).
- In memory, a moka LRU holding
--memory-cache-sizeentries (512 by default). Counted in entries, not bytes. - On disk, in
--cache-dir. The data file is named for the hash with no extension, alongside a<hash>.metasidecar holding the expiry timestamp.
--disk-sweep-interval-secs, deletes expired
entries, and then evicts the oldest until the directory is under
--max-disk-cache-bytes. The write path also sweeps out of band once a burst has
written a fraction of the budget, so growth stays bounded between ticks. Without
that, an attacker requesting many distinct (src, width, format) variants could
fill the disk.
Cache-Control: public, max-age=<cache-ttl> goes out with every successful
response, so a CDN or the browser will normally absorb the repeat traffic before
this service sees it.
Security
This service fetches URLs it did not choose. The guards below run in order, and each covers a case the one before it does not. The full treatment is in the security model.URL validation
URL validation
Length capped at 4,096 bytes. Control characters rejected. Scheme allow-list
of
http and https only. A host is required. Userinfo is rejected,
because http://internal-host@evil.example.com/ is a classic SSRF
indirection.SSRF: resolve, allow-list, then pin
SSRF: resolve, allow-list, then pin
Every hostname is DNS-resolved before connecting, and every address
returned must be globally routable unicast.It is an allow-list, not a deny-list: a deny-list silently permits every
range nobody thought to enumerate, and
240.0.0.0/4 is easy to overlook
while being real internal space in some cloud fabrics. An IPv4-mapped IPv6
address is unwrapped and checked as IPv4,
so ::ffff:127.0.0.1 cannot slip through as “not ::1”.The resolved addresses are then pinned into the HTTP client, so a second
lookup cannot rebind to a private address between the check and the connect.Redirects are disabled entirely
Redirects are disabled entirely
The redirect policy is
none(). Following redirects is a documented SSRF
bypass: the first hop passes the address check and the second one does not
have to. A 3xx is returned as-is and fails the success check.Magic bytes, and a decoder that must agree
Magic bytes, and a decoder that must agree
The response body must begin with a JPEG, PNG, GIF or WebP header, identified
from the bytes rather than from the extension or the
Content-Type.For an ISO base media file the ftyp marker at bytes 4 to 8 is required,
not just the brand at 8 to 12. Checking the brand alone would leave the first
eight bytes fully attacker-controlled, so a TIFF, EXR, DDS, BMP or QOI header
carrying mif1 at offset 8 would pass and then be handed to whichever decoder
the content really matched.Accepting the header is necessary but not sufficient: the format the decoder
infers from the real content must also be one of the four declared ones, so a
polyglot cannot be routed to a decoder this service never enabled.Size caps at three separate stages
Size caps at three separate stages
- Source bytes, checked against
Content-Lengthand again while streaming, because the header can be absent and can lie. Over the cap is a 413. - Decoded size, computed as declared width times height times the decoder’s real bytes per pixel, checked before any pixel buffer is allocated. That is the decompression-bomb guard, and it is a 422.
- Output pixels, checked after computing the resized dimensions. A width cap alone bounds nothing on a very tall source, and encode cost scales with pixels, so this is the limit that actually bounds per-request CPU.
Concurrency and deadlines
Concurrency and deadlines
Decode, resize and encode are CPU-bound and run on the blocking pool behind a
semaphore, defaulting to one permit per core. More permits than cores would
only trade throughput for latency and memory, since each in-flight transform
peaks in the hundreds of megabytes.Saturation makes a request wait, bounded by the whole-request deadline,
rather than refusing work the service could serve a moment later.Two deadlines:
fetch_timeout_secs (10) on the upstream fetch, and
request_timeout_secs (30) on the whole request, so a slow upstream plus a
slow encode cannot hold a connection and a transform permit indefinitely.Rate limiting, and its limitations
Rate limiting, and its limitations
An in-memory fixed-window counter, 100 requests per 60 seconds per client by
default.An IPv6 client is bucketed by its /64, not its address, because a routine
residential or cloud allocation is a whole /64 and per-address buckets let one
attacker mint unlimited distinct keys. An IPv4-mapped address shares the IPv4
bucket, so a dual-stack listener cannot be used to get a second allowance.The bucket map is sharded, pruned on a background timer rather than on the
request path, and hard-capped at 65,536 entries. Past the cap a shard drops
expired buckets first and then refuses rather than growing: memory is
bounded by construction, not by hoping the sweeper keeps up.It resets on restart and is not shared between instances. It is a last
line of defence, not a substitute for limits at the reverse proxy.
Operating notes
- Put it behind a reverse proxy. No authentication, in-process rate limiting
only, and the
<Image>URLs are root-relative anyway, so a proxy at/_slurp/imageis the intended shape rather than a hardening step. - The cache directory defaults to
/tmp. On a host that clears/tmpyou will re-transform everything after a reboot. Point--cache-dirsomewhere durable if that matters. - Watch for 502s. They mean DNS or the fetch failed, which usually means the source host is down or the URL is wrong, not that this service is unhealthy.
- A 403 is an SSRF refusal. If a legitimate internal image host is being refused, that is the allow-list working. Serve those images from a publicly routable host, or fetch them by another path.
Next
Images in templates
The
<Image> component and when to use it.Security model
The whole threat model, including the order these guards run in.