Skip to main content
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.
Slurp is 0.1.0, pre-1.0. The CLI surface here is unstable.

Running the service

It binds loopback by default and sends no CORS headers. The usual deployment is reverse-proxied at the same origin as the site, under /_slurp/image. It has no authentication. Expose it only if you mean to.
Binding a non-loopback address logs a warning saying so.

The URLs a template emits

renders a <picture> with three variants:
The URLs are root-relative, so the service has to be reachable at /_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 right Content-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 on Config 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 by sha256(src, width, format).
  1. In memory, a moka LRU holding --memory-cache-size entries (512 by default). Counted in entries, not bytes.
  2. On disk, in --cache-dir. The data file is named for the hash with no extension, alongside a <hash>.meta sidecar holding the expiry timestamp.
A background sweeper runs every --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.
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.
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.
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.
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.
  • Source bytes, checked against Content-Length and 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.
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.
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/image is the intended shape rather than a hardening step.
  • The cache directory defaults to /tmp. On a host that clears /tmp you will re-transform everything after a reboot. Point --cache-dir somewhere 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.