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'; +})()