Images: concurrency and SVG
The image endpoint (GET /_dusk/image, from the images() feature) reads a source for every request with a new URL: a local file through your source function, or a…
The image endpoint (GET /_dusk/image, from the images() feature) reads a source for every request with a new URL:
a local file through your source function, or a remote image from an origin in remotePatterns, up to
maxSourceBytes (8 MiB) each. There is no server cache, so many slow sources at once would hold memory and sockets.
Two caps bound that, and both are on with no configuration.
| Option | Default | Over the cap |
|---|---|---|
images.maxConcurrentFetches |
32 source reads in flight over all clients | 503 Service Unavailable |
images.maxConcurrentFetchesPerClient |
16 for one client address | 429 Too Many Requests |
Both answers carry Retry-After: 1 and Cache-Control: no-store, and the source is not read at all for the refused
request. A read counts from the moment the endpoint starts reading the source until the processor has returned, because
the bytes stay in memory until then. A slot is always given back: a failed fetch, an oversized source, a redirect and a
throwing processor all free theirs.
The client is the address the rate limiter uses (addressKey, so an IPv6 client is one /64, and
security.trustProxy decides whether a forwarded header is believed). A request with no known address is held to the
global cap only: it has no bucket of its own to share with strangers, so one such client cannot lock the others out of
a per-client cap. Behind a proxy, set trustProxy, or every visitor looks like the proxy and shares one client cap.
The per-client bookkeeping is bounded by construction: an entry exists only while that client has a read in flight, and
a read is counted in the global total first, so the map never holds more entries than maxConcurrentFetches, however
many addresses ask. A browser loading a page with twenty images opens them a few at a time and stays far below 16; raise
the per-client cap only if a page really loads that many new images in one burst.
import type { HandlerOptions } from "@shadow-js/dusk/server";
const options: Omit<HandlerOptions, "manifest"> = {
security: { allowedHosts: ["example.com"], trustProxy: { hops: 1 } },
images: {
remotePatterns: ["https://cdn.example"],
// A small server: at most 8 images being prepared at once, 4 per visitor.
maxConcurrentFetches: 8,
maxConcurrentFetchesPerClient: 4,
},
};
export default options;
Both options must be positive integers; anything else is a RangeError when the handler is created. This cap limits
what one server holds in flight. It does not replace a CDN in front of /_dusk/image, which also removes the repeated
fetches.
SVG is served locked#
An SVG is a document, not a bitmap: opened in its own tab it can carry <script>, foreignObject, on* attributes and
animations that write a javascript: URL into an href (<animate attributeName="href" values="javascript:...">).
From the app's origin that would be script with your cookies. The endpoint never rewrites a source, so it never trusts
it: every SVG leaves with
Content-Type: image/svg+xml
Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox
Content-Disposition: attachment
X-Content-Type-Options: nosniff
The sandbox directive without allow-scripts gives the document an opaque origin and no script, default-src 'none'
blocks everything it might load, attachment makes a browser that is sent to the URL download the file instead of
rendering it, and nosniff stops a mislabeled file from being treated as something else. None of this changes how
an image looks: <img src="/_dusk/image?...">, CSS background-image and <Image> ignore the disposition and never
run script in an SVG anyway. What stops working is opening the endpoint as a page, or embedding it in <object>,
<iframe> or <embed>, which <Image> does not do.
Why locking and not sanitizing: a sanitizer must understand every way SVG can run or load code (script,
foreignObject, on* attributes, href and xlink:href on any element, animate and set writing those, style
with url(), use of external documents, entities, namespaces) and must agree with each browser's parser about
what it removed. A sanitizer that misses one spelling is a stored script, and a sanitizer that is correct still changes
the file the designer exported. The locked response is correct for every SVG that can reach the route, including
one a custom processor returns, and it stays correct when browsers add features. The lock follows the media type, not
the exact string: parameters, case and spaces do not matter (Image/SVG+XML; charset=utf-8 is locked), and any svg or
XML media type counts. The header you see is always the normalized image/svg+xml.
If you need to inline user-supplied SVG into a page (not through the endpoint), sanitize it on the way in with a
maintained sanitizer and keep it out of innerHTML; the endpoint is for SVG you serve as an image.
Animated URLs in SVG you render#
The same trap exists in markup you build from data. An SVG animate or set can write an href by animation, so
values, from, to and by are checked exactly like href and src: a value that starts with javascript:,
vbscript: or data:text/html (after the controls and case browsers ignore) is not written, on the server and in the
browser. values is a ; list and one script entry drops the attribute. Ordinary values (0;1;0, #a;#b) are written
unchanged.
import type { JSX } from "@shadow-js/web";
export default function Spinner(props: { to: string }): JSX.Element {
return (
<svg viewBox="0 0 10 10" width="40" height="40">
<a href="/home">
<circle cx="5" cy="5" r="4" />
{/* props.to = "javascript:alert(1)" is dropped, not written. */}
<animate attributeName="r" from="1" to={props.to} dur="1s" />
</a>
</svg>
);
}
Still, do not forward user input into attributeName or animation attributes: the check is a safety net for the URL
schemes, not a license to build SVG from untrusted strings.