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:
| expression | result |
|---|---|
Bun.version | 1.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, queueMicrotask | all 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:
- Shadow the globals.
createSafeFunctionalready builds the parameter list; addingBun,fetch,Buffer,atob,btoa,console,Response,Request,Headers,WebAssembly,structuredClone,queueMicrotask,crypto,performance,navigatoras parameters bound toundefinedmakes 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. - 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.