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.
<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	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.
<!-- 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:
{@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,metaand eleven more -
Removes every
on*attribute, and any attribute whose value containsjavascript: - Removes every custom element (any tag name containing a hyphen)
-
Removes
style,is,integrity,nonceand thexmlnsattributes - Blocks all
data:URLs here, plusfile:
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.
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:
/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:inscript-srcand noevalof 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 withnew Functionbefore 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'.
// 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 requiresstyle-src-attr 'unsafe-inline'for them, because an attribute can take neither a nonce nor a hash. JAF'sstyle: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.DEVgate 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-attronly if your own markup uses literalstyleattributes