Skip to content
forma.
v3.2.0
☰
Browse documentation
Start here
Application guides
Architecture
Build interfaces
Native rendering
Tooling
Security and operations
API reference

Docs/Security and operations

FORMA 3.2

Security model

Understand the client trust boundaries, expression budgets and supported security posture.

View source ↗Markdown ↓Source fc76ea8
On this page

The threat-model description distinguishes historical automatic binding from the explicit mount contract shipped in 3.0. Two historical “unreleased” labels are clarified as “shipped by 3.0.0”; this does not identify their first release. The linked source snapshot preserves the original wording.

Reporting Vulnerabilities#

Report security vulnerabilities to victor@getforma.dev. Do not open public issues for security reports. We will respond within 48 hours and provide a fix timeline.

Architecture & Trust Boundaries#

FormaJS v1.0.0+ uses subpath exports to segment capabilities by trust level:

ImportCapabilitiesTrust Level
@getforma/coreSignals, DOM, components, state, events, islandsNo network, no eval, no filesystem — but see Unsanitized HTML sinks below
@getforma/core/runtimeHTML Runtime (data-* directives)Allowlist AST interpreter. No eval, no new Function, no with() in the artifact — grepped by scripts/verify-dist.mjs
@getforma/core/runtime-hardenedHTML Runtime (tree-shaken bundling)Identical source and identical guarantee; a second bundling, kept because two documented CDN URLs point at it
@getforma/core/httpcreateFetch, createSSE, createWebSocketNetwork access (intentional)
@getforma/core/storagecreateLocalStorage, createIndexedDBBrowser storage access (intentional)

The root entry's "no network" row is a real boundary, not a convention: fetch, WebSocket and the storage APIs live only behind their subpaths and are absent from the root barrel.

The proof is an EXACT export-surface pin per subpath, not a list of names asserted absent: toBeUndefined() on a name the barrel never had passes with any typo and can only fail if someone adds the export, whereas an exact set fails in both directions — a network primitive leaking into the root barrel, and a documented export silently disappearing from it.

Verified by: src/__tests__/index-surface.test.ts > "%s exports exactly the documented names"

Unsanitized HTML sinks#

h() builds DOM with document.createElement and writes text with textContent; the library never parses a markup string of its own. Six APIs, and two bindings of the HTML runtime, deliberately opt out. None of them sanitizes; all but mountMarkup should be treated as first-party-content-only, and mountMarkup bounds what the markup's expressions may do, not what the markup is:

  • dangerouslySetInnerHTML={{ __html }} — prop on h() / JSX; assigns innerHTML.
  • setHTMLUnsafe(el, html) — exported from @getforma/core; assigns innerHTML.
  • template(html) and templateMany(html) — exported from @getforma/core; parse the markup into a cached <template> that compiled output clones.
  • The srcdoc attribute — the browser parses the attribute value as a document, so attribute escaping does not neutralize it. It is emitted (a sandboxed <iframe srcdoc> is legitimate).
  • The HTML runtime's data-bind:srcdoc, and {item…} in a list row's srcdoc, are refused with FORMA_E_RUNTIME_SRCDOC before evaluation or writing a document. Their diagnostics identify the binding; neighboring bindings continue. Unreviewed mounts retain their stricter FORMA_E_UNREVIEWED_SRCDOC admission diagnostic.
  • mountMarkup(root, markup, { tier, grants }) — exported from @getforma/core/runtime; parses trustedHtml(markup) under the explicit root and updates through its returned handle, then binds it with the grants it names (see Tiered mounts below). Its markup is still parsed as HTML: a <form action> a user submits, or a <style> that restyles the page, is the application's admission to refuse, and unreviewed markup belongs in the opaque-origin preview frame.

Each raw API requires trustedHtml(markup), the named raw-HTML constructor (DR-19). Plain strings and look-alikes are refused with a stable FORMA_E_RAW_* code before the DOM sink, in development and production; mountMarkup uses FORMA_E_MOUNT_ARGS. The name is the control: a search for it finds every raw entry in an application, which is what a consumer's CI allowlist (SR8) lists with an owner and an end date. Where the browser has Trusted Types, trustedHtml() makes a TrustedHTML through Forma's policy forma, so a page whose policy carries require-trusted-types-for 'script'; trusted-types forma forma-runtime lets it through and reports or refuses a plain string at the same sink. Forma enforces this typed boundary even in browsers without native Trusted Types. What core writes of its own passes such a policy: mount() and its clears use replaceChildren(), and the runtime mount handle writes markup that arrived trusted through its own policy, forma-runtime, and its diff of that markup writes no on* attribute, script src, or URL attribute with a script scheme (see URL and handler attributes in each sink below). The policies are shared by every copy of core on a page through a registry on globalThis (Symbol.for('@getforma/core#trusted-types')) that holds no policy: it holds the mint trustedHtml() is, which SR8's lint lists 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. Said plainly: any script on the page, core's or not, can mint a real TrustedHTML of the forma policy through that registry, so trusted-types forma forma-runtime bounds which policy names exist, not who may call the mint; the boundary the directive enforces is that no script mints a forma-runtime value from a string, and a forma value marks a raw entry a consumer's allowlist (SR8) owns, as trustedHtml() itself does. h('script') is refused before construction with FORMA_E_SCRIPT_ELEMENT. See CSP.md.

Verified by: src/dom/__tests__/element-url-safety.test.ts > "srcdoc requires trustedHtml and strings are refused before the sink" 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 > "every raw sink refuses strings before parsing, including an already cached trusted value" 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/__tests__/runtime-srcdoc.test.ts > "data-bind:srcdoc is refused with its binding code while neighboring attributes work"

Supply Chain Security Notes#

There is no new Function in the HTML Runtime#

