Security

JAF escapes interpolated values according to where they land in the document.

There is no opting in, and no casual {@html} equivalent.

This page is what the framework guarantees, and the two places those guarantees stop.

Escaping by position

There is one escape function - it turns &, <, >, " and ' into entities.

What varies is whether it runs at all, and what runs alongside it.

Position What happens
Text content, plain expression Written with textContent. No string ever becomes markup, so injection is structurally impossible.
Text content inside a JSX expression HTML-escaped before the template string is built.
Attribute value, compiled binding Written with setAttribute(). A value cannot break out of the attribute.
Attribute value inside a JSX expression HTML-escaped, so quotes cannot terminate the attribute.
URL attribute (href, src, 16 others) Scheme-checked first. Dangerous URLs dropped, safe ones escaped.
Inline event handler (onclick="pick({item.id})") Encoded as a JavaScript value, not as program text.
class: and style: directives Applied through classList and style.setProperty. No string building at all.
Inside <pre>, <code>, <style>, <script> Braces are literal text. No binding is created.

The practical consequence: you cannot inject markup through data.

source
<script>
let comment = '<img src=x onerror="alert(1)">'
</script>

<!-- Renders the tag as visible text, not as an element -->
<p>{comment}</p>

Blocked URL schemes

Eighteen attributes count as URL-bearing: href, src, srcset, action, formaction, xlink:href, data, poster, background, cite, longdesc, ping, manifest, profile, codebase, classid, dynsrc and lowsrc.

A bound value on any of them is checked before it is written:

Scheme Result
javascript:, vbscript:, livescript:, jscript:, mocha: Blocked
data: with an HTML, XML, SVG or script media type Blocked
data:image/png;base64,... and other inert media types Allowed
https:, mailto:, relative paths, #anchor Allowed

The value is normalised first - whitespace and control characters stripped, lowercased, numeric HTML entities decoded - so JaVa&#x09;ScRiPt:alert(1) is caught along with the plain form.

srcset is split on commas and each candidate checked.

A blocked URL removes the attribute (compiled binding), substitutes an empty string (JSX interpolation) or is skipped (forwarded rest prop).

Nothing is thrown, and the warning is logged in development only: the page keeps working with a dead link rather than crashing.

This applies to bound values. A literal href="javascript:void(0)" you typed yourself is your own code, and is left alone.

Values inside inline handlers

An inline handler is the one place where HTML escaping would be no protection at all: the browser decodes an attribute before evaluating it, so an escaped quote still closes a string once the handler runs.

So JAF encodes handler interpolations as JavaScript values.

In expression position the value becomes a JSON literal; inside a JavaScript string literal it is escaped so it cannot terminate that literal and append statements.

source
<!-- item.id is "1);steal();//" -->
<for each={items}>
  <button onclick="pick({item.id})">Pick</button>
</for>

<!-- The handler receives the string "1);steal();//" as an argument.
     It does not become three statements. -->

Handlers in a component's own static template splice nothing at all: they are compiled into closures over component scope before the HTML is built.

The encoding above covers the string-rendered paths that remain - <for> rows, slot children and forwarded props.

The @unsafe opt-out

There is no {@html value}. To render markup you built yourself, prefix the expression with @unsafe:

source
{@unsafe showBio && <div class="bio">{trustedHtml}</div>}

@unsafe only works on an expression that already contains literal JSX. {@unsafe someHtmlString}, where the expression is just a variable, is classified as a text binding before the prefix is stripped: it fails to compile, logs a build warning, and renders nothing at all. It does not fall back to escaped output.

@unsafe also disables the URL guard for everything in that expression, because the URL check is part of the escaping pass it turns off.

Last resort, on markup you constructed, never on anything a user supplied.

The HTML sanitizer

An expression that produces markup but is neither literal JSX nor @unsafe goes through a DOM-based sanitizer before insertion. It is much stricter than the attribute-level URL guard:

  • Removes script, iframe, object, embed, form, base, svg, math, template, style, link, meta and eleven more
  • Removes every on* attribute, and any attribute whose value contains javascript:
  • Removes every custom element (any tag name containing a hyphen)
  • Removes style, is, integrity, nonce and the xmlns attributes
  • Blocks all data: URLs here, plus file:

Content Security Policy

Production builds run under a strict CSP: no unsafe-eval, no unsafe-inline for scripts.

That is a property of compilation, not a setting. Every component is compiled to a module at build time - its <script> body becomes a real function, and {count}, title="{label}" and every other binding become functions beside it - so there is nothing left for the browser to compile. A compiled template carries no on* attribute at all: handlers are entries in the component's handler table, attached with addEventListener, and the compiler refuses to emit a template with an inline handler still on it, so that cannot regress quietly.

Scoped styles are adopted as constructable stylesheets (CSSStyleSheet plus document.adoptedStyleSheets), which CSP does not govern as inline style. Style blocks are lifted out of the markup before it becomes DOM, because an element already written to the page has already been refused.

source
Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  style-src 'self';
  img-src 'self' data:;
  font-src 'self' data:;
  connect-src 'self';
  base-uri 'self';
  object-src 'none'

Everything an app adds on top is about the app: a font or image CDN, the API origins connect-src must allow, frame-ancestors for clickjacking. The framework asks for none of it, and it neither generates nor consumes a nonce - it adds no script element and no inline script to your page.

Strict CSP is a tested configuration. JAF's own suite serves every docs route under default-src 'self'; script-src 'self'; style-src 'self' in a real browser and asserts zero violations.

Source a visitor typed, and the policy it needs

One kind of app has source that does not exist until a visitor types it - a REPL, a playground, a docs site with a live editor. It still does not need an interpreter, and it does not need a server either: JAF's compiler runs in the browser, and what it produces there is the same module a vite build writes to disk. The page imports that module and mounts it through the ordinary mount.

The REPL on this site is that arrangement, and it is three documents with three policies, because they need different things:

source
/repl              script-src 'self'
/repl-frame.html   script-src 'self' blob:
/repl-compile.html script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval'
  • The page is an ordinary compiled JAF page and asks for nothing extra.
  • The frame that mounts your component imports the emitted module from a blob URL - a real module, evaluated by the engine's own module goal - so it needs blob: in script-src and no eval of any kind.
  • The frame that compiles holds SWC compiled to WebAssembly, and needs 'unsafe-eval' for one reason: the emitter checks its own output by parsing it with new Function before handing it back. Nothing is mounted in that document.

This is measured, not asserted. The whole REPL - all eighteen examples, children and scoped styles included - runs under those three policies with zero violations, on a host that serves static files and has no compile endpoint at all. Remove 'unsafe-eval' from the third line and every compile fails with the emitter's own self-check message, which is how we know it is the only thing that needs it.

The one opt-out: runtimeCompile

runtimeCompile: true keeps the older path: a compiled script string turned into a function with new Function, and the runtime interpreter that goes with it - the largest single thing a default build leaves out. Wherever that path runs, the document running it needs script-src 'unsafe-eval'.

source
// config/jaf.config.js
export default {
  runtimeCompile: true,
}

This site still sets it, and no longer needs it. jafjs.dev keeps runtimeCompile: true only so the REPL has something to fall back to on a browser whose WebAssembly never loads; the engine you get is named in the REPL's toolbar. Compiling in the browser is the path that runs, so do not read a policy off the page you are standing on - and turning the flag on to silence a build error is the one thing it is not for.

Two more caveats, both outside the framework's control:

  • Your own style="..." attributes. The browser requires style-src-attr 'unsafe-inline' for them, because an attribute can take neither a nonce nor a hash. JAF's style: directive writes through the CSSOM, so an app with no literal style attributes needs no exception.
  • The dev server still compiles from strings. It hands each component to the browser as source, behind an import.meta.env.DEV gate that folds out of a production build. Test a policy against a production build - do not copy a dev CSP report into a production audit.

To check your own build: build for production, search the built JavaScript for new Function( and eval( - a default build has neither - then serve it under the policy above with the console open.

Checklist

  • Do not reach for @unsafe - it is narrower and blunter than it looks
  • Never interpolate user data into a URL you also mark @unsafe
  • Validate on the server anyway - escaping is not authorisation, and JAF is a client framework
  • Watch the dev console - blocked URLs warn in development and are silent in production
  • Ship the policy above, adding style-src-attr only if your own markup uses literal style attributes