Skip to content

cli: implement Node.js hardening flags (--disable-proto, --disallow-code-generation-from-strings, --frozen-intrinsics) - #35766

Open
robobun wants to merge 12 commits into
mainfrom
farm/9be24fe8/node-hardening-flags
Open

cli: implement Node.js hardening flags (--disable-proto, --disallow-code-generation-from-strings, --frozen-intrinsics)#35766
robobun wants to merge 12 commits into
mainfrom
farm/9be24fe8/node-hardening-flags

Conversation

@robobun

@robobun robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

What

Implements the Node.js process-hardening flags that Bun was silently accepting and echoing in process.execArgv without honoring. An operator porting a Node.js lockdown configuration was getting zero protection plus a receipt claiming it was applied.

Reproduction (before this PR)

$ bun --disallow-code-generation-from-strings -e 'console.log(process.execArgv); try{eval("1");console.log("eval WORKS")}catch(e){console.log("blocked")}'
[ "--disallow-code-generation-from-strings", ... ]
eval WORKS

$ bun --frozen-intrinsics -e 'console.log("frozen:", Object.isFrozen(Array.prototype))'
frozen: false

$ bun --disable-proto=throw -e 'try{({}).__proto__;console.log("proto readable")}catch{console.log("throws")}'
proto readable

Node.js honors all of these.

Changes

--disallow-code-generation-from-strings: routes to JSC's setEvalEnabled(false, msg) in Zig::GlobalObject::finishCreation, which gates eval() and the Function/AsyncFunction/GeneratorFunction constructors with an EvalError matching V8's message text. Inherited by worker globals from the parent process's flags. node:vm contexts keep their own codeGeneration option, matching Node.js.

--disable-proto=delete|throw: deletes the Object.prototype.__proto__ accessor, and for =throw re-installs an accessor that throws ERR_PROTO_ACCESS. Applied to the main global, inherited by worker globals, and applied to node:vm contexts (matching Node.js). Invalid modes exit 12. Also fixes EventEmitter to use $getPrototypeOf(this) instead of this.__proto__ so node:events works under --disable-proto=throw.

--frozen-intrinsics: ports Node.js's lib/internal/freeze_intrinsics.js (Apache-2.0, SES/Caja-derived) as internal/freeze_intrinsics, triggered after --require/--import preloads (Node.js documented ordering, so polyfills land first) and before the entry module. Deep-freezes the ECMA-262 intrinsics plus console/timers, with the override-mistake mitigation so assignment on derived objects still works. Extends Node's coverage to JSC-exposed Float16Array, SuppressedError, DisposableStack, AsyncDisposableStack, ShadowRealm, and Iterator. Every builtin write to a now-frozen intrinsic (Error.stackTraceLimit in assert/inspect/pre_execution, Error.prepareStackTrace in util.getCallSites, console.* in trace_events) is guarded so those APIs do not throw under the flag. Bun's console carries _stdout/_stderr as data properties (vs Node's getters), so those are seeded as already-visited to keep stream prototypes unfrozen like Node.js. Emits the same ExperimentalWarning as Node.js.

--secure-heap / --secure-heap-min: recognised and warns that Bun links BoringSSL, which has no secure heap. Previously silent.

All three flags are stored process-wide (matching the existing --no-deprecation/--zero-fill-buffers pattern), so a worker inherits its parent's hardening. A worker that passes these via its own execArgv option from an unhardened parent is not yet honored; that matches the behavior of every sibling flag and fails closed in the security-relevant direction (a hardened parent's workers cannot opt out).

Verification

22 new tests in test/js/node/process/node-hardening-flags.test.ts covering: each flag's primary effect, worker-thread inheritance, node:vm context inheritance for --disable-proto, the override-mistake mitigation under --frozen-intrinsics, --require preload ordering (including a preload that patches Module.runMain), builtin-write guards under --frozen-intrinsics, invalid --disable-proto mode rejection, and no-flag baselines. All fail with the released binary and all pass with this change. test/js/node/events/, test/js/node/assert/, test/js/node/vm/vm.test.ts, and the vendored test-console-with-frozen-intrinsics.js all still pass.

