From a0b93fd9f0ed9d5775437eb4b511b033c60647bd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:55:04 +0000 Subject: [PATCH 01/12] cli: implement Node.js hardening flags (--disable-proto, --disallow-code-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. --- src/js/internal/freeze_intrinsics.ts | 335 ++++++++++++++++++ src/js/internal/process/pre_execution.ts | 8 + src/js/node/events.ts | 2 +- src/jsc/VirtualMachine.rs | 4 +- src/jsc/bindings/ErrorCode.ts | 1 + src/jsc/bindings/NodeVM.cpp | 2 + src/jsc/bindings/ZigGlobalObject.cpp | 33 ++ src/jsc/bindings/ZigGlobalObject.h | 2 + src/runtime/cli/Arguments.rs | 45 +++ .../node/process/node-hardening-flags.test.ts | 240 +++++++++++++ 10 files changed, 670 insertions(+), 2 deletions(-) create mode 100644 src/js/internal/freeze_intrinsics.ts create mode 100644 test/js/node/process/node-hardening-flags.test.ts diff --git a/src/js/internal/freeze_intrinsics.ts b/src/js/internal/freeze_intrinsics.ts new file mode 100644 index 000000000000..974fdd585b90 --- /dev/null +++ b/src/js/internal/freeze_intrinsics.ts @@ -0,0 +1,335 @@ +// Adapted from SES/Caja - Copyright (C) 2011 Google Inc. +// Copyright (C) 2018 Agoric +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// SPDX-License-Identifier: Apache-2.0 +// +// Port of Node.js lib/internal/freeze_intrinsics.js. Runs from +// internal/process/pre_execution before any user code, so the bare global +// lookups below observe pristine intrinsics. + +const ObjectDefineProperty = Object.defineProperty; +const ObjectFreeze = Object.freeze; +const ObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const ObjectGetOwnPropertyDescriptors = Object.getOwnPropertyDescriptors; +const ObjectGetOwnPropertyNames = Object.getOwnPropertyNames; +const ObjectGetOwnPropertySymbols = Object.getOwnPropertySymbols; +const ObjectGetPrototypeOf = Object.getPrototypeOf; +const ObjectPrototypeHasOwnProperty = Object.prototype.hasOwnProperty; +const ReflectOwnKeys = Reflect.ownKeys; +const SymbolIterator = Symbol.iterator; +const SymbolMatchAll = Symbol.matchAll; +const TypedArray = ObjectGetPrototypeOf(Uint8Array); + +export default function freezeIntrinsics(): void { + const intrinsicPrototypes: unknown[] = [ + // 20 Fundamental Objects + Object.prototype, // 20.1 + Function.prototype, // 20.2 + Boolean.prototype, // 20.3 + Symbol.prototype, // 20.4 + + Error.prototype, // 20.5 + AggregateError.prototype, + EvalError.prototype, + RangeError.prototype, + ReferenceError.prototype, + SyntaxError.prototype, + TypeError.prototype, + URIError.prototype, + + // 21 Numbers and Dates + Number.prototype, // 21.1 + BigInt.prototype, // 21.2 + Date.prototype, // 21.4 + + // 22 Text Processing + String.prototype, // 22.1 + ObjectGetPrototypeOf(String.prototype[SymbolIterator]()), // 22.1.5 StringIteratorPrototype + RegExp.prototype, // 22.2 + ObjectGetPrototypeOf(new RegExp("e")[SymbolMatchAll]("")), // 22.2.7 RegExpStringIteratorPrototype + + // 23 Indexed Collections + Array.prototype, // 23.1 + ObjectGetPrototypeOf(Array.prototype[SymbolIterator]()), // 23.1.5 ArrayIteratorPrototype + TypedArray.prototype, // 23.2 + Int8Array.prototype, + Uint8Array.prototype, + Uint8ClampedArray.prototype, + Int16Array.prototype, + Uint16Array.prototype, + Int32Array.prototype, + Uint32Array.prototype, + Float32Array.prototype, + Float64Array.prototype, + BigInt64Array.prototype, + BigUint64Array.prototype, + + // 24 Keyed Collections + Map.prototype, // 24.1 + ObjectGetPrototypeOf(new Map()[SymbolIterator]()), // 24.1.5 MapIteratorPrototype + Set.prototype, // 24.2 + ObjectGetPrototypeOf(new Set()[SymbolIterator]()), // 24.2.5 SetIteratorPrototype + WeakMap.prototype, // 24.3 + WeakSet.prototype, // 24.4 + + // 25 Structured Data + ArrayBuffer.prototype, // 25.1 + DataView.prototype, // 25.3 + + // 26 Managing Memory + WeakRef.prototype, // 26.1 + FinalizationRegistry.prototype, // 26.2 + + // 27 Control Abstraction Objects + ObjectGetPrototypeOf(ObjectGetPrototypeOf(Array.prototype[SymbolIterator]())), // 27.1.2 IteratorPrototype + ObjectGetPrototypeOf(ObjectGetPrototypeOf(ObjectGetPrototypeOf((async function* () {})()))), // 27.1.3 AsyncIteratorPrototype + Promise.prototype, // 27.2 + + // Other APIs / Web Compatibility + (console as { Console?: { prototype: object } }).Console?.prototype, + ]; + + const intrinsics: unknown[] = [ + // 10.2.4.1 ThrowTypeError + ObjectGetOwnPropertyDescriptor(Function.prototype, "caller")?.get, + + // 19 The Global Object + // 19.2 Function Properties of the Global Object + eval, + isFinite, + isNaN, + parseFloat, + parseInt, + decodeURI, + decodeURIComponent, + encodeURI, + encodeURIComponent, + + // 20 Fundamental Objects + Object, + Function, + Boolean, + Symbol, + Error, + AggregateError, + EvalError, + RangeError, + ReferenceError, + SyntaxError, + TypeError, + URIError, + + // 21 Numbers and Dates + Number, + BigInt, + Math, + Date, + + // 22 Text Processing + String, + ObjectGetPrototypeOf(String.prototype[SymbolIterator]()), + RegExp, + ObjectGetPrototypeOf(new RegExp("e")[SymbolMatchAll]("")), + + // 23 Indexed Collections + Array, + ObjectGetPrototypeOf(Array.prototype[SymbolIterator]()), + TypedArray, + Int8Array, + Uint8Array, + Uint8ClampedArray, + Int16Array, + Uint16Array, + Int32Array, + Uint32Array, + Float32Array, + Float64Array, + BigInt64Array, + BigUint64Array, + + // 24 Keyed Collections + Map, + ObjectGetPrototypeOf(new Map()[SymbolIterator]()), + Set, + ObjectGetPrototypeOf(new Set()[SymbolIterator]()), + WeakMap, + WeakSet, + + // 25 Structured Data + ArrayBuffer, + DataView, + Atomics, + JSON, + + // 26 Managing Memory + WeakRef, + FinalizationRegistry, + + // 27 Control Abstraction Objects + ObjectGetPrototypeOf(ObjectGetPrototypeOf(Array.prototype[SymbolIterator]())), // IteratorPrototype + ObjectGetPrototypeOf(ObjectGetPrototypeOf(ObjectGetPrototypeOf((async function* () {})()))), // AsyncIteratorPrototype + Promise, + ObjectGetPrototypeOf(function* () {}), // GeneratorFunction + ObjectGetPrototypeOf(async function* () {}), // AsyncGeneratorFunction + ObjectGetPrototypeOf(async function () {}), // AsyncFunction + + // 28 Reflection + Reflect, + Proxy, + + // B.2.1 + escape, + unescape, + + // Other APIs / Web Compatibility + clearImmediate, + clearInterval, + clearTimeout, + setImmediate, + setInterval, + setTimeout, + console, + ]; + + if (typeof SharedArrayBuffer !== "undefined") { + intrinsicPrototypes.push(SharedArrayBuffer.prototype); + intrinsics.push(SharedArrayBuffer); + } + if (typeof WebAssembly !== "undefined") { + intrinsicPrototypes.push( + WebAssembly.Module.prototype, + WebAssembly.Instance.prototype, + WebAssembly.Table.prototype, + WebAssembly.Memory.prototype, + WebAssembly.CompileError.prototype, + WebAssembly.LinkError.prototype, + WebAssembly.RuntimeError.prototype, + ); + intrinsics.push(WebAssembly); + } + if (typeof Intl !== "undefined") { + intrinsicPrototypes.push( + Intl.Collator.prototype, + Intl.DateTimeFormat.prototype, + Intl.ListFormat.prototype, + Intl.NumberFormat.prototype, + Intl.PluralRules.prototype, + Intl.RelativeTimeFormat.prototype, + ); + intrinsics.push(Intl); + } + + for (let i = 0; i < intrinsicPrototypes.length; i++) enableDerivedOverrides(intrinsicPrototypes[i]); + + const frozenSet = new WeakSet(); + // Node.js's global `console` exposes `_stdout`/`_stderr` behind getters, so + // its deep-freeze stops at the accessor functions. Bun's are own data + // properties, which would pull the live stream instances (and through them + // every stream prototype) into the freeze set. Seed them as already-visited + // so traversal stops at the stream boundary like Node.js. + const consoleObj = console as { _stdout?: object; _stderr?: object }; + if (consoleObj._stdout) frozenSet.add(consoleObj._stdout); + if (consoleObj._stderr) frozenSet.add(consoleObj._stderr); + for (let i = 0; i < intrinsics.length; i++) deepFreeze(intrinsics[i]); + + // 19.1 Value Properties of the Global Object + ObjectDefineProperty(globalThis, "globalThis", { + __proto__: null, + configurable: false, + writable: false, + value: globalThis, + } as PropertyDescriptor); + + function deepFreeze(root: unknown): void { + const freezingSet = new Set(); + + function enqueue(val: unknown): void { + if (Object(val) !== val) return; + if (frozenSet.has(val as object) || freezingSet.has(val as object)) return; + freezingSet.add(val as object); + } + + function doFreeze(obj: object): void { + ObjectFreeze(obj); + const proto = ObjectGetPrototypeOf(obj); + const descs = ObjectGetOwnPropertyDescriptors(obj); + enqueue(proto); + const keys = ReflectOwnKeys(descs); + for (let i = 0; i < keys.length; i++) { + const desc = descs[keys[i] as string]; + if (ObjectPrototypeHasOwnProperty.$call(desc, "value")) { + enqueue(desc.value); + } else { + enqueue(desc.get); + enqueue(desc.set); + } + } + } + + enqueue(root); + // New values added before forEach() has finished will be visited. + freezingSet.forEach(doFreeze); + freezingSet.forEach(frozenSet.add, frozenSet); + } + + // ES5 specified that simple assignment to a non-existent own property must + // fail if it would override an inherited non-writable data property. Replace + // each configurable own data property on the listed prototypes with an + // accessor that preserves that assignment-to-derived-object behaviour after + // freezing. + function enableDerivedOverride(obj: object, prop: PropertyKey, desc: PropertyDescriptor): void { + if (!ObjectPrototypeHasOwnProperty.$call(desc, "value") || !desc.configurable) return; + const value = desc.value; + + function getter(this: unknown) { + return value; + } + (getter as { value?: unknown }).value = value; + + function setter(this: unknown, newValue: unknown) { + if (obj === this) { + throw new TypeError(`Cannot assign to read only property '${String(prop)}' of object '${obj}'`); + } + if (ObjectPrototypeHasOwnProperty.$call(this, prop)) { + (this as Record)[prop as string] = newValue; + } else { + ObjectDefineProperty(this as object, prop, { + __proto__: null, + value: newValue, + writable: true, + enumerable: true, + configurable: true, + } as PropertyDescriptor); + } + } + + ObjectDefineProperty(obj, prop, { + __proto__: null, + get: getter, + set: setter, + enumerable: desc.enumerable, + configurable: desc.configurable, + } as PropertyDescriptor); + } + + function enableDerivedOverrides(obj: unknown): void { + if (!obj) return; + const descs = ObjectGetOwnPropertyDescriptors(obj); + if (!descs) return; + const names = ObjectGetOwnPropertyNames(obj); + for (let i = 0; i < names.length; i++) enableDerivedOverride(obj as object, names[i], descs[names[i]]); + const syms = ObjectGetOwnPropertySymbols(obj); + for (let i = 0; i < syms.length; i++) enableDerivedOverride(obj as object, syms[i], descs[syms[i] as unknown as string]); + } +} diff --git a/src/js/internal/process/pre_execution.ts b/src/js/internal/process/pre_execution.ts index 682006ae4c0d..a280861ddc34 100644 --- a/src/js/internal/process/pre_execution.ts +++ b/src/js/internal/process/pre_execution.ts @@ -261,6 +261,7 @@ function installExitTracing(): void { let traceEnv = false; let traceEnvJsStack = false; let traceExit = false; + let frozenIntrinsics = false; for (let i = 0; i < execArgv.length; i++) { const arg = execArgv[i]; @@ -292,6 +293,8 @@ function installExitTracing(): void { // keys its native-stack assertions on that string appearing. } else if (arg === "--trace-exit") { traceExit = true; + } else if (arg === "--frozen-intrinsics") { + frozenIntrinsics = true; } } @@ -333,6 +336,11 @@ function installExitTracing(): void { envTracePrintJsStack = traceEnvJsStack; installEnvTracing(); } + // Last: nothing after this may assign to an intrinsic prototype property. + if (frozenIntrinsics) { + process.emitWarning("Frozen intristics is an experimental feature and might change at any time", "ExperimentalWarning"); + require("internal/freeze_intrinsics")(); + } } export default {}; diff --git a/src/js/node/events.ts b/src/js/node/events.ts index cca5fa632b62..102124fd9883 100644 --- a/src/js/node/events.ts +++ b/src/js/node/events.ts @@ -62,7 +62,7 @@ var defaultMaxListeners = 10; // EventEmitter must be a standard function because some old code will do weird tricks like `EventEmitter.$apply(this)`. function EventEmitter(opts) { - if (this._events === undefined || this._events === this.__proto__._events) { + if (this._events === undefined || this._events === Object.getPrototypeOf(this)._events) { this._events = Object.create(null); this._eventsCount = 0; this[kShapeMode] = false; diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 191c40190c98..5ae7810bed42 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -2304,7 +2304,9 @@ impl VirtualMachine { // execArgv. (The JS side re-reads `process.execArgv`, so an explicit // empty execArgv under a traced parent stays a no-op there.) fn is_bootstrap_flag(arg: &[u8]) -> bool { - arg.starts_with(b"--trace-") || arg.starts_with(b"--stack-trace-limit") + arg.starts_with(b"--trace-") + || arg.starts_with(b"--stack-trace-limit") + || arg == b"--frozen-intrinsics" } let needs_pre_execution = bun_core::argv().into_iter().any(is_bootstrap_flag) || self diff --git a/src/jsc/bindings/ErrorCode.ts b/src/jsc/bindings/ErrorCode.ts index b36ab04d45ce..83d844c4b0b3 100644 --- a/src/jsc/bindings/ErrorCode.ts +++ b/src/jsc/bindings/ErrorCode.ts @@ -174,6 +174,7 @@ const errors: ErrorCodeMapping = [ ["ERR_PARSE_ARGS_INVALID_OPTION_VALUE", TypeError], ["ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL", TypeError], ["ERR_PARSE_ARGS_UNKNOWN_OPTION", TypeError], + ["ERR_PROTO_ACCESS", Error], ["ERR_POSTGRES_AUTHENTICATION_FAILED_PBKDF2", Error, "PostgresError"], ["ERR_POSTGRES_CONNECTION_CLOSED", Error, "PostgresError"], ["ERR_POSTGRES_CONNECTION_TIMEOUT", Error, "PostgresError"], diff --git a/src/jsc/bindings/NodeVM.cpp b/src/jsc/bindings/NodeVM.cpp index 4af8172edc49..60f17afe2f7a 100644 --- a/src/jsc/bindings/NodeVM.cpp +++ b/src/jsc/bindings/NodeVM.cpp @@ -1079,6 +1079,8 @@ void NodeVMGlobalObject::finishCreation(JSC::VM& vm) JSC::DeletePropertySlot slot; JSC::JSObject::deleteProperty(this, this, vm.propertyNames->Loader, slot); + Bun::applyNodeDisableProto(this); + vm.ensureTerminationException(); // Share the async context data with the parent global object. diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index a45cf0016dfe..073adb7718d9 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -238,6 +238,34 @@ BUN_DECLARE_HOST_FUNCTION(BUN__HTTP2_assertSettings); JSC_DECLARE_HOST_FUNCTION(jsFunctionMakeAbortError); +extern "C" bool Bun__Node__DisallowCodeGenerationFromStrings; +extern "C" uint8_t Bun__Node__DisableProto; + +JSC_DEFINE_HOST_FUNCTION(functionProtoAccessDisabled, (JSC::JSGlobalObject * globalObject, JSC::CallFrame*)) +{ + auto scope = DECLARE_THROW_SCOPE(JSC::getVM(globalObject)); + return Bun::throwError(globalObject, scope, Bun::ErrorCode::ERR_PROTO_ACCESS, + "Accessing Object.prototype.__proto__ has been disallowed with --disable-proto=throw"_s); +} + +namespace Bun { +void applyNodeDisableProto(JSC::JSGlobalObject* globalObject) +{ + if (!Bun__Node__DisableProto) [[likely]] + return; + auto& vm = JSC::getVM(globalObject); + auto* proto = globalObject->objectPrototype(); + JSC::DeletePropertySlot slot; + JSC::JSObject::deleteProperty(proto, globalObject, vm.propertyNames->underscoreProto, slot); + if (Bun__Node__DisableProto == 2) { + auto* thrower = JSC::JSFunction::create(vm, globalObject, 0, String(), functionProtoAccessDisabled, JSC::ImplementationVisibility::Public); + auto* accessor = JSC::GetterSetter::create(vm, globalObject, thrower, thrower); + proto->putDirectAccessor(globalObject, vm.propertyNames->underscoreProto, accessor, + static_cast(JSC::PropertyAttribute::Accessor) | static_cast(JSC::PropertyAttribute::DontEnum)); + } +} +} + using JSGlobalObject = JSC::JSGlobalObject; using Exception = JSC::Exception; using JSValue = JSC::JSValue; @@ -2832,6 +2860,11 @@ void GlobalObject::finishCreation(VM& vm) addBuiltinGlobals(vm); + if (Bun__Node__DisallowCodeGenerationFromStrings) [[unlikely]] { + setEvalEnabled(false, "Code generation from strings disallowed for this context"_s); + } + Bun::applyNodeDisableProto(this); + ASSERT(classInfo()); } diff --git a/src/jsc/bindings/ZigGlobalObject.h b/src/jsc/bindings/ZigGlobalObject.h index ca759a74da9e..11ed39699a9e 100644 --- a/src/jsc/bindings/ZigGlobalObject.h +++ b/src/jsc/bindings/ZigGlobalObject.h @@ -845,6 +845,8 @@ ALWAYS_INLINE void* vm(JSC::JSGlobalObject* lexicalGlobalObject) return WebCore::clientData(lexicalGlobalObject->vm())->bunVM; } +void applyNodeDisableProto(JSC::JSGlobalObject*); + } #ifndef RENAMED_JSDOM_GLOBAL_OBJECT diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index 9cd13cc7e6d6..070e1068bd33 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -307,6 +307,14 @@ pub(crate) const RUNTIME_PARAMS_: &[ParamType] = &[ parse_param!("--trace-exit"), parse_param!("--expose-internals"), parse_param!("--stack-trace-limit "), + // Node.js hardening flags. Applied at global-object creation time + // (ZigGlobalObject::finishCreation) so every realm — main thread, workers, + // test-isolation globals — sees them. + parse_param!("--disallow-code-generation-from-strings"), + parse_param!("--disable-proto "), + parse_param!("--frozen-intrinsics"), + parse_param!("--secure-heap "), + parse_param!("--secure-heap-min "), ]; pub(crate) const AUTO_OR_RUN_PARAMS: &[ParamType] = &[ @@ -686,6 +694,20 @@ pub(crate) static Bun__Node__ProcessNoDeprecation: core::sync::atomic::AtomicBoo #[unsafe(no_mangle)] pub(crate) static Bun__Node__ProcessThrowDeprecation: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false); +#[unsafe(no_mangle)] +pub(crate) static Bun__Node__DisallowCodeGenerationFromStrings: core::sync::atomic::AtomicBool = + core::sync::atomic::AtomicBool::new(false); + +#[repr(u8)] +#[derive(Copy, Clone, PartialEq, Eq)] +pub(crate) enum DisableProto { + Off, + Delete, + Throw, +} +#[unsafe(no_mangle)] +pub(crate) static Bun__Node__DisableProto: core::sync::atomic::AtomicU8 = + core::sync::atomic::AtomicU8::new(DisableProto::Off as u8); #[repr(u8)] #[derive(Copy, Clone, PartialEq, Eq)] @@ -1289,6 +1311,29 @@ pub fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result DisableProto::Delete, + b"throw" => DisableProto::Throw, + _ => { + bun_core::pretty_errorln!( + "error: invalid mode passed to --disable-proto: \"{}\". Must be one of \"delete\", \"throw\"", + BStr::new(mode), + ); + Global::exit(12); + } + }; + Bun__Node__DisableProto.store(mode as u8, core::sync::atomic::Ordering::Relaxed); + } + if args.option(b"--secure-heap").is_some() || args.option(b"--secure-heap-min").is_some() { + bun_core::warn!( + "--secure-heap is not supported: Bun links against BoringSSL, which does not implement a secure heap\n" + ); + } let use_system_ca = args.flag(b"--use-system-ca"); let use_openssl_ca = args.flag(b"--use-openssl-ca"); let use_bundled_ca = args.flag(b"--use-bundled-ca"); diff --git a/test/js/node/process/node-hardening-flags.test.ts b/test/js/node/process/node-hardening-flags.test.ts new file mode 100644 index 000000000000..ea4507c69b78 --- /dev/null +++ b/test/js/node/process/node-hardening-flags.test.ts @@ -0,0 +1,240 @@ +import { test as test_, expect, describe } from "bun:test"; +import { bunEnv, bunExe } from "harness"; + +const test = test_.concurrent; + +async function run(flags: string[], code: string) { + await using proc = Bun.spawn({ + cmd: [bunExe(), ...flags, "-e", code], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +describe("--disallow-code-generation-from-strings", () => { + test("eval() throws EvalError", async () => { + const { stdout, exitCode } = await run( + ["--disallow-code-generation-from-strings"], + `try { eval("1"); console.log("eval WORKS"); } catch (e) { console.log(e.name + ": " + e.message); }`, + ); + expect(stdout.trim()).toBe("EvalError: Code generation from strings disallowed for this context"); + expect(exitCode).toBe(0); + }); + + test("new Function() throws EvalError", async () => { + const { stdout, exitCode } = await run( + ["--disallow-code-generation-from-strings"], + `try { new Function("return 1")(); console.log("Function WORKS"); } catch (e) { console.log(e.name + ": " + e.message); }`, + ); + expect(stdout.trim()).toBe("EvalError: Code generation from strings disallowed for this context"); + expect(exitCode).toBe(0); + }); + + test("applies to worker threads", async () => { + const { stdout, exitCode } = await run( + ["--disallow-code-generation-from-strings"], + `const { Worker } = require("worker_threads"); + const w = new Worker('try { eval("1"); console.log("worker: eval WORKS"); } catch (e) { console.log("worker: " + e.name); }', { eval: true }); + w.on("exit", () => {});`, + ); + expect(stdout.trim()).toBe("worker: EvalError"); + expect(exitCode).toBe(0); + }); + + test("WebAssembly is not affected", async () => { + const { stdout, exitCode } = await run( + ["--disallow-code-generation-from-strings"], + `WebAssembly.compile(new Uint8Array([0,97,115,109,1,0,0,0])).then(() => console.log("wasm ok"), e => console.log("wasm blocked"));`, + ); + expect(stdout.trim()).toBe("wasm ok"); + expect(exitCode).toBe(0); + }); +}); + +describe("--disable-proto", () => { + test("=delete removes Object.prototype.__proto__", async () => { + const { stdout, exitCode } = await run( + ["--disable-proto=delete"], + `console.log(JSON.stringify({ + hasOwn: Object.prototype.hasOwnProperty("__proto__"), + read: typeof ({}).__proto__, + }))`, + ); + expect(JSON.parse(stdout)).toEqual({ hasOwn: false, read: "undefined" }); + expect(exitCode).toBe(0); + }); + + test("=delete makes __proto__ assignment a plain own property", async () => { + const { stdout, exitCode } = await run( + ["--disable-proto=delete"], + `const o = {}; o.__proto__ = { x: 1 }; console.log(JSON.stringify({ own: Object.hasOwn(o, "__proto__"), x: o.x }));`, + ); + expect(JSON.parse(stdout)).toEqual({ own: true, x: undefined }); + expect(exitCode).toBe(0); + }); + + test("=throw makes __proto__ getter throw ERR_PROTO_ACCESS", async () => { + const { stdout, exitCode } = await run( + ["--disable-proto=throw"], + `try { ({}).__proto__; console.log("readable"); } catch (e) { console.log(e.code + ": " + e.message); }`, + ); + expect(stdout.trim()).toBe( + "ERR_PROTO_ACCESS: Accessing Object.prototype.__proto__ has been disallowed with --disable-proto=throw", + ); + expect(exitCode).toBe(0); + }); + + test("=throw makes __proto__ setter throw ERR_PROTO_ACCESS", async () => { + const { stdout, exitCode } = await run( + ["--disable-proto=throw"], + `try { ({}).__proto__ = null; console.log("set ok"); } catch (e) { console.log(e.code); }`, + ); + expect(stdout.trim()).toBe("ERR_PROTO_ACCESS"); + expect(exitCode).toBe(0); + }); + + test("=throw still has own-property accessor on Object.prototype", async () => { + const { stdout, exitCode } = await run( + ["--disable-proto=throw"], + `const d = Object.getOwnPropertyDescriptor(Object.prototype, "__proto__"); + console.log(JSON.stringify({ + hasOwn: Object.prototype.hasOwnProperty("__proto__"), + enumerable: d.enumerable, + configurable: d.configurable, + hasGet: typeof d.get === "function", + hasSet: typeof d.set === "function", + }));`, + ); + expect(JSON.parse(stdout)).toEqual({ + hasOwn: true, + enumerable: false, + configurable: true, + hasGet: true, + hasSet: true, + }); + expect(exitCode).toBe(0); + }); + + test("=throw applies to worker threads", async () => { + const { stdout, exitCode } = await run( + ["--disable-proto=throw"], + `const { Worker } = require("worker_threads"); + const w = new Worker('try { ({}).__proto__; console.log("worker: readable"); } catch (e) { console.log("worker: " + e.code); }', { eval: true }); + w.on("exit", () => {});`, + ); + expect(stdout.trim()).toBe("worker: ERR_PROTO_ACCESS"); + expect(exitCode).toBe(0); + }); + + test("invalid mode exits non-zero", async () => { + const { stderr, exitCode } = await run(["--disable-proto=bogus"], `console.log("ran")`); + expect(stderr).toContain("invalid mode passed to --disable-proto"); + expect(exitCode).not.toBe(0); + }); + + test("applies to node:vm contexts (throw)", async () => { + const { stdout, exitCode } = await run( + ["--disable-proto=throw"], + `const vm = require("vm"); const ctx = vm.createContext(); + try { vm.runInContext("({}).__proto__", ctx); console.log("readable"); } + catch (e) { console.log(e.code); }`, + ); + expect(stdout.trim()).toBe("ERR_PROTO_ACCESS"); + expect(exitCode).toBe(0); + }); + + test("applies to node:vm contexts (delete)", async () => { + const { stdout, exitCode } = await run( + ["--disable-proto=delete"], + `const vm = require("vm"); const ctx = vm.createContext(); + console.log(vm.runInContext("Object.prototype.hasOwnProperty('__proto__')", ctx));`, + ); + expect(stdout.trim()).toBe("false"); + expect(exitCode).toBe(0); + }); +}); + +describe("--frozen-intrinsics", () => { + test("freezes Array.prototype", async () => { + const { stdout, exitCode } = await run( + ["--frozen-intrinsics"], + `console.log(JSON.stringify({ + arrProto: Object.isFrozen(Array.prototype), + objProto: Object.isFrozen(Object.prototype), + promise: Object.isFrozen(Promise), + math: Object.isFrozen(Math), + }))`, + ); + expect(JSON.parse(stdout)).toEqual({ arrProto: true, objProto: true, promise: true, math: true }); + expect(exitCode).toBe(0); + }); + + test("assigning to an intrinsic prototype throws in strict mode", async () => { + const { stdout, exitCode } = await run( + ["--frozen-intrinsics"], + `"use strict"; try { Array.prototype.push = 1; console.log("mutated"); } catch (e) { console.log(e.name); }`, + ); + expect(stdout.trim()).toBe("TypeError"); + expect(exitCode).toBe(0); + }); + + test("derived-object assignment still works (override mistake mitigation)", async () => { + const { stdout, exitCode } = await run( + ["--frozen-intrinsics"], + `const o = {}; o.toString = () => "overridden"; console.log(o.toString());`, + ); + expect(stdout.trim()).toBe("overridden"); + expect(exitCode).toBe(0); + }); + + test("globalThis itself is not frozen, but its globalThis slot is", async () => { + const { stdout, exitCode } = await run( + ["--frozen-intrinsics"], + `console.log(JSON.stringify({ + frozen: Object.isFrozen(globalThis), + slotConfigurable: Object.getOwnPropertyDescriptor(globalThis, "globalThis").configurable, + }))`, + ); + expect(JSON.parse(stdout)).toEqual({ frozen: false, slotConfigurable: false }); + expect(exitCode).toBe(0); + }); + + test("emits an ExperimentalWarning", async () => { + const { stderr, exitCode } = await run(["--frozen-intrinsics"], `1`); + expect(stderr).toContain("ExperimentalWarning"); + expect(stderr).toContain("experimental feature"); + expect(exitCode).toBe(0); + }); +}); + +describe("--secure-heap", () => { + test("is recognised and warns that BoringSSL lacks a secure heap", async () => { + const { stdout, stderr, exitCode } = await run( + ["--secure-heap=4096"], + `console.log(typeof require("crypto").secureHeapUsed())`, + ); + expect(stderr).toContain("--secure-heap is not supported"); + expect(stderr).toContain("BoringSSL"); + expect(stdout.trim()).toBe("undefined"); + expect(exitCode).toBe(0); + }); +}); + +describe("without flags", () => { + test("eval works", async () => { + const { stdout } = await run([], `console.log(eval("1+1"))`); + expect(stdout.trim()).toBe("2"); + }); + + test("__proto__ is readable", async () => { + const { stdout } = await run([], `console.log(({}).__proto__ === Object.prototype)`); + expect(stdout.trim()).toBe("true"); + }); + + test("Array.prototype is not frozen", async () => { + const { stdout } = await run([], `console.log(Object.isFrozen(Array.prototype))`); + expect(stdout.trim()).toBe("false"); + }); +}); From 52cd3449b99069f854e614419d84da38c5e813fe Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:57:34 +0000 Subject: [PATCH 02/12] [autofix.ci] apply automated fixes --- src/js/internal/freeze_intrinsics.ts | 3 ++- src/js/internal/process/pre_execution.ts | 5 ++++- test/js/node/process/node-hardening-flags.test.ts | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/js/internal/freeze_intrinsics.ts b/src/js/internal/freeze_intrinsics.ts index 974fdd585b90..567d36aea279 100644 --- a/src/js/internal/freeze_intrinsics.ts +++ b/src/js/internal/freeze_intrinsics.ts @@ -330,6 +330,7 @@ export default function freezeIntrinsics(): void { const names = ObjectGetOwnPropertyNames(obj); for (let i = 0; i < names.length; i++) enableDerivedOverride(obj as object, names[i], descs[names[i]]); const syms = ObjectGetOwnPropertySymbols(obj); - for (let i = 0; i < syms.length; i++) enableDerivedOverride(obj as object, syms[i], descs[syms[i] as unknown as string]); + for (let i = 0; i < syms.length; i++) + enableDerivedOverride(obj as object, syms[i], descs[syms[i] as unknown as string]); } } diff --git a/src/js/internal/process/pre_execution.ts b/src/js/internal/process/pre_execution.ts index a280861ddc34..15b8b1f50e33 100644 --- a/src/js/internal/process/pre_execution.ts +++ b/src/js/internal/process/pre_execution.ts @@ -338,7 +338,10 @@ function installExitTracing(): void { } // Last: nothing after this may assign to an intrinsic prototype property. if (frozenIntrinsics) { - process.emitWarning("Frozen intristics is an experimental feature and might change at any time", "ExperimentalWarning"); + process.emitWarning( + "Frozen intristics is an experimental feature and might change at any time", + "ExperimentalWarning", + ); require("internal/freeze_intrinsics")(); } } diff --git a/test/js/node/process/node-hardening-flags.test.ts b/test/js/node/process/node-hardening-flags.test.ts index ea4507c69b78..0f65adeeb141 100644 --- a/test/js/node/process/node-hardening-flags.test.ts +++ b/test/js/node/process/node-hardening-flags.test.ts @@ -1,4 +1,4 @@ -import { test as test_, expect, describe } from "bun:test"; +import { describe, expect, test as test_ } from "bun:test"; import { bunEnv, bunExe } from "harness"; const test = test_.concurrent; From 74cdd97a6c2c3eb1150f80d06d2ffcc25ccbc7f2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:12:59 +0000 Subject: [PATCH 03/12] address review: capture TypeError/String primordials, add Float16Array, use $getPrototypeOf, drop trailing newline, tighten exit-code assertion, trim comments, consolidate frozen-intrinsics tests --- src/js/internal/freeze_intrinsics.ts | 24 ++- src/js/node/events.ts | 2 +- src/runtime/cli/Arguments.rs | 6 +- .../node/process/node-hardening-flags.test.ts | 154 ++++++++++-------- 4 files changed, 95 insertions(+), 91 deletions(-) diff --git a/src/js/internal/freeze_intrinsics.ts b/src/js/internal/freeze_intrinsics.ts index 567d36aea279..a3277f301524 100644 --- a/src/js/internal/freeze_intrinsics.ts +++ b/src/js/internal/freeze_intrinsics.ts @@ -14,10 +14,10 @@ // limitations under the License. // SPDX-License-Identifier: Apache-2.0 // -// Port of Node.js lib/internal/freeze_intrinsics.js. Runs from -// internal/process/pre_execution before any user code, so the bare global -// lookups below observe pristine intrinsics. +// Port of Node.js lib/internal/freeze_intrinsics.js. Runs before user code. +const _String = String; +const _TypeError = TypeError; const ObjectDefineProperty = Object.defineProperty; const ObjectFreeze = Object.freeze; const ObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; @@ -70,6 +70,7 @@ export default function freezeIntrinsics(): void { Uint16Array.prototype, Int32Array.prototype, Uint32Array.prototype, + Float16Array.prototype, Float32Array.prototype, Float64Array.prototype, BigInt64Array.prototype, @@ -153,6 +154,7 @@ export default function freezeIntrinsics(): void { Uint16Array, Int32Array, Uint32Array, + Float16Array, Float32Array, Float64Array, BigInt64Array, @@ -233,11 +235,8 @@ export default function freezeIntrinsics(): void { for (let i = 0; i < intrinsicPrototypes.length; i++) enableDerivedOverrides(intrinsicPrototypes[i]); const frozenSet = new WeakSet(); - // Node.js's global `console` exposes `_stdout`/`_stderr` behind getters, so - // its deep-freeze stops at the accessor functions. Bun's are own data - // properties, which would pull the live stream instances (and through them - // every stream prototype) into the freeze set. Seed them as already-visited - // so traversal stops at the stream boundary like Node.js. + // In Node.js `console._stdout`/`_stderr` are getters; in Bun they are data + // properties, so seed them as visited to keep stream prototypes unfrozen. const consoleObj = console as { _stdout?: object; _stderr?: object }; if (consoleObj._stdout) frozenSet.add(consoleObj._stdout); if (consoleObj._stderr) frozenSet.add(consoleObj._stderr); @@ -283,11 +282,8 @@ export default function freezeIntrinsics(): void { freezingSet.forEach(frozenSet.add, frozenSet); } - // ES5 specified that simple assignment to a non-existent own property must - // fail if it would override an inherited non-writable data property. Replace - // each configurable own data property on the listed prototypes with an - // accessor that preserves that assignment-to-derived-object behaviour after - // freezing. + // Convert data properties to accessors so `derived.prop = x` still defines + // an own property after the inherited slot is frozen (ES5 override mistake). function enableDerivedOverride(obj: object, prop: PropertyKey, desc: PropertyDescriptor): void { if (!ObjectPrototypeHasOwnProperty.$call(desc, "value") || !desc.configurable) return; const value = desc.value; @@ -299,7 +295,7 @@ export default function freezeIntrinsics(): void { function setter(this: unknown, newValue: unknown) { if (obj === this) { - throw new TypeError(`Cannot assign to read only property '${String(prop)}' of object '${obj}'`); + throw new _TypeError(`Cannot assign to read only property '${_String(prop)}' of object '${obj}'`); } if (ObjectPrototypeHasOwnProperty.$call(this, prop)) { (this as Record)[prop as string] = newValue; diff --git a/src/js/node/events.ts b/src/js/node/events.ts index 102124fd9883..465f41c51da5 100644 --- a/src/js/node/events.ts +++ b/src/js/node/events.ts @@ -62,7 +62,7 @@ var defaultMaxListeners = 10; // EventEmitter must be a standard function because some old code will do weird tricks like `EventEmitter.$apply(this)`. function EventEmitter(opts) { - if (this._events === undefined || this._events === Object.getPrototypeOf(this)._events) { + if (this._events === undefined || this._events === $getPrototypeOf(this)._events) { this._events = Object.create(null); this._eventsCount = 0; this[kShapeMode] = false; diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index 070e1068bd33..d87cdcb1620c 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -307,9 +307,7 @@ pub(crate) const RUNTIME_PARAMS_: &[ParamType] = &[ parse_param!("--trace-exit"), parse_param!("--expose-internals"), parse_param!("--stack-trace-limit "), - // Node.js hardening flags. Applied at global-object creation time - // (ZigGlobalObject::finishCreation) so every realm — main thread, workers, - // test-isolation globals — sees them. + // Node.js hardening flags. Applied in ZigGlobalObject::finishCreation. parse_param!("--disallow-code-generation-from-strings"), parse_param!("--disable-proto "), parse_param!("--frozen-intrinsics"), @@ -1331,7 +1329,7 @@ pub fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result { expect(exitCode).toBe(0); }); - test("applies to worker threads", async () => { - const { stdout, exitCode } = await run( - ["--disallow-code-generation-from-strings"], - `const { Worker } = require("worker_threads"); - const w = new Worker('try { eval("1"); console.log("worker: eval WORKS"); } catch (e) { console.log("worker: " + e.name); }', { eval: true }); - w.on("exit", () => {});`, - ); - expect(stdout.trim()).toBe("worker: EvalError"); - expect(exitCode).toBe(0); - }); + test( + "applies to worker threads", + async () => { + const { stdout, exitCode } = await run( + ["--disallow-code-generation-from-strings"], + `const { Worker } = require("worker_threads"); + const w = new Worker('try { eval("1"); console.log("worker: eval WORKS"); } catch (e) { console.log("worker: " + e.name); }', { eval: true }); + w.on("exit", () => {});`, + ); + expect(stdout.trim()).toBe("worker: EvalError"); + expect(exitCode).toBe(0); + }, + SLOW, + ); test("WebAssembly is not affected", async () => { const { stdout, exitCode } = await run( @@ -117,21 +123,25 @@ describe("--disable-proto", () => { expect(exitCode).toBe(0); }); - test("=throw applies to worker threads", async () => { - const { stdout, exitCode } = await run( - ["--disable-proto=throw"], - `const { Worker } = require("worker_threads"); - const w = new Worker('try { ({}).__proto__; console.log("worker: readable"); } catch (e) { console.log("worker: " + e.code); }', { eval: true }); - w.on("exit", () => {});`, - ); - expect(stdout.trim()).toBe("worker: ERR_PROTO_ACCESS"); - expect(exitCode).toBe(0); - }); + test( + "=throw applies to worker threads", + async () => { + const { stdout, exitCode } = await run( + ["--disable-proto=throw"], + `const { Worker } = require("worker_threads"); + const w = new Worker('try { ({}).__proto__; console.log("worker: readable"); } catch (e) { console.log("worker: " + e.code); }', { eval: true }); + w.on("exit", () => {});`, + ); + expect(stdout.trim()).toBe("worker: ERR_PROTO_ACCESS"); + expect(exitCode).toBe(0); + }, + SLOW, + ); - test("invalid mode exits non-zero", async () => { + test("invalid mode exits 12", async () => { const { stderr, exitCode } = await run(["--disable-proto=bogus"], `console.log("ran")`); expect(stderr).toContain("invalid mode passed to --disable-proto"); - expect(exitCode).not.toBe(0); + expect(exitCode).toBe(12); }); test("applies to node:vm contexts (throw)", async () => { @@ -157,56 +167,56 @@ describe("--disable-proto", () => { }); describe("--frozen-intrinsics", () => { - test("freezes Array.prototype", async () => { - const { stdout, exitCode } = await run( - ["--frozen-intrinsics"], - `console.log(JSON.stringify({ - arrProto: Object.isFrozen(Array.prototype), - objProto: Object.isFrozen(Object.prototype), - promise: Object.isFrozen(Promise), - math: Object.isFrozen(Math), - }))`, - ); - expect(JSON.parse(stdout)).toEqual({ arrProto: true, objProto: true, promise: true, math: true }); - expect(exitCode).toBe(0); - }); - - test("assigning to an intrinsic prototype throws in strict mode", async () => { - const { stdout, exitCode } = await run( - ["--frozen-intrinsics"], - `"use strict"; try { Array.prototype.push = 1; console.log("mutated"); } catch (e) { console.log(e.name); }`, - ); - expect(stdout.trim()).toBe("TypeError"); - expect(exitCode).toBe(0); - }); - - test("derived-object assignment still works (override mistake mitigation)", async () => { - const { stdout, exitCode } = await run( - ["--frozen-intrinsics"], - `const o = {}; o.toString = () => "overridden"; console.log(o.toString());`, - ); - expect(stdout.trim()).toBe("overridden"); - expect(exitCode).toBe(0); - }); - - test("globalThis itself is not frozen, but its globalThis slot is", async () => { - const { stdout, exitCode } = await run( - ["--frozen-intrinsics"], - `console.log(JSON.stringify({ - frozen: Object.isFrozen(globalThis), - slotConfigurable: Object.getOwnPropertyDescriptor(globalThis, "globalThis").configurable, - }))`, - ); - expect(JSON.parse(stdout)).toEqual({ frozen: false, slotConfigurable: false }); - expect(exitCode).toBe(0); - }); - - test("emits an ExperimentalWarning", async () => { - const { stderr, exitCode } = await run(["--frozen-intrinsics"], `1`); - expect(stderr).toContain("ExperimentalWarning"); - expect(stderr).toContain("experimental feature"); - expect(exitCode).toBe(0); - }); + test( + "freezes ECMA-262 intrinsics but not globalThis, and emits ExperimentalWarning", + async () => { + const { stdout, stderr, exitCode } = await run( + ["--frozen-intrinsics"], + `console.log(JSON.stringify({ + arrProto: Object.isFrozen(Array.prototype), + objProto: Object.isFrozen(Object.prototype), + promise: Object.isFrozen(Promise), + math: Object.isFrozen(Math), + f16: Object.isFrozen(Float16Array.prototype), + console: Object.isFrozen(console), + globalThis: Object.isFrozen(globalThis), + slotConfigurable: Object.getOwnPropertyDescriptor(globalThis, "globalThis").configurable, + streams: Object.isFrozen(require("stream").Duplex.prototype), + }))`, + ); + expect(JSON.parse(stdout)).toEqual({ + arrProto: true, + objProto: true, + promise: true, + math: true, + f16: true, + console: true, + globalThis: false, + slotConfigurable: false, + streams: false, + }); + expect(stderr).toContain("ExperimentalWarning"); + expect(stderr).toContain("experimental feature"); + expect(exitCode).toBe(0); + }, + SLOW, + ); + + test( + "intrinsic prototype assignment throws but derived-object assignment still works", + async () => { + const { stdout, exitCode } = await run( + ["--frozen-intrinsics"], + `"use strict"; + let proto; try { Array.prototype.push = 1; proto = "mutated"; } catch (e) { proto = e.name; } + const o = {}; o.toString = () => "overridden"; + console.log(JSON.stringify({ proto, derived: o.toString() }));`, + ); + expect(JSON.parse(stdout)).toEqual({ proto: "TypeError", derived: "overridden" }); + expect(exitCode).toBe(0); + }, + SLOW, + ); }); describe("--secure-heap", () => { From d3d177124a69cceac419f78c51add1e9d027cdaf Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:33:28 +0000 Subject: [PATCH 04/12] freeze_intrinsics: destructure console._stdout/_stderr to satisfy oxlint --- src/js/internal/freeze_intrinsics.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/js/internal/freeze_intrinsics.ts b/src/js/internal/freeze_intrinsics.ts index a3277f301524..9b41cd3e2e78 100644 --- a/src/js/internal/freeze_intrinsics.ts +++ b/src/js/internal/freeze_intrinsics.ts @@ -237,9 +237,9 @@ export default function freezeIntrinsics(): void { const frozenSet = new WeakSet(); // In Node.js `console._stdout`/`_stderr` are getters; in Bun they are data // properties, so seed them as visited to keep stream prototypes unfrozen. - const consoleObj = console as { _stdout?: object; _stderr?: object }; - if (consoleObj._stdout) frozenSet.add(consoleObj._stdout); - if (consoleObj._stderr) frozenSet.add(consoleObj._stderr); + const { _stdout, _stderr } = console as { _stdout?: object; _stderr?: object }; + if (_stdout) frozenSet.add(_stdout); + if (_stderr) frozenSet.add(_stderr); for (let i = 0; i < intrinsics.length; i++) deepFreeze(intrinsics[i]); // 19.1 Value Properties of the Global Object From bee749e0f30f4675842b9f5fbc8ea36618c82994 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:50:50 +0000 Subject: [PATCH 05/12] freeze_intrinsics: cover SuppressedError, DisposableStack, AsyncDisposableStack, 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. --- src/js/internal/freeze_intrinsics.ts | 10 ++++++++++ test/js/node/process/node-hardening-flags.test.ts | 6 ++++++ 2 files changed, 16 insertions(+) diff --git a/src/js/internal/freeze_intrinsics.ts b/src/js/internal/freeze_intrinsics.ts index 9b41cd3e2e78..8c1f0fa26ec4 100644 --- a/src/js/internal/freeze_intrinsics.ts +++ b/src/js/internal/freeze_intrinsics.ts @@ -44,6 +44,7 @@ export default function freezeIntrinsics(): void { EvalError.prototype, RangeError.prototype, ReferenceError.prototype, + SuppressedError.prototype, SyntaxError.prototype, TypeError.prototype, URIError.prototype, @@ -96,6 +97,11 @@ export default function freezeIntrinsics(): void { ObjectGetPrototypeOf(ObjectGetPrototypeOf(Array.prototype[SymbolIterator]())), // 27.1.2 IteratorPrototype ObjectGetPrototypeOf(ObjectGetPrototypeOf(ObjectGetPrototypeOf((async function* () {})()))), // 27.1.3 AsyncIteratorPrototype Promise.prototype, // 27.2 + DisposableStack.prototype, + AsyncDisposableStack.prototype, + + // 28 Reflection + ShadowRealm.prototype, // Other APIs / Web Compatibility (console as { Console?: { prototype: object } }).Console?.prototype, @@ -127,6 +133,7 @@ export default function freezeIntrinsics(): void { EvalError, RangeError, ReferenceError, + SuppressedError, SyntaxError, TypeError, URIError, @@ -185,10 +192,13 @@ export default function freezeIntrinsics(): void { ObjectGetPrototypeOf(function* () {}), // GeneratorFunction ObjectGetPrototypeOf(async function* () {}), // AsyncGeneratorFunction ObjectGetPrototypeOf(async function () {}), // AsyncFunction + DisposableStack, + AsyncDisposableStack, // 28 Reflection Reflect, Proxy, + ShadowRealm, // B.2.1 escape, diff --git a/test/js/node/process/node-hardening-flags.test.ts b/test/js/node/process/node-hardening-flags.test.ts index af3226866395..f67211f095ce 100644 --- a/test/js/node/process/node-hardening-flags.test.ts +++ b/test/js/node/process/node-hardening-flags.test.ts @@ -178,6 +178,9 @@ describe("--frozen-intrinsics", () => { promise: Object.isFrozen(Promise), math: Object.isFrozen(Math), f16: Object.isFrozen(Float16Array.prototype), + suppressedError: Object.isFrozen(SuppressedError.prototype), + disposableStack: Object.isFrozen(DisposableStack.prototype), + asyncDisposableStack: Object.isFrozen(AsyncDisposableStack.prototype), console: Object.isFrozen(console), globalThis: Object.isFrozen(globalThis), slotConfigurable: Object.getOwnPropertyDescriptor(globalThis, "globalThis").configurable, @@ -190,6 +193,9 @@ describe("--frozen-intrinsics", () => { promise: true, math: true, f16: true, + suppressedError: true, + disposableStack: true, + asyncDisposableStack: true, console: true, globalThis: false, slotConfigurable: false, From cfc581597345da9b1d8c479d74e2466156ead3e4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:56:03 +0000 Subject: [PATCH 06/12] freeze_intrinsics: run after --require/--import, guard Error.stackTraceLimit, 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). --- src/js/internal/assert/assertion_error.ts | 7 +-- src/js/internal/freeze_intrinsics.ts | 10 ++++- src/js/internal/process/pre_execution.ts | 11 ----- src/js/internal/shared.ts | 7 +++ src/js/internal/util/inspect.js | 10 +++-- src/jsc/VirtualMachine.rs | 23 ++++++++-- src/jsc/bindings/ExposeNodeModuleGlobals.cpp | 16 +++++++ src/runtime/cli/Arguments.rs | 6 +++ .../node/process/node-hardening-flags.test.ts | 44 ++++++++++++++++++- 9 files changed, 111 insertions(+), 23 deletions(-) diff --git a/src/js/internal/assert/assertion_error.ts b/src/js/internal/assert/assertion_error.ts index 1890b9320888..89a6fd82288d 100644 --- a/src/js/internal/assert/assertion_error.ts +++ b/src/js/internal/assert/assertion_error.ts @@ -3,6 +3,7 @@ const { inspect } = require("internal/util/inspect"); const colors = require("internal/util/colors"); const { validateObject } = require("internal/validators"); +const { isErrorStackTraceLimitWritable } = require("internal/shared"); const { myersDiff, printMyersDiff, printSimpleMyersDiff } = require("internal/assert/myers_diff") as typeof Internal; const ErrorCaptureStackTrace = Error.captureStackTrace; @@ -274,9 +275,9 @@ class AssertionError extends Error { } = options; let { actual, expected } = options; - // NOTE: stack trace is always writable. + const stackTraceLimitWritable = isErrorStackTraceLimitWritable(); const limit = Error.stackTraceLimit; - Error.stackTraceLimit = 0; + if (stackTraceLimitWritable) Error.stackTraceLimit = 0; if (message != null) { if (operator === "deepStrictEqual" || operator === "strictEqual") { @@ -370,7 +371,7 @@ class AssertionError extends Error { } } - Error.stackTraceLimit = limit; + if (stackTraceLimitWritable) Error.stackTraceLimit = limit; this.generatedMessage = !message; ObjectDefineProperty(this, "name", { diff --git a/src/js/internal/freeze_intrinsics.ts b/src/js/internal/freeze_intrinsics.ts index 8c1f0fa26ec4..2271779b1fa1 100644 --- a/src/js/internal/freeze_intrinsics.ts +++ b/src/js/internal/freeze_intrinsics.ts @@ -31,7 +31,12 @@ const SymbolIterator = Symbol.iterator; const SymbolMatchAll = Symbol.matchAll; const TypedArray = ObjectGetPrototypeOf(Uint8Array); -export default function freezeIntrinsics(): void { +process.emitWarning( + "Frozen intristics is an experimental feature and might change at any time", + "ExperimentalWarning", +); + +{ const intrinsicPrototypes: unknown[] = [ // 20 Fundamental Objects Object.prototype, // 20.1 @@ -186,6 +191,7 @@ export default function freezeIntrinsics(): void { FinalizationRegistry, // 27 Control Abstraction Objects + Iterator, ObjectGetPrototypeOf(ObjectGetPrototypeOf(Array.prototype[SymbolIterator]())), // IteratorPrototype ObjectGetPrototypeOf(ObjectGetPrototypeOf(ObjectGetPrototypeOf((async function* () {})()))), // AsyncIteratorPrototype Promise, @@ -340,3 +346,5 @@ export default function freezeIntrinsics(): void { enableDerivedOverride(obj as object, syms[i], descs[syms[i] as unknown as string]); } } + +export default {}; diff --git a/src/js/internal/process/pre_execution.ts b/src/js/internal/process/pre_execution.ts index 15b8b1f50e33..682006ae4c0d 100644 --- a/src/js/internal/process/pre_execution.ts +++ b/src/js/internal/process/pre_execution.ts @@ -261,7 +261,6 @@ function installExitTracing(): void { let traceEnv = false; let traceEnvJsStack = false; let traceExit = false; - let frozenIntrinsics = false; for (let i = 0; i < execArgv.length; i++) { const arg = execArgv[i]; @@ -293,8 +292,6 @@ function installExitTracing(): void { // keys its native-stack assertions on that string appearing. } else if (arg === "--trace-exit") { traceExit = true; - } else if (arg === "--frozen-intrinsics") { - frozenIntrinsics = true; } } @@ -336,14 +333,6 @@ function installExitTracing(): void { envTracePrintJsStack = traceEnvJsStack; installEnvTracing(); } - // Last: nothing after this may assign to an intrinsic prototype property. - if (frozenIntrinsics) { - process.emitWarning( - "Frozen intristics is an experimental feature and might change at any time", - "ExperimentalWarning", - ); - require("internal/freeze_intrinsics")(); - } } export default {}; diff --git a/src/js/internal/shared.ts b/src/js/internal/shared.ts index 605fc2974f23..30569cbd0562 100644 --- a/src/js/internal/shared.ts +++ b/src/js/internal/shared.ts @@ -147,6 +147,12 @@ function once(callback, { preserveReturnValue = false } = kEmptyObject) { const kEmptyObject = ObjectFreeze(Object.create(null)); +function isErrorStackTraceLimitWritable(): boolean { + const desc = Object.getOwnPropertyDescriptor(Error, "stackTraceLimit"); + if (desc === undefined) return Object.isExtensible(Error); + return Object.prototype.hasOwnProperty.$call(desc, "writable") ? desc.writable! : desc.set !== undefined; +} + // Marks an addEventListener() options object so that dispatch still invokes the // listener after an unrelated listener called event.stopImmediatePropagation(). // `$kResistStopPropagation` is a private symbol the native EventTarget reads, so @@ -341,6 +347,7 @@ export default { once, getLazy, resistStopPropagation, + isErrorStackTraceLimitWritable, hasObserver, startPerf, diff --git a/src/js/internal/util/inspect.js b/src/js/internal/util/inspect.js index 09d55ecafcbe..56f1e09356e4 100644 --- a/src/js/internal/util/inspect.js +++ b/src/js/internal/util/inspect.js @@ -290,10 +290,12 @@ const codes = {}; // exported from errors.js return msg; }); codes[sym] = function NodeError(...args) { + const stlDesc = Object.getOwnPropertyDescriptor(Error, "stackTraceLimit"); + const stlWritable = stlDesc ? (stlDesc.writable ?? stlDesc.set !== undefined) : Object.isExtensible(Error); const limit = Error.stackTraceLimit; - Error.stackTraceLimit = 0; + if (stlWritable) Error.stackTraceLimit = 0; const error = new TypeError(); - Error.stackTraceLimit = limit; // Reset the limit and setting the name property. + if (stlWritable) Error.stackTraceLimit = limit; // Reset the limit and setting the name property. const msg = messages.get(sym); assert(typeof msg === "function"); @@ -315,9 +317,9 @@ const codes = {}; // exported from errors.js // addCodeToName + captureLargerStackTrace let err = error; const userStackTraceLimit = Error.stackTraceLimit; - Error.stackTraceLimit = Infinity; + if (stlWritable) Error.stackTraceLimit = Infinity; ErrorCaptureStackTrace(err); - Error.stackTraceLimit = userStackTraceLimit; // Reset the limit + if (stlWritable) Error.stackTraceLimit = userStackTraceLimit; // Reset the limit err.name = `${TypeError.name} [${sym}]`; // Add the error code to the name to include it in the stack trace. void err.stack; // Access the stack to generate the error message including the error code from the name. delete err.name; // Reset the name to the actual name. diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 5ae7810bed42..9891d537aec2 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -2274,6 +2274,20 @@ impl VirtualMachine { } } + /// Runs `internal/freeze_intrinsics` if `--frozen-intrinsics` was passed. + /// Called after `load_preloads` so polyfill preloads land before the freeze + /// (Node.js documents that `--require`/`--import` run first). The module + /// registry caches the evaluation, so repeat calls are no-ops. + fn maybe_freeze_intrinsics(&self) { + unsafe extern "C" { + static Bun__Node__FrozenIntrinsics: core::sync::atomic::AtomicBool; + } + // SAFETY: `#[no_mangle]` static defined in `bun_runtime::cli::Arguments`. + if unsafe { Bun__Node__FrozenIntrinsics.load(core::sync::atomic::Ordering::Relaxed) } { + crate::cpp::Bun__freezeIntrinsics(self.global()); + } + } + /// `reloadEntryPoint(entry_path)` — set `main`, generate the synthetic /// `bun:main` entry, run preloads, and kick off module evaluation. pub fn reload_entry_point( @@ -2304,9 +2318,7 @@ impl VirtualMachine { // execArgv. (The JS side re-reads `process.execArgv`, so an explicit // empty execArgv under a traced parent stays a no-op there.) fn is_bootstrap_flag(arg: &[u8]) -> bool { - arg.starts_with(b"--trace-") - || arg.starts_with(b"--stack-trace-limit") - || arg == b"--frozen-intrinsics" + arg.starts_with(b"--trace-") || arg.starts_with(b"--stack-trace-limit") } let needs_pre_execution = bun_core::argv().into_iter().any(is_bootstrap_flag) || self @@ -2375,6 +2387,8 @@ impl VirtualMachine { } } + self.maybe_freeze_intrinsics(); + // Note: reshaped for borrowck — capture raw ptr before &self call. let global = self.global; let global_ref = self.global(); @@ -2399,6 +2413,7 @@ impl VirtualMachine { JSValue::from_cell(promise).ensure_still_alive(); Ok(promise) } else { + self.maybe_freeze_intrinsics(); let global = self.global; let main_str = bun_core::String::from_bytes(self.main()); let promise = @@ -4531,6 +4546,8 @@ impl VirtualMachine { } } + self.maybe_freeze_intrinsics(); + // Note: reshaped for borrowck. let global = self.global; let main_str = bun_core::String::from_bytes(self.main()); diff --git a/src/jsc/bindings/ExposeNodeModuleGlobals.cpp b/src/jsc/bindings/ExposeNodeModuleGlobals.cpp index 44905bd58102..5aa6c69f6a03 100644 --- a/src/jsc/bindings/ExposeNodeModuleGlobals.cpp +++ b/src/jsc/bindings/ExposeNodeModuleGlobals.cpp @@ -108,6 +108,22 @@ extern "C" [[ZIG_EXPORT(nothrow)]] void Bun__preExecutionBootstrap(Zig::GlobalOb } } +// Evaluate `internal/freeze_intrinsics`. Called from +// VirtualMachine::reload_entry_point after --require/--import preloads have +// finished (Node.js documents that polyfill preloads run before the freeze). +// The registry caches the module, so repeat calls (hot reload, workers) are +// no-ops after the first. +extern "C" [[ZIG_EXPORT(nothrow)]] void Bun__freezeIntrinsics(Zig::GlobalObject* globalObject) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + globalObject->internalModuleRegistry()->requireId(globalObject, vm, Bun::InternalModuleRegistry::InternalFreezeIntrinsics); + if (auto* exception = scope.exception()) [[unlikely]] { + CLEAR_IF_EXCEPTION(scope); + Bun__reportError(globalObject, JSC::JSValue::encode(exception)); + } +} + // Set up require(), module, __filename, __dirname on globalThis for the REPL. // Creates a CommonJS module object rooted at the given directory so require() resolves correctly. extern "C" [[ZIG_EXPORT(check_slow)]] void Bun__REPL__setupGlobalRequire( diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index d87cdcb1620c..7fd6f9925316 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -695,6 +695,9 @@ pub(crate) static Bun__Node__ProcessThrowDeprecation: core::sync::atomic::Atomic #[unsafe(no_mangle)] pub(crate) static Bun__Node__DisallowCodeGenerationFromStrings: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false); +#[unsafe(no_mangle)] +pub static Bun__Node__FrozenIntrinsics: core::sync::atomic::AtomicBool = + core::sync::atomic::AtomicBool::new(false); #[repr(u8)] #[derive(Copy, Clone, PartialEq, Eq)] @@ -1313,6 +1316,9 @@ pub fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result DisableProto::Delete, diff --git a/test/js/node/process/node-hardening-flags.test.ts b/test/js/node/process/node-hardening-flags.test.ts index f67211f095ce..cdee016329dc 100644 --- a/test/js/node/process/node-hardening-flags.test.ts +++ b/test/js/node/process/node-hardening-flags.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test as test_ } from "bun:test"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, tempDir } from "harness"; const test = test_.concurrent; // --frozen-intrinsics and worker spawns are ~3s each under debug+ASAN. @@ -181,6 +181,7 @@ describe("--frozen-intrinsics", () => { suppressedError: Object.isFrozen(SuppressedError.prototype), disposableStack: Object.isFrozen(DisposableStack.prototype), asyncDisposableStack: Object.isFrozen(AsyncDisposableStack.prototype), + iterator: Object.isFrozen(Iterator), console: Object.isFrozen(console), globalThis: Object.isFrozen(globalThis), slotConfigurable: Object.getOwnPropertyDescriptor(globalThis, "globalThis").configurable, @@ -196,6 +197,7 @@ describe("--frozen-intrinsics", () => { suppressedError: true, disposableStack: true, asyncDisposableStack: true, + iterator: true, console: true, globalThis: false, slotConfigurable: false, @@ -223,6 +225,46 @@ describe("--frozen-intrinsics", () => { }, SLOW, ); + + test( + "node:assert still throws AssertionError (Error.stackTraceLimit is guarded)", + async () => { + const { stdout, exitCode } = await run( + ["--frozen-intrinsics"], + `try { require("assert").strictEqual(1, 2); console.log("no throw"); } + catch (e) { console.log(e.code); }`, + ); + expect(stdout.trim()).toBe("ERR_ASSERTION"); + expect(exitCode).toBe(0); + }, + SLOW, + ); + + test( + "--require preloads run before the freeze", + async () => { + using dir = tempDir("frozen-intrinsics-preload", { + "poly.cjs": `Array.prototype.myPolyfill = 1;`, + }); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--frozen-intrinsics", + "--require", + "./poly.cjs", + "-e", + `console.log(JSON.stringify({ frozen: Object.isFrozen(Array.prototype), poly: Array.prototype.myPolyfill }))`, + ], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(JSON.parse(stdout)).toEqual({ frozen: true, poly: 1 }); + expect(exitCode).toBe(0); + }, + SLOW, + ); }); describe("--secure-heap", () => { From a40c731834910baefdf6e9ee3bf5cd044cd2ac83 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:58:16 +0000 Subject: [PATCH 07/12] [autofix.ci] apply automated fixes --- src/js/internal/freeze_intrinsics.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/js/internal/freeze_intrinsics.ts b/src/js/internal/freeze_intrinsics.ts index 2271779b1fa1..d9bd58e6735e 100644 --- a/src/js/internal/freeze_intrinsics.ts +++ b/src/js/internal/freeze_intrinsics.ts @@ -31,10 +31,7 @@ const SymbolIterator = Symbol.iterator; const SymbolMatchAll = Symbol.matchAll; const TypedArray = ObjectGetPrototypeOf(Uint8Array); -process.emitWarning( - "Frozen intristics is an experimental feature and might change at any time", - "ExperimentalWarning", -); +process.emitWarning("Frozen intristics is an experimental feature and might change at any time", "ExperimentalWarning"); { const intrinsicPrototypes: unknown[] = [ From 82054866704311ff776f9f2ed757da1df7a0684a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:20:05 +0000 Subject: [PATCH 08/12] address review: guard last Error.stackTraceLimit write, use shared helper in inspect.js, array-based freeze queue, trim comments --- src/js/internal/freeze_intrinsics.ts | 32 ++++++++++---------- src/js/internal/process/pre_execution.ts | 5 +-- src/js/internal/util/inspect.js | 3 +- src/jsc/VirtualMachine.rs | 6 ++-- src/jsc/bindings/ExposeNodeModuleGlobals.cpp | 7 ++--- 5 files changed, 24 insertions(+), 29 deletions(-) diff --git a/src/js/internal/freeze_intrinsics.ts b/src/js/internal/freeze_intrinsics.ts index d9bd58e6735e..912048ed7bbd 100644 --- a/src/js/internal/freeze_intrinsics.ts +++ b/src/js/internal/freeze_intrinsics.ts @@ -247,12 +247,14 @@ process.emitWarning("Frozen intristics is an experimental feature and might chan for (let i = 0; i < intrinsicPrototypes.length; i++) enableDerivedOverrides(intrinsicPrototypes[i]); + const WeakSetAdd = WeakSet.prototype.add; + const WeakSetHas = WeakSet.prototype.has; const frozenSet = new WeakSet(); // In Node.js `console._stdout`/`_stderr` are getters; in Bun they are data // properties, so seed them as visited to keep stream prototypes unfrozen. const { _stdout, _stderr } = console as { _stdout?: object; _stderr?: object }; - if (_stdout) frozenSet.add(_stdout); - if (_stderr) frozenSet.add(_stderr); + if (_stdout) WeakSetAdd.$call(frozenSet, _stdout); + if (_stderr) WeakSetAdd.$call(frozenSet, _stderr); for (let i = 0; i < intrinsics.length; i++) deepFreeze(intrinsics[i]); // 19.1 Value Properties of the Global Object @@ -264,22 +266,25 @@ process.emitWarning("Frozen intristics is an experimental feature and might chan } as PropertyDescriptor); function deepFreeze(root: unknown): void { - const freezingSet = new Set(); + const queue: object[] = []; function enqueue(val: unknown): void { - if (Object(val) !== val) return; - if (frozenSet.has(val as object) || freezingSet.has(val as object)) return; - freezingSet.add(val as object); + const t = typeof val; + if ((t !== "object" && t !== "function") || val === null) return; + if (WeakSetHas.$call(frozenSet, val as object)) return; + WeakSetAdd.$call(frozenSet, val as object); + $putByValDirect(queue, queue.length, val as object); } - function doFreeze(obj: object): void { + enqueue(root); + for (let i = 0; i < queue.length; i++) { + const obj = queue[i]; ObjectFreeze(obj); - const proto = ObjectGetPrototypeOf(obj); + enqueue(ObjectGetPrototypeOf(obj)); const descs = ObjectGetOwnPropertyDescriptors(obj); - enqueue(proto); const keys = ReflectOwnKeys(descs); - for (let i = 0; i < keys.length; i++) { - const desc = descs[keys[i] as string]; + for (let k = 0; k < keys.length; k++) { + const desc = descs[keys[k] as string]; if (ObjectPrototypeHasOwnProperty.$call(desc, "value")) { enqueue(desc.value); } else { @@ -288,11 +293,6 @@ process.emitWarning("Frozen intristics is an experimental feature and might chan } } } - - enqueue(root); - // New values added before forEach() has finished will be visited. - freezingSet.forEach(doFreeze); - freezingSet.forEach(frozenSet.add, frozenSet); } // Convert data properties to accessors so `derived.prop = x` still defines diff --git a/src/js/internal/process/pre_execution.ts b/src/js/internal/process/pre_execution.ts index 682006ae4c0d..ead9767491a5 100644 --- a/src/js/internal/process/pre_execution.ts +++ b/src/js/internal/process/pre_execution.ts @@ -126,10 +126,11 @@ function printEnvTrace(kind: EnvOpKind, key: string | null): void { // The capture burns 3 frames on trace machinery (printEnvTrace, the // proxy trap, and the Error line); widen the limit so the user still // sees `Error.stackTraceLimit` real frames. + const stlWritable = require("internal/shared").isErrorStackTraceLimitWritable(); const limit = Error.stackTraceLimit; - Error.stackTraceLimit = limit + 3; + if (stlWritable) Error.stackTraceLimit = limit + 3; const stack = new Error().stack!.split("\n"); - Error.stackTraceLimit = limit; + if (stlWritable) Error.stackTraceLimit = limit; // stack[0] = "Error", [1] = printEnvTrace, [2] = the proxy trap. let corrected = false; for (let i = 3; i < stack.length; i++) { diff --git a/src/js/internal/util/inspect.js b/src/js/internal/util/inspect.js index 56f1e09356e4..e5ee08557229 100644 --- a/src/js/internal/util/inspect.js +++ b/src/js/internal/util/inspect.js @@ -290,8 +290,7 @@ const codes = {}; // exported from errors.js return msg; }); codes[sym] = function NodeError(...args) { - const stlDesc = Object.getOwnPropertyDescriptor(Error, "stackTraceLimit"); - const stlWritable = stlDesc ? (stlDesc.writable ?? stlDesc.set !== undefined) : Object.isExtensible(Error); + const stlWritable = require("internal/shared").isErrorStackTraceLimitWritable(); const limit = Error.stackTraceLimit; if (stlWritable) Error.stackTraceLimit = 0; const error = new TypeError(); diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 9891d537aec2..52e5ecada9c6 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -2274,10 +2274,8 @@ impl VirtualMachine { } } - /// Runs `internal/freeze_intrinsics` if `--frozen-intrinsics` was passed. - /// Called after `load_preloads` so polyfill preloads land before the freeze - /// (Node.js documents that `--require`/`--import` run first). The module - /// registry caches the evaluation, so repeat calls are no-ops. + /// Runs `internal/freeze_intrinsics` for `--frozen-intrinsics`. Called + /// after `load_preloads` so `--require`/`--import` polyfills land first. fn maybe_freeze_intrinsics(&self) { unsafe extern "C" { static Bun__Node__FrozenIntrinsics: core::sync::atomic::AtomicBool; diff --git a/src/jsc/bindings/ExposeNodeModuleGlobals.cpp b/src/jsc/bindings/ExposeNodeModuleGlobals.cpp index 5aa6c69f6a03..70d6d0e361f2 100644 --- a/src/jsc/bindings/ExposeNodeModuleGlobals.cpp +++ b/src/jsc/bindings/ExposeNodeModuleGlobals.cpp @@ -108,11 +108,8 @@ extern "C" [[ZIG_EXPORT(nothrow)]] void Bun__preExecutionBootstrap(Zig::GlobalOb } } -// Evaluate `internal/freeze_intrinsics`. Called from -// VirtualMachine::reload_entry_point after --require/--import preloads have -// finished (Node.js documents that polyfill preloads run before the freeze). -// The registry caches the module, so repeat calls (hot reload, workers) are -// no-ops after the first. +// Evaluate `internal/freeze_intrinsics`. Called after load_preloads so +// --require/--import polyfills land before the freeze (Node.js ordering). extern "C" [[ZIG_EXPORT(nothrow)]] void Bun__freezeIntrinsics(Zig::GlobalObject* globalObject) { auto& vm = JSC::getVM(globalObject); From e03fb7e05ad4db175b8f9a7bcd730051aceb7351 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:44:54 +0000 Subject: [PATCH 09/12] freeze_intrinsics: also run before a patched Module.runMain dispatches the entry --- src/jsc/VirtualMachine.rs | 2 ++ .../node/process/node-hardening-flags.test.ts | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 52e5ecada9c6..787d30a21581 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -2361,6 +2361,8 @@ impl VirtualMachine { } } + self.maybe_freeze_intrinsics(); + // Check if Module.runMain was patched. if self.has_patched_run_main { bun_core::hint::cold(); diff --git a/test/js/node/process/node-hardening-flags.test.ts b/test/js/node/process/node-hardening-flags.test.ts index cdee016329dc..7131f8e6f54e 100644 --- a/test/js/node/process/node-hardening-flags.test.ts +++ b/test/js/node/process/node-hardening-flags.test.ts @@ -265,6 +265,26 @@ describe("--frozen-intrinsics", () => { }, SLOW, ); + + test( + "freeze runs even when a --require preload patches Module.runMain", + async () => { + using dir = tempDir("frozen-intrinsics-runmain", { + "loader.cjs": `const M = require("module"); const orig = M.runMain; M.runMain = function (...a) { return orig.apply(this, a); };`, + "entry.js": `console.log(JSON.stringify({ frozen: Object.isFrozen(Array.prototype) }))`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "--frozen-intrinsics", "--require", "./loader.cjs", "entry.js"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(JSON.parse(stdout)).toEqual({ frozen: true }); + expect(exitCode).toBe(0); + }, + SLOW, + ); }); describe("--secure-heap", () => { From 4104e0ba891c8a365eb4240fced6f0c81e197d0d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:27:54 +0000 Subject: [PATCH 10/12] guard util.getCallSites prepareStackTrace writes and trace_events console instrumentation under --frozen-intrinsics --- src/js/internal/trace_events.ts | 1 + src/js/node/util.ts | 8 ++++---- test/js/node/process/node-hardening-flags.test.ts | 13 +++++++++---- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/js/internal/trace_events.ts b/src/js/internal/trace_events.ts index 0f26ade9e373..2a18fa36493f 100644 --- a/src/js/internal/trace_events.ts +++ b/src/js/internal/trace_events.ts @@ -696,6 +696,7 @@ function wrapFsAsyncMethod(original, names: string[]) { // semantics mirror Node: count starts at 1, countReset emits 0, time/timeLog/ // timeEnd emit 'b'/'n'/'e' under `time::