Security & the trust model.
Aktion has two security surfaces, and almost every mistake comes from
treating them as one. The program text is code — as
privileged as a <script> tag. Everything the program
renders is data — and that is what the sanitisers
defend. This page explains where the line falls, and what to do when your
program text is not fully trusted.
The mental model in one line
Sanitisers protect you from untrusted data flowing through a
trusted program. They cannot protect you from an untrusted program
author — that author would simply call eval. If program
text can come from somewhere you do not control, call
setGlobalAccessPolicy("safe") before you render anything.
Two layers, two threat models
The program text is trusted code
By default a program reaches the whole host realm: eval,
Function, document, fetch,
localStorage. Authoring a program is equivalent to shipping
a JavaScript file. This is deliberate — it is what makes the
language a productive authoring surface.
Everything it renders is untrusted data
API responses, LLM output, chat messages, tool results, URL parameters, user input. This is the surface the per-sink sanitisers defend, and it is the one that matters in the common case: a program you wrote, rendering values you did not.
The distinction decides which control you need. A cross-site-scripting attempt smuggled into a product name is a Layer 2 problem, and the sanitisers already handle it. A program authored by a prompt-injectable LLM is a Layer 1 problem, and no sanitiser addresses it — you need to narrow the global surface instead.
Aktion is not a sandbox by default
Do not read the sanitiser table below as "Aktion safely runs untrusted
programs". Under the default policy it does not, and it does not try to.
Reach for setGlobalAccessPolicy when the
program author is not trusted, and treat that as the load-bearing control.
Which case are you in?
| Where the program text comes from | Trust it as | What to do |
|---|---|---|
Your own repo, a build artefact, a reviewed .aktion file |
Your own source | Default "all" policy is appropriate. Sanitisers still cover the data you render. |
| An LLM you prompt, with a system prompt you control, no user-controlled tool output in context | Semi-trusted | Prefer "safe". A program that only builds UI never needs eval or document. |
| An LLM whose context includes untrusted content (retrieved pages, emails, tool results) | Untrusted — prompt injection is a code-execution path | setGlobalAccessPolicy("safe"), mandatory. |
| A multi-tenant database, a user-editable template, a URL parameter | Untrusted | setGlobalAccessPolicy("safe"), or an explicit allow-list array. |
Narrowing the global surface
setGlobalAccessPolicy gates the last step of identifier
resolution — the passthrough to the embedding realm. Knowing where that
step sits explains both what the policy can reach and what it cannot touch:
- user state (
$name) - bindings in scope
- action declarations
- user component declarations (PascalCase
function) - the curated
GLOBAL_NAMESPACESfast path - the component library
cleanup- host globals — policy-gated
Two consequences fall out of that ordering. Everything above step 8 is
resolved before the policy is consulted, so the policy cannot break it. And a
library component always wins over a same-named host constructor — the
Text and Map components beat DOM
Text and JS Map, under every policy.
import { setGlobalAccessPolicy, getGlobalAccessPolicy } from "aktion-runtime";
setGlobalAccessPolicy("safe"); // data + formatting only
getGlobalAccessPolicy(); // -> "safe"
| Policy | Reachable host globals | Use when |
|---|---|---|
"all" (default) |
Everything on globalThis, including eval, Function, document, fetch, localStorage. |
Program text is as trusted as your own source code. |
"safe" |
A fixed allow-list of data, formatting, and encoding globals — enumerated below. No code execution, no DOM, no network, no persistence. | Program text may come from anywhere you do not fully control. |
readonly string[] |
Exactly the names you list, and nothing else. | You need one extra capability beyond "safe", or a stricter set than it. |
What "safe" allows
Capabilities a program needs to compute with values — and nothing that grants code execution, DOM access, network access, or persistence:
| Category | Globals |
|---|---|
| Values & collections | BigInt Symbol Map Set WeakMap WeakSet Promise ArrayBuffer DataView and the nine typed-array constructors |
| Formatting & parsing | Intl RegExp Error TypeError RangeError SyntaxError isNaN isFinite parseInt parseFloat |
| Encoding | atob btoa TextEncoder TextDecoder encodeURIComponent decodeURIComponent encodeURI decodeURI structuredClone |
| URL parsing (inert — parses and formats, issues no request) | URL URLSearchParams |
| Inert data containers | Blob File FormData Headers |
| Diagnostics | console |
What "safe" blocks, and why
| Excluded | Reason |
|---|---|
eval Function WebAssembly | Code execution. Allowing any one of these makes the rest of the policy decorative. |
window self globalThis top parent frames document | Each one re-exposes the whole realm, including the names excluded here. |
fetch XMLHttpRequest EventSource WebSocket navigator Worker | Network and threads. $http is the vetted path — it flows through your host's interceptors. |
localStorage sessionStorage indexedDB caches | Persistence. storage is the vetted path. |
import Reflect Proxy | Reflection escapes — each can reconstruct a blocked capability. |
The curated fast path is not affected by the policy
Math, JSON, Object,
Array, Number, String,
Boolean, Date, Map,
Set, RegExp, Promise and friends
resolve through a curated table before the host passthrough, so
they keep working under every policy — they are already a vetted
surface. The same is true of Aktion's own namespaces
($http, storage, $util, …)
and of timers, which are handled by dedicated tracked handlers so they are
cleared on dispose rather than leaking past a replan. Setting
"safe" therefore does not break ordinary
value-crunching programs.
A custom allow-list
Pass an array when "safe" is the wrong shape — either
because you need one more capability, or because you want less. The array is
exhaustive: nothing outside it resolves.
// "safe", plus crypto for client-side id generation
setGlobalAccessPolicy([
"Map", "Set", "Promise", "Intl", "RegExp", "URL", "URLSearchParams",
"encodeURIComponent", "decodeURIComponent", "atob", "btoa", "console",
"crypto",
]);
// Stricter than "safe": formatting only, no containers, no console
setGlobalAccessPolicy(["Intl", "RegExp", "parseInt", "parseFloat", "isNaN"]);
A blocked name does not throw — it simply does not resolve, so the
expression evaluates as if the identifier were undeclared. Turn on
strict while developing to see
those lookups reported instead of silently yielding
undefined.
The policy is process-global
It is one setting for every program on the page — not per
<aktion-app> and not per context. Call it once at host
bootstrap, before mounting. In tests, reset it in an
afterEach or a later suite will inherit it.
What breaks under "safe"
Worth knowing before you flip it, because the failures are quiet rather than loud:
| Stops working | Use instead |
|---|---|
eval("…"), Function("…")() | Nothing — this is the point of the policy. |
window.x, globalThis.x, self.x, top.x, document.… | $dom where a vetted equivalent exists, otherwise restructure so the host does it. |
bare fetch(…) | $http({...}) — and it flows through your interceptors. |
localStorage, sessionStorage | storage, the vetted namespace. |
crypto.randomUUID(), navigator.…, alert/confirm/prompt, performance, location, history, matchMedia | Add the specific name via the array form if you genuinely need it. |
$script({ src }) — disabled outright, not merely restricted | Nothing. It loads and executes remote code, so any non-"all" policy switches it off. The resource bag comes back with error set and ready still false, so a program that gates on $s.ready degrades instead of crashing. |
The rule is simply "not in the 46 names above", so treat that table as the allow-list and this one as the common cases — not as an exhaustive deny-list.
Why the policy is not trivially bypassable
Narrowing globals would accomplish nothing on its own, because every lambda a
program can write is a real JavaScript function — and
f.constructor is Function. So three property names
are refused on every access path: dot access, computed access,
method-call dispatch, and both write paths.
$f = () => 1
$f.constructor // undefined — not the Function constructor
$f["cons" + "tructor"] // undefined — computed access is checked too
$o.__proto__ // falsy
$o.prototype // undefined
The same three segments (__proto__, constructor,
prototype) are refused on state writes, where they would
otherwise be a prototype-pollution primitive. A write through them is a silent
no-op rather than an error.
Residual risk — "safe" bounds capability, it does not isolate the realm
Be clear-eyed about what you are buying. "safe" removes code
execution, DOM, network, and storage capability. It is
not a hardened sandbox and has not been adversarially audited as
one. Specifically: Object stays available under every
policy and still exposes getPrototypeOf and
assign, which can be combined to reach and mutate shared
prototypes; and $http, storage,
$socket and $sse remain available by
design, so a "safe" program can still talk to the network
and still persist data.
For genuinely untrusted program text, do both: set the policy
and host the app in a cross-origin
<iframe> with a restrictive sandbox
attribute, so a residual escape cannot reach your origin's cookies or DOM.
Constrain the network at the host too —
registerHttpInterceptors plus a CSP
connect-src.
The shadow DOM is not a security boundary
<aktion-app> renders into a shadow root, which is easy to
mistake for isolation. It is not. A shadow root gives you style
encapsulation — the host page's CSS does not leak in, and the
component stylesheet does not leak out. It gives you no origin separation:
script running inside a shadow root has the host page's full origin
privileges, and can read cookies, localStorage, and the rest of
the document.
If you need a real boundary, use one the platform actually provides: a
cross-origin <iframe> with a restrictive
sandbox attribute. Inside the same document,
setGlobalAccessPolicy is the control that matters.
The sink-to-sanitiser table
These defend Layer 2 — untrusted values flowing through a trusted
program. They are always on; there is nothing to configure. The underlying
principle is worth more than the table: every URL-typed prop in the
library goes through one of two chokepoints
(sanitiseHref for link targets, sanitiseImageSrc for
image sources), so the guarantee holds for components added after this page
was written.
These are runtime behaviours, not validation errors. A
rejected value is dropped or replaced while rendering — the program
still parses and still passes
validateProgramSchema. So you will not catch a
rel: "stylesheet" or a javascript: href in CI by
validating the program; look for the missing output, or the
console.warn where one is emitted.
| Sink | Helper | What it does |
|---|---|---|
Anchor href — Link, BreadcrumbItem, NavbarItem, Markdown links |
sanitiseHref |
Allow-lists the http, https, mailto and tel schemes, plus fragment, root-relative, query-only and scheme-less relative paths. Rejects javascript:, vbscript:, data:, file:, protocol-relative //host/…, and control-character bypasses (java script: — C0 controls are stripped before scheme detection). A rejected URL collapses to a safe fallback: either # or no href at all, depending on the component. |
Image src — Image, Avatar, MediaCard, Hero, Testimonial, ChatBubble |
sanitiseImageSrc |
Allow-lists http(s):, data:image/*, blob:, and relative paths. Anything else becomes an empty string so the caller renders a placeholder. |
Inline style lengths — Container.maxWidth, Skeleton.height, … |
sanitiseCssLength |
Restricts the alphabet so semicolons and quotes cannot inject extra declarations. |
background-image: url(…) — Hero.imageSrc |
sanitiseCssUrl |
Drops characters that would close the url() literal. |
helpers.openUrl(…) from an action body |
sanitiseHref (renderer) |
The renderer sanitises before calling window.open. External windows open with noopener,noreferrer. |
Inline SVG — Svg, $theme({ icons }) |
sanitiseSvgMarkup |
Parses in an inert document and applies an element/attribute allow-list. Drops <script>, <foreignObject>, SVG <a> (whose href executes javascript:), <style>, <image>, SMIL <animate attributeName="href">, and every on*. Never assigns innerHTML on the live document. |
Rich-text HTML — Markdown output, RichTextEditor value and read-back |
HTML sanitiser | Same inert-document + allow-list strategy as the SVG path. Disallowed elements are unwrapped rather than deleted, so a stray <section> does not swallow the user's text. style is dropped outright and id is deliberately absent (a DOM-clobbering primitive); <img> survives, its onerror does not. It also sanitises on the way out of a contenteditable — a user can paste arbitrary markup, and an unsanitised read-back would persist it and re-inject it on the next render. |
Inline style colours — sx colour props, token values |
sanitiseCssColor |
Restricted alphabet, 64-char cap, plus explicit rejection of url(, expression(, javascript:, and @import. var(--token) passes. |
Styles — CSS body and the scope selector |
CSS filter + selector shape allow-list | CSS is applied via textContent, never parsed as HTML, and is rejected wholesale if it contains </style, <script, expression(, javascript:, behavior:, or @import (64 KB cap). The scope prop is itself a sink — it is concatenated into the generated sheet, so it must match a plain-selector shape (128-char cap). On rejection the whole sheet is dropped rather than emitted unscoped, since an unscoped sheet would leak every rule into the shadow root. Every rejection is announced with console.warn. |
$script({ src }) |
sanitiseScriptSrc + the policy gate |
http(s) or same-origin /, ./, ../ only; protocol-relative, javascript:, data: and blob: rejected. Disabled entirely whenever the global-access policy is not "all" — see what breaks under "safe". |
WebComponent — properties and attributes |
blocked-property list + URL sanitisers | Tag names must contain a hyphen, else the element collapses to a div with a one-time warn. on* is skipped on both maps; srcdoc is dropped; href/src/action/formaction/poster/ping attributes are sanitised. Sixteen property names are blocked, including innerHTML, outerHTML, insertAdjacentHTML, shadowRoot, contentEditable, style, id, and the three forbidden property names. Ordinary custom properties pass through untouched. |
$theme({ icons }) registration |
registerIcons → SVG allow-list |
Icon names must match /^[a-zA-Z0-9:_-]+$/; markup is capped at 16 KB and re-sanitised again at render time, so a registry reached by another route still cannot inject. |
Web-font import shorthand |
buildFontUrl |
Family names must match /^[A-Za-z0-9 ]{1,48}$/ and weights must be integers 100–900. The URL is constructed against fonts.googleapis.com — never taken from the program — so a hostile value cannot smuggle a different origin or a CSS payload into the page. |
| Markdown text and attributes | text/attribute escapers | Escapes attribute contexts as well as text (alt, fence info strings), decodes entities before scheme checks (javascript:), and isolates generated markup so a later pass cannot rewrite inside an earlier one. |
$head({…}) — title, meta, link, base, htmlAttrs |
per-field allow-lists | <base> limited to same-origin paths (an absolute base would re-target every relative URL in the host page); <link> limited to metadata/hint rel values (no stylesheet, preload, modulepreload); <html> attributes limited to lang/dir/class/data-*. Attribute names are validated so they cannot inject a second attribute into SSR output. |
HTMLTag attributes |
tag + attribute allow-list | Unknown tags collapse to div; on* dropped; href/src sanitised; srcset/srcdoc/data/background dropped; target="_blank" forced to carry rel="noopener noreferrer". |
Cookies — storage.cookies |
attribute validation | Name and value percent-encoded; path/domain validated so a ; cannot append attributes; SameSite always emitted, defaulting to Lax. |
State-path writes — $a.b.c = … |
forbidden-segment check | __proto__, constructor, and prototype segments are refused, so an untrusted key cannot reach Object.prototype. |
CSV export — DataGrid |
formula-injection guard | Cells starting =, +, -, @, TAB, or CR are prefixed so a spreadsheet reads them as text rather than a live formula. |
External links rendered by Link, NavbarItem, and the
Markdown renderer carry rel="noopener noreferrer", so the
destination cannot read the opener's document.referrer.
Resource bounds
Every one of these limits is data-driven, so without them untrusted data — a 40 MB Markdown blob, an array with a billion entries, a self-nesting component tree — could freeze the host tab. That makes them a security control, not just a performance one.
| Bound | Limit |
|---|---|
| Component nesting depth | 150 |
| Loop iterations per render, across all loops | 250,000 |
Array length for @Range / @Repeat | 100,000 |
| Markdown document / single line | 128 KB / 8 KB |
| SVG sanitiser input / nodes / depth | 64 KB / 4,096 / 32 |
| HTML sanitiser input / nodes / depth | 512 KB / 8,192 / 64 |
Styles CSS body / scope selector | 64 KB / 128 chars |
| Custom-icon markup | 16 KB |
Progress.segments / Text.lines | 200 / 20 (clamped) |
Highest materialised state index ($rows[n] = …) | 1,000,000 |
The failure modes differ, and the difference matters when you are debugging.
Over-long Markdown is truncated, so the beginning still
renders. Clamped props silently take the ceiling. Budget overruns throw
RuntimeBudgetError, which the host surfaces as a banner rather
than a hung tab — see
Performance for tuning the budget and
Error handling for catching it.
Keeping network access under host control
$http({...}) is the vetted network path, and it is the only one
left under a "safe" policy. Every request flows through your
host's interceptor chain, so auth headers, CORS workarounds, and
refresh-token retries stay yours to decide:
app.registerHttpInterceptors({
onRequest: (req) => ({ ...req, headers: { ...req.headers, Authorization: `Bearer ${token}` } }),
onResponse: (res) => res,
onError: (err) => { report(err); throw err; },
});
Use onRequest to enforce a destination allow-list when programs
are semi-trusted — the interceptor sees the final URL and can reject it
before the request leaves the page. See HTTP for the
full contract.
Content-Security-Policy
A CSP hardens the host page. It is useful defence in depth, and the two controls compose neatly here — but only in one direction, so it is worth being precise about which does what.
-
The runtime needs no
'unsafe-eval'. It uses neitherevalnornew Function; action and effect bodies are interpreted from the AST by a tree-walking evaluator. -
A program can still need it — under the
default
"all"policy a program may callevalorFunctionitself.setGlobalAccessPolicy("safe")removes that reachability, which is exactly what makes ascript-srcwithout'unsafe-eval'meaningful. The policy is what earns you the tighter CSP; the CSP does not substitute for the policy. -
Aktion never injects a
<script>element and never assignsinnerHTMLon the live document, soscript-srcneeds no'unsafe-inline'either. -
style-srcdoes need relaxing. The runtime emits inlinestyleattributes and injects<style>elements, so it needs'unsafe-inline'or a nonce/hash strategy.
The complete policy — including the style-src,
font-src, and img-src directives the renderer
actually requires — is in
Production & deployment.
Node-side tooling
On the server, Layer 1 gets sharper
renderToString evaluates the program. In the browser an
untrusted program is same-origin XSS; on your server it is
remote code execution, with the filesystem, the internal
network, and your environment variables in reach. Set the policy in the Node
entry point too, before you render, and run it in a separate low-privilege
process.
import { setGlobalAccessPolicy, renderToStaticMarkup } from "aktion-runtime";
setGlobalAccessPolicy("safe"); // before rendering anything untrusted
const html = renderToStaticMarkup(program);
Two more things to know about the SSR path. The $head allow-lists
apply there as well — in fact the attribute-name validation
exists specifically because the server path emits names outside quotes. And
the hydration snapshot it hands back is your job to escape for
the <script> context you serialise it into; the runtime does
not do that for you.
The build tooling has its own path-confinement story:
-
The Vite plugin confines
.aktionimports to the Vite project root, so a specifier like../../../../etc/passwdis refused rather than read — and, undervite dev, rather than served to the browser. The opt-out isaktion({ allowOutsideRoot: true }); use it only for trusted monorepo layouts. See Modules. -
create-aktionturns the project name into a filesystem path, so it rejects control characters, a leading-or~, absolute paths and drive letters,..segments, and Windows reserved device names. -
linkProgramtakes a caller-supplied module resolver, which means path confinement is your responsibility if you wire your own — the plugin supplies a confined one, but a custom resolver gets no root check for free.
Review checklist
- Can program text reach us from anywhere other than our own repo? If yes →
setGlobalAccessPolicy("safe"), set once at host setup. - Is an LLM's context free of untrusted content? If not, treat prompt injection as a code-execution path.
- Are
$httpdestinations constrained by anonRequestinterceptor? - Does the CSP cover
style-srcandfont-src, not justscript-src? (See deployment.) - Is anything relying on the shadow root for isolation rather than styling?
- Is the Node/SSR process running the same policy as the browser?
Reporting a vulnerability
Report security issues privately — please do not open a public issue
first. The repository's
SECURITY.md
carries the current contact route, the supported-version policy, and the
reporting scope, including which behaviours are considered by-design rather
than vulnerabilities. Behaviour reachable only under the default
"all" policy with attacker-authored program text is in the
by-design category — that is the surface
setGlobalAccessPolicy exists to close.
Next
Production & deployment
The full CSP, SRI pinning, SSR and hydration, and the pre-launch checklist.
Open the guide → AdvancedHTTP
The $http resource contract and the host interceptor chain.
LLM integration
Streaming programs from a model, and where the trust boundary sits.
Open the guide →