Skip to content

FE-523: Sanitize user-controlled keys in the simulation path - #9222

Merged
kube merged 3 commits into
mainfrom
cf/fe-523-sanitize-user-controlled-keys
Aug 26, 2026
Merged

FE-523: Sanitize user-controlled keys in the simulation path#9222
kube merged 3 commits into
mainfrom
cf/fe-523-sanitize-user-controlled-keys

Conversation

@kube

@kube kube commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

🌟 What is the purpose of this PR?

Closes the js/remote-property-injection class from FE-523: identity strings from imported .petrinaut files can no longer corrupt the records they key, and user-authored code paths lose their unguarded new Function gaps.

The problem

Every identity string in a net comes from imported .petrinaut files, whose schema accepts any string: place, transition, colour, metric and scenario ids, parameter variable names, colour element names. Used as a plain-object key:

  • Writing record["__proto__"] = value replaces the record's prototype instead of storing the entry.
  • Reading a missing constructor key returns an inherited function, which defeats ?? fallback and truthiness guards.

Two failures this caused: the HIR compiler dropped a __proto__-keyed lambda artifact while the fingerprint check still passed, and a token field named toString turned the packed-token encoder's ?? 0 default into NaN bytes.

The fix: three layers

A single Object.create(null) at the flagged lines is not enough: a null prototype does not survive structuredClone or JSON revival across worker hops, and scenario code-mode keys never pass a schema. The shared vocabulary lives in validation/record-keys.ts:

  1. Reject reserved identifiers at parseSDCPNFile and buildSimulation. The editor validators now also reject constructor, the one all-lowercase Object.prototype member they admitted.
  2. Contain with createUserKeyedRecord (no prototype) at every site that builds a record from these strings.
  3. Guard reads with getOwn (own properties only) wherever records cross serialization.
flowchart LR
    F[".petrinaut file"] --> P["parseSDCPNFile<br/>rejects reserved names"]
    E["Embedder net"] --> B["buildSimulation<br/>rejects reserved names"]
    P --> B
    B --> C["createUserKeyedRecord<br/>records without a prototype"]
    C --> H["worker hop<br/>structuredClone / JSON"]
    H --> G["getOwn<br/>own-property reads only"]
Loading

Also in this PR

  • Place visualizer code ran through new Function with no hardening at all, reachable by opening an imported file. It now gets the same sandbox treatment as scenario code, at compile and at each render.
  • The HIR pipeline itself was already sound; its __params binding is now a frozen prototype-free copy, so a hostile parameter name cannot read Object.prototype from compiled code.

🔗 Related links

  • FE-523
  • CodeQL alerts 1849518498 (dismissed in May; this makes the dismissals sound)
  • FE-521 (internal): enforcing the name validators in MutationProvider

🚫 Blocked by

Nothing.

