Skip to content

Commit cffc8d7

Browse files
authored
fix(runtime): suggest --unstable-unsafe-proto after __proto__ access (#35192)
Deno disables the `Object.prototype.__proto__` accessor by default to guard against prototype pollution. It did this by deleting the property, so reads return `undefined` and writes silently create a useless own property without changing the prototype. These quiet failures are hard to track down when code accidentally relies on `__proto__`. Making the accessor throw instead (#34730) surfaced those bugs but broke real packages such as Playwright, and was reverted (#34772). This takes a middle ground: it keeps the silent behavior, but installs an accessor that reproduces the deleted semantics exactly (read returns `undefined`, write creates an own data property, the prototype is unchanged) while recording the first access in a process-global flag. When the program later crashes, the uncaught-error formatter appends a hint to run again with `--unstable-unsafe-proto`. Programs that never touch `__proto__`, and runs with the flag already enabled, are unaffected. Reads and writes are recorded under separate flags because they fail differently. A write silently no-ops and the breakage surfaces downstream at an unrelated-looking line, so any later crash is enough to show the hint. A read instead returns `undefined` and crashes right at the access site (for example pnpm 11's `who.__proto__.constructor`), so the hint for a read is only shown when the crashing error itself mentions `__proto__`. That keeps programs that merely probe `__proto__` for feature detection, and then crash for an unrelated reason, from getting a spurious suggestion. One consequence is that the accessor is now present in both modes (which matches Node, where it always exists), so `Object.hasOwn(Object.prototype, "__proto__")` is now `true` by default rather than `false`. The detection in the existing tests and the unsafe-proto fixture therefore switches from checking presence to checking behavior: reading `__proto__` returns `undefined` only when the accessor is disabled. These are the real-world `__proto__` crashes the hint now points at, each of which runs correctly under `--unstable-unsafe-proto`: Closes #34694 Closes #34337 Closes #26584 Closes #24986 Closes #24413 Closes #35131
1 parent 2420701 commit cffc8d7

18 files changed

Lines changed: 237 additions & 11 deletions

File tree

runtime/fmt_errors.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
use std::borrow::Cow;
44
use std::fmt::Write as _;
55
use std::sync::LazyLock;
6+
use std::sync::atomic::AtomicBool;
7+
use std::sync::atomic::Ordering;
68

79
use color_print::cformat;
810
use color_print::cstr;
@@ -11,6 +13,24 @@ use deno_core::error::format_frame;
1113
use deno_core::url::Url;
1214
use deno_terminal::colors;
1315

16+
/// Set to `true` when user code assigns to `Object.prototype.__proto__` while
17+
/// the accessor is disabled (the default, unless `--unstable-unsafe-proto`).
18+
/// The assignment itself stays a silent no-op so fragile packages keep working
19+
/// (see denoland/deno#34730 / #34772, where throwing broke Playwright); this
20+
/// flag only lets the uncaught-error formatter nudge toward the escape hatch.
21+
/// Written by `op_proto_set_attempted`, read by `get_suggestions_for_terminal_errors`.
22+
pub static PROTO_SET_ATTEMPTED: AtomicBool = AtomicBool::new(false);
23+
24+
/// Set to `true` when user code reads `Object.prototype.__proto__` while the
25+
/// accessor is disabled (the read returns `undefined`). Unlike a write, a read
26+
/// crashes right at the access site (e.g. `who.__proto__.constructor` throws on
27+
/// the same line), so on its own a read flag is too noisy to nudge on: programs
28+
/// routinely probe `__proto__` for feature detection without anything going
29+
/// wrong. The error formatter therefore only suggests the escape hatch when
30+
/// this flag is set *and* the crashing error mentions `__proto__`.
31+
/// Written by `op_proto_get_attempted`, read by `get_suggestions_for_terminal_errors`.
32+
pub static PROTO_GET_ATTEMPTED: AtomicBool = AtomicBool::new(false);
33+
1434
#[derive(Debug, Clone)]
1535
struct ErrorReference<'a> {
1636
from: &'a JsError,
@@ -352,6 +372,46 @@ fn format_js_error_inner(
352372
}
353373

354374
fn get_suggestions_for_terminal_errors(e: &JsError) -> Vec<FixSuggestion<'_>> {
375+
let mut suggestions = get_message_suggestions(e);
376+
// A `__proto__` *write* silently no-ops and the breakage surfaces downstream
377+
// at an unrelated-looking line, so any later crash is reason enough to point
378+
// at the escape hatch. A `__proto__` *read* instead returns `undefined` and
379+
// blows up right at the access site, so we only nudge when `__proto__` is on
380+
// the crashing line/message, to avoid bothering programs that merely probed
381+
// `__proto__` once (feature detection) and then crashed for another reason.
382+
let info = if PROTO_SET_ATTEMPTED.load(Ordering::Relaxed) {
383+
Some(cstr!(
384+
"This program assigned to <u>Object.prototype.__proto__</>, which Deno disables by default."
385+
))
386+
} else if PROTO_GET_ATTEMPTED.load(Ordering::Relaxed)
387+
&& error_mentions_proto(e)
388+
{
389+
Some(cstr!(
390+
"This program read <u>Object.prototype.__proto__</>, which Deno disables by default (it returns <i>undefined</>)."
391+
))
392+
} else {
393+
None
394+
};
395+
if let Some(info) = info {
396+
suggestions.push(FixSuggestion::info(info));
397+
suggestions.push(FixSuggestion::hint(cstr!(
398+
"If this caused the error, run again with <u>--unstable-unsafe-proto</> to restore it."
399+
)));
400+
}
401+
suggestions
402+
}
403+
404+
fn error_mentions_proto(e: &JsError) -> bool {
405+
e.source_line
406+
.as_deref()
407+
.is_some_and(|l| l.contains("__proto__"))
408+
|| e
409+
.message
410+
.as_deref()
411+
.is_some_and(|m| m.contains("__proto__"))
412+
}
413+
414+
fn get_message_suggestions(e: &JsError) -> Vec<FixSuggestion<'_>> {
355415
if let Some(msg) = &e.message {
356416
if msg.contains("module is not defined")
357417
|| msg.contains("exports is not defined")

runtime/js/99_main.js

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import {
1717
op_internal_log,
1818
op_main_module,
1919
op_ppid,
20+
op_proto_get_attempted,
21+
op_proto_set_attempted,
2022
op_set_format_exception_callback,
2123
op_snapshot_options,
2224
op_worker_close,
@@ -39,7 +41,9 @@ const {
3941
ObjectDefineProperty,
4042
ObjectGetOwnPropertyDescriptors,
4143
ObjectHasOwn,
44+
ObjectIsExtensible,
4245
ObjectKeys,
46+
ObjectPrototype,
4347
ObjectPrototypeIsPrototypeOf,
4448
ObjectSetPrototypeOf,
4549
PromisePrototypeThen,
@@ -730,6 +734,64 @@ const executionModes = {
730734
jupyter: 8,
731735
};
732736

737+
let protoSetRecorded = false;
738+
let protoGetRecorded = false;
739+
740+
// By default Deno disables the `Object.prototype.__proto__` accessor for
741+
// security reasons (it enables prototype pollution), see
742+
// https://tc39.es/ecma262/#sec-get-object.prototype.__proto__
743+
//
744+
// Historically this was done with `delete Object.prototype.__proto__`, which
745+
// makes `obj.__proto__` reads return `undefined` and writes silently create a
746+
// useless own property. Making the accessor *throw* instead would surface those
747+
// silent bugs, but it broke real packages such as Playwright (see
748+
// denoland/deno#34730 / #34772), so we keep the silent behavior.
749+
//
750+
// Instead of deleting we install an accessor that reproduces the deleted
751+
// semantics exactly (read -> `undefined`, write -> own data property, prototype
752+
// unchanged) but additionally records the first read and the first write. When
753+
// the program later crashes, the uncaught-error formatter (runtime/fmt_errors.rs)
754+
// reads those flags and suggests `--unstable-unsafe-proto`. A write surfaces
755+
// downstream so any later crash triggers the nudge; a read crashes at the
756+
// access site, so the formatter additionally requires `__proto__` on the
757+
// failing line before nudging. The `__proto__` key in object literals (e.g.
758+
// `{ __proto__: null }`) is separate syntax and is unaffected.
759+
function disableProtoAccessor() {
760+
ObjectDefineProperty(ObjectPrototype, "__proto__", {
761+
__proto__: null,
762+
configurable: true,
763+
enumerable: false,
764+
// Distinct getter/setter function objects: the native accessor uses
765+
// separate functions and WPT asserts accessor get/set are unique.
766+
get: function __proto__() {
767+
if (!protoGetRecorded) {
768+
protoGetRecorded = true;
769+
op_proto_get_attempted();
770+
}
771+
return undefined;
772+
},
773+
set: function __proto__(value) {
774+
if (!protoSetRecorded) {
775+
protoSetRecorded = true;
776+
op_proto_set_attempted();
777+
}
778+
// Reproduce the previous `delete` behavior: a bare assignment created a
779+
// normal own data property. Skip non-extensible receivers, where that
780+
// assignment was a silent no-op in sloppy mode (and a throw in strict
781+
// mode); keeping it silent here matches the "stay silent" goal above.
782+
if (ObjectIsExtensible(this)) {
783+
ObjectDefineProperty(this, "__proto__", {
784+
__proto__: null,
785+
value,
786+
writable: true,
787+
enumerable: true,
788+
configurable: true,
789+
});
790+
}
791+
},
792+
});
793+
}
794+
733795
function bootstrapMainRuntime(runtimeOptions, warmup = false) {
734796
if (!warmup) {
735797
if (hasBootstrapped) {
@@ -922,9 +984,7 @@ function bootstrapMainRuntime(runtimeOptions, warmup = false) {
922984
}
923985

924986
if (!ArrayPrototypeIncludes(unstableFeatures, unstableIds.unsafeProto)) {
925-
// Removes the `__proto__` for security reasons.
926-
// https://tc39.es/ecma262/#sec-get-object.prototype.__proto__
927-
delete Object.prototype.__proto__;
987+
disableProtoAccessor();
928988
}
929989

930990
// Setup `Deno` global - we're actually overriding already existing global
@@ -1054,9 +1114,7 @@ function bootstrapWorkerRuntime(
10541114
delete finalDenoNs.mainModule;
10551115

10561116
if (!ArrayPrototypeIncludes(unstableFeatures, unstableIds.unsafeProto)) {
1057-
// Removes the `__proto__` for security reasons.
1058-
// https://tc39.es/ecma262/#sec-get-object.prototype.__proto__
1059-
delete Object.prototype.__proto__;
1117+
disableProtoAccessor();
10601118
}
10611119

10621120
// Setup `Deno` global - we're actually overriding already existing global

runtime/ops/bootstrap.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ deno_core::extension!(
2121
op_bootstrap_stderr_no_color,
2222
op_bootstrap_unstable_args,
2323
op_bootstrap_is_from_unconfigured_runtime,
24+
op_proto_set_attempted,
25+
op_proto_get_attempted,
2426
op_snapshot_options,
2527
],
2628
options = {
@@ -162,3 +164,24 @@ pub fn op_bootstrap_stderr_no_color(_state: &mut OpState) -> bool {
162164
pub fn op_bootstrap_is_from_unconfigured_runtime(state: &mut OpState) -> bool {
163165
state.borrow::<IsFromUnconfiguredRuntime>().0
164166
}
167+
168+
// Called (at most once) from the disabled `Object.prototype.__proto__` setter
169+
// in 99_main.js when user code assigns to `__proto__`. Records a process-global
170+
// flag so that, if the program later crashes, the uncaught-error formatter can
171+
// suggest `--unstable-unsafe-proto`. See `crate::fmt_errors::PROTO_SET_ATTEMPTED`.
172+
#[op2(fast)]
173+
pub fn op_proto_set_attempted() {
174+
crate::fmt_errors::PROTO_SET_ATTEMPTED
175+
.store(true, std::sync::atomic::Ordering::Relaxed);
176+
}
177+
178+
// Called (at most once) from the disabled `Object.prototype.__proto__` getter
179+
// in 99_main.js when user code reads `__proto__` (the read returns `undefined`).
180+
// Records a process-global flag; the uncaught-error formatter only acts on it
181+
// when the crashing error also mentions `__proto__`, since reads are common and
182+
// usually harmless. See `crate::fmt_errors::PROTO_GET_ATTEMPTED`.
183+
#[op2(fast)]
184+
pub fn op_proto_get_attempted() {
185+
crate::fmt_errors::PROTO_GET_ATTEMPTED
186+
.store(true, std::sync::atomic::Ordering::Relaxed);
187+
}

tests/registry/npm/@denotest/unsafe-proto-bin/1.0.0/cli.mjs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
#!/usr/bin/env node
22

3-
const hasUnsafeProto = Object.hasOwn(Object.prototype, "__proto__");
3+
// Detect by behavior, not presence: Deno keeps the `__proto__` accessor
4+
// installed even when disabled (so it can capture assignments), so reading it
5+
// is what actually distinguishes the modes — disabled returns `undefined`.
6+
const hasUnsafeProto = ({}).__proto__ !== undefined;
47
if (hasUnsafeProto) {
58
console.log("unsafe proto enabled");
69
} else {

tests/specs/run/unsafe_proto/main.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
console.log(Object.hasOwn(Object.prototype, "__proto__"));
1+
// Disabled by default: the accessor stays installed (so writes can be
2+
// captured) but reads return `undefined`, so this prints `false`.
3+
console.log(({}).__proto__ !== undefined);
24

35
new Worker(import.meta.resolve("./worker.js"), {
46
type: "module",
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
console.log(Object.hasOwn(Object.prototype, "__proto__"));
1+
console.log(({}).__proto__ !== undefined);
22
close();

tests/specs/run/unsafe_proto_flag/main.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
console.log(Object.hasOwn(Object.prototype, "__proto__"));
1+
// With --unstable-unsafe-proto the native accessor is restored, so reading
2+
// `__proto__` returns the prototype and this prints `true`.
3+
console.log(({}).__proto__ !== undefined);
24

35
new Worker(import.meta.resolve("./worker.js"), {
46
type: "module",
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
console.log(Object.hasOwn(Object.prototype, "__proto__"));
1+
console.log(({}).__proto__ !== undefined);
22
close();
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"tests": {
3+
"suggests_flag_after_proto_set": {
4+
"args": "run set_and_throw.js",
5+
"exitCode": 1,
6+
"output": "set_and_throw.out"
7+
},
8+
"no_suggestion_without_proto_set": {
9+
"args": "run throw_only.js",
10+
"exitCode": 1,
11+
"output": "throw_only.out"
12+
},
13+
"no_suggestion_with_unsafe_proto_flag": {
14+
"args": "run --unstable-unsafe-proto set_and_throw.js",
15+
"exitCode": 1,
16+
"output": "set_and_throw_unsafe.out"
17+
},
18+
"suggests_flag_after_proto_read_on_failing_line": {
19+
"args": "run read_and_throw.js",
20+
"exitCode": 1,
21+
"output": "read_and_throw.out"
22+
},
23+
"no_suggestion_when_proto_read_unrelated_to_crash": {
24+
"args": "run read_unrelated_throw.js",
25+
"exitCode": 1,
26+
"output": "read_unrelated_throw.out"
27+
}
28+
}
29+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Reading `__proto__` returns `undefined` while the accessor is disabled (the
2+
// default), so `who.__proto__.constructor` throws right at the access site.
3+
// Because the crashing line itself mentions `__proto__`, the uncaught-error
4+
// formatter suggests `--unstable-unsafe-proto`. This mirrors the real-world
5+
// crash in pnpm 11 (denoland/deno#34694).
6+
const who = {};
7+
who.__proto__.constructor;

0 commit comments

Comments
 (0)