Skip to content

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

Draft
kube wants to merge 1 commit into
mainfrom
cf/fe-523-sanitize-user-controlled-keys
Draft

FE-523: Sanitize user-controlled keys in the simulation path#9222
kube wants to merge 1 commit 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 four CodeQL js/remote-property-injection alerts from FE-523 and the wider class they belong to. Every identity string in a net (place, transition, colour, differential-equation, metric and scenario ids, parameter variable names, colour element names) comes from an imported .petrinaut file, whose schema accepts any string. Used as plain-object keys, names like __proto__ and constructor corrupt the records they touch: a __proto__ write with an object value replaces the record's prototype instead of storing the entry, and a read of a missing constructor key returns an inherited function that defeats ?? fallback and if (!entry) guards.

Two concrete failures before this change: artifacts.lambdas["__proto__"] = {...} in the HIR compiler dropped the artifact while the fingerprint check still passed, so the simulation started with a transition whose lambda silently did not exist; a token field named toString defeated the ?? 0 default in the packed-token encoder and produced NaN token bytes.

The fix is layered rather than the single Object.create(null) the ticket suggests, because no single layer covers every path:

  1. Reject at boundaries. findDangerousSdcpnKeys walks a net and reports every identifier on the DANGEROUS_RECORD_KEYS list (Object.prototype member names plus prototype). parseSDCPNFile rejects such files with the offending id named; buildSimulation repeats the check for nets supplied programmatically by embedders. The editor validators (variableNameSchema, colour element and scenario identifier schemas) now reject them at entry; constructor was the one all-lowercase name they admitted.
  2. Contain in prototype-free records. createUserKeyedRecord (Object.create(null)) is used at every site that builds a record keyed by these strings: HIR artifact records, engine frame snapshots, token records, parameter values, scenario accumulators, layout positions, experiment state. Rejection alone misses keys that never pass a schema: scenario code-mode keys come from whatever object the user's code returns.
  3. Guard reads. getOwn (own-property reads) at every lookup on records that crossed structuredClone or JSON, since both revive plain objects and a null prototype does not survive a worker hop. Containment alone does not survive serialization.

Separately, the review found that place visualizer code (place.visualizerCode) ran through new Function with no hardening at all: not strict mode, no shadowed globals, no constructor masking, reachable by opening an imported file and viewing a place. It now gets the same sandbox treatment as scenario code, at module evaluation and at each render. Dynamics, lambdas, kernels and metrics were already fine: they compile through the HIR, whose emitter quotes every embedded key with JSON.stringify, and the __params binding is now a frozen prototype-free copy so a hostile parameter name cannot read Object.prototype members from inside compiled code.

🔗 Related links

  • FE-523
  • CodeQL alerts 18495, 18496, 18497, 18498
  • FE-521 (internal): enforcing the name validators in MutationProvider, which this PR's schema tightening feeds into

🚫 Blocked by

Nothing.

🔍 What does this change?

  • New validation/record-keys.ts in @hashintel/petrinaut-core: DANGEROUS_RECORD_KEYS, isDangerousRecordKey, createUserKeyedRecord, getOwn, findDangerousSdcpnKeys, describeDangerousSdcpnKeys. Exported from the package index.
  • parseSDCPNFile rejects nets whose identifiers collide with Object.prototype member names (versioned and legacy formats); buildSimulation throws the same error for programmatic input.
  • Prototype-free records at every user-keyed build site: compileHirArtifacts sub-records, buildSimulation place/transition states, toSnapshot() in internal-frame.ts, coerceTokenRecord / decodeTokenRecord / readTokenRecord, deriveDefaultParameterValues / mergeParameterValues (the FE-523 alert sites), scenario compiler accumulators, flattenComponentInstances values, monte-carlo latestByMetricId, actual-mode markings, ELK layout positions, and four sites in the petrinaut UI package.
  • Own-property reads (getOwn) for HIR artifact lookups (build-simulation.ts, compiled-model.ts, experiments provider), initial marking values, and token-encoder defaults.
  • instantiate.ts binds __params as a frozen copy with no prototype. Metric evaluators keep their live rebinding contract; their record is prototype-free at construction instead (hir-metric.ts).
  • The two ad-hoc id === "__proto__" throws in createEngineFrameLayout generalise to isDangerousRecordKey.
  • variableNameSchema, colorElementSchema.name and scenarioParameterSchema.identifier reject reserved property names.
  • compile-visualizer.ts runs the compiled module in strict mode with SHADOWED_GLOBALS shadowed and wraps both module evaluation and each render in runSandboxed.
  • Architecture docs: new deep-dive Untrusted names attached to core.simulation.engine, with a reject/contain/guard lanes diagram and a three-entry-paths sequence diagram; the core.simulation.authoring user-code page documents the visualizer sandbox; the core.validation layer page documents record-keys.ts.
  • User guide: parameter variable-name rules stated in petri-net-extensions.md.

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 visualizer and scenario sandboxes are robustness hardening, not isolation: user code still executes in the host realm. The durable fix for hostile code is the HIR (or an isolated process server-side); the sandbox doc comments say the same.
  • Files that already contain a reserved-name identifier now fail to import, with an error naming the identifier. Nets already containing a parameter named constructor keep simulating, since every record on that path now stores it as an own property.
  • CodeQL may not recognise Object.create(null) as a sanitizer for the four existing alerts. If they do not auto-close, they can be dismissed pointing at record-keys.ts: the flagged writes now target prototype-free records and the keys are rejected at both boundaries.

🐾 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: the key list, prototype-free construction, own-property reads (including own __proto__ keys revived by JSON.parse), and the walk across every entity kind and subnets.
  • hir/instantiate.test.ts: a parameter named constructor reads its own value; a missing toString parameter reads undefined; compiled code cannot mutate the caller's record.
  • parameter-values.test.ts: the FE-523 alert sites with hostile names, plus prototype-free results.
  • file-format/parse-sdcpn-file.test.ts: hostile ids and element names rejected in versioned and legacy formats.
  • simulation/engine/build-simulation.test.ts: the simulation boundary throws on a hostile transition id; own-key initial marking iteration.
  • ui/lib/compile-visualizer.test.ts: globals shadowed for module and component bodies, constructor-chain escape blocked at compile and at render, strict mode enforced.
  • optimization.test.ts: updated: a reserved-name scenario parameter is now rejected at the model boundary, before the binding check it previously exercised.

❓ 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 (edit any exported file by hand). The import fails with an error naming the id.
  3. Add a parameter and try constructor as its 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

Not applicable: error paths and internal containment, covered by the tests above.

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.
@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 16, 2026 2:58am
petrinaut Ready Ready Preview Aug 16, 2026 2:58am
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
hashdotdesign-tokens Ignored Ignored Preview Aug 16, 2026 2:58am

@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
* `emit-buffer-js.ts`), so these sources mirror that shape directly.
*/
const lambdaSourceReading = (name: string): string =>
`(f64, u64, u8, placeBases, indices) => __params[${JSON.stringify(name)}]`;
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