ReviewOS

stacks/stx

safe-evaluator: the expression sandbox does not block Bun, fetch or Buffer, so a template expression can run a subprocess

#2078
Closed glennmichael123 opened this 8 days ago ·
8 days ago

safe-evaluator.ts is the module every template expression passes through, and its header says it exists to "reduce security risks from using new Function()". It blocks by denylist, and the denylist is missing the globals that matter most on Bun.

createSafeFunction compiles with Function(...contextKeys, body). The compiled function's scope chain is [params, global], so every global not shadowed by a context key is reachable from an expression. DANGEROUS_PATTERNS and DANGEROUS_IDENTIFIERS (src/safe-evaluator.ts:122 and :152) cover eval, Function, process, require, import, window, document, global, globalThis, constructor, prototype, Reflect, Proxy, Symbol. They do not cover Bun.

import { processExpressions } from '@stacksjs/stx/expressions'

await processExpressions('<p></p>', {}, '/tmp/t.stx')
// => '<p>PWNED\n</p>'

isExpressionSafe() returns true for that. Everything else I probed through safeEvaluate, all allowed:

expressionresult
Bun.version1.4.3
Bun.spawnSync(['/bin/echo','pwned'])runs the subprocess
fetch[Function fetch]
Buffer.from('aGk=','base64').toString()hi
atob('aGk=')hi
console[object console]
Response, WebAssembly, structuredClone, queueMicrotaskall reachable

Bun alone gives Bun.spawnSync (command execution), Bun.file (arbitrary read), Bun.write (arbitrary write). fetch gives SSRF and exfiltration of anything in scope.

Why this looks like an oversight rather than a decision

isForExpressionSafe() — same file, src/safe-evaluator.ts:1059 — blocks Bun, fetch, console and Deno explicitly:

/\bfetch\s*\(/i,
/\bBun\b/i,
/\bDeno\b/i,
/\bconsole\b/i,

So the file already knows these are dangerous. prepareExpression, which guards the path actually used by , :if, :for, :text and the rest, does not get the same list.

One more gap in the same function

Bracket notation with a dynamic key is allowed by default. BRACKET_NOTATION_PATTERN is /[\w)\]]\s*\[\s*['"]/ — it only matches a quoted key, and assertSafeBracketLiteralKeys only inspects quoted keys:

safeEvaluate('user[k]', { user: {}, k: 'constructor' })  // => [Function Object]

A template written as with key from a query parameter therefore reaches Object and whatever hangs off it. The config docblock for allowBracketNotation already warns that "a dynamic key path assembled at runtime (obj[userInput]) cannot be statically proven safe" — but that case isn't the one the flag gates.

Suggested direction

A denylist over Function() can't be completed — the next Bun release adds a global and the hole reopens. Two options that do close it:

  1. Shadow the globals. createSafeFunction already builds the parameter list; adding Bun, fetch, Buffer, atob, btoa, console, Response, Request, Headers, WebAssembly, structuredClone, queueMicrotask, crypto, performance, navigator as parameters bound to undefined makes them unreachable regardless of the regexes, and costs nothing at runtime. This is a small change and would be worth doing even alongside option 2.
  2. Parse instead of pattern-match. Walk the expression with the AST the package already has (src/ast.ts) and allow an explicit node set — member access, calls on allowed receivers, literals, operators — rejecting anything else. That is the only version that stays correct as the runtime grows.

In the meantime, the minimum is to give prepareExpression the list isForExpressionSafe already uses, so the two halves of the file agree.

Found while auditing Stacks and stx for hq.training. Verified against @stacksjs/stx@0.2.534 on Bun 1.4.3.

8 days ago

Related, from the same audit pass: #2079 (context-blind escaping), #2080 (the two unchecked exports in this file), #2081 (negated guards fail open). The minimum fix here — giving prepareExpression the denylist isForExpressionSafe already uses — also closes the safeEvaluateCode half of #2080.

8 days ago

Fixed on main by 6299e3f and 5415c87. The evaluator now shadows Bun and other host capabilities, filters hostile context bindings, rejects escaped blocked identifiers, exposes a data-only Object facade, and guards runtime-computed keys without breaking legitimate row[column] and loop indexing. Regression coverage includes subprocess/network globals and constructor-key access. Verified with the full suite: 16,014 passed, 0 failed; CI run 38897877529 is green. This landed after stx v0.2.544 and will ship in the next patch release.

Sign in to comment on this issue.