JSONata expression sandbox escape and arbitrary-code-execution boundaries¶
Three critical GitHub Security Advisories published 2026-08-21 describe the same durable pattern in the JSONata JavaScript expression library: a language that is supposed to be a safe, data-only query/transform layer can be escalated to arbitrary host code execution when the evaluator's built-in environment is not hermetic. All three land in CWE-94 (Code Injection) and are fixed in jsonata 2.2.1 (2.x line) and 1.8.8 (1.x line). The reusable operator insight: a "safe" expression/query language is only as safe as the lookup, clone, and function-call primitives its evaluator exposes to the expression. If any of those primitives can reach Object.prototype, Function, or constructor, the data layer is an RCE layer.
Source records:
- prototype-pollution → constructor-escape via
__lookupSetter__('__proto__')in thelookupfunction (missinghasOwnPropertycheck): GHSA-8gq3-vp5j-2grp / CVE-2026-77413; - bypassable
hasOwnPropertycheck inenvironment.lookupallowing$spread($string)to reachprocess.getBuiltinModule: GHSA-2943-5xfg-gq5f / CVE-2026-77414; $cloneoverwrite + destructing JSONata functions/lambdas ($merge.*) +proc.arguments.forEachinstead ofArray.prototype.forEachinapplyProcedure, chained to execution: GHSA-66mm-25pp-rfff / CVE-2026-77415.
Confirm the exact jsonata version, whether the evaluator runs in a server-side context with process available (Node.js), and whether user-controlled expressions reach the evaluator (search/transform UIs, ETL pipelines, workflow engines, query layers, report builders) before testing.
Lab-only, no live host command execution, inert sinks
Use a disposable Node.js host with the affected jsonata version pinned. Patch the final execution sink (process.getBuiltinModule, child_process.execSync, Function) with a denied recorder that captures the expression and rejects before any real command runs. Never point a real JSONata evaluator at an operator-controlled expression in production, and never execute a real shell command to prove the boundary.
Boundary map¶
| Surface | Intended authority | Untrusted input | Bounded positive |
|---|---|---|---|
lookup (functions.js) | data-only member access on the bound context | expression that reaches __proto__ via __lookupSetter__ | __lookupSetter__('__proto__') resolves constructor instead of a data property |
environment.lookup | hermetic built-in resolution | $spread($string) + $constructor | hasOwnProperty check bypassed; $constructor reaches a callable |
evaluateTransformExpression / $clone | non-mutating transform | overridden $clone returning a value verbatim | object mutation via transforms where the evaluator should treat inputs as read-only |
applyProcedure | function application via Array.prototype.forEach | proc.arguments.forEach (instance method) | per-call-site forEach instead of the frozen prototype method |
constructor → Function | no arbitrary code from data expressions | crafted expression returning a Function/execSync | denied recorder captures a process.getBuiltinModule('child_process') invocation attempt |
The finding is the broken binding between "expression" and "evaluator environment", not the JSONata result string. Capture the expression, the primitive that leaked the prototype/constructor, and the denied-sink record separately.
1. lookup prototype-pollution to constructor escape¶
GHSA-8gq3-vp5j-2grp (CVE-2026-77413) is the first record: the lookup function in functions.js performs a property read without a hasOwnProperty guard, so an expression can walk __proto__ and resolve an inherited callable. The published PoC chains __lookupSetter__('__proto__')(constructor) into a getter that returns child_process.execSync output.
Replayable validation:
- Pin
jsonatato the affected version (< 2.2.0on the 2.x line,< 1.8.8on the 1.x line) in an isolated Node.js host with no outbound network. - Patch
child_process.execSync/Function/process.getBuiltinModulewith a recorder that captures its arguments and throws / returns a fixed inert value instead of executing. - Evaluate the published-style expression (the one that builds a getter returning the
execSyncresult) throughjsonata(expr).evaluate({}). - Confirm the recorder observed the target call on the affected build and did not on the patched build.
- Record the exact primitive (
__lookupSetter__,__proto__,constructor) that carried the leak.
A bounded positive is data expression → lookup → __proto__/constructor reachable → denied recorder observes an execution-sink argument, on the affected build only. Report the version, the specific primitive chain, and the recorder capture. Do not claim host command execution without the recorder; a real execSync run is out of scope.
2. environment.lookup hasOwnProperty bypass¶
GHSA-2943-5xfg-gq5f (CVE-2026-77414) is the second record: a hasOwnProperty check in environment.lookup is bypassable, so $spread($string) can reconstruct a callable and reach process.getBuiltinModule. The published PoC builds Function("return process.getBuiltinModule('child_process').execSync('sh',{stdio:'inherit'})")().
Replayable validation:
- Same pinned-host setup as section 1.
- Recorder-patch the
Functionconstructor andprocess.getBuiltinModuleso any call is captured and denied. - Evaluate the published-style expression that sets
$hasOwnProperty := $spread($string)then$constructor("return process.getBuiltinModule(...)")(). - Confirm the recorder fires on the affected build and not on the patched build.
A bounded positive is $spread/$constructor chain → environment.lookup guard bypass → denied recorder observes process.getBuiltinModule/Function. Do not open a real shell; the proof is the recorder.
3. $clone overwrite, lambda destruct, and forEach per-call-site¶
GHSA-66mm-25pp-rfff (CVE-2026-77415) is the third record and the most layered: it combines (a) overwriting $clone so transforms mutate inputs the evaluator treats as read-only, (b) destructing JSONata functions/lambdas ($merge.*), and (c) applyProcedure calling proc.arguments.forEach (the per-object method) rather than Array.prototype.forEach. Chained, these reach arbitrary code.
Replayable validation:
- Pinned affected host; recorder-patch
child_process.execSyncand anyFunction/constructorsink. - Evaluate the published-style expression that defines
$clone := function($o){ $o }, destructs$merge, and drives__lookupGetter__/__lookupSetter__against a bound object. - Confirm the recorder observes the execution sink on the affected build only.
A bounded positive is $clone overwrite + lambda destruct + proc.arguments.forEach → execution-sink argument captured by the recorder. This record is the clearest statement of the pattern: when an evaluator hands its internal function/argument objects back to the expression, the data layer stops being data-only.
Reporting heuristics¶
Lead with the crossed primitive, not the version:
lookup→__proto__/__lookupSetter__→ inheritedconstructorreachable from dataenvironment.lookup→ bypassablehasOwnProperty→process.getBuiltinModule/Function$cloneoverwrite + lambda destruct +proc.arguments.forEach→ internal function/argument objects leak to the expression
Strong reports include the exact jsonata version, the evaluator runtime (Node.js vs browser — process.getBuiltinModule only matters server-side), the user-input path that reaches evaluate(), the specific primitive that leaked the prototype/constructor, the denied-sink capture, and the fixed-build negative control. Note explicitly whether the expression input is attacker-controlled in the target system; a library CVE without a reachable user-input path is a dependency finding, not an operator exploit path.
Notes on skipped adjacent items¶
The same 2026-08-21 scan reviewed the Xinference Llama3 tool-call eval() RCE (published as a separate page), a GeoTools jsonArrayContains unauthenticated SQLi, and a large VulDB-style WordPress/TRENDnet/Joomla/Linux-kernel wave. GeoTools and the product-specific records are tracked to state without publication — they are product/DB-boundary records without a new reusable operator pattern in this window.