🔍 What does this change?

  • New validation/record-keys.ts: DANGEROUS_RECORD_KEYS, createUserKeyedRecord, getOwn, and findDangerousSdcpnKeys (walks a net's identity strings), exported from the package index.
  • Boundary rejection in parseSDCPNFile (versioned and legacy formats) and buildSimulation; the two ad-hoc id === "__proto__" throws in createEngineFrameLayout generalise to the shared list.
  • Prototype-free records at every user-keyed build site: HIR artifact records, frame snapshots, token records, parameter values, scenario accumulators, layout positions, experiment state, and four UI sites.
  • Own-property reads for HIR artifact lookups, initial marking values and token-encoder defaults.
  • variableNameSchema, colour element names and scenario parameter identifiers reject reserved property names.
  • instantiate.ts binds __params as a frozen prototype-free copy; metric evaluators keep their live rebinding on a prototype-free record.
  • compile-visualizer.ts runs in strict mode with SHADOWED_GLOBALS and wraps module evaluation and each render in runSandboxed.
  • Docs: new Untrusted names architecture page under core.simulation.engine; the user-code and validation pages updated; parameter naming rules stated in the user guide.

Pre-Merge Checklist 🚀

🚢 Has this modified a publishable library?

This PR:

  • modifies an npm-publishable library and I have added a changeset file(s)

📜 Does this require a change to the docs?

The changes in this PR:

  • require changes to docs which are made as part of this PR

🕸️ Does this require a change to the Turbo Graph?

The changes in this PR:

  • do not affect the execution graph

⚠️ Known issues

  • The sandboxes are robustness hardening, not isolation; the durable fix for hostile code is the HIR (#9332 covers scenarios).
  • Files that already contain a reserved-name identifier now fail to import, with the identifier named in the error.
  • CodeQL may not recognise Object.create(null) as a sanitizer; if alerts 18495–18498 resurface, dismiss them pointing at record-keys.ts.

🐾 Next steps

  • FE-521 remains open for enforcing the name validators in MutationProvider and surfacing pre-existing invalid names in the Diagnostics tab.

🛡 What tests cover this?

  • validation/record-keys.test.ts (new): the key list, prototype-free construction, own-property reads (including own __proto__ keys revived by JSON.parse), the net walk.
  • hir/instantiate.test.ts (new): a parameter named constructor reads its own value; missing names read undefined; compiled code cannot mutate the caller's record.
  • Hostile-name cases added to parameter-values, parse-sdcpn-file and build-simulation tests; compile-visualizer.test.ts (new) covers shadowed globals, constructor-chain blocking and strict mode.

❓ How to test this?

  1. Checkout the branch and run the demo site (yarn dev in libs/@hashintel/petrinaut).
  2. Import a .petrinaut file with a transition id of constructor: the import fails naming the id.
  3. Try constructor as a parameter variable name: the properties panel rejects it.
  4. Give a place visualizer export default Visualization(() => <div>{String(typeof fetch)}</div>) and view the place: it renders undefined.

📹 Demo

Error paths and internal containment; covered by the tests above.

@vercel

vercel Bot commented Aug 16, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hash Ready Ready Preview Aug 26, 2026 9:06am
petrinaut Ready Ready Preview Aug 26, 2026 9:06am
petrinaut-docs Ready Ready Preview Aug 26, 2026 9:06am
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
hashdotdesign-tokens Ignored Ignored Preview Aug 26, 2026 9:06am

Request Review

@github-actions github-actions Bot added area/infra Relates to version control, CI, CD or IaC (area) area/libs Relates to first-party libraries/crates/packages (area) type/eng > frontend Owned by the @frontend team type/eng > backend Owned by the @backend team labels Aug 16, 2026
@kube kube assigned kube and unassigned CiaranMn Aug 16, 2026
Comment thread libs/@hashintel/petrinaut-core/src/hir/instantiate.test.ts Fixed
@cursor

cursor Bot commented Aug 26, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Touches the simulation trust boundary, untrusted file import, and user-code execution paths; changes are defensive but broad across engine, HIR, and UI.

Overview
Closes the js/remote-property-injection / prototype-pollution class for net identity strings from imported files: ids, parameter variable names, and colour element names can no longer corrupt records they key or leak inherited Object.prototype members into simulation logic.

Reject reserved names (__proto__, constructor, toString, …) at parseSDCPNFile and buildSimulation, with matching Zod checks on parameter variable names, colour element names, and scenario parameter identifiers. Contain user-keyed maps via createUserKeyedRecord (prototype-free) across HIR artifacts, frame snapshots, token/parameter records, scenario compilation, and several UI paths. Guard reads with getOwn where records may be revived from JSON or worker hops.

Compiled programs now receive __params as a frozen, prototype-free copy at HIR instantiation. Place visualizer compilation and render use the same runSandboxed hardening as scenario code (strict mode, shadowed globals, constructor-chain masking).

New validation/record-keys module is exported from core; architecture docs add an Untrusted names page.

Reviewed by Cursor Bugbot for commit 99c96ef. Bugbot is set up for automated code reviews on this repo. Configure here.

kube added 3 commits August 26, 2026 10:58
Reject net identifiers that collide with Object.prototype member names
at the file-import and simulation boundaries, build every record keyed
by user-authored strings without a prototype, guard artifact and marking
reads with own-property lookups, bind compiled-program parameters as
frozen prototype-free copies, and give place visualizer code the same
sandbox hardening as scenario code.
Fixed-literal sources in the instantiation test (CodeQL improper code
sanitization), getOwn accepts an absent record, cloneUserKeyedRecord
replaces three hand-rolled prototype-free copies, one success path in
parseSDCPNFile, and role lines match what the validation folder holds.
@kube
kube requested a review from CiaranMn August 26, 2026 09:23
@kube
kube added this pull request to the merge queue Aug 26, 2026
Merged via the queue into main with commit 7010fca Aug 26, 2026
74 checks passed
@kube
kube deleted the cf/fe-523-sanitize-user-controlled-keys branch August 26, 2026 10:13
@hash-release hash-release Bot mentioned this pull request Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/infra Relates to version control, CI, CD or IaC (area) area/libs Relates to first-party libraries/crates/packages (area) type/eng > backend Owned by the @backend team type/eng > frontend Owned by the @frontend team

Development

Successfully merging this pull request may close these issues.

3 participants