Add --disallow-code-generation-from-strings, with a strict level for every way a string becomes code

Merged
DY
1mo ago
+1227-47

What does this PR do?

Adds --disallow-code-generation-from-strings, at two levels.

FlagWhat it refuses
--disallow-code-generation-from-stringseval() and the Function constructors. Node.js's flag, with Node.js's behaviour.
--disallow-code-generation-from-strings=strictEvery 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 Worker has it, whatever execArgv the Worker is given. A Worker whose execArgv contains the flag throws as in Node.js: an Error with the code ERR_WORKER_INVALID_EXEC_ARGV and Node.js's message, whether or not the process has the flag, so execArgv: process.execArgv throws in a process that does. The check is in parse_worker_exec_argv_flags, with the other flags a Worker's execArgv can carry.
  • Every global object Bun makes later has it: ShadowRealm, bun test isolation, and under =strict every node:vm context, including one made with codeGeneration: { 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 reads BUN_OPTIONS after 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 (after append_options_env has 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 throughFlag=strict
eval(), direct and indirectrefusedrefused
Function, and the async, generator and async generator constructors, reached any wayrefusedrefused
ShadowRealm.prototype.evaluate(), and eval() inside a ShadowRealmrefusedrefused
vm.Script, runInThisContext, runInContext, runInNewContext, compileFunction, SourceTextModuleallowedrefused
eval() inside a node:vm contextallowedrefused
module._compile(), and a require.extensions handler that calls itallowedrefused
Assigning a different Module.wrapperallowedrefused
import, import() and require() of a data: or blob: URLallowedrefused
A Worker from a data: or blob: URL, or with eval: trueallowedrefused
A plugin that returns contents for the js, jsx, ts or tsx loaderallowedrefused
inspector.open(), bun:jsc's startRemoteDebugger()allowedrefused
napi_run_script()allowedrefused
$vm, JavaScriptCore's debugging global (BUN_JSC_useDollarVM=1)not definednot 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::CodeGenerationFromStrings is one process-wide atomic, raised with fetch_max. C++ reads it through Bun__codeGenerationFromStrings().
  • Zig::GlobalObject::finishCreation calls JavaScriptCore's existing setEvalEnabled(false, message) when the level is not Allowed. That covers eval, the Function constructors and ShadowRealm for the main global, Workers, ShadowRealm globals and bun test isolation globals. No WebKit change.
  • A node:vm context keeps its own codeGeneration.strings under the bare flag, as in Node.js, and is forced off under =strict.
  • What Bun compiles from a string itself asks Bun::mayNotMakeScriptFromStrings(). data: and blob: modules are refused where module source is fetched (fetchESMSourceCode, fetchCommonJSModule), so import, import() and require() share one check.

Also in this PR

  • bun:ffi cc() built its wrapper for a symbol that returns a cstring with new 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.execArgv treated a flag with an optional value (--inspect, this one) as taking the next argument, so bun --disallow-code-generation-from-strings script.js listed script.js. An optional value is only ever written --name=value.
  • ERR_WORKER_INVALID_EXEC_ARGV is new to Bun, and only this flag produces it. What web_worker.rs refuses reaches script through an ExceptionCode, which already has entries for Node.js codes (EVENT_RECURSION). This adds WORKER_INVALID_EXEC_ARGV, and WebWorker__create has 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 in docs/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 BunFlag=strict
The development server: import() with import attributes of a module outside the bundle (new Function in hmr-module.ts)throwsthrows
node:repl: repl.start(), REPLServerworksthrows
node:worker_threads: eval: trueworksthrows

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_OPTIONS is still read by a compiled executable. It cannot lower the level, but --preload in it loads a file before the entry point.
  • BUN_BE_BUN=1 makes a compiled executable run as the bun CLI. 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. A data: 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 a Bun.ModuleGraph with a globals overlay (inside run(), called by the host directly, and after dispose()), 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_TO is ignored with a warning and nothing connects to a listener that a process without =strict does connect to; BUN_JSC_useDollarVM=1 does not define $vm with the flag (and, in a debug build, does without it); a Worker's execArgv with 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 test runs its files at each level in one process, with --isolate and with --parallel (the --parallel cases 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, and bun test with no flag in its three modes.
  • test/bundler/compile-argv.test.ts: three new cases compile an executable with the flag in compile.execArgv and check the refusals and process.execArgv on the main thread, in a Worker, and in a Worker given execArgv: [], twice with BUN_OPTIONS set 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 set BUN_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 shared main.js reads its arguments with eval(). With the bare flag, eval() throws and napi_run_script returns its result, in Bun and in Node.js alike. With =strict, the same call throws the EvalError.
  • test/cli/bun.test.ts -t help (26) and the bun-run.test.ts cases that pass -c= (4) pass.
  • test/js/node/module/node-module-module.test.js (50), test/js/node/no-addons.test.ts (2) and test/js/node/process/process.test.js -t execArgv (2) pass.

Not verified:

  • The two new bun:ffi tests in test/js/bun/ffi/cc.test.ts are skipped on an ASAN build, as the rest of that file's cc() 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.ts running 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.