Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions ext/webgpu/01_webgpu.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,10 @@ class GPUValidationError extends GPUError {
this[_message] = message;
}
}
core.registerErrorClass("GPUValidationError", GPUValidationError);
core.registerErrorBuilder(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Downgrading these to builder-only creates a slow/fast asymmetry that's currently invisible: builder-only classes get the to_v8_error fallback on the slow path, but the fast path (throw_js_error_class) has no fallback and returns a plain Error with the right name string but the base Error prototype, so e instanceof GPUValidationError would be false.

Harmless today — all webgpu ops returning GPUError take a scope/webidl args and are slow-only — but if a fast op ever returns one it fails silently. The selection rule ("classes with private-slot / webidl-brand state must be builder-only") isn't documented anywhere, and everything else defaults to registerErrorClass, so the next person adding a brand-backed error class will hit this at throw time. A short comment here explaining why these three can't use registerErrorClass would help.

"GPUValidationError",
(message) => new GPUValidationError(message),
);

class GPUOutOfMemoryError extends GPUError {
constructor(message) {
Expand All @@ -117,15 +120,21 @@ class GPUOutOfMemoryError extends GPUError {
this[_message] = message;
}
}
core.registerErrorClass("GPUOutOfMemoryError", GPUOutOfMemoryError);
core.registerErrorBuilder(
"GPUOutOfMemoryError",
(message) => new GPUOutOfMemoryError(message),
);

class GPUInternalError extends GPUError {
constructor() {
super(illegalConstructorKey);
this[webidl.brand] = webidl.brand;
}
}
core.registerErrorClass("GPUInternalError", GPUInternalError);
core.registerErrorBuilder(
"GPUInternalError",
() => new GPUInternalError(),
);

class GPUPipelineError extends DOMException {
#reason;
Expand Down
15 changes: 9 additions & 6 deletions libs/core/00_infra.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,9 @@

const errorMap = {};
// Maps a registered error class name to its constructor. Unlike `errorMap`
// (which stores builder closures), this keeps a reference to the class itself
// so the Rust side can restore the exact prototype when building an exception
// natively (e.g. from inside a V8 fast call, where re-entering JS to call
// `buildCustomError` is forbidden). Null prototype so class names can't
// collide with `Object.prototype` members.
// (which stores builder closures), this lets native error construction
// restore the exact prototype. Null prototype and immutable entries keep
// lookups independent of inherited properties or later map mutations.
const errorConstructors = { __proto__: null };
// Builtin v8 / JS errors
registerErrorClass("Error", Error);
Expand Down Expand Up @@ -132,7 +130,12 @@

function registerErrorClass(className, errorClass) {
registerErrorBuilder(className, (msg) => new errorClass(msg));
errorConstructors[className] = errorClass;
ObjectDefineProperty(errorConstructors, className, {
value: errorClass,
writable: false,
enumerable: true,
configurable: false,
});
}

function registerErrorBuilder(className, errorBuilder) {
Expand Down
119 changes: 83 additions & 36 deletions libs/core/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,29 +268,84 @@ pub fn throw_js_error_class(
scope: &mut v8::PinScope,
error: &dyn JsErrorClass,
) {
let exception = build_js_error_class_exception(scope, error);
let class = error.get_class();
let prototype = registered_error_class_prototype(scope, &class);
let exception =
build_js_error_class_exception(scope, error, &class, prototype);
scope.throw_exception(exception);
}

/// Builds a JS exception for `error` using only native V8 APIs, without
/// re-entering JavaScript.
/// Throws `error` using its registered class when native construction is
/// available.
///
/// This mirrors what the JS `Deno.core.buildCustomError` callback does
/// (restoring the registered error class so `instanceof` holds, and attaching
/// the additional properties), but never invokes user-reachable JS. That makes
/// it safe to call from inside a V8 fast call, where re-entering JS is
/// forbidden: the callback could run an attacker-controlled prototype setter
/// that detaches an `ArrayBuffer` argument out from under the JIT, a
/// use-after-free (GHSA-p4r3-6jgx-4cj5).
/// Returns `false` when the class cannot be reconstructed natively, including
/// classes that only have a custom JavaScript builder. Callers that support
/// custom builders may then fall back to [`to_v8_error`].
pub fn try_throw_js_error_class(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

throw_js_error_class (above) and try_throw_js_error_class duplicate the get_class + prototype-lookup + build + throw sequence. The former is behaviorally if !try_throw_js_error_class(scope, error) { build with prototype=None; throw } — expressing it that way keeps fast- and slow-call error shapes from silently diverging when one is edited.

Relatedly, the get_own_property_descriptor -> cast -> has_own_property("value") -> get("value") dance in registered_error_class_prototype appears twice back-to-back; since it encodes the security-critical "never read through an accessor" invariant, a single own_data_property(scope, obj, key) helper would keep both copies from drifting.

scope: &mut v8::PinScope,
error: &dyn JsErrorClass,
) -> bool {
let class = error.get_class();
let Some(prototype) = registered_error_class_prototype(scope, &class) else {
return false;
};
let exception =
build_js_error_class_exception(scope, error, &class, Some(prototype));
scope.throw_exception(exception);
true
}

fn registered_error_class_prototype<'s, 'i>(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This re-derives trust from the live JS object on every throw: two get_own_property_descriptor calls, proxy/accessor/own-data-property checks, and several v8::String::new allocations per throw on a hot path.

The entire proxy/accessor defense here and the new immutable ObjectDefineProperty in 00_infra.js exist only because the prototype is looked up at throw time from a mutable object. Since registerErrorClass now installs immutable entries, each class's prototype is fixed at registration — snapshotting it into a Rust-side HashMap<String, v8::Global<v8::Value>> (or a field on ExceptionState) when registerErrorClass runs would reduce throwing to a map lookup and let you delete this whole threat model rather than defend against it per-throw. Worth considering as the deeper fix.

scope: &mut v8::PinScope<'s, 'i>,
class: &str,
) -> Option<v8::Local<'s, v8::Value>> {
let constructors = JsRealm::exception_state_from_scope(scope)
.js_error_constructors
.borrow()
.clone()?;
let constructors = v8::Local::new(scope, constructors);
let class_key = v8::String::new(scope, class)?;
let descriptor =
constructors.get_own_property_descriptor(scope, class_key.into())?;
let descriptor =
TryInto::<v8::Local<v8::Object>>::try_into(descriptor).ok()?;
let value_key = v8::String::new(scope, "value")?;
if descriptor.has_own_property(scope, value_key.into()) != Some(true) {
return None;
}
let ctor = descriptor.get(scope, value_key.into())?;

// Proxy constructors and non-standard constructor objects retain their
// custom-builder behavior. For ordinary functions, inspect the own property
// descriptor so an unusual accessor cannot run during error construction.
if !ctor.is_function() || ctor.is_proxy() {
return None;
}
let ctor = TryInto::<v8::Local<v8::Object>>::try_into(ctor).ok()?;
let prototype_key = v8::String::new(scope, "prototype")?;
let descriptor =
ctor.get_own_property_descriptor(scope, prototype_key.into())?;
let descriptor =
TryInto::<v8::Local<v8::Object>>::try_into(descriptor).ok()?;
if descriptor.has_own_property(scope, value_key.into()) != Some(true) {
return None;
}
let prototype = descriptor.get(scope, value_key.into())?;
prototype.is_object().then_some(prototype)
}

/// Builds a JS exception for `error` using native V8 APIs.
///
/// Reading data properties off the cached `errorConstructors` map and calling
/// `SetPrototype` / `CreateDataProperty` / `DefineOwnProperty` never execute
/// user JS, so the fast-call contract is upheld.
/// This mirrors the observable shape produced by `buildCustomError` for a
/// registered class: it restores the registered prototype, defines the class
/// name and additional properties directly on the exception, and records the
/// additional-property keys used when converting the exception back to Rust.
fn build_js_error_class_exception<'s, 'i>(
scope: &mut v8::PinScope<'s, 'i>,
error: &dyn JsErrorClass,
class: &str,
prototype: Option<v8::Local<'s, v8::Value>>,
) -> v8::Local<'s, v8::Value> {
let class = error.get_class();
let message = v8::String::new(scope, &error.get_message()).unwrap();
let exception = v8::Exception::error(scope, message);

Expand All @@ -304,35 +359,21 @@ fn build_js_error_class_exception<'s, 'i>(
// `Deno.errors.NotFound`) by re-parenting the prototype, so that
// `err instanceof Deno.errors.NotFound` holds. We never call the class
// constructor (that would run JS); we only borrow its `.prototype`.
let constructors = JsRealm::exception_state_from_scope(scope)
.js_error_constructors
.borrow()
.clone();
if let Some(constructors) = constructors {
let constructors = v8::Local::new(scope, constructors);
let class_key = v8::String::new(scope, &class).unwrap();
if let Some(ctor) = constructors.get(scope, class_key.into())
&& let Ok(ctor) = TryInto::<v8::Local<v8::Object>>::try_into(ctor)
{
let prototype_key = v8::String::new(scope, "prototype").unwrap();
if let Some(prototype) = ctor.get(scope, prototype_key.into())
&& prototype.is_object()
{
exception_obj.set_prototype(scope, prototype);
}
}
if let Some(prototype) = prototype {
exception_obj.set_prototype(scope, prototype);
}

// Set `name` explicitly. The registered classes assign `this.name` in their
// constructor (an own property), which the prototype swap above does not
// reproduce since we never run the constructor. The registration key matches
// that assigned name, so using the class here mirrors `new ErrorClass(...)`.
let name_key = v8::String::new(scope, "name").unwrap();
let class_value = v8::String::new(scope, &class).unwrap();
exception_obj.create_data_property(
let class_value = v8::String::new(scope, class).unwrap();
exception_obj.define_own_property(
scope,
name_key.into(),
class_value.into(),
v8::PropertyAttribute::NONE,
);

// Copy the additional properties (e.g. `code`) and record their keys under
Expand All @@ -341,8 +382,9 @@ fn build_js_error_class_exception<'s, 'i>(
let mut added_keys = vec![];
for (key, value) in error.get_additional_properties() {
let key = v8::String::new(scope, &key).unwrap();
// Match `buildCustomError`: don't clobber a property the class defines.
if exception_obj.has(scope, key.into()) == Some(true) {
// Don't clobber properties already present on the new exception. Checking
// only the exception itself keeps inherited accessors out of construction.
if exception_obj.has_own_property(scope, key.into()) == Some(true) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This switches the clobber check from prototype-chain to own-only, which diverges from the buildCustomError path you're claiming to mirror. buildCustomError used if (!(key in error)), skipping a key defined anywhere on the class prototype chain; has_own_property only looks at the exception itself.

So a registered class that exposes e.g. code via a prototype getter would previously keep that getter authoritative, but now an op-supplied code additional property is defined as an own data property that shadows it. Latent today (no in-tree registered class does this), but it's a silent behavioral difference between the native and builder paths worth a comment at least.

continue;
}
let value = match value {
Expand All @@ -351,7 +393,12 @@ fn build_js_error_class_exception<'s, 'i>(
}
PropertyValue::Number(value) => v8::Number::new(scope, value).into(),
};
exception_obj.create_data_property(scope, key.into(), value);
exception_obj.define_own_property(
scope,
key.into(),
value,
v8::PropertyAttribute::NONE,
);
added_keys.push(v8::Local::<v8::Value>::from(key));
}
if !added_keys.is_empty() {
Expand Down
4 changes: 2 additions & 2 deletions libs/core/runtime/exception_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ pub(crate) struct ExceptionState {
RefCell<Option<v8::Global<v8::Function>>>,
/// Map of registered error class name to its constructor (the JS
/// `Deno.core.errorConstructors` object). Used to natively rebuild an
/// exception with the correct prototype without re-entering JS, e.g. from a
/// V8 fast call. See [`crate::error::throw_js_error_class`].
/// exception with the correct prototype across op dispatch paths. See
/// [`crate::error::throw_js_error_class`].
pub(crate) js_error_constructors: RefCell<Option<v8::Global<v8::Object>>>,
pub(crate) js_handled_promise_rejection_cb:
RefCell<Option<v8::Global<v8::Function>>>,
Expand Down
46 changes: 39 additions & 7 deletions libs/core/runtime/tests/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use std::task::Poll;

use deno_error::JsErrorBox;

use crate::JsBuffer;
use crate::JsRuntime;
use crate::RuntimeOptions;
use crate::op2;
Expand All @@ -16,7 +17,14 @@ async fn test_error_builder() {
Err(JsErrorBox::new("DOMExceptionOperationError", "abc"))
}

deno_core::extension!(test_ext, ops = [op_err]);
#[op2]
fn op_registered_err(
#[buffer(detach)] _buffer: JsBuffer,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This test op never exercises the fast-call path the fix is about. #[buffer(detach)] hits dispatch_fast.rs's Arg::Buffer(_, BufferMode::Detach, _) => return Ok(None), so no fast callback is generated and ops.op_registered_err(new Uint8Array()) in error_builder_test.js routes through the slow path (try_throw_js_error_class).

The GHSA-p4r3-6jgx-4cj5 scenario is specifically a buffer-carrying fast op throwing while buildCustomError runs attacker JS — a detach-mode arg can't reproduce it. So the fast path is only covered by the Rust unit test calling throw_js_error_class directly, never through real fast dispatch with a live buffer arg. A regression that broke only the fast path would leave this JS test green. Consider a regular (non-detach) buffer arg so the op is actually fast-call eligible.

) -> Result<(), JsErrorBox> {
Err(JsErrorBox::new("RegisteredError", "registered message"))
}

deno_core::extension!(test_ext, ops = [op_err, op_registered_err]);
let mut runtime = JsRuntime::new(RuntimeOptions {
extensions: vec![test_ext::init()],
..Default::default()
Expand Down Expand Up @@ -46,13 +54,11 @@ fn syntax_error() {
assert_eq!(frame.column_number, Some(12));
}

// `throw_js_error_class` is what the op2 fast-call error path uses. Unlike the
// slow path it cannot re-enter JS to call `buildCustomError`, so it rebuilds
// the exception natively. This verifies that the native rebuild still restores
// the registered error class (`instanceof`), name, message, the `code`
// additional property, and the round-trip key symbol.
// Native construction is shared by op dispatch paths for registered classes.
// Verify that it restores the class and defines the expected own properties
// without consulting inherited accessors.
#[test]
fn fast_call_error_preserves_class() {
fn native_registered_error_preserves_class() {
use std::borrow::Cow;

use deno_error::JsErrorClass;
Expand Down Expand Up @@ -102,6 +108,20 @@ fn fast_call_error_preserves_class() {
}
};
Deno.core.registerErrorClass("MyError", globalThis.MyError);
globalThis.nameSetterCalls = 0;
globalThis.codeSetterCalls = 0;
Object.defineProperty(globalThis.MyError.prototype, "name", {
configurable: true,
set() {
globalThis.nameSetterCalls++;
},
});
Object.defineProperty(globalThis.MyError.prototype, "code", {
configurable: true,
set() {
globalThis.codeSetterCalls++;
},
});
"#,
)
.unwrap();
Expand Down Expand Up @@ -135,12 +155,24 @@ fn fast_call_error_preserves_class() {
if (!(e instanceof globalThis.MyError)) {
throw new Error("expected instanceof MyError, got " + e.name);
}
if (globalThis.nameSetterCalls !== 0) {
throw new Error("name setter ran during native error construction");
}
if (globalThis.codeSetterCalls !== 0) {
throw new Error("code setter ran during native error construction");
}
if (!Object.hasOwn(e, "name")) {
throw new Error("expected own name property");
}
if (e.name !== "MyError") {
throw new Error("expected name 'MyError', got " + e.name);
}
if (e.message !== "custom message") {
throw new Error("expected message 'custom message', got " + e.message);
}
if (!Object.hasOwn(e, "code")) {
throw new Error("expected own code property");
}
if (e.code !== "E_CUSTOM") {
throw new Error("expected code 'E_CUSTOM', got " + e.code);
}
Expand Down
Loading
Loading