Not in scope

The general "unknown --flag exits 0" behaviour is left as-is: changing it would be a much broader compatibility break, and the specific hardening fail-open this PR addresses is resolved by implementing the flags rather than rejecting them.

…ode-generation-from-strings, --frozen-intrinsics)

These flags were silently accepted and echoed in process.execArgv but had
no effect, so an operator porting a Node.js lockdown configuration would
get zero protection plus a receipt claiming it was applied.

--disallow-code-generation-from-strings: routes to JSC's
  setEvalEnabled(false, msg) in Zig::GlobalObject::finishCreation, which
  gates eval() and the Function/AsyncFunction/GeneratorFunction
  constructors with an EvalError matching V8's text. Applies to worker
  globals. node:vm contexts keep their own codeGeneration option, matching
  Node.js.

--disable-proto=delete|throw: deletes the Object.prototype.__proto__
  accessor, and for =throw re-installs an accessor that throws
  ERR_PROTO_ACCESS. Applied to the main global, worker globals, and
  node:vm contexts (matching Node.js). Invalid modes exit 12. Also fixes
  EventEmitter to use Object.getPrototypeOf(this) instead of
  this.__proto__ so node:events works under --disable-proto=throw.

--frozen-intrinsics: ports Node.js's lib/internal/freeze_intrinsics.js
  (Apache-2.0, SES/Caja-derived) as internal/freeze_intrinsics, triggered
  from internal/process/pre_execution before user code. Deep-freezes the
  ECMA-262 intrinsics plus console/timers, with the override-mistake
  mitigation so assignment on derived objects still works. Bun's console
  carries _stdout/_stderr as data properties (vs Node's getters), so those
  are seeded as already-visited to keep stream prototypes unfrozen like
  Node.js.

--secure-heap / --secure-heap-min: recognised and warns that Bun links
  BoringSSL, which has no secure heap; the call would have failed silently
  otherwise.
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 34 seconds

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 43c46808-cc83-4636-8d42-52e57befe0b3

📥 Commits

Reviewing files that changed from the base of the PR and between df6c7ee and f7726a8.

📒 Files selected for processing (17)
  • src/js/internal/assert/assertion_error.ts
  • src/js/internal/freeze_intrinsics.ts
  • src/js/internal/process/pre_execution.ts
  • src/js/internal/repl/node-errors.js
  • src/js/internal/shared.ts
  • src/js/internal/trace_events.ts
  • src/js/internal/util/inspect.js
  • src/js/node/events.ts
  • src/js/node/util.ts
  • src/jsc/VirtualMachine.rs
  • src/jsc/bindings/ErrorCode.ts
  • src/jsc/bindings/ExposeNodeModuleGlobals.cpp
  • src/jsc/bindings/NodeVM.cpp
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/bindings/ZigGlobalObject.h
  • src/runtime/cli/Arguments.rs
  • test/js/node/process/node-hardening-flags.test.ts

Comment @coderabbitai help to get the list of available commands.

Comment thread src/js/internal/freeze_intrinsics.ts Outdated
Comment thread src/js/internal/freeze_intrinsics.ts
Comment thread src/js/internal/freeze_intrinsics.ts Outdated
Comment thread src/js/internal/freeze_intrinsics.ts Outdated
Comment thread src/runtime/cli/Arguments.rs Outdated
@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:56 AM PT - Jul 25th, 2026

@robobun, your commit a0b93fd is building: #81185

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:05 PM PT - Jul 25th, 2026

@robobun, your commit f7726a8 is building: #81938

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Status: at f7726a8. Fail-before/fail-after verified locally (17/22 fail on released 1.3.14, 22/22 pass on this build).

CI:

Since the original push:

  • --frozen-intrinsics runs after --require/--import preloads via a dedicated Bun__freezeIntrinsics hook (Node.js documented ordering), including when a preload patches Module.runMain
  • Guarded every builtin write to a now-frozen intrinsic: Error.stackTraceLimit (assert, inspect, pre_execution), Error.prepareStackTrace (util.getCallSites), console.* (trace_events instrumentation)
  • Extended freeze coverage to Float16Array, SuppressedError, DisposableStack, AsyncDisposableStack, ShadowRealm, Iterator
  • Freeze traversal uses an array work queue (no Set.prototype.forEach dependency)
  • Various review nits: primordial captures, $getPrototypeOf, exit-code assertion, trailing newline, oxlint, dedupe helper

Regression-checked: test/js/node/events/ (75 pass), test/js/node/assert/ (396 pass), test/js/node/vm/vm.test.ts (212 pass), vendored test-console-with-frozen-intrinsics.js (exit 0).

Comment thread src/runtime/cli/Arguments.rs
Comment thread src/js/internal/freeze_intrinsics.ts
Comment thread test/js/node/process/node-hardening-flags.test.ts Outdated
Comment thread src/js/node/events.ts Outdated
Comment thread src/js/internal/freeze_intrinsics.ts
Comment thread src/js/internal/process/pre_execution.ts Outdated
…y, use $getPrototypeOf, drop trailing newline, tighten exit-code assertion, trim comments, consolidate frozen-intrinsics tests
Comment thread src/js/internal/freeze_intrinsics.ts
Comment thread src/js/internal/freeze_intrinsics.ts
Comment thread src/js/internal/freeze_intrinsics.ts
Comment thread src/js/internal/freeze_intrinsics.ts
…sableStack, ShadowRealm

JSC exposes these (ES Explicit Resource Management and ShadowRealm
proposals); Node.js does not, so they are absent from the port source.
Same coverage gap as Float16Array.
Comment thread src/js/internal/freeze_intrinsics.ts
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/js/internal/freeze_intrinsics.ts
…ceLimit, cover Iterator

- Move the freeze from pre_execution to a dedicated module-eval hook
  (Bun__freezeIntrinsics) called from reload_entry_point after load_preloads,
  so polyfill preloads land on intrinsic prototypes before they are frozen.
  Node.js documents that --require/--import run before the freeze.
- Guard Error.stackTraceLimit writes in assertion_error.ts and util/inspect.js
  with isErrorStackTraceLimitWritable so node:assert throws AssertionError
  (not TypeError) under --frozen-intrinsics. Added the helper to
  internal/shared.
- Add Iterator to the intrinsics list (its %IteratorPrototype%.constructor
  is an accessor, so deepFreeze never reached it).
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/jsc/bindings/ExposeNodeModuleGlobals.cpp Outdated
Comment thread src/js/internal/shared.ts
Comment thread src/js/internal/util/inspect.js Outdated
Comment thread src/js/internal/freeze_intrinsics.ts Outdated
…lper in inspect.js, array-based freeze queue, trim comments
Comment thread src/jsc/VirtualMachine.rs
Comment thread src/jsc/bindings/ExposeNodeModuleGlobals.cpp
Comment thread src/jsc/VirtualMachine.rs
Comment thread src/js/internal/freeze_intrinsics.ts
…sole instrumentation under --frozen-intrinsics
Comment thread src/js/node/util.ts
Comment thread src/js/internal/shared.ts
Comment thread src/js/node/util.ts Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The f7726a8 fix for util.getCallSites() looks right. Beyond the inline nit, this run also checked whether Intl.Locale/Segmenter/DisplayNames/DurationFormat prototypes are missing from the freeze — they're reached transitively via deepFreeze(Intl), so coverage matches Node's list. Given the scope (ZigGlobalObject/NodeVM finishCreation, VM entry-point ordering, a 347-line ported SES freeze) and that these are hardening flags, worth a human sign-off.

Extended reasoning...

All six prior review rounds have been addressed in-PR; the remaining inline comment is a follow-up nit about per-worker execArgv overrides that follows the pre-existing pattern of sibling process-wide flags and fails closed in the security-relevant direction. Deferring rather than approving because the change is large and security-adjacent — it wires new behaviour into Zig::GlobalObject::finishCreation, NodeVMGlobalObject::finishCreation, and three call sites in VirtualMachine::reload_entry_point, and ports Node's SES-derived freeze_intrinsics — which is exactly the kind of surface a maintainer should eyeball once before merge.

Comment thread src/runtime/cli/Arguments.rs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants