fix(dom): validate NamedNodeMap receivers and Attr arguments

This commit is contained in:
ldm0
2026-10-22 15:12:24 +08:00
committed by Donough Liu
parent b4b9534808
commit 45be4e3531
7 changed files with 320 additions and 5 deletions
@@ -18,7 +18,7 @@ pub(in crate::native_bridge::document) use methods::{
named_node_map_set_named_item_ns_method_callback,
};
#[derive(WebApiFunctionTemplate)]
#[webapi(interface = web_api_interfaces::NamedNodeMap, enumerable)]
#[webapi(interface = web_api_interfaces::NamedNodeMap, enumerable, receiver)]
struct NamedNodeMapPrototypeDeclaration {
#[webapi(accessor_property, getter = named_node_map_length_getter_callback)]
length: (),
@@ -42,6 +42,9 @@ pub(crate) fn live_named_node_map_wrapper<'s>(
wrapper.set_internal_field(0, element.into()),
"NamedNodeMap wrapper must expose its owner element field"
);
web_api_interfaces::NamedNodeMap::DESCRIPTOR
.initialize(scope, wrapper)
.expect("NamedNodeMap wrapper must have native identity");
super::super::super::super::bindings::set_named_constructor_prototype(
scope,
wrapper,
@@ -11,6 +11,7 @@ pub(super) fn named_node_map_element<'s>(
scope: &mut v8::PinScope<'s, '_>,
map: v8::Local<'s, v8::Object>,
) -> Option<v8::Local<'s, v8::Object>> {
let map = moli_webapi_declare::web_api_object_target(scope, map)?;
get_private_object(scope, map, NAMED_NODE_MAP_ELEMENT_SLOT).or_else(|| {
map.get_internal_field(scope, 0)
.and_then(|value| v8::Local::<v8::Object>::try_from(value).ok())
@@ -30,6 +30,13 @@ struct NamedNodeMapNamespaceArgs {
local_name: String,
}
#[derive(webidl::WebIdlArgs)]
#[webidl(prefix = "NamedNodeMap")]
struct NamedNodeMapAttrArgs<'s> {
#[webidl(required, interface = web_api_interfaces::Attr)]
attr: v8::Local<'s, v8::Object>,
}
pub(in crate::native_bridge::document) fn named_node_map_item_method_callback<'a>(
scope: &mut v8::PinScope<'a, '_>,
args: v8::FunctionCallbackArguments<'a>,
@@ -77,15 +84,20 @@ pub(in crate::native_bridge::document) fn named_node_map_set_named_item_method_c
rv.set_null();
return;
};
let Some(parsed) = webidl::parse_args::<NamedNodeMapAttrArgs<'a>>(scope, &args) else {
return;
};
let attr = moli_webapi_declare::web_api_object_target(scope, parsed.attr)
.expect("converted Attr has native identity");
if let Some((runtime_ptr, _)) = detached_native_element_runtime_and_handle(scope, element) {
crate::custom_elements::with_custom_element_reaction_scope(scope, runtime_ptr, |scope| {
if let Some(value) = detached_native_set_attribute_node(scope, element, args.get(0)) {
if let Some(value) = detached_native_set_attribute_node(scope, element, attr.into()) {
rv.set(value);
}
});
return;
}
let Some(value) = live_set_attribute_node(scope, element, args.get(0)) else {
let Some(value) = live_set_attribute_node(scope, element, attr.into()) else {
rv.set_null();
return;
};
@@ -168,15 +180,20 @@ pub(in crate::native_bridge::document) fn named_node_map_set_named_item_ns_metho
rv.set_null();
return;
};
let Some(parsed) = webidl::parse_args::<NamedNodeMapAttrArgs<'a>>(scope, &args) else {
return;
};
let attr = moli_webapi_declare::web_api_object_target(scope, parsed.attr)
.expect("converted Attr has native identity");
if let Some((runtime_ptr, _)) = detached_native_element_runtime_and_handle(scope, element) {
crate::custom_elements::with_custom_element_reaction_scope(scope, runtime_ptr, |scope| {
if let Some(value) = detached_native_set_attribute_node(scope, element, args.get(0)) {
if let Some(value) = detached_native_set_attribute_node(scope, element, attr.into()) {
rv.set(value);
}
});
return;
}
let Some(value) = live_set_attribute_node(scope, element, args.get(0)) else {
let Some(value) = live_set_attribute_node(scope, element, attr.into()) else {
rv.set_null();
return;
};
@@ -83,3 +83,5 @@ mod window_current_event_private;
mod video_codec_support;
mod event_prototype_attributes;
mod named_node_map_receivers;
@@ -0,0 +1,126 @@
use super::*;
#[test]
fn named_node_map_receivers_validate_native_identity_before_attr_conversion() {
let mut vm = new_parsed_test_vm(
"https://named-node-map-receivers.test/",
"<!doctype html><iframe id=child></iframe>",
);
let result = vm
.eval(include_str!(
"../../../../tests/fixtures/named-node-map-receivers.js"
))
.expect("NamedNodeMap receiver matrix should complete");
assert_eq!(vm.eval("__uiEventResults.total").unwrap(), "1058");
assert_eq!(
result,
"true",
"{}",
vm.eval("JSON.stringify(__uiEventResults.rows.filter(row => !row.passed))")
.unwrap(),
);
}
#[test]
fn named_node_map_receivers_and_attr_arguments_accept_registered_native_proxies() {
let mut vm = new_parsed_test_vm(
"https://named-node-map-native-proxies.test/",
"<!doctype html><iframe id=child></iframe>",
);
vm.eval(
r#"
globalThis.nativeMapRecords = [];
globalThis.nativeMapTraps = 0;
const other = document.querySelector('#child').contentWindow;
function handler() {
return {
get() {nativeMapTraps++; throw new Error('get trap');},
getPrototypeOf() {nativeMapTraps++; throw new Error('prototype trap');}
};
}
for (const owner of [globalThis, other]) {
for (const flavor of ['live', 'html', 'xml', 'parsed']) {
const doc = flavor === 'live' ? owner.document :
flavor === 'html' ? owner.document.implementation.createHTMLDocument('attributes') :
flavor === 'xml' ? owner.document.implementation.createDocument(null, 'root') :
new owner.DOMParser().parseFromString('<root/>', 'application/xml');
const root = flavor === 'xml' || flavor === 'parsed' ? doc.documentElement : doc.createElement('div');
root.setAttribute('data-x', 'one');
const map = root.attributes;
const attr = doc.createAttributeNS('urn:native', 'n:data-native'); attr.value = 'native';
const nativeMap = Proxy.revocable(map, handler());
const nativeAttr = Proxy.revocable(attr, handler());
nativeMapRecords.push({root, map, attr, mapProxy: nativeMap.proxy, attrProxy: nativeAttr.proxy,
mapRevoke: nativeMap.revoke, attrRevoke: nativeAttr.revoke,
callee: owner === globalThis ? other : globalThis});
}
}
true;
"#,
)
.unwrap();
vm.with_default_context_scope_and_checkpoint_for_test(|scope, _host_ptr| {
let global = scope.get_current_context().global(scope);
let key = v8::String::new(scope, "nativeMapRecords").unwrap();
let value = global.get(scope, key.into()).unwrap();
let records = v8::Local::<v8::Array>::try_from(value).unwrap();
assert_eq!(records.length(), 8);
for index in 0..records.length() {
let value = records.get_index(scope, index).unwrap();
let record = v8::Local::<v8::Object>::try_from(value).unwrap();
for (name, is_map) in [("mapProxy", true), ("attrProxy", false)] {
let key = v8::String::new(scope, name).unwrap();
let value = record.get(scope, key.into()).unwrap();
let proxy = v8::Local::<v8::Proxy>::try_from(value).unwrap();
moli_webapi_declare::register_web_api_proxy(scope, proxy).unwrap();
assert_eq!(
crate::web_api_interfaces::NamedNodeMap::is_instance(scope, proxy.into()),
is_map,
);
assert_eq!(
crate::web_api_interfaces::Attr::is_instance(scope, proxy.into()),
!is_map,
);
}
}
Ok(())
})
.unwrap();
assert_eq!(
vm.eval(
r#"(() => {
for (const record of nativeMapRecords) {
const {root, map, attr, mapProxy, attrProxy, mapRevoke, attrRevoke, callee} = record;
const prototype = callee.NamedNodeMap.prototype;
const length = Object.getOwnPropertyDescriptor(prototype, 'length').get;
const fails = body => {
try { body(); return false; }
catch (error) { return Object.getPrototypeOf(error) === callee.TypeError.prototype; }
};
if (length.call(mapProxy) !== 1 || prototype.item.call(mapProxy, 0) !== root.getAttributeNode('data-x')) return false;
if (prototype.setNamedItem.call(mapProxy, attrProxy) !== null || attr.ownerElement !== root) return false;
if (prototype.getNamedItem.call(mapProxy, 'n:data-native') !== attr) return false;
if (prototype.removeNamedItem.call(mapProxy, 'n:data-native') !== attr || attr.ownerElement !== null) return false;
if (prototype.setNamedItemNS.call(mapProxy, attrProxy) !== null || attr.ownerElement !== root) return false;
if (prototype.getNamedItemNS.call(mapProxy, 'urn:native', 'data-native') !== attr) return false;
if (prototype.removeNamedItemNS.call(mapProxy, 'urn:native', 'data-native') !== attr || attr.ownerElement !== null) return false;
if (length.call(mapProxy) !== 1 || root.attributes !== map) return false;
let conversions = 0;
const name = {toString() {conversions++; throw 1;}};
if (!fails(() => length.call(new Proxy(mapProxy, {})))) return false;
if (!fails(() => prototype.getNamedItem.call(new Proxy(mapProxy, {}), name))) return false;
if (!fails(() => prototype.setNamedItem.call(mapProxy, new Proxy(attrProxy, {})))) return false;
if (!fails(() => prototype.setNamedItemNS.call(mapProxy, root))) return false;
if (conversions !== 0 || nativeMapTraps !== 0) return false;
attrRevoke();
if (!fails(() => prototype.setNamedItem.call(mapProxy, attrProxy))) return false;
mapRevoke();
if (!fails(() => length.call(mapProxy))) return false;
}
return nativeMapTraps === 0;
})()"#,
)
.unwrap(),
"true",
);
}
@@ -0,0 +1,166 @@
(() => {
const rows = [];
const check = (name, body) => {
try { rows.push({name, passed: body() === true}); }
catch (error) { rows.push({name, passed: false, error: String(error)}); }
};
const typeError = (body, realm) => {
try { body(); return false; }
catch (error) { return Object.getPrototypeOf(error) === realm.TypeError.prototype; }
};
const realms = [['top', globalThis], ['child', document.querySelector('#child').contentWindow]];
const entries = [['item', 1], ['getNamedItem', 1], ['getNamedItemNS', 2],
['setNamedItem', 1], ['setNamedItemNS', 1], ['removeNamedItem', 1], ['removeNamedItemNS', 2]];
const setup = (owner, flavor = 'live') => {
const doc = flavor === 'live' ? owner.document :
flavor === 'html' ? owner.document.implementation.createHTMLDocument('attributes') :
flavor === 'xml' ? owner.document.implementation.createDocument(null, 'root') :
new owner.DOMParser().parseFromString('<root/>', 'application/xml');
const root = flavor === 'xml' || flavor === 'parsed' ? doc.documentElement : doc.createElement('div');
root.setAttribute('data-x', 'one');
root.setAttributeNS('urn:map', 'p:y', 'two');
return {doc, root, map: root.attributes};
};
for (const [calleeName, callee] of realms) {
const prototype = callee.NamedNodeMap.prototype;
const length = Object.getOwnPropertyDescriptor(prototype, 'length').get;
const methods = Object.fromEntries(entries.map(([name]) => [name, prototype[name]]));
check(`${calleeName} length prototype descriptor`, () => {
const d = Object.getOwnPropertyDescriptor(prototype, 'length');
return d.enumerable && d.configurable && d.get.name === 'get length' && d.get.length === 0 &&
d.set === undefined && Object.getPrototypeOf(d.get) === callee.Function.prototype;
});
for (const [name, arity] of entries) {
check(`${calleeName} ${name} prototype descriptor`, () => {
const d = Object.getOwnPropertyDescriptor(prototype, name);
return d.enumerable && d.configurable && d.writable && d.value.name === name &&
d.value.length === arity && Object.getPrototypeOf(d.value) === callee.Function.prototype;
});
}
for (const [ownerName, owner] of realms) {
for (const flavor of ['live', 'html', 'xml', 'parsed']) {
const label = `${calleeName} borrows ${ownerName} ${flavor}`;
const {doc, root, map} = setup(owner, flavor);
check(`${label} factory realm and SameObject`, () =>
Object.getPrototypeOf(map) === owner.NamedNodeMap.prototype &&
map instanceof owner.NamedNodeMap && root.attributes === map);
check(`${label} borrowed getters and lookups retain Attr identity`, () =>
length.call(map) === 2 && methods.item.call(map, 0) === root.getAttributeNode('data-x') &&
methods.getNamedItem.call(map, 'data-x') === map[0] &&
methods.getNamedItemNS.call(map, 'urn:map', 'y') === root.getAttributeNodeNS('urn:map', 'y'));
check(`${label} live mutation retains cached Attr identity`, () => {
const attr = methods.getNamedItem.call(map, 'data-x');
root.setAttribute('data-x', 'updated'); root.setAttribute('data-late', 'later');
return root.attributes === map && length.call(map) === 3 &&
methods.getNamedItem.call(map, 'data-x') === attr && attr.value === 'updated' &&
methods.getNamedItem.call(map, 'data-late').value === 'later';
});
check(`${label} set and remove preserve owner and return value`, () => {
const sample = setup(owner, flavor), attr = sample.doc.createAttribute('data-new'); attr.value = 'new';
return methods.setNamedItem.call(sample.map, attr) === null && attr.ownerElement === sample.root &&
length.call(sample.map) === 3 && methods.getNamedItem.call(sample.map, 'data-new') === attr &&
methods.removeNamedItem.call(sample.map, 'data-new') === attr && attr.ownerElement === null &&
length.call(sample.map) === 2;
});
check(`${label} namespace mutation preserves owner and return value`, () => {
const sample = setup(owner, flavor), attr = sample.doc.createAttributeNS('urn:new', 'n:later'); attr.value = 'new';
return methods.setNamedItemNS.call(sample.map, attr) === null && attr.ownerElement === sample.root &&
methods.getNamedItemNS.call(sample.map, 'urn:new', 'later') === attr &&
methods.removeNamedItemNS.call(sample.map, 'urn:new', 'later') === attr &&
attr.ownerElement === null && length.call(sample.map) === 2;
});
check(`${label} replacement returns the previous Attr`, () => {
const sample = setup(owner, flavor), old = sample.root.getAttributeNode('data-x');
const next = sample.doc.createAttribute('data-x'); next.value = 'replacement';
return methods.setNamedItem.call(sample.map, next) === old && old.ownerElement === null &&
next.ownerElement === sample.root && methods.getNamedItem.call(sample.map, 'data-x') === next &&
length.call(sample.map) === 2;
});
check(`${label} prototype removal preserves native backing`, () => {
const sample = setup(owner, flavor); Object.setPrototypeOf(sample.map, null);
const attr = sample.doc.createAttribute('data-new');
return length.call(sample.map) === 2 && methods.getNamedItem.call(sample.map, 'data-x') === sample.map[0] &&
methods.setNamedItem.call(sample.map, attr) === null &&
methods.removeNamedItem.call(sample.map, 'data-new') === attr;
});
check(`${label} author Proxy still forwards own indexed and named data`, () => {
const sample = setup(owner, flavor), proxy = new Proxy(sample.map, {});
return proxy[0] === sample.map[0] && proxy['data-x'] === sample.map['data-x'];
});
check(`${label} intrinsic iterator preserves Attr values`, () => {
const sample = setup(owner, flavor), iterate = prototype[Symbol.iterator];
const values = Array.from(iterate.call(sample.map));
return iterate === callee.Array.prototype.values && values.length === 2 &&
values[0] === methods.item.call(sample.map, 0) && values[1] === methods.item.call(sample.map, 1);
});
}
const sample = setup(owner);
let traps = 0, conversions = 0;
const handler = {get() {traps++; throw 1;}, getPrototypeOf() {traps++; throw 1;}, has() {traps++; throw 1;}};
const proxy = new Proxy(sample.map, handler);
const revoked = Proxy.revocable(sample.map, handler); revoked.revoke();
const invalid = [['null', null], ['undefined', undefined], ['plain', {}], ['number', 1],
['prototype', owner.NamedNodeMap.prototype], ['forged', Object.create(owner.NamedNodeMap.prototype)],
['inherited instance', Object.create(sample.map)], ['author Proxy', proxy], ['revoked Proxy', revoked.proxy],
['Node', sample.root], ['Attr', sample.root.getAttributeNode('data-x')],
['NodeIterator', owner.document.createNodeIterator(sample.root)], ['DOMTokenList', sample.root.classList]];
const converted = {toString() {conversions++; throw 1;}, valueOf() {conversions++; throw 1;}};
for (const [receiverName, receiver] of invalid) {
check(`${calleeName} length rejects ${ownerName} ${receiverName} in callee realm`, () =>
typeError(() => length.call(receiver), callee));
for (const [name] of entries) {
const attr = sample.doc.createAttribute('data-never');
const args = name === 'item' ? [0] : name.endsWith('NS') ?
name.startsWith('set') ? [attr] : ['urn:map', 'y'] : name.startsWith('set') ? [attr] : ['data-x'];
check(`${calleeName} ${name} rejects ${ownerName} ${receiverName} with valid arguments`, () =>
typeError(() => methods[name].call(receiver, ...args), callee));
const poisoned = name.startsWith('set') ? [new Proxy(attr, handler)] :
name.endsWith('NS') ? [converted, converted] : [converted];
check(`${calleeName} ${name} rejects ${ownerName} ${receiverName} before conversion`, () =>
typeError(() => methods[name].call(receiver, ...poisoned), callee) && conversions === 0 && traps === 0);
}
}
check(`${calleeName} ${ownerName} receiver validation executes no author traps or conversions`, () =>
traps === 0 && conversions === 0 && length.call(sample.map) === 2 && sample.root.getAttribute('data-x') === 'one');
for (const [name, arity] of entries) {
for (let supplied = 0; supplied < arity; supplied++) {
check(`${calleeName} ${name} enforces ${ownerName} required arguments after ${supplied} supplied`, () =>
typeError(() => methods[name].call(sample.map, ...Array(supplied).fill(null)), callee));
}
}
const realAttr = sample.doc.createAttribute('data-invalid');
const revokedAttr = Proxy.revocable(realAttr, handler); revokedAttr.revoke();
const badAttributes = [['undefined', undefined], ['null', null], ['plain', {}], ['Node', sample.root],
['prototype', owner.Attr.prototype], ['forged', Object.create(owner.Attr.prototype)],
['inherited instance', Object.create(realAttr)], ['author Proxy', new Proxy(realAttr, handler)],
['revoked Proxy', revokedAttr.proxy]];
for (const name of ['setNamedItem', 'setNamedItemNS']) {
for (const [argumentName, argument] of badAttributes) {
check(`${calleeName} ${name} rejects ${ownerName} ${argumentName} Attr argument before mutation`, () =>
typeError(() => methods[name].call(sample.map, argument), callee) && traps === 0 &&
length.call(sample.map) === 2 && sample.root.getAttribute('data-x') === 'one');
}
}
check(`${calleeName} ${ownerName} valid argument conversion retains order`, () => {
const trace = [];
const namespace = {toString() {trace.push('namespace'); return 'urn:map';}};
const localName = {toString() {trace.push('name'); return 'y';}};
return methods.getNamedItemNS.call(sample.map, namespace, localName) === sample.root.getAttributeNodeNS('urn:map', 'y') &&
trace.join(',') === 'namespace,name';
});
}
check(`${calleeName} factory uses intrinsic identity after constructor replacement`, () => {
const saved = Object.getOwnPropertyDescriptor(callee, 'NamedNodeMap');
try {
Object.defineProperty(callee, 'NamedNodeMap', {...saved, value: function FakeMap() { throw 1; }});
const sample = setup(callee);
return Object.getPrototypeOf(sample.map) === prototype && length.call(sample.map) === 2 &&
methods.getNamedItem.call(sample.map, 'data-x') === sample.map[0];
} finally { Object.defineProperty(callee, 'NamedNodeMap', saved); }
});
}
if (new Set(rows.map(row => row.name)).size !== rows.length) throw new Error('Duplicate NamedNodeMap probe names');
globalThis.__uiEventResults = {complete: true, total: rows.length,
passed: rows.filter(row => row.passed).length, rows};
return rows.every(row => row.passed);
})()