Skip to content

Security and the threat model

A surface is data: A2UI messages describing a tree of catalog components, a data model and the actions wired to them. It has no script, no HTML passthrough, no iframe or webview, and no component that downloads code. The expression language is a closed operator set over the data model; the client functions are the catalog’s built-ins plus whatever the host registers by name. Themes are data too: colours as hex, fonts by family (system or host-supplied), recipes as style objects. Extensions and painter overrides are host code compiled into the app; a surface has no way to send one.

Every renderer replays the same fixtures, so what a surface can and cannot do is identical on React, SolidJS, the <exponential-surface> web component, SwiftUI, Compose and gpui. The web renderers share one URL and media filter (the web layer of @exponential_app/eui); the web component’s src and post pass it too, before a transport exists, and its standalone bundle carries it. The Rust core behind the three native painters is fuzzed nightly; the TypeScript reference runs property tests on the same modules.

A surface can A surface cannot
Compose any catalog component with props, slots, styles and conditions Run code of any kind, or load a component that is outside the catalog and every registered extension
Bind props to its data model and to host sources the host registered (and a package listed) Read anything outside its own data model, or follow a source its package did not list
Call the built-in client functions, and host functions by name through the policy gate Call a host function the host did not register, or one the policy denies
Link to and load media from URLs the URL and media policy allow Open javascript:, file: or data: links, or reach private, loopback and link-local hosts unless the host opts them in
Ask for a theme by id or send one as JSON Make a renderer fetch a font or image from somewhere the media policy denies, or crash it with a bad theme (the default stands in)

The host is the boundary. Four things are yours to decide, all plain options of ExponentialHost (see Write a host plugin):

Hook What it decides Default
functions Which registered host functions a surface may call: deny wins, then allow, then ask (your consent hook), then default; a package’s functions list narrows it further. allow for a registered name; built-ins always run
sources Which binding sources a package’s surfaces may follow (sources on the package), matched as scheme:name or a prefix. any registered scheme
urls Allowed schemes, an optional host allowlist, the base for relative URLs, and allowPrivate for the private hosts you opt back in. https http mailto tel; private hosts denied
media The same for every src, plus the header rules that add auth to your own API; mediaStreamUrl for authed video. https http data; private hosts denied; file only when listed

The consent hook is where a person says yes or no to an ask decision. The Exponential app’s card names the tool and shows its arguments; a destructive-sounding call takes the destructive answer. Make yours show the same two things, since a name alone gives too little to decide on.

Every core enforces the same input limits; past one, the input is refused with an issue and no work is done. Media loads are capped before anything decodes.

Limit Value What happens past it
Components per surface 20000 the rest is dropped, one issue
Nesting depth 48 a deeper node is the Unknown placeholder
One server message 4 MiB INVALID_MESSAGE
Template items 10000 the rest is left unrendered
A data pointer 1024 bytes, 64 segments the write is refused
An image 20 MiB, 30 s, 32 Mpx across every frame fails like a 404; the fallback paints
Video / audio without mediaStreamUrl (React, SolidJS, the web component) the same bytes and seconds for the whole download fails like a 404

The Rust core’s recursive passes grow their stack on the heap when a host thread runs low, so an iOS secondary thread never overflows. A panic inside the core stays on the Rust side of the FFI: a call that cannot throw answers a safe empty value, a call that can throw answers UiError.Panic, and the surface stays usable. Deep or wide inputs are the fuzzers’ bread and butter; the nightly job runs every target with address sanitizer.

Found a way for a surface to do something from the A surface cannot column above? Please keep it out of the public issues. Write to security@exponential.at with the message that triggers it (JSON or JSONL) and the renderer and version. You get an answer within three working days, a fix lands as a patch release of every affected package, and the fixture that locks it joins the conformance suite so it can never regress on any platform.

Hardening requests (a limit you would tune, a policy option you miss) go in a regular issue on the repository.