Expressions in data-* attributes are evaluated by an allowlist AST interpreter (src/expr/): lexer, precedence-climbing parser, validator, tree-walking interpreter. Every shipped artifact — dist/runtime.js, dist/runtime.cjs, the three classic-script builds (the FormaRuntime global, its hardened twin, and the preview frame's script, which defines no global), the hardened pair, and the Node-compatible runtime build — contains zero eval, zero new Function and zero with(). There is no opt-in switch, because there is no fallback to switch on: setUnsafeEval(), isUnsafeEvalAllowed() and data-forma-unsafe-eval were removed along with the code they guarded.

scripts/verify-dist.mjs greps every built artifact as the last step of npm run build, so this is asserted on the bytes that ship rather than inferred from the source.

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"

Threat model#

Three models drive every rule below. They are written down because the design that preceded this one failed the first outright.

  • T1 — the attacker controls the expression SOURCE. Applications that pass attacker-controlled markup into an explicit mount also admit its data-computed or data-on:click expressions. Earlier runtime versions automatically bound injected elements through a document observer. Forma 3.0 binds only explicitly mounted markup; DOM insertion alone does not activate directives. The expression grammar still has to withstand hostile source admitted by the application.
  • T2 — the attacker controls VALUES, not source: data-fetch responses, data-forma-state attributes, localStorage via data-persist.
  • T3 — resource exhaustion: huge arrays or deeply nested callbacks from T2 data.

Why an allowlist, and what the blocklist it replaced could not do#

The previous design compiled the expression with new Function and ran it inside with (proxy) { … }, guarded by a nine-name blocklist. Two structural failures, both verified rather than theorised:

  1. with() + Proxy is not a sandbox. The proxy's has trap answered key in scope.getters. Returning false does not mean "undefined" — it means "not mine, keep walking the scope chain", terminating at the global object. Every identifier that was not a declared state key resolved to the real global: document, fetch, localStorage, window.
  2. A static string scan cannot see a computed key. items['constructor'], items[k] with k from server JSON, items['cons' + 'tructor'] and items[String.fromCharCode(…)] all reached Array → Function, and the blocked name never appeared in the source text. That chain worked in the CSP-safe path, in the hardened build, with no new Function in the library.

The replacement inverts the relationship. A blocklist over a full evaluator must enumerate every hostile input, and the evaluator's semantics are the attacker's toolkit. An allowlist over an AST makes the interpreter's semantics the only toolkit, and it contains nothing dangerous — there is no equivalent of (2) against a table lookup, because "constructor" is not a key in any table.

The five guarantees#

  • G1 — closed token set. The lexer never hands source to JavaScript. It recognises identifiers, decimal numbers, quoted strings, template literals and a fixed punctuator list; every other byte is a syntax error with a column. Only the eight simple escapes are decoded — \u, \x and octal are rejected at the lexer, which kills unicode-escaped-key bypasses before any semantics exist to bypass.
  • G2 — total consumption. The parser must end at EOF. Leftover tokens are an error, never a silent fallback. This is the direct fix for the "no branch matched, return null, fall through to eval" shape of the parser it replaces.
  • G3 — exhaustive union. AST nodes are a closed TypeScript discriminated union, and the validator and the interpreter both end their switch with const never: never = node. Adding a node kind without adding both cases is a compile error.
  • G4 — the validator is not the interpreter. Two independent passes. The validator asserts node kinds, shape constraints and budgets; the interpreter re-asserts every safety-critical invariant (key filter, receiver kind, call target) at evaluation time rather than trusting the validator.
  • G5 — no escape hatch. No path to new Function, eval, dynamic property dispatch, or a real intrinsic, enforced by a gate that reads the source of src/expr/**.

Verified by: src/expr/__tests__/no-escape-hatch.test.ts > "src/expr contains no path to the Function constructor or a global" Verified by: src/expr/__tests__/no-escape-hatch.test.ts > "no property is read off a value with a computed key outside safeRead" Verified by: src/expr/__tests__/validate.test.ts > "every AST kind has a validator case and an interpreter case"

Identifier resolution never consults globalThis#

Names resolve against, in order: arrow-parameter frames, data-list row locals, element magics ($el, $event, $refs, $dispatch, $refetch), the scope's own state and computed getters, and one frozen null-prototype table of captured intrinsics (Math, JSON, Object, Array, Date.now, Number, String, Boolean, parseInt, parseFloat). Then it fails, with a reported FORMA_E_UNRESOLVED.

document, fetch, window, localStorage and process are therefore not blocked — they are unreachable, because there is no code path that could find them. That is a property of the resolver's shape, not of a list someone has to keep current.

Verified by: src/expr/__tests__/adversarial.test.ts > "no global is reachable by name"

Method dispatch never reads a property of the receiver#

text
kind = kindOf(recv)                // Array.isArray / typeof — never instanceof
tbl  = METHODS[kind]
if (!hasOwn(tbl, safeKey(m))) throw MethodDenied
Reflect.apply(tbl[m], recv, args)  // captured intrinsic, frozen at module init

That one rule defeats, by construction: dynamic-key constructor access, prototype-pollution reach, receiver-supplied method impersonation (a T2 object carrying its own filter is not an Array, so filter is not offered — and even for a real Array the captured intrinsic runs, not the own property), Array.prototype poisoning by another page script, getter side effects on the method-lookup step, and .call / .apply / .bind escalation.

Two audited helpers are the only places member access is implemented:

  • safeKey(k) — coerces to string, rejects symbols, rejects keys longer than 128 characters, and rejects constructor, __proto__, prototype, __defineGetter__ / __defineSetter__ / __lookupGetter__ / __lookupSetter__, eval, Function, call, apply, bind, caller, callee, arguments. It runs on the evaluated key, whatever syntax produced it, which is what makes every spelling of a bypass the same case and all of them dead.
  • safeRead(recv, key) — a nullish base yields undefined (absent data, not a failure); host receivers dispatch by kind; length is allowed on arrays and strings; a plain object or array data key requires an own property whose descriptor is a data descriptor, so a poisoned getter never runs and the prototype chain is never walked; anything else is a reported denial.

Verified by: src/expr/__tests__/adversarial.test.ts > "every spelling of a constructor reach is denied" Verified by: src/expr/__tests__/adversarial.test.ts > "a poisoned Array.prototype.filter is not what runs" Verified by: src/expr/__tests__/adversarial.test.ts > "an accessor property is refused instead of invoked" Verified by: src/expr/__tests__/adversarial.test.ts > "a receiver's own filter is never invoked"

The DOM is reachable only through a wrapper#

$el, $event, $refs and everything they hand back — classList, style, dataset, closest(), querySelector(), event.target — are wrapped, and the wrapper survives every hop. Reads, writes and calls are restricted to fixed per-kind tables. $el.ownerDocument, $el.parentNode, $el.innerHTML, $el.style.cssText, $event.view and $refs.x.ownerDocument.location.href are denials with a diagnostic, not answers.

Before this, $refs returned the raw element, and data-text="{$refs.r.ownerDocument.location.href}" read the real page URL from inside a "CSP-safe" expression in the hardened build, with no diagnostic at all.

Verified by: src/expr/__tests__/adversarial.test.ts > "$refs.r.ownerDocument.location.href is denied" Verified by: src/dom/__tests__/el-magic-safety.test.ts > "$refs hands back a wrapped element, not the live node"

Tiered mounts: roots handed in, grants, a bound on $el (2.1.0)#

Tiered mounts were introduced in 2.1.0 to bound the authority of admitted markup (v2 TH11). In 3.0, mountMarkup(root, markup, { tier, grants, config }) is the only HTML-runtime binding path, including for first-party pages. The application supplies marked reviewed markup, an owned root, the required tier and only the grants that content needs (decision brief DR-14, DR-15, L-36, L-47; the full reference is docs/HTML-RUNTIME.md, Mounting markup you did not write):

  • Every binding belongs to an explicit mount. Loading the runtime, DOMContentLoaded and DOM changes do not bind markup. The runtime has no body observer or tierless lifecycle exports. mountMarkup() requires a tier, its handle owns updates/disposal, and captured ownership permits disposal after a scope is moved or detached. The old manual script attribute is obsolete; its development warning grants no authority.

  • Its configuration comes only from its call. Set the step budget through the mount's config.exprBudget; script-tag attributes and global configuration do not change a mount.

  • The wrapper is bounded by the root. Every hop from $el, $event or $refs that yields an element is checked against the root, and one past it is refused with FORMA_E_GRANT_DOM unless the mount grants dom; so is a selector that asks about an ancestor or a sibling ($el.matches('[data-email^="a"] *') would read an attribute of the page one character at a time). closest() without dom never tests an element outside the root: its walk stops there and answers null, as no match does. (Before formajs#30's review it tested the outside ancestors and refused a match, and that refusal against no match read an ancestor's data-secret a character at a time in all three engines.)

  • data-fetch sends only what the fetch grant names (methods, path prefixes checked after the URL parser normalises the path, cookies or not, a timeout up to 30 s), and click() needs navigate, as does $dispatch of an activation event (click, auxclick, DOMActivate, submit, reset): WebKit follows a link, submits or resets a form on an untrusted click, auxclick or DOMActivate, and Firefox on an untrusted submit or reset. Redirects are refused before a second request: the grant authorizes the initial URL only. A network or redirect rejection before a response writes FORMA_E_FETCH_REQUEST: data-fetch failed before a response; redirects are not followed to its error target; the browser does not distinguish those causes. A status outside 200-299 is an error, never data.

  • Every denial is a coded formajs:diagnostic, whatever any diagnostics switch says, counted per mount.

  • An unreviewed mount refuses every origin but an opaque one, takes no grants, and renders no <link>, no srcdoc, no on* handler attribute and no URL attribute with a script scheme: the markup is parsed once, in the root's own context, and every <link>, srcdoc attribute, on* attribute and URL attribute whose value has a script scheme (judged against its element's tag, as every attribute sink judges it) is removed from those nodes (FORMA_E_UNREVIEWED_LINK, FORMA_E_UNREVIEWED_SRCDOC, FORMA_E_UNREVIEWED_HANDLER, FORMA_E_UNREVIEWED_URL), the nodes themselves are written (never a string the root parses again: a second parse re-created a <link> the first had not shown, through nested <form>, <math>, <mglyph> and <style>), the live subtree is checked after every write and the render refused if anything is found, and data-bind:srcdoc is refused before it runs. <link> and srcdoc because WebKit opened a connection for <link rel="preconnect"> from inside the sandboxed preview frame, which no CSP directive governs, and for one inside an <iframe srcdoc> (the A2 audit, S1; formajs#35's review, B1 and S2). Handler and URL attributes so that the frame does not depend on its policy alone for them (FORMA-276): its hash-only script-src and Trusted Types refused them before, and still do.

Leaving out grants gives none in 3.0. The application chooses the tier and passes grants only for content whose hash matches what was reviewed (L-47); Forma enforces what the mount grants and cannot check which tier content deserves.

Verified by: src/__tests__/runtime-tiered-mount.test.ts > "binds only under the root it is handed: markup injected outside it stays unbound" Verified by: src/__tests__/runtime-explicit-mount.test.ts > "import, DOMContentLoaded, injected nodes and state-attribute changes never bind without a handed root" Verified by: src/__tests__/runtime-explicit-mount.test.ts > "exports only explicit mount lifecycle and host inspection helpers" Verified by: src/__tests__/runtime-explicit-mount.test.ts > "warns once about the obsolete manual switch and names the replacement mount call" Verified by: src/__tests__/runtime-tiered-mount.test.ts > "no hop from $el reaches past the mount root: %s" Verified by: src/__tests__/runtime-tiered-mount.test.ts > "closest() without dom never tests an element outside the root: an outside ancestor's attribute cannot be read a character at a time" Verified by: src/__tests__/runtime-tiered-mount.test.ts > "$dispatch of an activation event needs navigate: %s" Verified by: src/__tests__/runtime-tiered-mount.test.ts > "a selector that reads past the element needs dom: %s" Verified by: src/__tests__/runtime-tiered-mount.test.ts > "with explicit empty grants a generated tree binds neither data-fetch nor $el.click()" Verified by: src/__tests__/runtime-tiered-mount.test.ts > "a 404 with a JSON body lands in the error: target, not the data target" Verified by: src/__tests__/runtime-tiered-mount.test.ts > "a seeded denial raises one coded diagnostic, whatever the page's diagnostics switch says" Verified by: src/__tests__/runtime-tiered-mount.test.ts > "an unreviewed mount refuses any origin but an opaque one, and any grant" Verified by: src/__tests__/runtime-tiered-mount.test.ts > "an unreviewed mount renders no <link>: each is removed before the markup reaches the document" Verified by: src/__tests__/runtime-tiered-mount.test.ts > "an unreviewed mount parses its markup once, in the root's context, and writes no string to the root" Verified by: src/__tests__/runtime-tiered-mount.test.ts > "an unreviewed mount renders no srcdoc: the attribute is removed, a binding that would write one is refused, and each is reported" Verified by: src/__tests__/runtime-tiered-mount.test.ts > "an unreviewed mount fails closed: a <link> that reaches the root after the strip is removed, reported, and the markup refused" Verified by: src/__tests__/runtime-tiered-mount.test.ts > "an unreviewed mount renders no script URL and no handler attribute: each is removed and reported, on the first render and on update" Verified by: src/__tests__/runtime-tiered-mount.test.ts > "an unreviewed mount fails closed: a script URL or handler attribute that reaches the root after the strip is removed, reported, and the markup refused"

Termination and budgets#

The language has no loops, no recursion, no generators and no way to name or store a function, so every expression terminates by construction — an arrow is legal only in the callback slot of nine allowlisted array methods, and cannot be assigned, stored, returned or re-invoked. Budgets therefore bound cost, not hanging: 4,096 source characters, 512 AST nodes, depth 32, arrow nesting 2, four call arguments, and a 100,000-step evaluation budget configurable per mount through config.exprBudget. The step budget counts interpreter nodes, not the work inside a built-in, so every built-in call is also charged before it runs against an allocation budget (DR-16): 250,000 units, where a unit is about the cost of one Array.from slot. A call pays a unit for each array slot or string character it produces or converts; string calls that amplify nothing (case mapping, searching, trimming, slicing) pay their measured cost per character. Case mapping is tiered by the costliest character of the text: Latin-1, and the general punctuation, currency signs and letterlike symbols that map to themselves (U+2000 to U+20CF, U+2100 to U+2125: curly quotes, dashes, …, €, ™), lowercase at 1/32 of a unit a character; text whose first character above those is in a script whose ordinary text measured 4 to 19 ns a character (Latin Extended, Greek, Cyrillic, Armenian, Hebrew, Arabic, the Indic scripts, Thai, Vietnamese, CJK, kana, Hangul, emoji) at 1/16; anything else (Georgian, Cherokee, Greek Extended, fullwidth Latin) at 1/2. Turkish İ (U+0130) is a Latin Extended-A letter, so text with it is priced 1/16 too, though it lowercases to two characters and measured 27 to 30 ns. Lowercasing 260 characters of English therefore costs about 8 units, and of Greek about 16. A case-mapping call on more than 1,048,576 characters, the result cap it would pass anyway, is refused before it runs. Each call is charged the weight of its arguments as text (most built-ins convert an array argument with join; the few that read arguments as data, such as Object.keys and Array.from, are charged their element count instead, and a string method reads a string argument for a thirty-second of a unit a character), then its row in src/expr/cost.ts (map the receiver's length, repeat the receiver's length times the count, Array.from the length it is asked for, a default sort the comparisons and the length of each string element it compares, …). Converting an array or object with an operator ('' + list, `${list}`, list < 1, x += list) is charged the same way; ===, and == against null or between two objects, convert nothing and are not charged.

Two bounds keep the stack, not the cost, in check (FORMA-274). The budgets count steps and units, not how deep the engine's stack goes, and two shapes overflowed it inside every budget: a RangeError (Firefox: InternalError) out of mountMarkup, or an uncaught page error.

  • A value nested more than 100 arrays or objects deep is refused with FORMA_E_BUDGET (a value nested over 100 deep) before anything converts it. Converting a value recurses through its nesting (join, String(), a default sort, JSON.stringify, an operator), and String() of nested arrays overflowed past 785 levels in Firefox 157 (622 under 2,000 frames of a caller), 2,822 in Node 24 (2,185), 4,112 in Chromium 156 (3,178) and 4,990 in WebKit 27.2 (4,771). The cap of 100 leaves more than sixfold headroom below the lowest measured overflow depth, Firefox's 622 under caller load. Engine stack sizes vary, so this is a conservative policy rather than a universal stack bound. The check is part of the walk that prices every conversion (weigh), so it covers every source a value comes from: state, a response, storage, setScopeValue, a handler's write, and an expression that built it (Array.from({length: 10000}).reduce(a => [a], 0) made 10,000 levels inside every budget, which no check where data enters would see). With the toString check below, the full walk took 3 to 12% longer than before at 1.2 MB and 12 MB of records (medians 6 and 10%), and the walk the runtime makes, stopped at the 250,000-unit room, -6 to +6% (medians 0 to 4%); at 0.12 MB the difference is within the noise (Node 24.22.0, weigh against main's, seven rounds each the median of 21 calls after 5 warm-ups).
  • At most 32 evaluations nest: a computed read by another, or a handler that $dispatch or click() runs from inside an evaluation, each nest one more. A chain of 1,000 computeds overflowed Chromium's and Firefox's stack out of mountMarkup, and a handler that dispatched its own event to itself recursed into an uncaught page error in all three engines. Boundary tests exercise 31 computeds plus their binding and 31 relayed handlers converting a value 100 deep in the browser engines. The 33rd is refused with FORMA_E_BUDGET before it starts. A computed refused this way has read nothing, so it is computed again on its next read after the task, instead of keeping the refusal as its value.

Both are final and arm nothing, like the refusals that check a length or a count (FORMA-249). Together they leave the stack room: 31 handlers relayed by $dispatch, the last converting a value 100 deep, ran in Chromium 156, Firefox 157 and WebKit 27.2. What they do not bound is the nesting of the markup itself: Known gaps.

Operators on strings are charged too (FORMA-028). Joining keeps the pieces as a rope, which the first read of it copies whole, so a short expression with no state could build a 524,288-character rope in 19 steps and read copies of it for seconds. Now +, += and template literals pay a 128th of a unit for each character they build past 511; comparing two long strings pays a 128th a character compared; an operator that parses a long string as a number (s * 1, -s, s < 1, s == 1) pays an eighth a character; and comparing, or reading a character of, a string the same expression has just built with + pays for copying it (8 units plus a 32nd a character, to 512). Each case of the review that found this is refused in under 30 ms at 4x CPU throttling, where 2.0.3 took 0.6 to 4.3 s. A write that would grow an array past 250,000 elements is refused, so a handler cannot make a sparse array longer than one evaluation could build. Past the budget the call is refused with FORMA_E_BUDGET and never runs, so Array.from({length: 12000000}) costs nothing, where 2.0.1 built a 139 MB array in a 300 ms task before refusing it. repeat ≤ 10,000 and flat depth ≤ 8 are refused before the call as before, and results stay capped at 1 MiB strings (now for += too) and 1,000,000-element arrays.

Both budgets are one per task, and one per event (FORMA-028). The runtime keeps a meter of what the evaluations of the current task have spent, and every evaluation it starts counts on from it; one that starts inside another (a data-computed read by a binding, a handler that $el.click() or $dispatch reaches) spends the running one's budget. Ten bindings at the cap in one widget used to run ten budgets in one task, 382 ms at 4x. A binding refused only because the bindings before it in its task spent the budget is not reported: it re-runs in the next task, where it starts first, so the ten bindings render one task at a time, none over 42 ms at 4x (p99 of 41 runs). A binding over the budget on its own is reported as before; a computed refused inside a reader is evaluated alone at the start of the next task, so its failure is never cached as its answer. A binding waits only if the task had spent allocation units before it. A charge that a cost-table row sizes directly and that is larger than the whole budget is reported at once; a weighed value that heavy, arriving late in a busy task, is counted only to the room left, so it waits once, is weighed in full when it runs first, and is then reported once. Once a binding waits, each later charge in the task is counted only to 10,000 units: lighter bindings still render now, and heavier values are refused after counting that far, cheaply, and render in later tasks; a waiting binding is not weighed again in full in every retry (FORMA-173). The cap bounds what each binding after a wait counts; together, the refusals after a wait count at most 50,000 units in a task, and one cap more, however many bindings wait. Past that the cap is 0: every later charge in the task is refused at once (a list on its length alone), and a light binding that charges anything waits a tick too. 400 bindings after a wait, each a little over or under the cap, made tasks of 66 and 69 ms p99 at 4x with the cap alone, and 24 and 15 ms with the allowance; ten or twenty bindings each weighing most of the budget, or twenty each a little heavier than the room left, at most 24 ms (scripts/measure-task-budget.mjs, group F). The cap is read from the meter at each charge, so a binding the reactive flush runs inside another evaluation sees a cap set while that one ran. A binding too heavy for any task, refused with nothing spent before it in its task, is weighed to the whole budget and reported, once; that refusal arms the same cap as a wait and counts the cap toward the same allowance (FORMA-178), so the bindings after it are counted only to the cap: one that is too heavy for any task waits, is weighed in full once in a task where it runs first, and is reported there. Only a refusal the budget meter makes does this. A refusal that checks a length or a count (repeat() past 10,000, flat() deeper than 8, a string past 1 MiB) arms nothing itself, so a binding after it that fits renders in the same task (FORMA-249); but what the evaluation charged before that check stays on the task's meter, as any evaluation's charges do, and repeat() and flat() are charged their cost-table row before their count or depth is checked. So 'x'.repeat(20000) leaves its 20,000-unit row charge on the meter and is then refused on its count, arming nothing; but a charge before the check that does not fit is an allocation refusal, which arms the cap: 'x'.repeat(300000) and 'x'.repeat(1000).repeat(20000) are refused on their row charge before the count is read, and Array.from({length: 300000}).flat(99) on Array.from's before flat() runs (FORMA-250, from FORMA-249's review). Several such bindings are therefore reported one to a task, not all at once, and a binding that would have fitted the room left after a reported refusal waits a task if it is past the cap. A light binding still renders in the same task only while at most five bindings too heavy for any task come before it: each reported refusal or wait counts 10,000 units toward the 50,000-unit allowance, so the cap is 0 from the sixth and a light binding that charges anything waits. Measured on twenty label.toUpperCase() bindings after N such bindings (FORMA-249): none waits for N up to 5; with each of the N refused at once on a charge a cost-table row sizes, six are reported to a task and the light bindings wait 1 tick for N=6 to 10, 2 for N=12, 3 for N=20 and 6 for N=40; with each a 75,000-number list converted to text, one is reported to a task and they wait N-5 ticks (1 for N=6, 5 for N=10, 35 for N=40). Before FORMA-178 they rendered in the same task in every case; the delay is stale text for those ticks, not a longer task. Only light bindings that come after the heavy ones in document order wait; those before them render as before. In milliseconds, as FORMA-249's review measured it (Chromium 156 at 4x CPU throttling on an M1 Pro, median of 7 runs at a load average of 5 to 8, where one tick took about 4.3 ms): for N=40 in the list form the last light binding rendered about 152 ms after the update, against 96 ms before FORMA-178, when one 96 ms task blocked the page until all of them rendered, and the longest task is now 4.5 to 4.8 ms; in the row form, 16 ms against 6.5 ms. Twenty bindings each of a 300,000-unit list made a 53 ms task at 4x and forty a 99 ms task (FORMA-173's final review); now the worst task is at most 19 ms for twenty and 6 ms for forty, each reported once (scripts/measure-task-budget.mjs, group G). A handler cannot be re-run later, so an event the runtime has not seen starts a fresh meter, unless an evaluation dispatched it ($dispatch, $el.click()): that event is part of the evaluation's task, and keeps its meter and its cap. The handlers an event reaches as it bubbles share its meter; the bindings they update spend what is left, and render in the next task if it is not enough. The runtime's own conversions of a result to text (FORMA-025: data-text, data-bind:*, data-model, data-persist, and a data-list row's {item} placeholders and key) are charged the same way inside the binding's budget before they run, so a list of a thousand references to one 300,000-character string is refused before String() would write 300 million characters. The budgets are sized from the tail of measurements at 4x CPU throttling, with garbage collection counted (scripts/measure-alloc-budget.mjs, scripts/measure-task-budget.mjs).

What the budgets do not bound. The meter is zeroed by each event the runtime has not seen (begin), not only between tasks, so the events one task delivers each get a budget: one click or keystroke dispatches several, and a form submission one per control (review S1). A widget that binds handlers to all of them can spend a budget per event in one task; under A14 every widget is the founder's reviewed code, and that is carried with the cases below. A price per character cannot see content, and three costs depend on it. A string search whose pattern's first characters fill the text ('ab' in 'aaaa…') measured 39 ns a character at 4x where ordinary text takes under 1, so one evaluation of such searches ran up to 315 ms. A string stored two bytes a character (any string that held a character above U+00FF, or a slice of one) costs up to about four times its case-mapping tier's charge, so seven or eight toLowerCase calls on 1 million such characters ran 56 ms, and up to 100 ms for alternating-case text. And the tier of a text is read from its first character above Latin-1, because a full scan of two-byte text measured 6 to 7 ns a character, most of the cost of lowercasing it: lowercasing all-capital Greek measured up to 19 ns a character, about three times its 1/16 charge, so four calls on 960,000 such characters ran 73 ms, Turkish İ text, priced 1/16 with no prefix, 76 ms for one call on a million characters before the 1 MiB cap refused its two-million-character result, and text that opens with one Greek letter and continues in Cherokee (up to 39 ns a character) 90 ms. The bound is what one task's 4 million characters at 1/16 cost at the costliest lowercasing measured, about 40 ns a character: about 160 ms a task (review S3). Each is bounded per task, to one evaluation, not to 50 ms. A data-list pass is bounded by a 24,000-unit absolute cap (FORMA-172, FORMA-289), separately from the task's expression budget. A row weighs 1, plus 1 for each element and attribute of its template, including nested <template> contents. A pass above the cap is refused on its length before an item is read or a row is built. For a weight-2 row the diagnostic is FORMA_E_BUDGET ("a data-list of rows weighing 2 is refused past 12000 rows (rows × weight is capped at 24000)"). It names the list's expression, marks data-forma-expr-error, and retains existing rows. The refusal itself charges nothing and arms no task cap; evaluation work before it remains on the shared meter (FORMA-249).

The list cap follows the framework's measured ordinary-row task budget, not application templates. CI run 40032265860 measured 3.5 seconds for 80,000 weight-2 <li> rows and 4.6 seconds for 11,428 weight-14 directive table rows, on Ubuntu Chromium at 4x CPU throttling. The larger cost is 4,600 ms / 159,992 units = 28.75 microseconds per unit. At 24,000 units that predicts 690 ms, leaving about 31% below the one-second target; this extrapolation is an estimate, not a current measurement. e2e/expr-cost.spec.ts now prints three at-cap timings and their maximum for neutral list, directive-table and attribute-heavy row shapes, and requires that maximum to stay at or below 1,000 ms on Linux Chromium at 4x. Each shape must also render 1,000 rows, and refuse one row above its cap in less than 50 ms. The cap admits 1,000 rows of weight 24 or below; the six-cell directive table in the framework's own fixture weighs 14.

CI run 40108798933 verified the new cap on commit 8e2c203e with Linux Chromium at 4x. The maximum of three samples was 219.8 ms for 12,000 ordinary list rows (24,000 units), 245.3 ms for 1,714 directive table rows (23,996 units), and 83 ms for 1,000 attribute-heavy rows (24,000 units). The respective 1,000-row controls took 42.5, 127 and 61 ms, and every one-row-over refusal took at most 5.1 ms. The check passed all gates; these current measurements remain distinct from the historical estimate used to choose the cap.

This is a measured budget for those ordinary shapes, not a universal wall-clock bound. Under the cap a list still renders synchronously, and a pass may exceed a 50 ms frame budget. Paginate or virtualize long lists; a page can be requested where data is produced, or sliced with items.slice(start, start + 200) (the expression charges the array length, admitting arrays up to about 250,000 items). Incremental rendering belongs to FORMA-255.

The cap is per pass: an outer list can build multiple inner lists in one task, and each is checked separately. Exact {item.x} placeholders receive placement pricing; forms with spaces or without braces do not receive that same shortcut. Element/attribute count also does not price native work: <iframe> cloning/insertion measured 4.6 ms per element at 4x, <video> 306 microseconds and <audio> 243 microseconds, versus about 1 microsecond for a median element. One hundred iframe rows took 374–471 ms. Repeated invalid directives also report once per row. Comments and repeated {item} placeholders in one text node can add cloning or conversion work without increasing the row's element/attribute weight. These cases can exceed the ordinary-shape target; a per-task allowance or cost-aware scheduler remains FORMA-255's scope.

The largest ordinary evaluation is near the line too: the 9,000-record search measured 24 to 50 ms median at 4x across six scripts (ASCII 24 ms, Greek 50), where 2.0.1 measured 19 to 42 ms in the same session.

Verified by: src/expr/__tests__/adversarial.test.ts > "a nested callback over a large array trips the step budget" Verified by: src/__tests__/runtime-throw-paths.test.ts > "%s of a value nested 10,000 deep is refused with FORMA_E_BUDGET on its element" Verified by: src/__tests__/runtime-throw-paths.test.ts > "a value an expression builds itself is refused the same way: no entry point sees it" Verified by: src/__tests__/runtime-throw-paths.test.ts > "a deep value from data-fetch, data-persist, setScopeValue or a handler write is refused where it is converted" Verified by: src/__tests__/runtime-throw-paths.test.ts > "a value 100 deep renders and one 101 deep is refused; a nested dictionary still serializes" Verified by: src/__tests__/runtime-throw-paths.test.ts > "a chain of 31 computeds read by a binding renders; one of 32 is refused with FORMA_E_BUDGET, never a stack overflow" Verified by: src/__tests__/runtime-throw-paths.test.ts > "a handler that dispatches its own event to itself stops at the cap, with one diagnostic" Verified by: src/__tests__/runtime-throw-paths.test.ts > "a computed refused for nesting is computed again on its next read, not cached as failed: %s" Verified by: src/expr/__tests__/adversarial.test.ts > "an argument that would allocate hundreds of megabytes is refused" Verified by: src/expr/__tests__/native-cost.test.ts > "every allowlisted native refuses an argument heavier than the budget before it runs" Verified by: src/expr/__tests__/native-cost.test.ts > "every size-driven cost refuses a hostile size before the native runs" Verified by: src/expr/__tests__/native-cost.test.ts > "the budget is per evaluation and cumulative across its calls" Verified by: src/expr/__tests__/native-cost.test.ts > "an operator does not convert a value heavier than the budget" Verified by: src/expr/__tests__/native-cost.test.ts > "loose equality charges only an operand it converts" Verified by: src/expr/__tests__/native-cost.test.ts > "a handler cannot grow an array past the allocation budget" Verified by: src/expr/__tests__/native-cost.test.ts > "a native that reads its argument as data is charged its element count, not its text" Verified by: src/expr/__tests__/native-cost.test.ts > "a text search over records is answered as on main" Verified by: src/expr/__tests__/native-cost.test.ts > "case mapping is charged by the costliest character of its receiver" Verified by: src/expr/__tests__/string-growth.test.ts > "every round-2 rope case is refused before the long task" Verified by: src/expr/__tests__/string-growth.test.ts > "a template literal and += are charged by the length they produce" Verified by: src/expr/__tests__/string-growth.test.ts > "comparing long strings is charged by the characters compared" Verified by: src/expr/__tests__/string-growth.test.ts > "comparing or reading a string just built is charged for copying it" Verified by: src/expr/__tests__/string-growth.test.ts > "an operator that parses a long string as a number is charged for it" Verified by: src/expr/__tests__/string-growth.test.ts > "a string method reads a string argument for a thirty-second of a unit a character" Verified by: src/expr/__tests__/ordinary-search.test.ts > "a search over 900 and 9,000 records of %s text is answered" Verified by: src/__tests__/runtime-task-budget.test.ts > "ten bindings at the cap spend one budget per task and all of them render" Verified by: src/__tests__/runtime-task-budget.test.ts > "a binding over the budget on its own is reported, not retried" Verified by: src/__tests__/runtime-task-budget.test.ts > "a chain of computeds spends one budget between them" Verified by: src/__tests__/runtime-task-budget.test.ts > "a computed refused inside a heavy reader is evaluated again on its own" Verified by: src/__tests__/runtime-task-budget.test.ts > "the handlers one event reaches share one budget" Verified by: src/__tests__/runtime-task-budget.test.ts > "a length or count refusal arms nothing, but the charges before it stay on the meter and one that does not fit arms the cap" Verified by: src/__tests__/runtime-text-cost.test.ts > "data-text of a value heavier than the budget is refused before it is converted" Verified by: src/__tests__/runtime-text-cost.test.ts > "every other conversion of a result to text is refused before it runs" Verified by: src/__tests__/runtime-list-cap.test.ts > "a list of 250,000 items is refused before any item is read" Verified by: src/__tests__/runtime-list-cap.test.ts > "a list of Array.from({length: 249990}) builds no row" Verified by: src/__tests__/runtime-list-cap.test.ts > "a list exactly at the cap renders, and one row more is refused" Verified by: src/__tests__/runtime-list-cap.test.ts > "a refused list is reported once, naming the data-list and its row limit, and keeps the rows it had" Verified by: src/__tests__/runtime-list-cap.test.ts > "a page of a long list renders: the remedy the docs give for a refused list" Verified by: src/__tests__/runtime-list-cap.test.ts > "a 1,000-row table with directives in every row, and an ordinary list, render as before" Verified by: src/__tests__/runtime-list-cap.test.ts > "a directive row at the cap retains its rows when one more is refused" Verified by: src/__tests__/runtime-list-cap.test.ts > "the contents of a <template> in a row count toward its weight" Verified by: src/__tests__/runtime-list-cap.test.ts > "each list inside the rows of another is checked on its own" Verified by: src/__tests__/runtime-list-cap.test.ts > "a list under the cap charges the task nothing: the bindings in its rows and after it render in the same task" Verified by: src/__tests__/runtime-list-cap.test.ts > "a refused list delays no binding and no list after it"

Failure is always visible#

An expression that cannot be compiled or cannot be evaluated is not evaluated, and says so: a console.error naming the code, the message and the column, a formajs:diagnostic event, an entry in getDiagnostics(), and a data-forma-expr-error / data-forma-handler-error attribute on the element. The binding writes nothing — the DOM keeps what it had, and the string undefined is never rendered. undefined is a legitimate VALUE, so it can never double as an error signal.

Verified by: src/__tests__/failure-semantics.test.ts > "a denied expression leaves the previous text in place and never renders undefined" Verified by: src/__tests__/failure-semantics.test.ts > "a genuine runtime bug is not swallowed as an expression denial"

A data-on: name in Vue or Alpine modifier syntax is reported the same way (FORMA-269). data-on:click.stop listened for an event named click.stop, which no browser dispatches, so the handler never ran and nothing said so. A key event with anything after a dot, or an event whose first suffix is one of their modifiers (stop, prevent, once, self, …; docs/HTML-RUNTIME.md lists them), is now refused as FORMA_E_UNSUPPORTED on its element and binds no listener. Refusing a modifier does not perform it: under a refused data-on:submit.prevent the form still submits, as before FORMA-269. Any other dotted name is a custom event and binds as before.

Verified by: src/__tests__/runtime-event-modifiers.test.ts > "data-on:click.stop is refused with a coded diagnostic that names the attribute, and binds no listener" Verified by: src/__tests__/runtime-event-modifiers.test.ts > "plain event names and dotted custom events bind as before, with no diagnostic" Verified by: src/__tests__/runtime-event-modifiers.test.ts > "a refused .prevent prevents nothing: the default still happens, as before FORMA-269"

An allowlisted native that throws is reported the same way (FORMA-271). JSON.parse('{'), (1).toFixed(500), (1).toString(1), Object.keys(null), Array.from(null), [].reduce(f), a higher-order method called with no callback, a selector the DOM cannot parse ($el.matches('#1')) and classList.add('') throw on their arguments. Each is now refused as FORMA_E_CALL_FAILED on the binding that made the call, with a message that names the method and the native's error, its text cut at 200 characters (number.toFixed() threw RangeError: toFixed() digits argument must be between 0 and 100). The rest of the markup binds. Before, the exception escaped mountMarkup from any binding and left the whole module unbound. From a handler it was an uncaught page error with no diagnostic.

Only the native call is converted. The interpreter makes every allowlisted call through one wrapper. An error that Forma's own code throws while a native runs it passes through as it is, whether it is a real bug or an expression error with its own code: an arrow callback, a handler that click() dispatches, a computed that a callback reads, and the runtime magics $dispatch and $refetch. Code an application put in reach and a native runs is converted, because the native call is what threw: a toString or getter of a value the application put in state, or the application's own override of an element method.

The refusal is final and arms no cap, like the refusals that check a length or a count (FORMA-249): the same call would throw again in a later task. What the call was charged before it ran stays on the task's meter.

The message can quote part of the call's argument, which may be T2 data, up to the 200-character cut. In Chromium 156, JSON.parse quotes the first characters of the text it refused, and a DOM method quotes the selector it could not parse. The message reaches the console, getDiagnostics() and the formajs:diagnostic event, and so any telemetry that hooks them; the last two are skipped only when the mount turns diagnostics off. The preview frame forwards it to the host as untrusted text. A throw during a render reached the host before this change too, as FRAME_E_RENDER; a throw at event time was an uncaught error in the frame and did not reach the host at all, and now reaches it the same way. Markup cannot read it.

What else threw is reported the same way (FORMA-274). FORMA-271's review listed what still escaped mountMarkup, or failed at event time as an uncaught page error. Each was reproduced in happy-dom and Chromium 156 with the built runtime, and each is now a coded diagnostic on the binding that met it, the rest of the markup binding and working:

  • a data-bind: or data-class: with nothing after the colon is refused when it is bound, FORMA_E_UNSUPPORTED (data-bind: has no name); the runtime wrote the empty name (setAttribute(''), and in browsers classList.toggle(''));
  • the runtime's own DOM write of a binding's value that the browser refuses: data-model writing a file input's value or a non-number to a <progress> or <meter>; under an enforcing Trusted Types policy, data-bind:src or data-text on a <script>, or a src in a data-list row. FORMA_E_CALL_FAILED (data-model write threw InvalidStateError: …), at mount and on every later run, where a later run used to reach onError() only;
  • a value nested too deep (Termination and budgets);
  • a value converted to text or a number whose toString is not a function: an object JSON shadowed ({"toString": 1}), or an array a handler gave one (list.toString = 1). Converting it threw a TypeError wherever something but a native converted it: an operator, a template literal, data-text, data-bind:, Forma's own argument checks, a key, a data-list key, a style or dataset write. It is refused before the conversion, FORMA_E_CALL_FAILED (a value whose toString is not a function), by the same walk. Only where a value is converted to text or a number, and only the values that conversion reaches: the value, and each element of an array it converts, at any depth; never an object's members, which converting the object does not touch. A value serialized or handed on as data is never refused for it: JSON.stringify's value, $dispatch's detail, data-persist, a row's whole {item}, Array.from's array-like. So a dictionary keyed by user input that has a toString key still serializes, persists, renders as a row and travels as an event's detail;
  • a sort comparator's result, which the native converts after the comparator stepped, so the wrapper took its error for Forma's: it is priced, and refused, as an operator's operand is;
  • an event-time property write the browser refuses ($el.dataset['a-b'] = 1, a file input's value, a <progress> or <meter> value, max or min, and under Trusted Types a <script>'s textContent or innerText): made through the native wrapper, FORMA_E_CALL_FAILED ($el.value = … threw InvalidStateError: …) on the handler's element;
  • a DOM method that answers a promise that rejects (Chromium 156's scrollIntoView({behavior: 'bad'})): the rejection is caught and reported on the element, $el.scrollIntoView() rejected TypeError: …, never left unhandled;
  • what application code throws through a native that cannot be converted to text (Object.create(null), an object whose toString throws): named an unprintable value, where the wrapper's own String(e) threw in its place;
  • a namespace member's call now names its namespace to the wrapper, so only the allowlist's own namespaces (JSON, Math, …) count as natives: a member that is Forma's own function would pass its errors through.

In happy-dom, click() and dispatchEvent hand a listener's exception back to their caller, which a browser never does: there, an exception a handler raised before its first step would be filed as the click()'s. That is a unit-test artifact; in Chromium a listener's exception is the page error it is, and the handler that clicked is not blamed.

"Markup cannot throw out of mountMarkup" is still not a guarantee (FORMA-274's decision). The FORMA-274 matrix runs every directive, and each way an expression hands a value to the runtime, a native or a DOM method (85 bodies, 15 of them handlers, one of which writes the value as a key onto an array), with each of 48 hostile T2 values (nested 10,000, 101 and 100 deep, a string past 1 MiB, 300,000 numbers, odd numbers, radix and digit extremes, empty and invalid names and selectors, script URLs, __proto__, own toString and valueOf keys, a dictionary with them, the key toString, records), at mount, at event time, on later values and through update(): in happy-dom and in Chromium 156 nothing throws out of mountMarkup, no handler raises an uncaught page error, nothing rejects unhandled, and every diagnostic has a FORMA_E_ code; in happy-dom, where the test can watch it, no later run reaches onError() either. The expression grammar's own depth limits held for 4,000-character chains of !, -, typeof, parentheses, array literals and if, in happy-dom and in Chromium 156, Firefox 157 and WebKit 27.2. But the nesting of the markup is not bounded: a data-list nested a few hundred levels deep still overflows the stack, and Known gaps lists that and what else remains. So the claim stands for values and expressions, not for markup in general, and the probes guarding it stay correctness.

Verified by: src/expr/__tests__/native-throw.test.ts > "no allowlisted native throws a raw exception, whatever plain data it is given, and these are the ones that refuse" Verified by: src/expr/__tests__/native-throw.test.ts > "the message names the method and the error, and is bounded" Verified by: src/expr/__tests__/native-throw.test.ts > "code an application put in reach and a native runs is converted: a toString of a value in state, an override of an element method" Verified by: src/expr/__tests__/native-throw.test.ts > "a real bug in an arrow callback passes through as it is" Verified by: src/expr/__tests__/native-throw.test.ts > "a runtime magic is Forma code, not a native: what it throws passes through as it is" Verified by: src/expr/__tests__/native-throw.test.ts > "a real bug in an evaluation a DOM method runs (a handler a click() dispatches) passes through as it is" Verified by: src/expr/__tests__/native-throw.test.ts > "a budget refusal inside a callback keeps its defer, so it still waits for a later task" Verified by: src/__tests__/runtime-native-throw.test.ts > "one native that throws costs one binding: every directive reports on its own element and the rest of the module works" Verified by: src/__tests__/runtime-native-throw.test.ts > "a real bug in code a native runs is rethrown, not filed as the native failing" Verified by: src/__tests__/runtime-native-throw.test.ts > "a native that throws is final: it arms nothing, and what it was charged before it ran stays on the task's meter" Verified by: src/__tests__/runtime-throw-paths.test.ts > "a data-bind: or data-class: with no name is refused at bind time with FORMA_E_UNSUPPORTED, and the rest of the module mounts" Verified by: src/__tests__/runtime-throw-paths.test.ts > "a write the browser refuses is reported on its element, at mount and on a later run: %s" Verified by: src/__tests__/runtime-throw-paths.test.ts > "what a write is handed is checked and charged before it is written, so the write itself is all the wrapper catches" Verified by: src/__tests__/runtime-throw-paths.test.ts > "is refused with FORMA_E_CALL_FAILED before anything converts it, where it threw a TypeError out of mountMarkup and the handler" Verified by: src/__tests__/runtime-throw-paths.test.ts > "an object of the application with its own toString function still converts, and what that function throws is the native call that threw" Verified by: src/__tests__/runtime-throw-paths.test.ts > "a dictionary with a toString key still serializes, persists, renders as a row and travels as $dispatch detail" Verified by: src/__tests__/runtime-throw-paths.test.ts > "the same dictionary is refused where it would be converted, at any depth of an array, never as an object member" Verified by: src/__tests__/runtime-throw-paths.test.ts > "an array a handler gave its own toString is refused wherever it is converted" Verified by: src/__tests__/runtime-throw-paths.test.ts > "%s is reported on its element as FORMA_E_CALL_FAILED, not thrown to the event" Verified by: src/__tests__/runtime-throw-paths.test.ts > "the rejection is reported on the element, not left unhandled" Verified by: src/__tests__/runtime-throw-paths.test.ts > "%s is reported as FORMA_E_CALL_FAILED, not a TypeError out of the handler" Verified by: src/expr/__tests__/native-throw.test.ts > "what a native throws that cannot be converted to text is still reported, named as unprintable" Verified by: src/expr/__tests__/native-throw.test.ts > "a namespace member is a native only if the namespace is the allowlist's own: a runtime magic's member passes what it throws through" Verified by: src/expr/__tests__/native-throw.test.ts > "happy-dom's click() hands a listener's exception back to its caller, where a browser reports it: the step rule keeps Forma's own as Forma's" Verified by: src/__tests__/runtime-throw-paths.test.ts > "every directive given %s: nothing throws out of mountMarkup, an event or a later run, and every failure is coded" Verified by: src/__tests__/runtime-throw-paths.test.ts > "the grammar's own depth limits hold for 4,000-character chains: each is a coded FORMA_E_LIMIT, never a stack overflow"

In Chromium, e2e/native-throw.spec.ts checks the DOM methods that throw only in a browser (classList.add(''), classList.add('a b'), classList.toggle(''), classList.replace('', 'x')): each handler reports a coded diagnostic, and no uncaught page error reaches the page. It also checks FORMA-274's browser-only cases (an empty data-class: name, $el.dataset['a-b'] = 1, the rejected scrollIntoView), the stack bounds, that a dictionary with a toString key still serializes, persists, renders as a row and travels as detail while an array given one is refused where converted, the browser's side of the click() artifact, and runs the FORMA-274 matrix: no mountMarkup throw, page error or unhandled rejection.

Residual risks#

  • toString coercion. Rendering a value as text, interpolating it into a template literal, or Array#join-ing it calls that value's toString. JSON data cannot supply a function, but it can shadow toString with a value that is not one ({"toString": 1}), and a handler can write one onto an array; converting such a value threw. It is refused where it would be converted, arrays included, and never where it is serialized or handed on as data (FORMA-274, Failure is always visible). A few places are charged as if they converted a value they only read: Array.of, reduce's initial value, a callback's thisArg, an options object of scrollIntoView or focus, a force argument, JSON.parse's reviver and a checkbox's data-model; and list.join() checks the array's own toString, which join does not call. Such a value is refused there too. (A radio's data-model does convert its value, to compare it with the radio's.) An app that puts class instances, or objects with their own toString function, into state supplies the method itself; it is then the app's own code, not the attacker's, under T2. What that function throws is reported when a native or the runtime's data-text converts the value, and escapes as before when an operator or data-bind: does.
  • An expression is still code. Under T1 the grammar bounds what an injected expression can do — no globals, no network, no DOM outside the allowlist, no non-termination — but it can still read and write the declared state of the scope it was injected into, and call the DOM methods on the allowlist. Never build a data-* attribute from user input.
  • Expression budget configuration. The application can raise a mount's step budget through config.exprBudget; it remains responsible for the admitted work. A script tag's data-forma-expr-budget is ignored and grants no authority.
  • What a tiered mount does not bound. $dispatch events (other than the activation events navigate covers) bubble out of the root to the host's listeners, which is how a widget talks to its host: a listener treats their type and detail as the widget's data. focus() and scrollIntoView() act on elements inside the root but move the page's focus and scroll. data-persist reads and writes the origin's localStorage under forma:<key>, shared with every other mount and page that persists the same key. A local selector can learn a bounded number of bits about the page around its element through state the element inherits (:disabled under a disabled fieldset, :read-write under contenteditable) or its position among its siblings, never a value. Loading the 3.0 runtime never binds markup outside a handed root.

fetch in the HTTP module#

@getforma/core/http uses the browser's native fetch API for createFetch, createSSE, and createWebSocket. This is opt-in — it is not included in the main @getforma/core entry point.

Security Hardening (v0.5.0 – 2.0.0)#

  • $el safe proxy (0.7.0, superseded): allowlisted $el reads through a Proxy. Superseded by the host-wrapper model above, which applies the same discipline to $event and $refs — the two the Proxy did not cover.
  • findBlockedMethod (0.7.0, removed): a static scan for constructor, __proto__, eval and Function in expression text, including bracket concatenation. Removed with the evaluator it guarded; a string scan cannot see items[k] where k arrives from server JSON, and the allowlist interpreter checks the evaluated key instead.
  • Allowlist AST expression interpreter (shipped by 3.0.0): replaced the regex parser and the new Function fallback outright — see Supply Chain Security Notes above.
  • SSR escapeAttr + scheme detection (1.0.10): Escapes <, >, ', ", &. isDangerousUrl blocks javascript:, vbscript: and data:text/html in URL-bearing attributes, normalizing away the whitespace/control characters browsers ignore in a scheme, so java\tscript: is caught too. Attribute names are validated and on* handler attributes are dropped case-insensitively. Applies to the data-bind:* and data-list runtime sinks in both builds, and since FORMA-276 to an attribute that a mount's update() changes on an element it keeps, and to the unreviewed tier's strip; since FORMA-277 to SVG animation values too.
  • CSP parser operator precedence (0.7.1): Fixed to match JavaScript semantics (addition before comparison, AND before OR).
  • RPC argument sanitization + CSRF mitigation + authorization hook (1.4.0): see the RPC section below.
  • Client/SSR sink parity, data:image/svg+xml context rule, store prototype guard, tag-name validation (shipped by 3.0.0): see the URL scheme posture below and CHANGELOG.md. The reconciler's update path joined the parity in FORMA-276, and SVG animation values in every sink in FORMA-277. Markup a compiled or reviewed mount renders first is the application's admission of its own markup and is written as it is: URL and handler attributes in each sink below says what each sink does.

URL and handler attributes in each sink#

Each sink that checks an attribute applies the same check, isUnsafeAttrWrite in src/security/url-safety.ts, or its URL half isDangerousUrlAttr where the sink refuses an on* name itself first (is_dangerous_url_attr and is_event_handler_attr in the Rust walker). It refuses an on* handler attribute, and a URL attribute (href, src, action, formaction, xlink:href, poster, background, data) whose value has a script scheme: javascript:, vbscript: or data:text/html in any case, once the whitespace and control characters a URL parser ignores are taken out, and data:image/svg+xml except on an image sink. Markup is checked after the HTML parser has decoded its character references, so &#106;avascript: is the same case as javascript:. The table says which sinks check, and where markup is written as it is.

An SVG animation value is judged as a URL attribute too (FORMA-277, URL-attribute parity): to, from, by and values on <set> and on every <animate…> element (animate, animateTransform, animateMotion, animateColor: a tag that is animate followed only by ASCII letters, digits or _), tag and name in any ASCII case. An animation's attributeName can name href or xlink:href. Each ;-separated item is judged (values is a list; splitting the other three can only refuse more), with no image context, since the target is not known. The values are judged whatever attributeName says, so neither the order the two are written in nor a later change of attributeName, by a binding or an update, lets one through. Each item is judged by the same conservative prefix rule as a URL attribute (docs/INV1.md): an item that begins with javascript or vbscript, with or without a colon, or with data:text/html is refused, so javascript-guide is refused there too. Numbers, coordinates, transforms and colours never begin with one of those, so animations of other attributes keep their values. Wherever the table says a URL attribute is removed, refused or not written, so is such a value.

SinkURL attribute with a script schemeon* attribute
SSR walkernot emittednot emitted
h() (static and reactive props, xlink:), hydration adoptionnot writtennot written
data-bind:*, {item…} in a data-list row's attributesremovedremoved
$el.setAttribute()refused, FORMA_E_METHOD_DENIEDrefused, FORMA_E_METHOD_DENIED
mountMarkup() first render, tiers compiled and reviewedwritten as the markup has itwritten as the markup has it
update(), tiers compiled and reviewed: an attribute it changes on an element the diff keepsremoved (FORMA-276)removed
update(), tiers compiled and reviewed: an element it adds, the content of a scope it replaces or resets, or the whole markup when it replaces the root's content (the root's keyed children share no data-forma-id with it)written as the markup has itwritten as the markup has it
tier unreviewed, first render and every updateremoved and reported, FORMA_E_UNREVIEWED_URLremoved and reported, FORMA_E_UNREVIEWED_HANDLER

The first render, and what an update writes as new, is the markup the application handed mountMarkup() as trustedHtml(): its admission, as for setHTMLUnsafe() (Unsanitized HTML sinks above). Untrusted data belongs in a binding, which checks it, and untrusted markup in tier unreviewed. The diff refuses what it would change, as h()'s reactive props refuse a new value, and says nothing, like the other sinks. A value the update leaves as it was is not written, so it is not checked again.

The new markup an update() diffs is parsed and held in a document with no browsing context (FORMA-277, defence in depth), and only what the diff writes is adopted into the page. A node that stays in that document, such as the new copy of an element the diff keeps, loads no resource and runs no handler of its own, even for an attribute the diff then refuses to write. Tier unreviewed still parses in the root's own context (The preview frame in docs/HTML-RUNTIME.md), so its nodes are made in the page's document: the strip removes what it refuses in the same task, before any event of theirs is dispatched, the frame's policy governs what they load, and the diff then holds them in the same inert document.

Verified by: src/__tests__/renderer-contract.test.ts > "drops every script scheme written to %s" Verified by: src/__tests__/renderer-contract.test.ts > "drops every entity-spelled script scheme on %s" Verified by: src/__tests__/renderer-contract.test.ts > "never writes %s as an attribute" Verified by: src/__tests__/runtime-tiered-mount.test.ts > "a %s mount's first render keeps its markup's own attributes; an update that changes one on an element it keeps applies the URL and handler check" Verified by: src/__tests__/renderer-contract.test.ts > "drops every script scheme written to <%s %s>" Verified by: src/__tests__/renderer-contract.test.ts > "keeps ordinary animation values on <%s %s>" Verified by: src/security/__tests__/url-safety.test.ts > "keeps the values real animations use on every animation element" Verified by: src/__tests__/renderer-contract.test.ts > "update(): a kept <animate> whose attributeName changes to href with a script value" Verified by: src/__tests__/runtime-tiered-mount.test.ts > "an unreviewed mount renders no script URL in an SVG animation value: each is removed and reported, on the first render and on update" Verified by: src/__tests__/reconcile.test.ts > "no node an update copies from belongs to the page: a new element, a RESET scope and a REPLACE scope" Verified by: src/__tests__/reconcile.test.ts > "a kept element's parsed copy, parsed in the container's context, is moved out of the page before the diff reads it"

The Rust walker runs the shared INV1 vectors (crates/forma-ir/tests/fixtures/parity/inv1.json), animation values included, through its static and dynamic attributes, as core runs them through h() and adoption. In Chromium, the hostile-widget matrix (e2e/hostile-widgets) writes its URL payloads as static attributes through update() in four contexts and as an animation value in two, bound and through update(); e2e/frame.spec.ts checks that none reaches the preview frame's document, after a render and after an update; and e2e/update-diff.spec.ts checks that an update's parsed copy of a kept <img> loads nothing and runs no handler, and that every update path still adopts what it writes.

data: URL posture#

data:text/html is blocked unconditionally. data:image/svg+xml is conditional, because the same value is inert in one sink and executable in another: fetched through an image sink (<img src>, <image href>, <video poster>/src, <audio src>, <source src>, the legacy background attribute) the browser decodes it in image mode where script never runs; loaded through a document sink (<iframe src>, <object data>, <a href>, <use href>) it is parsed as a document and an onload= inside it fires.

So: allowed for image-context sinks, blocked for document-context sinks, and blocked when the sink is unknown — a call site that forgets to pass the element tag gets the strict answer.

srcset is deliberately not scheme-checked: its value is a comma-separated candidate list with descriptors that a single scheme test would mis-parse, and every candidate is fetched in image mode.

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" Verified by: src/security/__tests__/url-safety.test.ts > "blocks data:image/svg+xml when no tag is supplied"

Island props#

Compiled SSR island state is read from the first head block once per Document, with a 1 MiB UTF-8 cap. Each instance receives a fresh projection of its declared schema: undeclared and forbidden keys never reach component props, including in nested objects and array elements. Identity and schema checks complete before any requested component or trigger runs. Legacy inline and shared-spill channels are refused. sanitizePropsDeep(props) remains a standalone iterative utility for other data paths; it is not the state decoder or a schema bypass. It mutates in place and throws TypeError if a forbidden own key cannot be deleted. The input may already be partially modified when it throws and must not be used as sanitized data. Harmless frozen data is accepted.

Verified by: src/dom/__tests__/activate-isolation.test.ts > "island activation projects only declared safe fields at every depth for both instances" Verified by: src/dom/__tests__/activate-isolation.test.ts > "standalone sanitization still strips props nested 10,000 deep" Verified by: src/dom/__tests__/activate-isolation.test.ts > "sanitizePropsDeep strips forbidden keys at every depth"

Store prototype-pollution guard#

createStore refuses __proto__, constructor and prototype writes through both setState and the proxy's set trap, so untrusted JSON cannot replace the store's prototype and forge fields the app never defined.

What is not covered: Object.setPrototypeOf(state, x) and Object.defineProperty(state, …) have no traps and are not intercepted. Both are explicit API calls in your own code, not shapes a JSON payload can forge.

Verified by: src/state/__tests__/store-proto-hijack.test.ts > "setState with a proto key does not replace the store prototype" Verified by: src/state/__tests__/store-proto-hijack.test.ts > "assigning proto directly on the proxy does not replace the prototype"

Known gaps#

Stated here rather than left for a reader to discover:

  • An expression is still code under T1 — the grammar bounds what it can reach, not the fact that it runs. See Residual risks above.

  • A compiled or reviewed mount writes its first render as the markup has it. Its URL and handler attributes, SVG animation values included, are not checked there, nor on an element an update() adds; only an attribute the update changes on an element it keeps is (FORMA-276). This is the application's admission of its own markup, not a sanitizer: URL and handler attributes in each sink above.

  • The nesting of the markup is not bounded, so markup can still throw out of mountMarkup (FORMA-274). A data-list pass binds its new rows as it builds them, so a list in a row renders inside the outer list's pass and the stack grows with each level: in Chromium 156 a data-list nested 1,000 levels (as the parser builds it, at most 512 elements deep) overflowed the stack out of mountMarkup, and 500 levels with a few directives a row. Binding nested lists also costs more than linearly in their depth (each level binds its template's elements against the row before they are lifted out): in happy-dom 14 levels took 0.4 to 0.7 s, before FORMA-274 as after it, so markup nested that deep hangs before it overflows. Neither is from a value or an expression; both are left for a follow-up.

  • A binding's later run that throws something other than an expression error reaches onError() only, and the console in a development build; in production it says nothing. FORMA-274's matrix found none from a value, but the markup can still make one: a data-if on the root of a data-list row removes the row from the list's own record, and the next change to the list throws NotFoundError inside its pass.

  • In happy-dom (unit tests only) click() and dispatchEvent rethrow a listener's exception, and the parser does not cap nesting at 512 as browsers do: 500 nested data-lists overflow Node's stack there.

  • Modifier syntax the FORMA-269 check does not recognise still binds a dead handler, with no diagnostic. The check refuses only a key event with a dotted suffix and an event whose first suffix is one of the Vue and Alpine modifiers docs/HTML-RUNTIME.md lists. These still listen for their literal name, which never fires:

    • a first suffix that is not in the list: click.foo, click.foo.stop;
    • an empty first suffix: click., click..stop;
    • Alpine's .camel and .dot (data-on:custom-event.camel);
    • Datastar's __ syntax (data-on:click__prevent).

    Svelte's data-on:click|preventDefault stays one attribute in Chromium, data-on:click|preventdefault, and its handler never runs on a click.

    Verified by: src/__tests__/runtime-event-modifiers.test.ts > "modifier syntax the check does not recognise is not detected: it listens for its literal name, with no diagnostic"

    In Chromium, e2e/event-modifiers.spec.ts checks the Svelte form.

Supported Versions#

VersionSupported
3.2.xCoordinated line; support starts with its publication record under the stack policy.
3.1.xPublished coordinated line; Critical and High security fixes under the stack policy.
3.0.xPublished unified line; Critical and High security fixes under the stack policy.
2.xMaintenance line; Critical and High fixes through the date in the stack policy.
< 2.0No

The stack security policy is the authoritative support schedule and response target for published releases. Its dates derive from the original publication records.

Find your next answer.

Search Forma documentation, guides and examples.