Add --disallow-code-generation-from-strings, with a strict level for every way a string becomes code
What does this PR do?
Adds --disallow-code-generation-from-strings, at two levels.
| Flag | What it refuses |
|---|---|
--disallow-code-generation-from-strings | eval() and the Function constructors. Node.js's flag, with Node.js's behaviour. |
--disallow-code-generation-from-strings=strict | Every way a string becomes code in the process. Bun only. |
A refusal is a synchronous, catchable EvalError: Code generation from strings disallowed for this context, which is Node.js's message. import() of a refused URL rejects with it. A value other than strict is a startup error.
The use is a long-running server, usually a bun build --compile executable, that wants a guarantee that no string becomes code after startup: in any Worker, any realm, and any Bun.ModuleGraph.
The level is the process's
It is set at startup and is only ever raised.
- There is no API that lowers it.
- Every
Workerhas it, whateverexecArgvtheWorkeris given. AWorkerwhoseexecArgvcontains the flag throws as in Node.js: anErrorwith the codeERR_WORKER_INVALID_EXEC_ARGVand Node.js's message, whether or not the process has the flag, soexecArgv: process.execArgvthrows in a process that does. The check is inparse_worker_exec_argv_flags, with the other flags a Worker'sexecArgvcan carry. - Every global object Bun makes later has it:
ShadowRealm,bun testisolation, and under=stricteverynode:vmcontext, including one made withcodeGeneration: { strings: true }. - The level a compiled executable is built with (
--compile-exec-argv) is a floor. The argument parser keeps an option's last value and readsBUN_OPTIONSafter the embedded flags, so on its own it would let the environment lower it. Bun raises the level from the embedded flags themselves, read as the parser is given them (afterappend_options_envhas taken quotes and backslashes off).
Given more than once on a command line, the last one counts, as for any option. There is no change to the argument parser: --flag and --flag=strict are what its existing optional value (<STR>?, as --inspect uses) already reads.
What each level refuses
| A string becomes code through | Flag | =strict |
|---|---|---|
eval(), direct and indirect | refused | refused |
Function, and the async, generator and async generator constructors, reached any way | refused | refused |
ShadowRealm.prototype.evaluate(), and eval() inside a ShadowRealm | refused | refused |
vm.Script, runInThisContext, runInContext, runInNewContext, compileFunction, SourceTextModule | allowed | refused |
eval() inside a node:vm context | allowed | refused |
module._compile(), and a require.extensions handler that calls it | allowed | refused |
Assigning a different Module.wrapper | allowed | refused |
import, import() and require() of a data: or blob: URL | allowed | refused |
A Worker from a data: or blob: URL, or with eval: true | allowed | refused |
A plugin that returns contents for the js, jsx, ts or tsx loader | allowed | refused |
inspector.open(), bun:jsc's startRemoteDebugger() | allowed | refused |
napi_run_script() | allowed | refused |
$vm, JavaScriptCore's debugging global (BUN_JSC_useDollarVM=1) | not defined | not defined |
The debugger evaluates what its client sends and turns the engine's eval setting back on to do it, so =strict refuses it. Asking for it with --inspect, --inspect-wait, --inspect-brk or BUN_INSPECT is a startup error. BUN_INSPECT_CONNECT_TO is ignored with a warning and the program runs: Bun's VS Code extension sets it in every integrated terminal by default, so it does not mean that anybody asked to debug this process. Nothing connects.
$vm evaluates strings (evaluateWithScopeExtension, createBuiltin) and makes global objects that are not Bun's, where eval() is on. Release builds are not affected: Bun's WebKit compiles it in only with assertions (BUN_ENABLE_JSDOLLARVM is ASSERT_ENABLED), and on 1.4.0 BUN_JSC_useDollarVM=1 sets the option and defines nothing. In a debug build it ran strings at both levels, so useDollarVM is forced off after the BUN_JSC_* overrides are applied, which makes a debug build behave as a release build does.
node:inspector's in-process Session needs no gate: it does not implement Runtime.evaluate, compileScript, runScript, callFunctionOn or Debugger.evaluateOnCallFrame, and its other Debugger methods need inspector.open(). Nothing else in bun:jsc, bun:internal-for-testing or the other bun: modules runs source text.
Not affected at either level: importing files (literal or computed specifiers, and modules embedded in a compiled executable), new Worker(file), require() of files and builtins, Bun.ModuleGraph, JSON.parse, RegExp, eval() of a non-string, WebAssembly, native addons, vm.createContext(), vm.SyntheticModule, and a plugin that returns an exports object or contents for a data loader.
How it works
bun_core::CodeGenerationFromStringsis one process-wide atomic, raised withfetch_max. C++ reads it throughBun__codeGenerationFromStrings().Zig::GlobalObject::finishCreationcalls JavaScriptCore's existingsetEvalEnabled(false, message)when the level is notAllowed. That coverseval, theFunctionconstructors andShadowRealmfor the main global, Workers,ShadowRealmglobals andbun testisolation globals. No WebKit change.- A
node:vmcontext keeps its owncodeGeneration.stringsunder the bare flag, as in Node.js, and is forced off under=strict. - What Bun compiles from a string itself asks
Bun::mayNotMakeScriptFromStrings().data:andblob:modules are refused where module source is fetched (fetchESMSourceCode,fetchCommonJSModule), soimport,import()andrequire()share one check.
Also in this PR
bun:fficc()built its wrapper for a symbol that returns acstringwithnew Function, so it would have thrown at both levels. The generated function only called through and wrapped the result, which the fixed-arity closures beside it now do.process.execArgvtreated a flag with an optional value (--inspect, this one) as taking the next argument, sobun --disallow-code-generation-from-strings script.jslistedscript.js. An optional value is only ever written--name=value.ERR_WORKER_INVALID_EXEC_ARGVis new to Bun, and only this flag produces it. Whatweb_worker.rsrefuses reaches script through anExceptionCode, which already has entries for Node.js codes (EVENT_RECURSION). This addsWORKER_INVALID_EXEC_ARGV, andWebWorker__createhas an out-parameter that says when it applies.- The flag has no help text, which hides it from
bun --help, like the other long Node.js flags: its name is wider than the 30 columns that names are printed in. It is documented indocs/runtime/code-generation-from-strings.mdx.
What stops working in Bun itself
I searched all of src/js for eval, the Function constructors, node:vm and _compile.
| Part of Bun | Flag | =strict |
|---|---|---|
The development server: import() with import attributes of a module outside the bundle (new Function in hmr-module.ts) | throws | throws |
node:repl: repl.start(), REPLServer | works | throws |
node:worker_threads: eval: true | works | throws |
Every built-in module still loads under =strict. I required each entry of builtinModules and read every own property: repl.REPLServer is the only public one that throws, so import { REPLServer } from "node:repl" is the one import that fails. bun test runs under it, including mock.module() and inline snapshots.
Outside the flag
- A program that writes a file and imports it runs that file.
BUN_OPTIONSis still read by a compiled executable. It cannot lower the level, but--preloadin it loads a file before the entry point.BUN_BE_BUN=1makes a compiled executable run as thebunCLI. The embedded program does not start and the flags embedded with it do not apply. A compile-time option to ignore both variables is a separate change.- A literal
data:import is resolved by the bundler at build time, so in a compiled executable it is part of the program. Adata:URL computed at run time is refused.
How did you verify your code works?
macOS arm64, debug build.
Node.js v25.9.0 with --disallow-code-generation-from-strings, which the bare flag matches:
direct eval REFUSED EvalError: Code generation from strings disallowed for this context
indirect eval REFUSED EvalError: Code generation from strings disallowed for this context
new Function REFUSED EvalError: Code generation from strings disallowed for this context
fn.constructor REFUSED EvalError: Code generation from strings disallowed for this context
AsyncFunction REFUSED EvalError: Code generation from strings disallowed for this context
vm.runInThisContext ALLOWED 2
vm.runInNewContext ALLOWED 2
new vm.Script ALLOWED object
vm.compileFunction ALLOWED 2
eval inside vm new context ALLOWED 2
module._compile ALLOWED 2
import(data:) ALLOWED 2
WebAssembly.compile ALLOWED ok
Worker, no execArgv REFUSED EvalError execArgv=["--disallow-code-generation-from-strings"]
Worker, execArgv: [] REFUSED EvalError execArgv=[]
Worker, execArgv: [the flag] ERR_WORKER_INVALID_EXEC_ARGV
Tests:
test/js/node/disallow-code-generation-from-strings.test.ts(new, 28 tests). One fixture runs every route in the tables above, plus Workers and aBun.ModuleGraphwith aglobalsoverlay (insiderun(), called by the host directly, and afterdispose()), with no flag, the bare flag, and=strict. Also: given more than once, the last counts, in four orders and sources; the flag after the script's name is the script's argument; an unknown value, and each way of asking for the inspector under=strict, is a startup error;BUN_INSPECT_CONNECT_TOis ignored with a warning and nothing connects to a listener that a process without=strictdoes connect to;BUN_JSC_useDollarVM=1does not define$vmwith the flag (and, in a debug build, does without it); a Worker'sexecArgvwith the flag throws, at each level of the process, and the same script run under Node.js gives the same output with no flag and with the bare flag;bun testruns its files at each level in one process, with--isolateand with--parallel(the--parallelcases fail when the flag is not passed on to the processes it starts).- Passes with the debug build. With
USE_SYSTEM_BUN=1(1.4.0), 23 of the 28 fail. The five that pass check things that were already so: the flag after the script's name is the script's argument, a release build has no$vm, andbun testwith no flag in its three modes.
- Passes with the debug build. With
test/bundler/compile-argv.test.ts: three new cases compile an executable with the flag incompile.execArgvand check the refusals andprocess.execArgvon the main thread, in a Worker, and in a Worker givenexecArgv: [], twice withBUN_OPTIONSset to the bare flag, one of those with the embedded flag in quotes. All three also pass the bare flag on the executable's own command line, which is not read as flags, and setBUN_JSC_useDollarVM=1. The first of those fails when the floor is disabled, and the quoted one fails on the commit before its fix.test/napi/napi.test.ts -t napi_run_script(6, two new). The new tests load the real addon from their own fixture, because the sharedmain.jsreads its arguments witheval(). With the bare flag,eval()throws andnapi_run_scriptreturns its result, in Bun and in Node.js alike. With=strict, the same call throws theEvalError.test/cli/bun.test.ts -t help(26) and thebun-run.test.tscases that pass-c=(4) pass.test/js/node/module/node-module-module.test.js(50),test/js/node/no-addons.test.ts(2) andtest/js/node/process/process.test.js -t execArgv(2) pass.
Not verified:
- The two new
bun:ffitests intest/js/bun/ffi/cc.test.tsare skipped on an ASAN build, as the rest of that file'scc()tests are, so they did not run locally. They ran and passed in CI on Linux. The same script, run by hand on macOS, works with no flag, the bare flag and=strict. - The development-server row comes from reading
hmr-module.ts, not from running the dev server. - Linux and Windows: not built or run locally. In CI at 7964abca494, the last commit that changes code, every test shard passed on Debian 13 (x64, aarch64 and x64 with ASAN), Ubuntu 25.04 and Alpine 3.23 (x64 and aarch64 each), Windows (2019 x64 and 11 aarch64) and macOS aarch64. The logs show
disallow-code-generation-from-strings.test.tsrunning in shards that passed on Debian 13 x64, Debian 13 aarch64 and Windows 2019 x64. The two macOS x64 jobs had not finished when this was written. The one commit since then changes only the docs page.