Skip to main content

Slurp security model

What slurp defends against, how, and where it does not. The reporting policy is in SECURITY.md. Every property here names the file and the symbol that enforces it, so a claim can be checked. If you find one that the code does not deliver, that is a bug in its own right.

Threat Model

Users and Trust Levels

Theme developers write .slurp files and run slurp build. They are trusted to write well-intentioned templates but are not trusted with secrets outside explicitly designated middleware files. They control the full template surface. End users view the rendered HTML in a browser. They are untrusted: their data may be stored by the application and reflected back into templates via context variables. They must not be able to inject executable HTML through any expression path. Operators deploy the compiled output and run the image optimizer and dev server. They control environment variables, network configuration, and reverse proxy setup.

Attack Surfaces


Security Properties

Escaping (Compiler)

In HTML text context, all ${ expr } expression output is HTML-escaped before being written into the rendered document. The escaping is applied by the renderer’s Node::Expression arm in Renderer::render_node, calling escape_html in renderer/escape.rs, and covers the five characters that are dangerous in HTML text context: / is not escaped: it has no special meaning in HTML text, and escaping it would corrupt URLs and date strings. This escaping is compile-time and always on, and there is no opt-out. HTML entity escaping alone is not the right answer everywhere, and slurp does not apply it everywhere. The renderer picks an escaper from the context the slot lands in: a <script> body, a JavaScript-evaluated attribute, a style attribute and srcdoc each get their own treatment. The next section is the whole of that behaviour, including the places where the answer is a refusal rather than an escaper.

How ${ } is treated in each context

