Skip to content

Commit 5441be5

Browse files
authored
fix(js): stop application JavaScript from aborting core through URL.parse, stack traces and import() (#341)
* fix(js): close URL.parse, call-site and dynamic import abort paths Application JavaScript could panic native code inside a V8 callback, which aborts the process hosting core: - URL.parse ran a subclass constructor or a brand setter between parsing and reading the serialization, so a nested parse left the op without one. It now constructs through the captured URL constructor. - Error.prepareStackTrace received call sites whose cached source positions an Array.prototype setter could poison. Stacks are now always formatted by deno_core, and the hook is ignored. - A failed dynamic import unwrapped the rejection's message. import() now always rejects. Refs #300. * fix(js): throw URL.parse's TypeError from a captured constructor
1 parent fed21c6 commit 5441be5

4 files changed

Lines changed: 97 additions & 5 deletions

File tree

‎crates/chunk-js/README.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,10 @@ numbers, strings, booleans and bigints, `ArrayBuffer`s, typed arrays and `DataVi
3636
and class instances, throws `DataCloneError`, unlike in browsers (an object whose internal state can't be read from
3737
JavaScript, such as an iterator or a `URL`, clones as a plain object if its prototype was replaced to look like one);
3838
`crypto.subtle.digest` with SHA-1/256/384/512 on up to 1 MiB; ordinary `ArrayBuffer`s and typed arrays; and `console`
39-
methods, whose output (at most 32 messages and 16 KiB per call) the backend logs. Not available: filesystem, network,
40-
process, timers, Node or Deno globals, runtime imports, `Intl` and locale methods, `performance`, weak references,
39+
methods, whose output (at most 32 messages and 16 KiB per call) the backend logs. `URL.parse` returns a plain `URL`,
40+
also when called on a subclass. An error's `stack` is always a string: `Error.prepareStackTrace` is ignored, so code
41+
never receives call-site objects. Not available: filesystem, network, process, timers, Node or Deno globals, `import()`
42+
(every call rejects, including a module importing itself), `Intl` and locale methods, `performance`, weak references,
4143
WebAssembly, `SharedArrayBuffer` and resizable `ArrayBuffer`s. A promise that can never settle fails the call, and so
4244
does an unhandled rejection.
4345

‎crates/chunk-js/src/runtime.rs‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,15 @@ impl State {
9898
),
9999
..Default::default()
100100
});
101+
// deno_core's default callback honours Error.prepareStackTrace with call sites whose
102+
// methods panic once application code has modified Array.prototype.
103+
runtime.v8_isolate().set_prepare_stack_trace_callback(deno_core::error::format_stack_trace);
104+
// deno_core's dynamic import unwraps properties of a failed import's error, which
105+
// application code controls.
106+
runtime.v8_isolate().set_host_import_module_dynamically_callback(|scope, _, _, _, _| reject_import(scope));
107+
runtime
108+
.v8_isolate()
109+
.set_host_import_module_with_phase_dynamically_callback(|scope, _, _, _, _, _| reject_import(scope));
101110
runtime.op_state().borrow_mut().put(None::<Capabilities>);
102111
runtime.op_state().borrow_mut().put(None::<crate::actions::ActionCapabilities>);
103112
runtime.op_state().borrow_mut().put(crate::actions::Env(Json::empty()));
@@ -205,6 +214,14 @@ impl State {
205214
}
206215
}
207216

217+
fn reject_import<'s>(scope: &mut v8::PinScope<'s, '_>) -> Option<v8::Local<'s, v8::Promise>> {
218+
let resolver = v8::PromiseResolver::new(scope)?;
219+
let message = v8::String::new(scope, "Runtime imports are unavailable")?;
220+
let error = v8::Exception::type_error(scope, message);
221+
resolver.reject(scope, error)?;
222+
Some(resolver.get_promise(scope))
223+
}
224+
208225
fn js_error(error: impl std::fmt::Display) -> Error {
209226
Error::JavaScript(error.to_string())
210227
}

‎crates/chunk-js/src/tests.rs‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,3 +533,63 @@ fn structured_clone_reads_built_ins_by_brand_despite_modified_prototypes() {
533533
json!({"map": 42, "view": 0x0203, "symbols": 0, "errors": vec!["DataCloneError"; 5], "shared": true, "array": [true, 3, ["0", "2"]], "keys": ["a", "b"]})
534534
);
535535
}
536+
537+
#[test]
538+
fn url_parse_survives_constructors_and_setters_that_parse_urls() {
539+
let execution = run(r#"
540+
class Nested extends URL { constructor(...args) { super(...args); new URL("HTTP://nested/"); } }
541+
const subclass = Nested.parse("HTTP://a/x");
542+
const brand = Object.getOwnPropertySymbols(new URL("http://a/"))[0];
543+
let nested = false;
544+
Object.defineProperty(URL.prototype, brand, {
545+
set(value) {
546+
Object.defineProperty(this, brand, { value });
547+
if (nested) return;
548+
nested = true;
549+
new URL("HTTP://nested/");
550+
nested = false;
551+
},
552+
});
553+
const Native = TypeError;
554+
globalThis.TypeError = function () { return {}; };
555+
let missing;
556+
try { URL.parse(); } catch (error) { missing = error instanceof Native; }
557+
return [subclass.href, subclass instanceof Nested, URL.parse("HTTP://b/").href, URL.parse("c", "HTTP://b/x/y").href, URL.parse("nope"), missing];
558+
"#)
559+
.unwrap();
560+
assert_eq!(value(&execution), json!(["http://a/x", false, "http://b/", "http://b/x/c", null, true]));
561+
}
562+
563+
#[test]
564+
fn stacks_are_strings_that_ignore_prepare_stack_trace() {
565+
let execution = run(r#"
566+
Error.prepareStackTrace = (_, frames) => frames;
567+
for (const index of [0, 1, 2]) Object.defineProperty(Array.prototype, index, { get() { throw 1; }, set() {} });
568+
return new Error("boom").stack;
569+
"#)
570+
.unwrap();
571+
let stack = value(&execution);
572+
assert!(
573+
stack.as_str().unwrap().starts_with("Error: boom\n at default (chunk:deployment/build-a:4:16)"),
574+
"{stack}"
575+
);
576+
let Err(Error::JavaScript(thrown)) = run("Error.prepareStackTrace = () => 'custom'; throw new TypeError('boom');")
577+
else {
578+
panic!("expected a JavaScript error");
579+
};
580+
assert!(thrown.starts_with("TypeError: boom\n at default (chunk:deployment/build-a:1:"), "{thrown}");
581+
}
582+
583+
#[test]
584+
fn imports_at_runtime_reject_without_aborting() {
585+
let execution = run("try { await import(import.meta.url); } catch (error) { return error.message; }").unwrap();
586+
assert_eq!(value(&execution), json!("Runtime imports are unavailable"));
587+
let source = r"
588+
import(import.meta.url).catch(() => {});
589+
await 0;
590+
Error.stackTraceLimit = 0;
591+
throw Object.assign(new Error('x'), { message: 42 });
592+
export default () => 1;
593+
";
594+
assert!(matches!(Deployment::new("build-a".into(), source.into(), Limits::default()), Err(Error::JavaScript(_))));
595+
}

‎crates/chunk-js/src/web.js‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
const log = core.ops.op_chunk_log;
1010
const digest = core.ops.op_chunk_digest;
1111
const Uint8 = Uint8Array;
12+
const { TypeError } = globalThis;
1213
// A JavaScript clone, since V8's serializer runs host-object hooks keyed by
1314
// the global Symbol.for("Deno.core.hostObject") brand. It accepts an explicit
1415
// list of types and throws DataCloneError for anything else. Built-ins are
@@ -188,10 +189,22 @@
188189
return encodeInto(source, destination);
189190
}
190191
}
192+
// deno_web's URL.parse runs application code, a subclass constructor or a setter on the brand,
193+
// between parsing and reading the result, and its op panics if that code parses another URL.
194+
const NativeURL = url.URL;
195+
const canParse = NativeURL.canParse;
196+
NativeURL.parse = {
197+
parse(input, base = undefined) {
198+
if (arguments.length === 0) throw new TypeError("Failed to execute 'URL.parse': 1 argument required");
199+
input = `${input}`;
200+
if (base !== undefined) base = `${base}`;
201+
return canParse(input, base) ? new NativeURL(input, base) : null;
202+
},
203+
}.parse;
191204
Object.assign(globalThis, {
192205
TextEncoder,
193206
TextDecoder: encoding.TextDecoder,
194-
URL: url.URL,
207+
URL: NativeURL,
195208
URLSearchParams: url.URLSearchParams,
196209
atob: base64.atob,
197210
btoa: base64.btoa,
@@ -200,8 +213,8 @@
200213
return clone(value);
201214
},
202215
});
203-
delete url.URL.createObjectURL;
204-
delete url.URL.revokeObjectURL;
216+
delete NativeURL.createObjectURL;
217+
delete NativeURL.revokeObjectURL;
205218
const levels = ["debug", "log", "info", "warn", "error"];
206219
globalThis.console = Object.freeze(
207220
Object.fromEntries(

0 commit comments

Comments
 (0)