[]previous and next page

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…

For agentsUpdated

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.

src/entry-server.tsts
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

http
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.

src/routes/spinner.tsxtsx
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.