Read this before interpolating untrusted data anywhere other than HTML text or an ordinary attribute. 1. Inside a <script> body: a compile error unless you declare the context. <script> is a raw-text element: the HTML parser does not decode character references inside it. HTML entity escaping is therefore the wrong escaper and stops nothing, and no automatic escaper is correct either, because escape_js_string is built for a value sitting inside a JS string literal and would leave 1;alert(1) completely intact in var n = ${ count }. So slurp refuses instead. Renderer::render_element tracks script_depth, Renderer::render_node routes an expression under it to render_script_expression, and an expression whose filter chain contains neither js nor json (renderer::expr_has_script_filter) is rejected:
The rule is enforced twice. lib.rs’s security walk carries an in_script flag on CheckCtx and raises the error at compile time, which is what slurp build and slurp validate report. Renderer::render_script_expression is the render-time backstop for the paths that render without going through the security gate (section rendering, and the WASM render exports); there it emits nothing and records a diagnostic rather than falling back to an escaper that does not fit. A filtered value is emitted verbatim into a script body. Without that exemption | js is unusable: var a = "${ v | js }" rendered a literal &#39; inside the JS string, so there was no working way to interpolate into a script at all. The exemption is reached only while script_depth > 0; everywhere else | js output is still entity-escaped.
{$let} remains the option that needs no filter: it serialises to a JSON <script type="application/json"> payload through escape_script_safe, which encodes < as < (killing both </script> breakout and <!-- comment-state confusion) and the two JS line separators, while staying valid JSON. 2. Inside a JavaScript-evaluated attribute: escaped automatically, by position. This covers native on* handlers and every Alpine attribute that evaluates its value as JavaScript: @click, x-on:*, x-data, x-init, x-effect, x-show, x-text, x-html, x-if, x-for, x-model, and the : / x-bind: binding forms. The set is renderer::is_js_attr, which strips modifier suffixes so x-model.lazy is recognised. Entity escaping alone is not enough here, and escape_attr’s own doc comment says so: the HTML parser decodes character references in an attribute value before Alpine or the event-handler compiler reads the string. So Renderer::eval_interpolated escapes each ${ } slot before the attribute escaper runs, per slot rather than over the whole expanded value, which keeps the theme’s own quotes intact while neutralising only the data between them. Which escaper is right depends on where the slot lands, and that is a lexical property of the author’s own text. renderer::js_slot::slot_quote_contexts walks the literal segments between the slots and reports, for each slot, the quote character of the JS string literal enclosing it or None. Inside a string literal - escape_js_string is a complete answer, and is applied:
With v set to ');alert(1);(' this renders add('\');alert(1);(\''). The value stays one JS string literal. Entity escaping in an attribute is reversible, so composing escape_attr on top afterwards is harmless: the browser decodes the entities and the backslashes are still there. In statement or expression position - escape_js_string is no answer at all, because ; and ( have no JS string escape. renderer::json_literal encodes the value as a JSON literal instead, which is self-delimiting whatever it contains: a string becomes a quoted string, a number stays a bare number, and neither can contribute a ; or a ( to the surrounding program.
renders x-init="n = &#34');alert(1);('&#34", which the browser decodes to n = "');alert(1);('". A JS-evaluated attribute carries different kinds of value and only the author can tell them apart, so there are three opt-outs. | js declares “this is data inside a string literal”; | unsafe_js declares “this is JavaScript the theme itself wrote” (a prop composed into a handler, which escaping would corrupt); | json declares an already-encoded value. A declared slot is passed through unescaped. A false | js declaration is a compile error. A declared slot is emitted verbatim, so a wrong claim is worse than no claim:
That is lib.rs check_js_slot_positions at compile time, with Renderer::escape_slot as the render-time half. An undeclared slot in the same position is not an error: it becomes a JSON literal, and numeric slots such as x-show="active == ${ loop.index }" are ordinary and cannot be proved safe statically. In development mode the renderer warns on every undeclared JS slot (warn_undeclared_js_slot) so the author is told where to state intent; in production mode it is silent. 3. Inside a style attribute or a bound :style. There is no useful escape for CSS: CSS escapes are only valid inside identifiers and strings, and a value slot in a style attribute can sit in any of a dozen grammatical positions. So renderer::escape::escape_css_value removes the structural characters instead - ; { } ( ) " ' \ @ * < > and NUL. : and / are kept: neither can open a new declaration on its own, and both are needed by the legitimate background:url(${ path }) shape, while dropping * is enough to make /* comment injection impossible.
With c set to a);color:red;x:url(b this renders style="background:url(acolor:redx:urlb)". The injected declaration is gone. renderer::is_css_attr routes style, :style and x-bind:style. CSS neutralisation runs before the JS pass in escape_slot, because doing it afterwards would strip the backslashes that pass just added. In development mode, a value the strip actually changed produces a warning (warn_css_stripped), since silently mangling a value is how a slot becomes a mystery bug. A <style> body is not covered. escape_css_value is routed by attribute name; nothing applies it to text inside a <style> element. Do not interpolate untrusted data there. 4. Inside srcdoc. srcdoc on an <iframe> holds an entire HTML document, so its content is entity-decoded once by the attribute parser and then parsed as HTML. Renderer::srcdoc_guard runs the same ammonia allowlist that {html expr sanitize} uses over the value, at the layer the iframe will actually see:
With d set to <img src=x onerror=alert(1)><b>ok</b> this renders srcdoc='<img src="x"><b>ok</b>'. The handler is gone and the markup survives. Sanitising rather than entity-escaping keeps srcdoc able to carry markup, which is the one thing it exists for. The bound form (:srcdoc / x-bind:srcdoc) is not handled here: its value is a JavaScript expression evaluated in the browser, not markup, so the JS rules in point 2 cover it instead. A related trap, not a security issue but a silent one. slurp interpolates ${ } inside script bodies and attributes, so an ordinary JavaScript template literal can be consumed by the compiler. This is now diagnosed in both places:
is the <script> case from point 1 and is a hard compile error - the message names template literals explicitly and tells you to build the string by concatenation. In an attribute:
raises a warning (JsTemplateLiteralInAttribute, from parser::html::warn_js_template_literal). It is a warning rather than an error because slurp can legitimately interpolate a server value inside a backtick literal; the compiler cannot tell the author’s client-side item from a server-side one, only that the shape is the one that goes wrong. The warning is suppressed when the backtick is unterminated: a lone backtick is far more often ordinary prose. Note that slurp validate shows warnings only with --warnings.

Raw HTML Output

{html expr} emits the expression value without escaping. It is for cases where the developer controls the HTML content (e.g., a rich-text body stored in a CMS field). It is the developer’s responsibility to ensure the content is safe. Do not use {html} with content that originates from end-user input. {html expr sanitize} runs the expression value through the ammonia crate before output. Ammonia operates on an allowlist of safe HTML elements and attributes, which is significantly safer than a blocklist approach because novel attack vectors are blocked by default. The allowlist is the contract. Naming the tags that get stripped would be misleading, because anything absent from the list below is stripped, including tags that do not exist yet. script, iframe, object, embed, applet, form, input, button, select, textarea, link, style, base and meta are all absent from it, but they are not special cases. Allowed tags (the tag allowlist in renderer/sanitize.rs): p br hr span div section article aside header footer main nav h1-h6 ul ol li dl dt dd a strong em b i u s strike del ins mark sup sub small abbr cite code pre kbd samp var q blockquote img figure figcaption picture source table thead tbody tfoot tr th td caption colgroup col time data address details summary Allowed attributes (the attribute allowlist in renderer/sanitize.rs): class id title lang dir aria-label aria-describedby aria-hidden aria-live aria-atomic aria-relevant role href target src alt width height loading decoding sizes colspan rowspan scope headers datetime value open Every other attribute is dropped, which is how all on* event handlers are removed: not by a blocklist, but by never being allowed. Allowed URL schemes: http, https, mailto. A javascript:, data: or vbscript: URL in any allowed URL attribute is removed with the attribute. Anchor hardening: any <a> carrying a target has rel="noopener noreferrer" injected (the link_rel setting in renderer/sanitize.rs), so an opened page cannot reach back through window.opener and the referrer is not leaked. The same sanitizer is what Renderer::srcdoc_guard runs over an srcdoc value, so everything in this subsection applies there too. Weigh these before treating sanitize as sufficient:
  • img, source and picture are allowed with src, so sanitized content can still load off-origin resources. (srcset is NOT allowed: ammonia’s URL-scheme check does not parse a srcset candidate list, so a data:text/html candidate survived it. Dropping the attribute keeps the scheme rule above true.) That is a tracking beacon: it leaks the reader’s IP address and a referrer to whoever authored the content. If that matters, strip HTML entirely rather than sanitizing it.
  • class and id are allowed, so sanitized content can adopt your stylesheet’s classes and can collide with element IDs your own scripts query.
The sanitize flag is the right default when displaying user-generated rich text. It is not appropriate where no HTML at all should appear: use a plain ${ } expression there.

Environment Variables

The compiler never reads the process environment. It has no environment-variable lookup at all. env is an ordinary root in the render context, populated entirely by whatever host embeds the compiler, exactly like site or product. The protection below is therefore a check on a NAME written in a template, not a check on a value. What the compiler enforces. A path whose root is env and whose second segment literally begins with SLURP_SECRET_ is a compile error (SecretEnvInTemplate). The check is check_env_key_name in lib.rs, in the analysis pass, before any output is generated.
The refusal is unconditional, including inside middleware. check_env_key_name takes no is_middleware argument and has no branch on it, so there is no file in which a SLURP_SECRET_* name is permitted. Middleware may read env (it is one of the three allowed roots), but not a key carrying that prefix. Both spellings of the access are covered. env.X and env["X"] are the same access, and the prefix test once ran on the dotted form only. check_expr’s Index arm re-joins a parenthesised (env)["..."], whose two halves would otherwise be checked independently with neither being a rooted env.KEY path. A subscript key that is not a compile-time constant is refused outright (check_env_key_expr), because there is no way to prove statically that env[k] is not a secret. What it does not enforce. The match is on the literal prefix and nothing else. ${ env.STRIPE_KEY } compiles cleanly, and renders whatever the host put at context.env.STRIPE_KEY. So this check catches a template author who names a secret in the obvious way; it cannot catch one who names it in any other way, and it cannot catch a host that seeded a secret into the context under a different key. The host’s responsibility, and it is the load-bearing half. Because the compiler cannot see your environment, the following is on the embedder, not on slurp:
  1. Do not put secrets in the render context. The context is rendered into HTML. Anything in it can reach the page. The prefix check is a guard rail, not a boundary.
  2. If you populate env from the process environment, filter it. Copy across an explicit allowlist of keys, or copy only keys carrying a prefix you have chosen as public. Never pass std::env::vars() through wholesale.
  3. Do not strip a prefix on the way in. If you inject SLURP_SECRET_FOO into the context under the key FOO, you have disabled the check: ${ env.FOO } is then a permitted path pointing at a secret. Keep the name the template writes identical to the name the check sees.
A convention is sometimes assumed where SLURP_PUBLIC_FOO is exposed to templates as env.FOO and env.FOO is resolved back to SLURP_SECRET_FOO. No such mechanism exists, in the compiler or the dev server. Do not rely on it, and note that implementing the prefix-stripping half of it without the resolution half is exactly the mistake described in point 3.

Middleware Scope Restriction

Middleware files are permitted to inspect the incoming request and make routing decisions. To prevent middleware from being repurposed as a general-purpose data exfiltration layer, the compiler enforces that a path inside a middleware file may only have one of three roots (check_path in lib.rs):
  • request - the incoming request, for example request.cookies.session, request.headers, request.method, request.path
  • env - so a middleware can read configuration. Note this does not extend to SLURP_SECRET_*, which is refused everywhere; see the section above
  • loop - the automatic loop variables, so {each} works inside a middleware
Any other path root is a compile error (MiddlewareScopeViolation). Note this is enforced on the ROOT only: the compiler does not restrict which fields of request a middleware reads, and the shape of request is supplied by the host. A subscript inside a path is itself an author-written expression and is checked in its own right, so ${ foo[request.cookies.session] } does not slip past the guard by hiding inside an outer path. Conversely, outside a middleware file:
  • request.* is a compile error (MiddlewareScopeViolation, the root == "request" branch of check_path)
  • {redirect} is a compile error (RedirectOutsideMiddleware, the Node::Redirect arm of check_node)
  • {next} is a compile error (RedirectOutsideMiddleware, the Node::Next arm of check_node)
“Middleware” is a caller-supplied flag, not a path convention. It comes from CompileOptions.is_middleware. A host that sets that flag on the wrong file disables every rule in this section for it, and a host that never sets it makes middleware uncompilable. Nothing about a file’s location or name makes it middleware.

Image Optimizer Security

The image optimizer (slurp-image) is an HTTP microservice that fetches and transcodes remote images. It is a natural target for SSRF and resource exhaustion attacks.

SSRF Protection

The src URL parameter is validated before any network request is made:
  1. The URL must parse as a valid URL (max length 4,096 bytes; no null bytes or control characters < 0x20).
  2. Only http:// and https:// schemes are permitted. file://, ftp://, gopher://, data:, and all other schemes are rejected with a DisallowedScheme error.
  3. Userinfo components (user:pass@host) are rejected to prevent credential injection.
  4. After DNS resolution, every resolved IP address must be globally routable unicast before the connection is opened. This is an allowlist, not a blocklist, so any address space not explicitly routable fails closed:
    • IPv4 is accepted only in 1.0.0.0 - 223.255.255.255, which excludes 0.0.0.0/8, multicast 224.0.0.0/4, reserved 240.0.0.0/4 and the 255.255.255.255 broadcast address outright.
    • Within that span these special-purpose blocks are rejected: 10.0.0.0/8, 100.64.0.0/10 (CGNAT), 127.0.0.0/8 (loopback), 169.254.0.0/16 (link-local, and therefore cloud metadata), 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.88.99.0/24 (6to4 anycast), 192.168.0.0/16, 198.18.0.0/15 (benchmarking), 198.51.100.0/24 and 203.0.113.0/24.
    • IPv6 is accepted only within global unicast 2000::/3, which excludes ::1, ::, fc00::/7 (ULA), fe80::/10 (link-local), ff00::/8 (multicast) and 100::/64. Inside 2000::/3, the documentation range 2001:db8::/32 and Teredo 2001::/32 are also rejected.
    • Addresses that embed an IPv4 target are unwrapped and checked as IPv4, because on a dual-stack or tunnelled host that is where the packet actually goes: IPv4-mapped ::ffff:a.b.c.d, IPv4-compatible ::a.b.c.d, NAT64 64:ff9b::/96 and 6to4 2002::/16.
CNAME chains and hosts that resolve to multiple addresses are all checked; any single non-routable address in the resolution set causes the request to be aborted (SsrfBlocked). DNS rebinding is mitigated in code. Validating the resolved addresses would be pointless if the HTTP client then performed its own second lookup when connecting, since that lookup can return a private address. The client is pinned to the exact addresses that just passed validation (reqwest::ClientBuilder::resolve_to_addrs, processor.rs), so the connection can only reach an address already checked. Redirects are refused outright (redirect::Policy::none()), which closes the other half of the same bypass.

Content-Length and Timeout

  • The Content-Length response header is inspected before the download body is consumed. Any response claiming more than 20 MB is rejected immediately (SourceTooLarge).
  • The body is then streamed with a running total, and aborted the moment it exceeds the same cap. Content-Length can be absent or can lie, so the header check alone is not the limit.
  • Remote fetch requests time out after 10 seconds.
  • A whole-request deadline (default 30 seconds) is applied by a timeout layer, covering the transform as well as the fetch.

Decompression Bomb Protection

In this order:
  1. A pre-decode dimension check, which is the real defence. The file header is parsed for its declared dimensions and colour type before any pixel buffer is allocated, and the request is rejected with DecompressionBomb if width x height x bytes_per_pixel exceeds 50 MB. The per-pixel cost comes from the decoder’s own colour type, so a 16-bit source is charged at its true 8 bytes per pixel. The arithmetic saturates, so dimensions near u32::MAX cannot wrap to a small product and slip through. A tiny file claiming 60000 x 60000 is refused here, having allocated nothing.
  2. An allocation limit handed to the decoder, in case the header understates the real decode cost.
Separately, the output pixel count (default 8 MP) is capped after accounting for the requested width, because a width cap alone bounds nothing on a very tall source and encode cost scales with pixels.

Magic Bytes Validation

The optimizer validates the actual file header (magic bytes), not the Content-Type response header or the file extension in the URL. A server returning Content-Type: image/jpeg with a file that does not begin with the JPEG magic bytes (FF D8 FF) will be rejected. Decodable source formats and their required headers: Any file whose header does not match one of these signatures is rejected (InvalidMagicBytes). These four decoders are the only ones linked into the binary. image/Cargo.toml builds the crate with default-features = false, features = ["jpeg", "webp", "png", "gif"]. The default feature set would additionally link bmp, dds, exr, ff, hdr, ico, pnm, qoi, tga and tiff. That matters because format selection at decode time is by content, not by the header we matched: without the restriction, a file that satisfied the magic check could still be routed to a decoder this document never declared. A recognised header is not sufficient on its own. After the magic check, the format the decoder infers from the content must equal the format the header declared, or the request is rejected. AVIF and HEIC are a special case: an ISO base media header (the ftyp marker at bytes 4 to 8, with brand avif, avis, heic or mif1) is recognised so that such a file is reported as an unsupported source rather than as corrupt data, but no ISO base media decoder is compiled in. AVIF is an output format only.

Path Traversal Prevention

The src parameter is parsed as a URL by the url crate. Path traversal sequences (e.g., /../, %2F..%2F) are normalised by the URL parser during validation. Additionally, file:// and all non-HTTP schemes are blocked at the scheme-check step, so a file:///etc/passwd URL cannot reach the filesystem.

Rate Limiting

The image optimizer applies in-memory rate limiting of 100 requests per 60 seconds per client (RateLimiter::check_and_record in image/src/handler.rs). The window is TUMBLING, not sliding. The counter is hard-reset once the window has elapsed, so a client that empties its allowance at the end of one window and again at the start of the next can be admitted up to twice the nominal rate across the boundary. That is the standard fixed-window tradeoff. This limiter is a last line of defence, not the primary one. An IPv6 client is bucketed by its /64 rather than by its address (ClientKey::V6Net64), because a routine allocation is a whole /64 and per-address buckets would let one client mint unlimited distinct keys. An IPv4-mapped IPv6 address collapses into the IPv4 bucket, so a dual-stack listener cannot yield a second allowance. The bucket table is sharded over 16 mutexes, capped per shard, and pruned on a background timer rather than on the request path; a poisoned mutex fails closed. This limit resets on process restart and is not shared across multiple optimizer instances. For production deployments, rate limiting should additionally be enforced at the reverse proxy layer (e.g., Nginx limit_req, Cloudflare rate limiting) to survive restarts and horizontal scaling.

Resource Exhaustion

Decode, resize and encode are seconds of CPU work on a large image. They run on the blocking pool, never on an async worker, behind a global permit defaulting to one in-flight transform per core (--max-concurrent-transforms). Without both halves, enough concurrent large requests pin every async worker, at which point the process can no longer accept connections or run its own rate limiter.

Network Exposure Defaults

The service binds loopback by default and sends no CORS headers by default. It fetches attacker-supplied URLs, so exposing it is an explicit act. Pass --bind 0.0.0.0 to accept connections from the network and --cors-allow-origin <origin> (repeatable, or *) to enable CORS.

Dev Server Security

The dev server (slurp-dev) is designed exclusively for local development and must not be exposed to the network.

Static File Serving - Path Traversal Prevention

Traversal is rejected in two places, because the dev server has two different ways of turning a URL into a filesystem path. First, at the top of request handling. security::request_path_is_safe rejects the raw request path before any branch touches it: any .. (ParentDir) component, a drive prefix such as C:, an absolute root, or an embedded NUL results in a 404. This guard is applied once, at the entry point, so every downstream branch inherits it - the static-file branch, the dynamic bracket-route branch, and the SSR branch. Percent-encoding is not a bypass: the HTTP layer has already decoded the path by the time this runs, so %2e%2e and ..%2f are seen as the .. they are. Second, per file. Static file candidates go through security::safe_path, which:
  1. Strips leading slashes to prevent absolute path interpretation.
  2. Rejects .. (ParentDir), drive-prefix and root components before canonicalization as an early defence-in-depth layer.
  3. Calls std::fs::canonicalize on the joined path, resolving all symlinks and remaining traversal sequences. Canonicalization fails (returning None) if the path does not exist, resulting in a 404 rather than a server error.
  4. Verifies that the canonical result has the canonical theme root as a prefix, comparing component-wise so that a sibling directory sharing a textual prefix cannot pass.
The dynamic bracket-route file and the SSR source file are not built by safe_path, so each is additionally re-checked with security::is_contained after canonicalization. The SSR check matters most: that branch does not merely serve bytes, it reads and compiles a .slurp file.

WebSocket (HMR) - Origin Restriction

The HMR WebSocket endpoint validates the Origin header. Only this dev server’s own origin is accepted: http:// on a loopback host (localhost, 127.0.0.1, ::1) at the port the server is listening on. A missing Origin, a null Origin, a foreign origin, and a loopback origin on a different port are all rejected with a 403. The connection’s peer address is also required to be loopback, but that check is defence in depth only and is not what prevents cross-site WebSocket hijacking. A malicious page runs in the developer’s own browser, so its TCP peer is loopback like any other tab. WebSockets have no same-origin policy and send no preflight, so the Origin header - which is set by the browser and cannot be forged by page script - is the only check that actually works here. This matters because the HMR feed is not empty: it carries changed page paths and build errors whose file field is an absolute local filesystem path.

DNS Rebinding - Host Restriction

A request carrying a Host header must name a loopback host, otherwise it is refused with a 403 that explains why. A request with NO Host header at all is allowed through, so that HTTP/1.0 clients still work; a browser always sends one, and a browser is the only thing a rebinding attack can drive. Binding to 127.0.0.1 is not sufficient on its own: in a DNS rebinding attack the attacker’s own domain is made to resolve to 127.0.0.1, at which point the browser treats the attacker’s origin as same-origin with the dev server and every response becomes cross-origin readable. The Host header is the only place that lie is visible. A developer who points a hosts-file entry at the dev server can opt that name in with --allow-host <host> (repeatable).

Backend Proxy

/api/* and /auth/* are proxied to --backend-url with the request’s method, body, Cookie and Authorization forwarded. What keeps that from being a confused deputy for any page on the internet:
  • A request carrying a foreign Origin is refused with a 403. An absent Origin is allowed, because a top-level navigation and a command-line client both send none. This is what stops a no-preflight cross-site POST with credentials: 'include' from landing a state-changing write on the developer’s backend.
  • The upstream’s access-control-allow-origin response header is not relayed. That header describes the upstream’s own CORS policy (dev backends commonly send *); replaying it here would hand a foreign page read access to responses this proxy fetched with the developer’s cookies. content-type, set-cookie, location and cache-control are relayed.
Fixture responses (--fixtures) scope access-control-allow-origin to the requesting dev origin and add Vary: Origin. They never send *. All HTTP methods are forwarded rather than restricted to a safe subset. Themes legitimately POST, PUT and DELETE against their backend during development, and a method allowlist would break that without adding protection the Origin and Host guards do not already provide.

SLURP_MODE Injection

The dev server injects <script>window.SLURP_MODE = 'dev'</script> into every page it serves (dev-server/src/server.rs, HMR_INJECT_TEMPLATE). It always writes 'dev' and has no code path that writes any other value. It is the only producer of window.SLURP_MODE in this repository. slurp build does not emit it, and there is no build pipeline here that sets 'production'. A page built with slurp build and served by any host other than the dev server has window.SLURP_MODE undefined. That matters because the runtime branches on this value. Anything reading it must treat an absent value as production, so that the safe behaviour is the default rather than something a host has to remember to opt into. A host that wants editor or dev behaviour must set the global itself, before the runtime loads.

Runtime Security

The runtime (@bytesell/slurp-runtime) is an optional browser bundle. A page that uses no {fetch}, no {$let}, no {try} and no client navigation does not need it at all, and the whole of this subsection is then moot. No eval() - The runtime contains no eval(), no new Function(), and no string-argument setTimeout. Verified by grep across runtime/src. It does, however, load Alpine.js, and Alpine evaluates its own attribute values as JavaScript. That is Alpine’s design, and it is why the JS-evaluated-attribute case above is a real injection sink rather than a theoretical one. Fetch interceptor scope - The interceptor is read from window.SLURP_FETCH_INTERCEPTOR, a global, not from an options object (callFn in runtime/src/fetch/index.ts). SlurpRuntimeOptions.fetchInterceptor still appears in shared/interfaces.ts but nothing in the repository reads or implements it; treat that type field as stale. The gate is an allowlist. The interceptor runs only when window.SLURP_MODE is 'dev' or 'editor', so an absent or unrecognised mode means no interception. That matters because nothing in this repository ever sets a production value (see the SLURP_MODE subsection above): a blocklist keyed on === 'production' would have made an undefined mode permissive on every built page, which is exactly the failure this shape avoids. DOM manipulation - The runtime never uses innerHTML for content that came off the network. A client navigation parses the incoming document with DOMParser, then swaps in the parsed node with replaceWith (doSwap in runtime/src/core/navigation.ts); the new title is copied with textContent. The runtime contains no innerHTML write at all: the dev-mode error overlay builds every node with createElement and assigns every error field via textContent (runtime/src/core/dev.ts). {html} nodes are rendered by the Rust compiler at build time and never touched by the runtime. cn() class helper - cn() is twMerge(clsx(...)). Neither library executes code or interprets strings as HTML; both return a plain class string. Not a security property, and do not use it as one: navigate=false opts a link out of client navigation, and the compiler and runtime agree on the spelling. It is an author convenience for links that must be full page loads. It is not a boundary: it lives in markup the page itself controls, so nothing about it constrains an attacker.

VSCode Extension / Language Server

The diagnostic provider parses .slurp files to provide real-time error highlighting. It calls the parse-only validate entry point (vscode-extension/src/extension.ts); render is never invoked from the extension. No expressions are evaluated and no network requests are made during parsing, so a malicious .slurp file opened in an editor cannot execute code through this path. Most WASM exports do not run the compile-time security checks, and exactly one does.
  • parse, render, render_dev, validate, extract_schema and render_section do not run the security walk. They exist for browser and editor hosts where a preview is not a deploy, and the rules they skip are server-side concerns.
  • validate_full(source, is_middleware) runs it. It is validate plus slurp_compiler::check_security, a separate export rather than a change to validate, so the editor-preview hosts are unaffected while an authoring tool can get build parity.
The distinction matters because several rules live in the security walk and not in the parser: an unfiltered ${ } in a <script> body, a false | js claim in JavaScript statement position, env.SLURP_SECRET_*, request.* outside middleware, and {redirect} / {next} outside middleware. A tool that calls only validate will report a file clean that slurp build then rejects. If you are integrating slurp into an authoring tool, publish gate or CI check, call validate_full. At the call site:
  • is_middleware must match the value the eventual compile will use, because the rules genuinely differ. A caller with no way to know passes false, which is what compile, compile_with_registry and the slurp validate CLI all do.
  • The security walk returns on its first violation (it is a Result, not an accumulator), so at most one security diagnostic is appended per call, while parse diagnostics accumulate. Fix the reported one and re-run to see the next.
Rendering through WASM still enforces nothing: render and render_dev do not call the walk. The render-time backstops described earlier in this document (render_script_expression, escape_slot) do run there, so a <script> body slot and a false | js claim are still caught, but the middleware and secret-env rules are not. Do not use a WASM render as the production render path.

The shared contract (compiler/src/ast.rs, compiler/src/error.rs, shared/interfaces.ts)

compiler/src/ast.rs and compiler/src/error.rs are the Rust definitions. shared/interfaces.ts is the TypeScript view of the same wire shapes, and is the only file in shared/; it is imported by the WASM package, the language server, the runtime, the Prettier plugin and the VS Code extension.

Error codes

compiler/src/error.rs defines dedicated ErrorCode variants for security violations: SecretEnvInTemplate, MiddlewareScopeViolation, RedirectOutsideMiddleware, UnsafeScriptInterpolation and JsTemplateLiteralInAttribute. All of them are present in the TypeScript SlurpErrorCode union in interfaces.ts, so IDEs and language servers can surface them as first-class security diagnostics rather than generic parse errors. The file field on SlurpError / CompileError is a plain String with no length limit or path sanitization. Diagnostics are shown in an IDE and on a terminal, not in rendered HTML, so this is not an XSS surface as used. It becomes one if a host serialises a diagnostic into a web response: the dev server’s HMR error message carries SlurpError[] over WebSocket, and a consumer must render file and message as text, never as HTML.

The AST

The sanitization decision is recorded at parse time, on Node::RawHtml { expr, sanitize }, so it cannot be lost in a later pass. The renderer checks the flag unconditionally when emitting raw HTML. AttributeValue::Interpolated is likewise a parse-time record rather than a render-time guess. The parser builds it only from a quoted attribute string containing ${ } slots, which is what lets eval_interpolated know there is author text around the data and read a quote context out of it. An AttributeValue::Expr carries no such surrounding text, so there is no quote context to read, and the renderer does not substitute a strict branch there: it evaluates the expression, runs the srcdoc guard, and applies escape_attr only. See “Attribute expressions are theme trust” under Known Limitations. That is a trust decision, not an escaping decision. ExprKind::Call lets f(a, b) PARSE. It is not an execution surface: there are no callable functions in slurp, and the renderer’s ExprKind::Call arm in eval_expr evaluates every call to Value::Null, so ${ Math.max(a, b) } yields an empty string rather than a result or an error. If callable built-ins are ever added, the allowlist belongs in the security walk rather than in the AST. Until then, treat any tooling that suggests slurp has functions as wrong.

shared/interfaces.ts

SlurpRuntimeOptions.fetchInterceptor declares (fn, args) => Promise<unknown>. The type is stale: nothing reads it. The live mechanism is the window.SLURP_FETCH_INTERCEPTOR global described under Runtime Security, and the field should be wired up or removed. An interceptor sees every fetch URL and could exfiltrate them, which is why the runtime admits one only in dev or editor mode. SerializedAST is Record<string, unknown>, an opaque blob, so TypeScript consumers do not depend on the internal AST shape. A host that hands such a blob back to the compiler must treat it as untrusted input: a malformed AST is a route to a panic or to incorrect output.

Known Limitations

  • An interpolated slot in JavaScript STATEMENT position is encoded, not escaped. In a JS-evaluated attribute, a slot outside any string literal becomes a JSON literal. That is self-delimiting and cannot contribute structure to the surrounding program, which is the security property that matters, but it is not a general-purpose escape: the value’s JavaScript type is whatever JSON gives it, so a string slot arrives quoted where the author may have expected a bare token. If you meant a bare value, say so with | json; if you meant theme-written code, say so with | unsafe_js.
  • attr={ expr } on a JS-evaluated attribute is theme trust. The unquoted expression form carries no surrounding author text, so there is no quote context to read, and the renderer does NOT substitute a strict branch: the value is emitted with escape_attr only, which is inert in an attribute the JS engine will evaluate. srcdoc is the one exception, guarded whoever composed it, because that value becomes a whole document. But a {html attrs} attribute spread is a different matter and is explicitly not a sanitizer for untrusted data: its whole purpose is forwarding Alpine directives, and Alpine evaluates the value of every directive and every bind as JavaScript. x-show="alert(1)", :title="alert(1)" and x-for="i in [alert(1)]" all pass the name gate today, unavoidably - an allowlist that admits :disabled="loading" cannot also exclude :title="alert(1)", because they are the same construct. What is_safe_attr_name buys is narrowing, not safety: it rejects native on* handlers (including via a :onclick / x-bind:onclick alias) and the four initialization-time Alpine sinks. Never route untrusted data into an attribute spread.
  • A bound URL attribute is checked only when its value is a whole string literal. :href and x-bind:src carry a JavaScript expression rather than a URL, so renderer::bound_url_literal unwraps one layer of string literal before the scheme test. A binding that is a concatenation, an identifier or a ternary is left alone, because its value is not knowable at compile time and neutralising it would break ordinary bindings. Such an expression cannot place an attacker-chosen scheme in the leading position, which is what the check exists to catch, but a value assembled in the browser is outside this guard by construction. (Until 2026-08-05 the bound form was not checked AT ALL: the value reached the test already wrapped in quotes, so the leading-scheme match never fired while href and href={ expr } were both neutralised correctly. Pinned by a_script_scheme_cannot_ride_in_through_a_bound_url_attribute.)
  • srcset scheme checking is partial. is_url_attr lists srcset, but has_forbidden_url_scheme inspects only the leading scheme, and an srcset value is a comma-separated list of url descriptor pairs. A script scheme in the second or later candidate is not caught. Candidate URLs are fetched as images, so this is a resource-load question rather than a script-execution one. Splitting srcset correctly means implementing its grammar, and that grammar is not implemented.
  • A <style> body has no CSS neutralisation. escape_css_value is routed by attribute NAME (is_css_attr covers style, :style, x-bind:style), so nothing applies it to text inside a <style> element. Unlike the <script> case there is no compile-time refusal there either. Do not interpolate untrusted data into a <style> body.
  • {html} without sanitize trusts the theme developer. If a developer passes user-generated content through {html} without sanitize, XSS is possible. This is a trade-off: there is no way to detect it statically without understanding the data flow of the entire application.
  • The compile-time checks assume a trusted host. is_middleware is a caller-supplied flag and env is a caller-supplied context root, so a host that mislabels a file or seeds a secret into the context defeats both checks without the compiler being able to notice. The embedding host is inside the trust boundary; the theme author is only partly inside it, per the trust levels at the top of this document.
  • Rendering is total, so most failures are silent. Missing data yields "", budget overruns truncate and record a diagnostic, and unknown section settings and block types are dropped rather than rejected. That suits a multi-tenant service where a template must never take a page down, and it means a template can be badly wrong and still render a 200. Build with --verbose to see warnings; without it they are hidden.
  • Rate limiting is in-memory. The image optimizer’s rate limiter resets when the process restarts and is not shared between multiple instances. Production deployments must add rate limiting at the reverse proxy layer.
  • Dev server is not hardened for network exposure. The Origin, Host and path-traversal guards are designed to protect against local threats (other browser tabs, a malicious page the developer visits, locally running malware). They are not a substitute for network-level access controls. The dev server binds to 127.0.0.1 only and offers no flag to widen that.
  • The image optimizer has no source-domain allowlist. It will fetch from any globally routable host. DNS rebinding itself is mitigated in code (see “SSRF Protection” above: the connection is pinned to the addresses that passed validation, and redirects are refused), so an RPZ resolver is not required for that. Add an explicit allowlist of permitted source domains where the set of sources is known: it turns an open fetcher into a closed one.
  • Image cache entries are keyed by URL, not by response. Two requests for the same src, width and format share a cache entry, so an upstream that varies its response by request header or by client will have only its first response cached and served to everyone for the TTL.