From ba83b15f86e933220ebb94ad03fb255c12325984 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Tue, 8 Sep 2026 05:56:12 +0800 Subject: [PATCH] fix(console): avoid extra page code during log capture --- .../src/domains/runtime/tests/console.rs | 82 ++++++++++++ .../src/context_bootstrap/shared/console.rs | 119 ++++++++++-------- .../window_events/console.rs | 51 +++++++- .../script_vm/tests/browser_api/console.rs | 82 ++++++++++++ .../src/script_vm/tests/browser_api/mod.rs | 1 + .../src/worker/thread/tests/postmessage.rs | 33 +++++ 6 files changed, 312 insertions(+), 56 deletions(-) create mode 100644 moli-renderer-v8/src/script_vm/tests/browser_api/console.rs diff --git a/moli-protocol/src/domains/runtime/tests/console.rs b/moli-protocol/src/domains/runtime/tests/console.rs index cc87c820f..ee896a560 100644 --- a/moli-protocol/src/domains/runtime/tests/console.rs +++ b/moli-protocol/src/domains/runtime/tests/console.rs @@ -1310,6 +1310,88 @@ async fn runtime_console_api_called_preserves_basic_argument_shapes() { assert_eq!(args[11]["unserializableValue"], json!("1n")); } +#[tokio::test(flavor = "multi_thread")] +async fn runtime_console_capture_does_not_run_page_hooks_and_keeps_remote_objects() { + let mut ctx = TestContext::new(); + with_loaded_document_async(&mut ctx, "").await; + + // Exercise both states: reporting must be safe even before an Inspector + // Runtime session exists; enabling it must retain real remote object ids. + for (index, enabled) in [false, true].into_iter().enumerate() { + if enabled { + enable_runtime_and_take_execution_context_id_async(&mut ctx, 206_850).await; + } + ctx.sent.clear(); + let command_id = 206_851 + index as u64; + ctx.process_async(json!({ + "id": command_id, + "method": "Runtime.evaluate", + "params": {"expression": r#" + (() => { + const hits = []; + const object = { + answer: 42, + get getter() { hits.push('getter'); return 1; }, + toJSON() { hits.push('toJSON'); return {}; }, + [Symbol.toPrimitive]() { hits.push('toPrimitive'); return 'object'; } + }; + const proxy = new Proxy({}, { + get() { hits.push('Proxy.get'); }, + ownKeys() { hits.push('Proxy.ownKeys'); return []; }, + getOwnPropertyDescriptor() { hits.push('Proxy.descriptor'); } + }); + const error = new Error('console'); + Object.defineProperty(error, 'stack', {get() { hits.push('stack'); return 'stack'; }}); + console.log('safe-capture', object, proxy, error); + return JSON.stringify(hits); + })() + "#} + })).await; + let response = take_response_by_id(&mut ctx, command_id); + assert_eq!( + response["result"]["result"]["value"], + json!("[]"), + "{response}" + ); + if !enabled { + continue; + } + + wait_until_message(&mut ctx, None, "safe console object", |message| { + message["method"] == json!("Runtime.consoleAPICalled") + && message["params"]["args"][0]["value"] == json!("safe-capture") + }) + .await; + let object_id = ctx + .sent + .iter() + .find(|message| { + message["method"] == json!("Runtime.consoleAPICalled") + && message["params"]["args"][0]["value"] == json!("safe-capture") + }) + .unwrap()["params"]["args"][1]["objectId"] + .as_str() + .expect("native object id") + .to_owned(); + ctx.process_async(json!({ + "id": 206_853, + "method": "Runtime.getProperties", + "params": {"objectId": object_id, "ownProperties": true} + })) + .await; + let properties = take_response_by_id(&mut ctx, 206_853); + let properties = properties["result"]["result"] + .as_array() + .expect("inspectable object"); + assert!(properties.iter().any(|property| { + property["name"] == json!("answer") && property["value"]["value"] == json!(42) + })); + assert!(properties.iter().any(|property| { + property["name"] == json!("getter") && property["get"]["type"] == json!("function") + })); + } +} + #[tokio::test(flavor = "multi_thread")] async fn runtime_console_error_does_not_invoke_error_prepare_stack_trace() { let mut ctx = TestContext::new(); diff --git a/moli-renderer-v8/src/context_bootstrap/shared/console.rs b/moli-renderer-v8/src/context_bootstrap/shared/console.rs index dbdfbcf02..a85e93f0c 100644 --- a/moli-renderer-v8/src/context_bootstrap/shared/console.rs +++ b/moli-renderer-v8/src/context_bootstrap/shared/console.rs @@ -41,24 +41,16 @@ pub(in crate::context_bootstrap) fn append_console_message<'s>( level: &str, ) { let mut parts = Vec::with_capacity(args.length().max(0) as usize); + let mut arg_snapshot_values = Vec::with_capacity(parts.capacity()); for index in 0..args.length() { - let value = args.get(index); - let text = value - .to_string(scope) - .map(|value| value.to_rust_string_lossy(scope)) - .unwrap_or_else(|| String::from("undefined")); - parts.push(text); + let snapshot = console_arg_remote_object_json(scope, args.get(index)); + parts.push(console_arg_text(&snapshot)); + arg_snapshot_values.push(snapshot); } let text = parts.join(" "); let message = format!("{level}: {text}"); let stack = current_console_stack(scope); - let mut arg_snapshot_values = Vec::with_capacity(args.length().max(0) as usize); - for index in 0..args.length() { - let value = args.get(index); - arg_snapshot_values.push(console_arg_remote_object_json(scope, value)); - } - if let Some(buffers) = current_console_message_buffers(scope) { let mut buffers = buffers.borrow_mut(); buffers.messages.push(message.clone()); @@ -146,10 +138,9 @@ pub(crate) fn console_arg_remote_object_json( return serde_json::json!({ "type": "number" }); } if value.is_string() { - let value = value - .to_string(scope) - .map(|value| value.to_rust_string_lossy(scope)) - .unwrap_or_default(); + let value = v8::Local::::try_from(value) + .expect("string console argument") + .to_rust_string_lossy(scope); return serde_json::json!({ "type": "string", "value": value, @@ -158,17 +149,25 @@ pub(crate) fn console_arg_remote_object_json( if value.is_function() { return serde_json::json!({ "type": "function", - "description": value_description(scope, value), + "description": console_value_description(scope, value), }); } if value.is_symbol() { + let symbol = v8::Local::::try_from(value).expect("symbol console argument"); + let description = v8::Local::::try_from(symbol.description(scope)) + .map(|description| description.to_rust_string_lossy(scope)) + .unwrap_or_default(); return serde_json::json!({ "type": "symbol", - "description": value_description(scope, value), + "description": format!("Symbol({description})"), }); } if value.is_big_int() { - let mut description = value_description(scope, value); + // ToString of a primitive BigInt cannot invoke author conversion hooks. + let mut description = value + .to_string(scope) + .map(|value| value.to_rust_string_lossy(scope)) + .unwrap_or_default(); description.push('n'); return serde_json::json!({ "type": "bigint", @@ -176,50 +175,70 @@ pub(crate) fn console_arg_remote_object_json( }); } + // This is the renderer-owned reporting snapshot, not the Inspector's + // RemoteObject. The original V8 console still supplies inspectable objectIds + // to CDP. Never serialize/coerce objects here: getters, toJSON, conversion + // hooks and Proxy traps belong to the page, not to log bookkeeping. + let subtype = if value.is_proxy() { + Some("proxy") + } else if value.is_array() { + Some("array") + } else if value.is_native_error() { + Some("error") + } else if value.is_reg_exp() { + Some("regexp") + } else if value.is_date() { + Some("date") + } else if value.is_promise() { + Some("promise") + } else if value.is_map() { + Some("map") + } else if value.is_set() { + Some("set") + } else if value.is_typed_array() { + Some("typedarray") + } else if value.is_array_buffer() { + Some("arraybuffer") + } else { + None + }; let mut object = serde_json::json!({ - "type": "object", - "description": value_description(scope, value), + "type": "object", "description": console_value_description(scope, value) }); - if let Some(serialized) = json_serializable_console_value(scope, value) - && let Some(object) = object.as_object_mut() - { - object.insert("value".to_owned(), serialized); - } - if value.is_array() - && let Some(object) = object.as_object_mut() - { - object.insert( - "subtype".to_owned(), - serde_json::Value::String("array".to_owned()), - ); + if let Some(subtype) = subtype { + object["subtype"] = serde_json::json!(subtype); } object } -fn json_serializable_console_value( +fn console_value_description( scope: &mut v8::PinScope<'_, '_>, value: v8::Local<'_, v8::Value>, -) -> Option { - let json = { - let try_catch = std::pin::pin!(v8::TryCatch::new(scope)); - let scope = try_catch.init(); - let body = v8::json::stringify(&scope, value)?; - let body = body.to_rust_string_lossy(&scope); - if body == "undefined" { - return None; - } - body - }; - serde_json::from_str(&json).ok() -} - -fn value_description(scope: &mut v8::PinScope<'_, '_>, value: v8::Local<'_, v8::Value>) -> String { +) -> String { + // V8 implements ToDetailString with NoSideEffectsToString under a + // no-script scope. Unlike ToString, this cannot call author conversion + // hooks, but still retains useful native Error/function descriptions. value - .to_string(scope) + .to_detail_string(scope) .map(|value| value.to_rust_string_lossy(scope)) .unwrap_or_default() } +fn console_arg_text(snapshot: &serde_json::Value) -> String { + if let Some(value) = snapshot.get("value") { + return value + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| value.to_string()); + } + snapshot + .get("description") + .or_else(|| snapshot.get("unserializableValue")) + .and_then(serde_json::Value::as_str) + .unwrap_or("undefined") + .to_owned() +} + fn record_runtime_observable_console_source_event( scope: &mut v8::PinScope<'_, '_>, message: String, diff --git a/moli-renderer-v8/src/context_bootstrap/window_events/console.rs b/moli-renderer-v8/src/context_bootstrap/window_events/console.rs index fc44362d1..1422c63a1 100644 --- a/moli-renderer-v8/src/context_bootstrap/window_events/console.rs +++ b/moli-renderer-v8/src/context_bootstrap/window_events/console.rs @@ -153,7 +153,7 @@ fn call_original_console_method<'s>( forwarded_args.push(args.get(index)); } let suppress_page_stack_hook = forwarded_args.iter().any(|value| value.is_native_error()) - && error_prepare_stack_trace_is_function(scope); + && error_prepare_stack_trace_has_page_hook(scope); if suppress_page_stack_hook { scope.set_prepare_stack_trace_callback(inspector_console_stack_without_page_hook); } @@ -163,13 +163,52 @@ fn call_original_console_method<'s>( } } -fn error_prepare_stack_trace_is_function(scope: &mut v8::PinScope<'_, '_>) -> bool { +fn error_prepare_stack_trace_has_page_hook(scope: &mut v8::PinScope<'_, '_>) -> bool { let global = scope.get_current_context().global(scope); - global - .get(scope, v8str(scope, "Error").into()) + let Some(descriptor) = own_property_descriptor(scope, global, "Error") else { + return false; + }; + // Inspect descriptor data, never the property itself. Looking for a page + // hook must not invoke an Error/prepareStackTrace accessor or Proxy trap. + if own_descriptor_value(scope, descriptor, "get").is_some_and(|v| v.is_function()) { + return true; + } + let Some(error) = own_descriptor_value(scope, descriptor, "value") + .and_then(|value| v8::Local::::try_from(value).ok()) + else { + return false; + }; + if error.is_proxy() { + return true; + } + let Some(descriptor) = own_property_descriptor(scope, error, "prepareStackTrace") else { + return false; + }; + ["value", "get"].into_iter().any(|key| { + own_descriptor_value(scope, descriptor, key).is_some_and(|value| value.is_function()) + }) +} + +fn own_property_descriptor<'s>( + scope: &mut v8::PinScope<'s, '_>, + object: v8::Local<'s, v8::Object>, + name: &'static str, +) -> Option> { + object + .get_own_property_descriptor(scope, v8str(scope, name).into()) .and_then(|value| v8::Local::::try_from(value).ok()) - .and_then(|error| error.get(scope, v8str(scope, "prepareStackTrace").into())) - .is_some_and(|value| value.is_function()) +} + +fn own_descriptor_value<'s>( + scope: &mut v8::PinScope<'s, '_>, + descriptor: v8::Local<'s, v8::Object>, + name: &'static str, +) -> Option> { + let key = v8str(scope, name); + if descriptor.has_own_property(scope, key.into()) != Some(true) { + return None; + } + descriptor.get(scope, key.into()) } fn inspector_console_stack_without_page_hook<'s>( diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/console.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/console.rs new file mode 100644 index 000000000..68aec6a15 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/console.rs @@ -0,0 +1,82 @@ +use super::*; + +#[test] +fn console_reporting_does_not_add_page_object_conversions() { + let mut vm = new_storage_test_vm("https://console-reporting.test/"); + let result = vm.eval(r#" +(() => { + const hits = []; + let functionConversions = 0; + const object = { + get property() { hits.push('getter'); return 1; }, + toJSON() { hits.push('toJSON'); return {}; }, + toString() { hits.push('toString'); return 'object'; }, + [Symbol.toPrimitive]() { hits.push('toPrimitive'); return 'object'; } + }; + const proxy = new Proxy({}, { + get() { hits.push('Proxy.get'); }, + ownKeys() { hits.push('Proxy.ownKeys'); return []; }, + getOwnPropertyDescriptor() { hits.push('Proxy.getOwnPropertyDescriptor'); } + }); + const fn = function named() {}; + fn.toString = () => { functionConversions++; return 'function'; }; + const error = new Error('console'); + Object.defineProperty(error, 'stack', {enumerable: true, get() { hits.push('stack'); return 'stack'; }}); + for (const method of ['log','info','warn','error','debug','trace']) { + console[method](object, proxy, fn, error, Symbol('console'), 1n); + } + // Chromium's original V8 console converts a function once per call. The + // renderer reporting copy must not add another conversion to that path. + return JSON.stringify([hits, functionConversions]); +})() +"#).unwrap(); + assert_eq!(result, "[[],6]"); +} + +#[test] +fn console_reporting_cannot_turn_a_throwing_conversion_into_a_page_exception() { + let mut vm = new_storage_test_vm("https://console-reporting.test/"); + assert_eq!( + vm.eval( + r#" +const object = { + [Symbol.toPrimitive]() { throw new Error('conversion must not run'); }, + toJSON() { throw new Error('serialization must not run'); } +}; +console.log(object); +'continued' +"# + ) + .unwrap(), + "continued" + ); +} + +#[test] +fn console_reporting_does_not_read_a_page_prepare_stack_trace_getter() { + let mut vm = new_storage_test_vm("https://console-reporting.test/"); + let result = vm + .eval( + r#" +(() => { + let hits = 0; + const original = Object.getOwnPropertyDescriptor(Error, 'prepareStackTrace'); + Object.defineProperty(Error, 'prepareStackTrace', { + configurable: true, + get() { hits++; return () => 'page stack'; } + }); + try { + console.log(new Error('console')); + const afterConsole = hits; + void new Error('explicit stack').stack; + return `${afterConsole}|${hits}`; + } finally { + delete Error.prepareStackTrace; + if (original) Object.defineProperty(Error, 'prepareStackTrace', original); + } +})() +"#, + ) + .unwrap(); + assert_eq!(result, "0|1"); +} diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs index 612cf6dcd..91d9730e4 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs @@ -2,6 +2,7 @@ use super::*; mod broadcast_channel; mod chrome; +mod console; mod crypto_misc; mod crypto_subtle_aes; mod crypto_subtle_digest; diff --git a/moli-renderer-v8/src/worker/thread/tests/postmessage.rs b/moli-renderer-v8/src/worker/thread/tests/postmessage.rs index 23ad063b7..e174391c6 100644 --- a/moli-renderer-v8/src/worker/thread/tests/postmessage.rs +++ b/moli-renderer-v8/src/worker/thread/tests/postmessage.rs @@ -33,6 +33,39 @@ async fn worker_compression_streams_roundtrip_all_formats() { ); } +#[tokio::test] +async fn worker_console_capture_does_not_serialize_or_coerce_page_objects() { + ensure_v8(); + let mut handle = spawn_worker( + r#" + let hits = 0; + const object = { + get property() { hits++; return 1; }, + toJSON() { hits++; return {}; }, + [Symbol.toPrimitive]() { hits++; return 'object'; } + }; + const proxy = new Proxy({}, { + get() { hits++; }, ownKeys() { hits++; return []; } + }); + console.log(object, proxy); + postMessage(hits); + "# + .into(), + "https://console-worker.test/worker.js".into(), + ); + loop { + let message = timeout(TIMEOUT, handle.recv()) + .await + .expect("worker console probe") + .expect("worker result"); + if matches!(message, WorkerToParentMessage::Console(_)) { + continue; + } + assert_eq!(expect_post_json(message), "0"); + break; + } +} + #[tokio::test] async fn worker_postmessage_to_parent() { ensure_v8();