From 8a0bd3a04881b1c0ff52fb40166d4d7a7e5aa4e4 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Sun, 13 Sep 2026 12:16:03 +0800 Subject: [PATCH] fix(navigator): freeze language arrays in every realm Freeze the native language snapshot before caching it, and retain frozen snapshots across identity overrides. Cover Window, Worker, shared worker, borrowed realm getters and live CDP profile changes. --- moli-protocol/src/domains/emulation/tests.rs | 23 +++++++++++-- .../navigator_runtime/navigator.rs | 14 +++++--- .../script_vm/tests/lazy_window_surfaces.rs | 17 ++++++++-- .../src/worker/thread/tests/postmessage.rs | 20 +++++++++++- .../tests/fixtures/navigator-languages.js | 32 +++++++++++++++++++ 5 files changed, 96 insertions(+), 10 deletions(-) create mode 100644 moli-renderer-v8/tests/fixtures/navigator-languages.js diff --git a/moli-protocol/src/domains/emulation/tests.rs b/moli-protocol/src/domains/emulation/tests.rs index dd3ad74ca2..6746b2c3e0 100644 --- a/moli-protocol/src/domains/emulation/tests.rs +++ b/moli-protocol/src/domains/emulation/tests.rs @@ -1936,17 +1936,21 @@ async fn set_user_agent_override_applies_complete_chromium_identity_profile() { "params": { "awaitPromise": true, "returnByValue": true, - "expression": r#"(async () => JSON.stringify({ + "expression": r#"(async () => { + globalThis.__identityLanguages = navigator.languages; + return JSON.stringify({ userAgent: navigator.userAgent, platform: navigator.platform, language: navigator.language, languages: navigator.languages, + languagesFrozen: Object.isFrozen(navigator.languages), base: navigator.userAgentData.toJSON(), high: await navigator.userAgentData.getHighEntropyValues([ 'architecture', 'bitness', 'formFactors', 'fullVersionList', 'platformVersion', 'uaFullVersion', 'wow64' ]) - }))()"# + }); + })()"# } })) .await; @@ -1961,6 +1965,7 @@ async fn set_user_agent_override_applies_complete_chromium_identity_profile() { assert_eq!(identity["platform"], json!("Linux x86_64")); assert_eq!(identity["language"], json!("fr-CA")); assert_eq!(identity["languages"], json!(["fr-CA", "fr;q=0.9"])); + assert_eq!(identity["languagesFrozen"], json!(true)); assert_eq!(identity["base"]["platform"], json!("Linux")); assert_eq!(identity["base"]["brands"][0]["brand"], json!("Chromium")); assert_eq!(identity["high"]["architecture"], json!("x86")); @@ -2000,7 +2005,15 @@ async fn set_user_agent_override_applies_complete_chromium_identity_profile() { "sessionId": "SID-1", "params": { "returnByValue": true, - "expression": "JSON.stringify({ platform: navigator.platform, languages: navigator.languages, uaData: navigator.userAgentData.toJSON() })" + "expression": r#"JSON.stringify({ + platform: navigator.platform, + languages: navigator.languages, + languagesFrozen: Object.isFrozen(navigator.languages), + oldLanguages: __identityLanguages, + oldLanguagesFrozen: Object.isFrozen(__identityLanguages), + languagesReplaced: __identityLanguages !== navigator.languages, + uaData: navigator.userAgentData.toJSON() + })"# } })) .await; @@ -2013,6 +2026,10 @@ async fn set_user_agent_override_applies_complete_chromium_identity_profile() { .expect("identity result should parse"); assert_eq!(identity["platform"], json!("Win32")); assert_eq!(identity["languages"], json!(["en-US", "en"])); + assert_eq!(identity["languagesFrozen"], json!(true)); + assert_eq!(identity["oldLanguages"], json!(["fr-CA", "fr;q=0.9"])); + assert_eq!(identity["oldLanguagesFrozen"], json!(true)); + assert_eq!(identity["languagesReplaced"], json!(true)); assert_eq!(identity["uaData"]["brands"], json!([])); assert_eq!(identity["uaData"]["platform"], json!("")); diff --git a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs index ff1398b509..ec03f1c829 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs @@ -970,10 +970,16 @@ fn delete_object_property( fn build_languages_array<'s>( scope: &mut v8::PinScope<'s, '_>, languages: &[String], -) -> v8::Local<'s, v8::Array> { - serialize_v8_array(scope, languages) +) -> Result> { + let array = serialize_v8_array(scope, languages) .or_else(|| serialize_v8_array(scope, [""])) - .unwrap_or_else(|| v8::Array::new(scope, 0)) + .unwrap_or_else(|| v8::Array::new(scope, 0)); + // NavigatorLanguage returns a FrozenArray in both Window and Worker realms. + // Freeze the native snapshot before it can enter the lazy subobject cache. + if array.set_integrity_level(scope, v8::IntegrityLevel::Frozen) != Some(true) { + return Err(anyhow!("failed to freeze Navigator.languages")); + } + Ok(array) } fn build_navigator_connection<'s>( @@ -1056,7 +1062,7 @@ pub(super) fn build_lazy_navigator_subobject_in_current_realm<'s>( let value: v8::Local<'s, v8::Value> = match subobject { NavigatorSubobject::Languages => { let languages = effective_navigator_languages(scope, backing); - build_languages_array(scope, &languages).into() + build_languages_array(scope, &languages)?.into() } NavigatorSubobject::MimeTypes | NavigatorSubobject::Plugins => { build_navigator_plugin_collection_subobject(scope, backing, subobject)? diff --git a/moli-renderer-v8/src/script_vm/tests/lazy_window_surfaces.rs b/moli-renderer-v8/src/script_vm/tests/lazy_window_surfaces.rs index cba881facc..58714e30d8 100644 --- a/moli-renderer-v8/src/script_vm/tests/lazy_window_surfaces.rs +++ b/moli-renderer-v8/src/script_vm/tests/lazy_window_surfaces.rs @@ -691,7 +691,7 @@ fn navigator_scalar_access_does_not_materialize_unused_same_object_children() { "#, ) .expect("Navigator lazy subobjects should materialize"), - r#"{"same":true,"languagesFrozen":false,"permissionsRealm":true,"mediaDevicesRealm":true,"storageRealm":true,"clipboardRealm":true,"geolocationRealm":true,"mediaCapabilitiesRealm":true}"# + r#"{"same":true,"languagesFrozen":true,"permissionsRealm":true,"mediaDevicesRealm":true,"storageRealm":true,"clipboardRealm":true,"geolocationRealm":true,"mediaCapabilitiesRealm":true}"# ); assert_eq!( default_navigator_subobjects(&mut vm), @@ -732,6 +732,18 @@ fn navigator_scalar_access_does_not_materialize_unused_same_object_children() { ); } +#[test] +fn navigator_languages_are_frozen() { + let mut vm = new_storage_test_vm("https://navigator-languages.test/"); + assert_eq!( + vm.eval(include_str!( + "../../../tests/fixtures/navigator-languages.js" + )) + .expect("Navigator.languages must satisfy the FrozenArray contract"), + "ok" + ); +} + #[test] fn detached_child_screen_keeps_identity_and_zeros_geometry() { let mut vm = new_storage_test_vm("https://detached-child-screen.test/"); @@ -807,6 +819,7 @@ fn borrowed_navigator_getters_materialize_subobjects_in_the_receiver_realm() { const permissions = permissionsGetter.call(child.navigator); return JSON.stringify({ languagesSame: languages === child.navigator.languages, + languagesFrozen: Object.isFrozen(languages), languagesRealm: Object.getPrototypeOf(languages) === child.Array.prototype, permissionsSame: permissions === child.navigator.permissions, @@ -817,7 +830,7 @@ fn borrowed_navigator_getters_materialize_subobjects_in_the_receiver_realm() { "#, ) .expect("borrowed Navigator getters should use the receiver realm"), - r#"{"languagesSame":true,"languagesRealm":true,"permissionsSame":true,"permissionsRealm":true}"# + r#"{"languagesSame":true,"languagesFrozen":true,"languagesRealm":true,"permissionsSame":true,"permissionsRealm":true}"# ); assert!( default_navigator_subobjects(&mut vm).is_empty(), diff --git a/moli-renderer-v8/src/worker/thread/tests/postmessage.rs b/moli-renderer-v8/src/worker/thread/tests/postmessage.rs index 7dbbd16c43..178dbd622b 100644 --- a/moli-renderer-v8/src/worker/thread/tests/postmessage.rs +++ b/moli-renderer-v8/src/worker/thread/tests/postmessage.rs @@ -2082,6 +2082,23 @@ async fn worker_offscreen_canvas_exposes_webgl_identity_consistently() { ); } +#[tokio::test] +async fn worker_navigator_languages_are_frozen() { + ensure_v8(); + let mut handle = spawn_worker( + format!( + "try {{ postMessage({}); }} catch (error) {{ postMessage(String(error)); }} close();", + include_str!("../../../../tests/fixtures/navigator-languages.js") + ), + "https://navigator-languages.test/worker.js".into(), + ); + let message = timeout(TIMEOUT, handle.recv()) + .await + .expect("worker language contract must settle") + .expect("worker must return a language contract result"); + assert_eq!(expect_post_json(message), r#""ok""#); +} + #[tokio::test] async fn worker_media_capabilities_matches_the_software_chromium_profile() { ensure_v8(); @@ -2240,6 +2257,7 @@ async fn shared_worker_navigator_exposes_canonical_user_agent_data() { sameObject: uaData === navigator.userAgentData, userAgent: navigator.userAgent, languages: Array.from(navigator.languages), + languagesFrozen: Object.isFrozen(navigator.languages), brands: uaData.brands, emptyKeys: Object.keys(empty), selectedKeys: Object.keys(selected), @@ -2271,7 +2289,7 @@ async fn shared_worker_navigator_exposes_canonical_user_agent_data() { "JSON.stringify(__wire)", ) .await, - r#"{"constructorType":"function","dataType":"object","instance":true,"sameObject":false,"userAgent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.1.2.3 Safari/537.36","languages":["de-DE","de"],"brands":[{"brand":"Chromium","version":"146"},{"brand":"Not-A.Brand","version":"24"},{"brand":"Google Chrome","version":"146"}],"emptyKeys":["brands","mobile","platform"],"selectedKeys":["architecture","brands","mobile","platform"],"architecture":"x86"}"# + r#"{"constructorType":"function","dataType":"object","instance":true,"sameObject":false,"userAgent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.1.2.3 Safari/537.36","languages":["de-DE","de"],"languagesFrozen":true,"brands":[{"brand":"Chromium","version":"146"},{"brand":"Not-A.Brand","version":"24"},{"brand":"Google Chrome","version":"146"}],"emptyKeys":["brands","mobile","platform"],"selectedKeys":["architecture","brands","mobile","platform"],"architecture":"x86"}"# ); } diff --git a/moli-renderer-v8/tests/fixtures/navigator-languages.js b/moli-renderer-v8/tests/fixtures/navigator-languages.js new file mode 100644 index 0000000000..77c7acfc81 --- /dev/null +++ b/moli-renderer-v8/tests/fixtures/navigator-languages.js @@ -0,0 +1,32 @@ +// NavigatorLanguage.languages is a FrozenArray in its own realm. +(() => { + const languages = navigator.languages; + const check = (value, message) => { if (!value) throw new Error(message); }; + check(Array.isArray(languages), 'languages must be an Array'); + check(Object.getPrototypeOf(languages) === Array.prototype, 'languages array realm'); + check(languages.every(value => typeof value === 'string'), 'language values'); + check(Object.isFrozen(languages), 'languages must be frozen'); + check(!Object.isExtensible(languages), 'languages must not be extensible'); + const length = Object.getOwnPropertyDescriptor(languages, 'length'); + check(!length.writable && !length.configurable && !length.enumerable, 'length descriptor'); + for (let index = 0; index < languages.length; index++) { + const item = Object.getOwnPropertyDescriptor(languages, index); + check(!item.writable && !item.configurable && item.enumerable, 'item descriptor'); + } + const before = JSON.stringify(languages); + check(!Reflect.set(languages, '0', 'changed'), 'Reflect.set must fail'); + check(!Reflect.defineProperty(languages, 'extra', {value: true}), 'extra property must fail'); + if (languages.length) check(!Reflect.deleteProperty(languages, '0'), 'delete must fail'); + for (const mutate of [ + () => { 'use strict'; languages[0] = 'changed'; }, + () => languages.push('changed'), + () => { 'use strict'; languages.length = 0; } + ]) { + let threw = false; + try { mutate(); } catch (error) { threw = error instanceof TypeError; } + check(threw, 'strict mutation must throw TypeError'); + } + check(JSON.stringify(languages) === before, 'cached language values must not mutate'); + check(JSON.stringify(navigator.languages) === before, 'later reads must remain unchanged'); + return 'ok'; +})()