This document is the security mental model for the SoHL system and the standing guardrails every change must respect. It is written for both human and AI developers: if you are adding a feature, reviewing a PR, or letting an agent touch this codebase, read this first.
It is derived from a full security review of the system (see the epics linked in Tracking). The findings themselves live in the issue tracker; this document distills the durable rules and decisions so we do not reintroduce the same classes of bug.
Threat model
SoHL is a FoundryVTT game system. The code runs in players’ and GMs' browsers. The attacker-controlled inputs that matter are:
- Installed content — world save files, modules, and shared compendium packs. Their item/actor/effect fields, action definitions, expression strings, domain-registry entries, and imported JSON are authored by whoever produced the package, and are then rendered or executed on the client of every user who installs the content, including the GM.
- Chat messages and their flags — these propagate to every connected client
and are re-rendered on each. Any client, including a non-GM player, can craft
message content and
data-*attribute values.
The high-consequence outcomes we defend against:
- Arbitrary code execution in a victim’s browser (full Foundry API access with their session and world) via a compiled string or unsafe deserialization.
- Stored/DOM XSS via HTML built from data and rendered into sheets, dialogs, or chat.
- Client-side denial of service via catastrophic-backtracking regexes.
- Cross-actor state corruption via a client acting on documents it does not own.
The load-bearing consequence: untrusted data is the primary attack surface, not untrusted code. A malicious module author already runs code; our job is to ensure that data — a chat flag, an effect field, a serialized payload — can never become code or script on someone else’s machine.
The guardrails at a glance
Six standing rules follow, in priority order. The first — reference code, never compile it from data — is the keystone: it was the top finding of the security review, and holding it is what makes the rest tractable. Each rule links to its full statement below and to where the contract is specified/enforced.
| # | Guardrail | The rule in one line | Specified / enforced in |
|---|---|---|---|
| 1 | Reference code, never compile it | Data carries a reference (__kind, an intrinsic method name, or a Macro UUID) — never source; functions are never serialized | Entity serialization contract · Expressions · Macros and Actions |
| 2 | Safe serialization | defaultFromJSON revives no code and defaultToJSON emits no function; revived __kind data is untrusted constructor input | Entity serialization contract |
| 3 | HTML rendering / XSS | Escaped data binding + an allowlist sanitizer; never interpolate data into template source | Chat-card dispatch contract |
| 4 | Cross-client authorization | Client-side gating is UX; the real boundary is document ownership — at write time and at chat-card dispatch | Chat-card dispatch → Authorization · Actor state sovereignty |
| 5 | No unbounded regex (ReDoS) | Remove quantifier ambiguity; a length cap is not a ReDoS guard | — |
| 6 | Red-flag checklist | The grep-able patterns that block a PR until proven safe | — |
The core principle: reference code, never compile it from data
Never turn data into executable code. No eval, no new Function, no
Function/AsyncFunction constructor, no Handlebars.compile of
data-derived source — anywhere a value could have originated from installed
content or a cross-client message.
Data may only ever carry a reference to code that already exists:
| Reference | For | Encoding | Resolves to |
|---|---|---|---|
| Class kind | Domain objects round-tripped through JSON | __kind tag | A constructor looked up in src/utils/kindRegistry.ts |
| Method | A shipped behavior on a Logic class (intrinsic) | method name | A bound method on the scoped target logic |
| Macro | GM-authored “homebrew” behavior created after ship | Macro UUID | A Foundry Macro, run via Macro#execute() |
Functions themselves are never serialized — not as source, and not as a
reference. A domain object carries data plus its __kind, and any behavior
is re-derived locally on the receiving client from that kind and data.
An attacker can put any value in a reference slot, but the worst they can do is select something the system already ships — they can never introduce new code. This makes the dangerous state unrepresentable rather than merely screened.
Where this is enforced. The JSON round-trip that drops functions on the way
out and revives no code on the way in is the
Entity serialization contract;
the action executor model — an intrinsic method name or a Macro UUID,
never a code body — is SohlAction and
Macros and Actions; the class registry is
src/utils/kindRegistry.ts; and the only string→value path permitted on
untrusted data is the AST-allowlist SafeExpression. There is deliberately
no function-id/__funcref__ registry: nothing that needs to survive a
round-trip is a function, so none is serialized at all.
Why not a sandbox / denylist?
The system previously screened author-supplied script with a regex denylist
(textToFunction) before compiling it with new Function. A denylist over
source text is not a security boundary. During review it was defeated four
independent ways, each verified by execution:
- Unicode identifier escapes —
fetchis the identifierfetch; the literal substring never appears in the source, so a\bfetch\bscan misses it. - Function-constructor by string concatenation —
(()=>{})['con'+'structor']('return this')()reachesglobalThis; the wordconstructornever appears as one literal. - Template-literal interpolation — a comment/string stripper deletes
`${...}`before scanning, but it still executes at runtime. - Comment interposition —
x./**/constructorslips past a\.\s*constructorpattern.
These are not patchable one at a time. The only sound options are an AST
allowlist (parse, then permit a fixed set of nodes/identifiers — this is what
SafeExpression does for predicates and why it is safe) or not
compiling at all (the reference model above). For behavior driven by
untrusted data — installed content and cross-client messages — SoHL chose not
compiling at all: SafeExpression (src/entity/expr/SafeExpression.ts) is the
only string→logic path on that surface, and it is an allowlist, not a denylist.
The one sanctioned exception: the GM Expression Library. textToFunction
(the old denylist screen) still ships, used by the expression-helper registry
(src/entity/expr/ExpressionHelperRegistry.ts) to compile helper bodies loaded
from a GM-chosen JSON file (the Expression Library settings menu). This is a
different trust tier: the GM deliberately selects a local file, exactly as
they would install a module — it is not installed-package or cross-client data,
and the screen is documented as “a sandbox, not a hard security boundary.” So it
is acceptable there and only there. Do not route any untrusted or
cross-client input through textToFunction, and do not add new callers of it —
if you need author-supplied logic on the untrusted surface, use SafeExpression
(sync values) or a Macro (async behavior).
Why not digital signatures?
Signing serialized code was considered and rejected. A symmetric MAC cannot work in a client-only Foundry system: there is no server-side secret, so any client that can verify a signature can also forge one — and the attacker is one of those clients. Asymmetric signing would only help a hypothetical “signed content pack” feature, and even then verifies provenance, not safety. Foundry macros give us provenance for free and at write time (see below), with no key to distribute or leak. Prefer the reference model; reach for signatures only if a future feature genuinely must move novel code across the trust boundary, and then only with asymmetric keys plus an AST allowlist.
GM “homebrew”: use Foundry macros, not a bespoke sandbox
Post-ship, GM-authored behavior runs through Foundry Macro documents,
referenced by UUID and executed with Macro#execute(). This inherits Foundry’s
own, audited permission model:
- Creating or importing a script macro is blocked unless the user holds the
MACRO_SCRIPTpermission (common/documents/macro.mjs). A malicious compendium cannot land auto-running script on a player who lacks it. Macro#execute()re-checkscanUserExecute(ownership andMACRO_SCRIPT) before running, and passes{ speaker, actor, token, ...scope }.
Rules for using macros:
- Only ever execute via
Macro#execute(), never by reading a macro’scommandinto your own compiler. - Only invoke on the client that owns the acting document — keep the target-addressed acknowledge model (a defense runs because the defender’s user clicked, on the defender’s client). A player-authored chat flag must never cause a macro to run on the GM’s client without the GM’s action.
- Understand the trade-off: a macro runs with full page authority. This is authorization, not isolation — the same trust decision as installing a module, but gated and explicit rather than silent. True isolation of shared homebrew (Realm/iframe/worker with a brokered API) is a separate, much larger effort and is out of scope.
Note: intrinsic action executors resolve by method-name lookup on a bound target logic. That is already safe (no compilation, the name is system-authored, and it resolves to an existing method) and is not a
new Functionpath — it does not need to become a macro.
Extension points: which tool for which need
The safe extension mechanism depends on two axes — who authors it (shipped in code vs. GM post-ship) and how it runs (a synchronous value vs. asynchronous imperative behavior):
| Shipped (in code) | GM-authored (post-ship) | |
|---|---|---|
| Synchronous, returns a value | a method / intrinsic | a SafeExpression (AST allowlist) |
| Asynchronous, imperative | a method / intrinsic | a Foundry Macro (Macro#execute) |
Two consequences to internalize:
- A GM who needs a synchronous computed value uses a
SafeExpression, not a macro.SafeExpressionparses to an AST, allowlists nodes, and evaluates synchronously and safely. Macros are asynchronous (Macro#executereturns aPromise), so they cannot return a value to a synchronous caller. - Synchronous imperative GM code is intentionally unsupported. You cannot
let an untrusted author supply synchronous side-effecting code without
compiling it — which is exactly the RCE this model removes. If you hit this,
express the value as a
SafeExpression, or restructure so the work runs asynchronously (a macro) and the synchronous path reads a cached result.
Guardrail: safe serialization
defaultToJSON / defaultFromJSON in src/utils/helpers.ts are the JSON
round-trip for domain objects. Their security contract:
defaultFromJSONnever revives executable code. There is nonew Functionpath and no function-reference path. (The historical__func__:/deserializeFnpath that compiled a string into a function has been removed — do not reintroduce it.)defaultToJSONnever emits a function. Functions are dropped toundefined— no source, and no reference. Behavior is not serialized.- Reconstruction of
__kind-tagged objects goes through the kind registry and should validate/allowlist the tag and the shape — a client can craft the JSON, so treat revived data as untrusted input to the constructor. buildActionScopereads chat-carddata-scope(fully attacker-controlled) and rejects any legacy__func__:marker outright as defense-in-depth.
A serialized object carries data plus its __kind. If the receiving client
needs a behavior, it re-derives it locally from the kind and data (e.g. a small
strategy enum resolved to a shipped function on that side) — never by carrying a
function across the wire, and never by JSON.parse + evaluate.
Guardrail: HTML rendering / XSS
Author-controlled strings (item/actor/effect names and descriptions, domain and calendar names, modifier breakdowns) reach dialogs, sheets, and chat cards.
- Never interpolate data into Handlebars template source. Building a
template string with
`...${item.name}...`and thenHandlebars.compileturns the name into markup — stored XSS, and with prototype access enabled, a template-injection code-execution primitive. Put values in the data context ({{name}}, auto-escaped) or build inputs with thefoundry.applications.fields.*DOM factories. - Do not enable
allowProtoMethodsByDefault/allowProtoPropertiesByDefaultwhen the Handlebars context could carry non-plain objects; they disable the prototype-access guard. - Escape data destined for raw-HTML sinks (
DialogV2/Dialogcontent,innerHTML) withfoundry.utils.escapeHTML, or render through a template.i18n.localize/formatdo not escape. - Do not
{{{triple-mustache}}}untrusted or data-derived HTML. If a value is assembled from data (e.g. a modifier breakdown), escape at the source or use double-mustache. - Prefer an allowlist sanitizer (DOMPurify or
foundry.applications.ux.TextEditor.cleanHTML) over a hand-rolled tag/attribute denylist. Denylist sanitizers miss whitespace/entity-obfuscatedjavascript:,data:URLs,<base>, SVGxlink:href, survivingstyle, and mXSS.
Guardrail: cross-client authorization
- Client-side gating is UX, not authorization. Removing a button at render
time (e.g. gating automated-defense buttons by
actor.isOwner) only changes what a cooperating client shows. A malicious client can call the handler directly. The real authorization boundary is Foundry’s document-ownership check at write time — a client can only update documents it owns. - Chat-card clicks are authorized by the handler document’s ownership. A card
addresses each button to the actor that should handle it; running the action
mutates that actor’s own state, so authorization is document ownership. The
dispatch resolves the handler and honors the click only if
doc.isOwner(resolveAuthorizedChatCardHandler, a GM owns all) — for intrinsic actions as well asSCRIPT— before any dialog,buildActionScoperevival, or logic runs; eachonChatCardButtonalso re-checksthis.isOwnerso a direct call is refused too. The render-timegateAutomatedDefenseButtonsis UX only. See the Chat-card dispatch contract — Authorization. - Actor-state sovereignty. An actor mutates only itself. Cross-actor effects go through a target-addressed chat acknowledge button, resolved on the target’s own client. See Architecture and Extension Points.
Guardrail: no unbounded regex on data (ReDoS)
A regex driven by attacker-influenced input can hang the client tab.
- Length caps are not a ReDoS guard. A short pattern with nested quantifiers still backtracks catastrophically.
- Remove quantifier ambiguity (do not let a character class overlap the separator that starts a repeated group; use atomic/possessive forms).
- For user-influenced patterns, use a non-backtracking matcher, an evaluation timeout, or restricted regex features.
Red-flag checklist (for reviewers and AI agents)
Treat any of these as a blocker until proven safe against the threat model:
eval(,new Function(,Function(,AsyncFunction, or adeserializeFn-style “compile a string” path.textToFunctionhas exactly one sanctioned caller — the GM Expression Library (above); a new caller, or anytextToFunctionreachable from untrusted/cross-client data, is a blocker.Handlebars.compile(on a string built by interpolating data..innerHTML =/insertAdjacentHTML/{{{ }}}/new Handlebars.SafeStringwith a value that could come from data.allowProtoMethodsByDefault/allowProtoPropertiesByDefault.- A hand-rolled HTML sanitizer (tag/attribute denylist).
- A
data-*attribute or chat flag passed todefaultFromJSON/JSON.parse+ evaluate. - A document
update/deletein a socket/hook/chat handler with noisOwner/canUserModify/isGMcheck. - A
RegExpbuilt from, or matched against, attacker-influenced data without a backtracking bound. - A new string→function or string→predicate path (other than
SafeExpression, or the single sanctioned GM Expression Library caller oftextToFunction).
Tracking
- The reference-code remediation is tracked under the “eliminate runtime code
compilation” epic; the XSS and ReDoS hardening under their respective epics.
Browse
gh issue list --label security. - The class registry:
src/utils/kindRegistry.ts. The predicate allowlist: SafeExpression. GM homebrew runs through FoundryMacrodocuments.