fix(navigator): freeze and cache languages per realm

This commit is contained in:
ldm0
2026-10-18 05:51:05 +08:00
committed by Donough Liu
parent 4ee081b81b
commit ca22988ad7
5 changed files with 96 additions and 10 deletions
+20 -3
View File
@@ -1961,17 +1961,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;
@@ -1986,6 +1990,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"));
@@ -2025,7 +2030,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;
@@ -2038,6 +2051,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!(""));
@@ -964,10 +964,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<v8::Local<'s, v8::Array>> {
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>(
@@ -1050,7 +1056,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)?
@@ -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(),
@@ -2504,6 +2504,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),
@@ -2535,7 +2536,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"}"#
);
}
@@ -3334,3 +3335,20 @@ async fn worker_does_not_expose_window_audio_node_interface() {
.expect("channel closed");
assert_eq!(expect_post_json(message), r#"{"AudioNode":false}"#);
}
#[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""#);
}
+32
View File
@@ -0,0 +1,32 @@
// NavigatorLanguage.languages is a FrozenArray<DOMString> 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';
})()