From cbb41950af739ea5a2fc17487e2fdb288c3da9c0 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Sat, 3 Oct 2026 03:47:20 +0800 Subject: [PATCH] fix(dom): mask AX password values per UTF-16 code unit Count UTF-16 code units when masking password values to match Chromium. Cover initial and live values across ASCII, BMP, supplementary characters, combining sequences, ZWJ emoji, and empty passwords, verified against Chromium 145 in 14 cases. --- moli-dom/src/accessibility/ax_properties.rs | 63 +++++++++++++++------ 1 file changed, 45 insertions(+), 18 deletions(-) diff --git a/moli-dom/src/accessibility/ax_properties.rs b/moli-dom/src/accessibility/ax_properties.rs index 626cd0057e..2c73447d31 100644 --- a/moli-dom/src/accessibility/ax_properties.rs +++ b/moli-dom/src/accessibility/ax_properties.rs @@ -403,9 +403,10 @@ pub(super) fn ax_value(document: &NativeDom, node_id: NodeId, node: &Node) -> Op | InputType::Submit ) => { - // Chromium exposes a password value only as one bullet per character. + // Chromium exposes a password value as one bullet per UTF-16 code unit. if element.input_type() == InputType::Password { - "\u{2022}".repeat(element.input_value().chars().count()) + let value = element.input_value(); + "\u{2022}".repeat(value.encode_utf16().count()) } else { element.input_value() } @@ -939,22 +940,48 @@ mod tests { } #[test] - fn password_value_is_exposed_as_bullets_only() { - let (mut document, root) = document_with_root(); - let input = document.create_element("input"); - assert!(document.set_attribute(input, "type", "password")); - assert!(document.set_attribute(input, "value", "secret")); - assert!(document.append_child(root, input)); + fn password_value_is_masked_per_utf16_code_unit() { + // Chromium's Accessibility.getFullAXTree masks both initial and live values. + for (password, bullet_count) in [ + ("secret", 6), + ("密码", 2), + ("😀", 2), + ("a😀密", 4), + ("e\u{301}", 2), + ("👩\u{200d}💻", 5), + ("", 0), + ] { + for live_value in [false, true] { + let (mut document, root) = document_with_root(); + let input = document.create_element("input"); + assert!(document.set_attribute(input, "type", "password")); + assert!(document.append_child(root, input)); + if live_value { + assert!(document.set_attribute(input, "value", "initial")); + let element = document + .node_mut(input) + .expect("password input") + .data_mut() + .as_element_mut() + .expect("password input element"); + assert!(element.set_input_value(password)); + } else { + assert!(document.set_attribute(input, "value", password)); + } - let value = ax_value( - &document, - input, - document.node(input).expect("password input"), - ) - .expect("password input has an AX value"); - assert_eq!( - value["value"], - "\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}" - ); + let value = ax_value( + &document, + input, + document.node(input).expect("password input"), + ); + let expected = (bullet_count > 0).then(|| { + json!({ + "type": "string", + "value": "\u{2022}".repeat(bullet_count), + }) + }); + assert_eq!(value, expected, "password={password:?}, live={live_value}"); + } + } } }