FormaJS needs no unsafe-inline and no unsafe-eval, in any build. There is no switch that could change that: expressions in data-* attributes are evaluated by an allowlist AST interpreter, and no shipped artifact contains eval, new Function or with(). The opt-in fallback that used to exist has been deleted, not disabled.
The flagship example in docs/HTML-RUNTIME.md — arrow-function callback and all — is served in CI under a real Content-Security-Policy: script-src 'self' response header, and the browser console is asserted to report zero violations.
Verified by src/__tests__/runtime-csp-default.test.ts > "no build can reach new Function, with any configuration"
Verified by src/__tests__/runtime-csp-default.test.ts > "never constructs a function, not even one that would have succeeded"
Verified by src/__tests__/build-artifacts.test.ts > "no build emits new Function or a with() scope wrapper"
How It Works#
Strict CSP headers block inline styles and scripts unless they carry a nonce:
style-src 'nonce-abc123' 'self';
script-src 'nonce-abc123' 'self';FormaJS handles this in two ways:
Scripts: <script> tags rendered by forma-server's page renderer include a nonce attribute. No inline event handlers are used — FormaJS attaches events via addEventListener, and an on* attribute is dropped in any casing on both the client and SSR paths.
Verified by src/dom/__tests__/element-url-safety.test.ts > "drops ONCLICK-cased string props instead of writing an inline handler"
Styles: The h() function applies styles via the CSSOM API (Object.assign(el.style, ...)) instead of el.style.cssText or setAttribute('style', ...). CSSOM property assignment is not blocked by CSP — only string-based style injection is.
Style Patterns — All CSP-Safe#
// Object style (always worked)
h('div', { style: { color: 'red', fontSize: '14px' } })
// String style (CSP-safe since v1.0.9)
// Internally parsed into individual properties via CSSOM
h('div', { style: 'color: red; font-size: 14px' })
// Reactive object style
h('div', { style: () => ({ borderColor: active() ? '#6366f1' : '#333' }) })
// Reactive string style
h('div', { style: () => `border-top: 3px solid ${color()}` })All four patterns work under strict CSP. Internally, string styles are parsed by parseCssString() into property objects, then applied via Object.assign(el.style, ...).
What CSP Blocks (and FormaJS Avoids)#
| Technique | Blocked by CSP? | FormaJS uses it? |
|---|---|---|
el.style.cssText = '...' | Yes (style-src without unsafe-inline) | No — removed in v1.0.9 |
el.setAttribute('style', '...') | Yes | No |
innerHTML with style="..." | Yes | Not for library-generated markup — h() builds DOM with createElement/textContent. innerHTML is reached only through the explicit opt-in sinks: dangerouslySetInnerHTML, setHTMLUnsafe(), template() and templateMany(), mountMarkup() and its returned handle's update(), and the srcdoc attribute (the browser parses its value as a document), each of which takes trustedHtml(). See SECURITY.md and Trusted Types. |
Object.assign(el.style, {...}) | No (CSSOM API) | Yes — all styles go through this |
el.style.color = 'red' | No (CSSOM API) | Yes (via Object.assign) |
new Function(...) | Yes (script-src without unsafe-eval) | No — an allowlist AST interpreter is used instead, in every build. There is no opt-in and no fallback; scripts/verify-dist.mjs greps every published artifact. |
eval(...) | Yes (script-src without unsafe-eval) | No — same gate. |
with (scope) { … } | No, but it defeats scope isolation | No — removed with the evaluator that used it. See SECURITY.md. |
Common Issues#
"Applying inline style violates CSP directive"#
Cause: Using a version of FormaJS before v1.0.9 that still uses el.style.cssText.
Fix: Update @getforma/core to v1.0.9+.
"Refused to execute inline script"#
Cause: A <script> tag is missing its nonce attribute. This happens if you inject scripts via innerHTML, create them with document.createElement('script') without setting the nonce.
Fix: Return forma-server's PageResponse from a typed PageHandle::render call. The renderer draws the nonce and attaches the document policy. Put code in the bundle and pass data as declared props or escaped config_json; render_page, caller-supplied nonces and config_script are removed in Forma 3.0. See the migration guide.
"Refused to apply inline style" on <style> tags#
Cause: A <style> tag is missing its nonce attribute. Forma-server's checked renderers add their nonce to the personality CSS <style> tag automatically. If you create <style> elements in JavaScript, they won't have nonces.
Fix: Use CSS classes instead of dynamic <style> injection. Or use el.style.property = value (CSSOM) which is not blocked.
"An expression rendered nothing and the console says data-forma-expr-error"#
Cause: The expression is outside the grammar, or it named something the allowlist does not offer. It was not evaluated, by design — the binding left the DOM alone rather than writing an empty string.
The console.error beside the attribute names the cause with a stable code and a column: FORMA_E_SYNTAX (the parser refused the text), FORMA_E_UNRESOLVED (an identifier that is not state, not a magic and not one of the frozen namespaces — document, window and fetch land here), FORMA_E_METHOD_DENIED / FORMA_E_PROPERTY_DENIED (not on the allowlist for that receiver), FORMA_E_CALL_DENIED (a call whose target is not an allowlisted callable: a value held in state, or a method passed as an argument), FORMA_E_KEY_DENIED (constructor, __proto__, call, …), FORMA_E_ASSIGN_DENIED (unknown or read-only target), FORMA_E_UNSUPPORTED (parsed, but outside the grammar: an arrow function anywhere but a callback's first argument, a block body, destructured parameters, a computed object key, a handler statement that is not an assignment, an update, an if or a call), FORMA_E_LIMIT / FORMA_E_BUDGET (a parse or evaluation budget), and under a tiered mount FORMA_E_GRANT_DOM / FORMA_E_GRANT_NAVIGATE (a grant the mount does not give; see docs/HTML-RUNTIME.md).
Fix: Rewrite it within the grammar (see docs/HTML-RUNTIME.md, The expression grammar is an allowlist, not a blocklist) or precompute the value server-side. There is no fallback to opt into: adding 'unsafe-eval' to your policy will not make it run, because FormaJS has no code path that would use it. Call getDiagnostics() for the full list, or listen for the formajs:diagnostic event.
Server Configuration (forma-server)#
forma-server generates strict CSP headers automatically via build_csp_header():
default-src 'none';
script-src 'nonce-{random}' 'self';
style-src 'nonce-{random}' 'self';
connect-src 'self';
img-src 'self' data:;
font-src 'self';
frame-ancestors 'none';
base-uri 'none';
form-action 'self'Every page render generates a unique cryptographic nonce. Scripts and the personality <style> tag get this nonce. Everything else must come from 'self' (same origin).
The preview frame's document is the one place a script is allowed by hash, not by nonce: forma-server writes formajs-frame.global.js inline into that document (PageResponse::frame), and the document's policy names the script's SHA-256 (script-src 'sha256-…') with no 'self', so the opaque-origin frame runs exactly that runtime and nothing a module could add. Styles there are allowed by nonce, which the hash-allowed runtime has none of to give a <style> element, so a module styles itself with style attributes only. The runtime is built so its hash holds: one classic script, byte-identical from build to build, with no </script, <!-- or source map comment (see docs/HTML-RUNTIME.md, The preview frame).
Verified by src/__tests__/runtime-frame.test.ts > "the frame runtime is one classic script a server can inline as it is"
Verified by src/__tests__/runtime-frame.test.ts > "two builds of the frame runtime are byte-identical"
This header is satisfied by every build. The hardened artifact is no longer a stronger guarantee — it is the same runtime, bundled without code splitting — so pick either on size and packaging grounds alone.
Do not add unsafe-inline or unsafe-eval. FormaJS is designed to work without them.
Note that img-src 'self' data: above permits data: images. FormaJS matches that posture: data:image/svg+xml is allowed only for image-context sinks (<img src>, <video poster>, …) and blocked for document-context sinks (<iframe src>, <object data>, <a href>, <use href>), where the browser would parse it as a document and run script inside it.
Verified by src/security/__tests__/url-safety.test.ts > "allows data:image/svg+xml for image-context sinks"
Verified by src/security/__tests__/url-safety.test.ts > "blocks data:image/svg+xml for document-context sinks"
Trusted Types#
A policy carrying require-trusted-types-for 'script' makes the browser refuse
a string written to an HTML sink (innerHTML, a <template>'s innerHTML, an
iframe's srcdoc, insertAdjacentHTML, …) or a script sink (an on*
attribute, a script's src) unless a Trusted Types policy the page allows made
it. forma-server 3.x includes both directives in every enforced document policy:
Content-Security-Policy: …; require-trusted-types-for 'script'; trusted-types forma forma-runtimeThis applies in debug and release, with or without a reporting endpoint. A
route may use the named relax_trusted_types(reason) exit; it removes only
these enforced directives and retains them in Report-Only. The nonblank reason
is checked at runtime; owner and end date are checked by the consumer's SR8
forma-raw-lint allowlist in CI, not by a runtime calendar.
forma and forma-runtime are core's two policies:
formamakes whattrustedHtml(markup)returns, the one raw-HTML entry (docs/API.md). Every raw sink writes atrustedHtml()value as theTrustedHTMLit is, so the page accepts it. Core refuses strings and look-alikes with stable codes before the browser sink, regardless of whether native Trusted Types is available.forma-runtimebelongs to the internal parser used by the publicmountMarkup()and returnedMountHandle.update()APIs. Internal reconciliation trims trusted markup before parsing, so browserTrustedHTMLis minted again, trimmed, through this policy. A changed iframesrcdocwithin that trusted markup uses the same policy. Plain strings are refused before parsing and are never minted; reconciliation is not a public export.
Both pass markup through unchanged: what they enforce is that a raw entry is
named. Each is created once per page, on first use, since an enforcing page
refuses a second policy of one name, and every copy of core on the page (the
runtime's IIFE beside core's ESM build, say) shares it through a registry on
globalThis under the key @getforma/core#trusted-types. The registry holds
no policy: it holds the mint trustedHtml() is, so a script that reaches the
key can mint forma values as trustedHtml() can (SR8's lint lists the key
as a raw entry), and a forma-runtime parser that takes only a browser
TrustedHTML, so on an enforcing page no script can mint a forma-runtime
value from a string.
If the page refuses either name (it is not listed, or another script created
it first), core writes markup as a string, which the page then refuses, and a
development build logs FORMA_W_TT_POLICY_REFUSED once.
What core writes of its own passes such a policy:
mount(), the function it returns, and a reactivedangerouslySetInnerHTMLthat becomesnullempty an element withreplaceChildren(), which no policy checks.mountMarkup()and its returned handle'supdate()pass trusted markup to the internalforma-runtimeparser. Reconciliation writes noon*attribute or scriptsrc: a changed one is removed, as h() and adoption write noon*name either. Raw strings are refused before parsing or a cached-markup shortcut.
Raw APIs accept only named trusted markup. Runtime data-bind:srcdoc and
{item…} interpolation cannot carry that value, so both are refused before
the DOM sink with a coded diagnostic identifying the binding. Their neighboring
attributes continue to work; there is no uncaught native TypeError.
Under the header above, Report-Only and enforced, e2e/trusted-types.spec.ts
runs core's own test pages and every raw sink given trustedHtml(), including
a reconcile() diff that changes an iframe's srcdoc and a button's onclick,
and asserts zero violations; a string written to innerHTML raises one
report, and the page still renders under Report-Only. It runs in Chromium on
every merge, and in Firefox and WebKit before each release.
Verified by src/dom/__tests__/trusted-html.test.ts > "core's own writes raise no Trusted Types violation: mount, unmount, the null clear, and every sink given trustedHtml()"
Verified by src/dom/__tests__/trusted-html.test.ts > "reconcile() parses trusted markup through the forma-runtime policy and a string through no policy"
Verified by src/dom/__tests__/trusted-html.test.ts > "reconcile()'s diff mints a changed srcdoc of trusted markup and writes no on* or script src"
Verified by src/dom/__tests__/trusted-html.test.ts > "a string cannot enter reconcile after trusted markup populated its cache"
Verified by src/dom/__tests__/trusted-html.test.ts > "the shared registry holds no policy, and its forma-runtime handle mints nothing from a string"
Verified by src/dom/__tests__/trusted-html.test.ts > "trustedHtml() returns a TrustedHTML from the forma policy where the browser has Trusted Types"
Verified by src/__tests__/runtime-srcdoc.test.ts > "data-bind:srcdoc is refused with its binding code while neighboring attributes work"
Testing CSP#
Check the browser console for CSP violations. They appear as:
Refused to apply inline style because it violates the following
Content Security Policy directive: "style-src 'nonce-...' 'self'"If you see this with FormaJS v1.0.9+, the issue is outside FormaJS — check for:
innerHTMLcontainingstyle="..."attributesel.style.cssText = '...'in non-FormaJS code- Third-party libraries that inject inline styles