fix(forms): validate FormData native argument brands

This commit is contained in:
ldm0
2026-10-06 03:09:00 +08:00
committed by Donough Liu
parent ca54aa017e
commit 706f22f3ec
8 changed files with 233 additions and 111 deletions
@@ -10,10 +10,11 @@ use crate::{
native_bridge::{
JsContextHost,
element::{form_associated_form_owner, is_valid_submit_button},
node_runtime_and_handle_from_object, throw_dom_exception,
node_relevant_context, node_runtime_and_handle_from_object_or_detached,
throw_dom_exception,
},
util::serialize_v8_iter_array,
webidl,
web_api_interfaces, webidl,
};
use moli_webapi_declare::WebApiObject;
@@ -115,77 +116,82 @@ pub(super) fn form_data_constructor_callback<'s>(
return;
}
let mut entries = Vec::new();
let Some(parsed) = webidl::parse_args::<FormDataConstructorArgs<'s>>(scope, &args) else {
return;
};
if let Some(form) = parsed.form {
let Ok(form) = v8::Local::<v8::Object>::try_from(form) else {
throw_type_error(scope, "FormData constructor requires an HTMLFormElement");
return;
};
if object_string_property_defined(scope, form, "tagName")
.is_none_or(|tag| !tag.eq_ignore_ascii_case("form"))
{
throw_type_error(scope, "FormData constructor requires an HTMLFormElement");
return;
let form = match parsed.form {
Some(value) => {
let Ok(form) = v8::Local::<v8::Object>::try_from(value) else {
throw_type_error(scope, "FormData constructor requires an HTMLFormElement");
return;
};
if !web_api_interfaces::HTMLFormElement::is_instance(scope, form) {
throw_type_error(scope, "FormData constructor requires an HTMLFormElement");
return;
}
Some(form)
}
let Ok((form_runtime_ptr, form_handle)) = node_runtime_and_handle_from_object(scope, form)
None => None,
};
// WebIDL converts the optional submitter even when the form is omitted.
let submitter = match parsed.submitter {
Some(value) if !value.is_null_or_undefined() => {
let Ok(submitter) = v8::Local::<v8::Object>::try_from(value) else {
throw_type_error(
scope,
"FormData constructor submitter must be an HTMLElement or null",
);
return;
};
if !web_api_interfaces::HTMLElement::is_instance(scope, submitter) {
throw_type_error(
scope,
"FormData constructor submitter must be an HTMLElement or null",
);
return;
}
Some(submitter)
}
_ => None,
};
let mut entries = Vec::new();
if let Some(form) = form {
let Ok((form_runtime_ptr, form_handle)) =
node_runtime_and_handle_from_object_or_detached(scope, form)
else {
throw_type_error(scope, "FormData constructor requires an HTMLFormElement");
return;
};
let submitter = match parsed.submitter {
Some(value) if value.is_null_or_undefined() => None,
Some(value) => match v8::Local::<v8::Object>::try_from(value) {
Ok(submitter) => {
let Ok((submitter_runtime_ptr, submitter_handle)) =
node_runtime_and_handle_from_object(scope, submitter)
else {
throw_type_error(
scope,
"FormData constructor submitter must be a submit button",
);
return;
};
if submitter_runtime_ptr != form_runtime_ptr {
throw_dom_exception(
scope,
"NotFoundError",
8,
"The specified element is not owned by this form element.",
);
return;
}
let runtime = unsafe { &*form_runtime_ptr };
if !is_valid_submit_button(runtime, submitter_handle) {
throw_type_error(
scope,
"FormData constructor submitter must be a submit button",
);
return;
}
if form_associated_form_owner(runtime, submitter_handle) != Some(form_handle) {
throw_dom_exception(
scope,
"NotFoundError",
8,
"The specified element is not owned by this form element.",
);
return;
}
Some(submitter)
}
Err(_) => {
throw_type_error(
scope,
"FormData constructor submitter must be an HTMLElement or null",
);
return;
}
},
None => None,
};
if let Some(submitter) = submitter {
let Ok((submitter_runtime_ptr, submitter_handle)) =
node_runtime_and_handle_from_object_or_detached(scope, submitter)
else {
throw_type_error(
scope,
"FormData constructor submitter must be a submit button",
);
return;
};
let runtime = unsafe { &*submitter_runtime_ptr };
if !is_valid_submit_button(runtime, submitter_handle) {
throw_type_error(
scope,
"FormData constructor submitter must be a submit button",
);
return;
}
if submitter_runtime_ptr != form_runtime_ptr
|| form_associated_form_owner(runtime, submitter_handle) != Some(form_handle)
{
throw_dom_exception(
scope,
"NotFoundError",
8,
"The specified element is not owned by this form element.",
);
return;
}
}
let Some(next_entries) = construct_form_data_entries_for_form(
scope,
form_runtime_ptr,
@@ -219,6 +225,8 @@ pub(crate) fn construct_form_data_entries_for_form<'s>(
return None;
}
let context = node_relevant_context(scope, form).unwrap_or_else(|| scope.get_current_context());
let scope = &mut v8::ContextScope::new(scope, context);
let entries = serialize_form_data_controls(scope, runtime_ptr, form_handle, submitter);
let entries = dispatch_form_data_event_with_entries(scope, form, &entries).unwrap_or(entries);
unsafe { &mut *runtime_ptr }.end_form_data_construction(form_handle);
@@ -252,7 +260,7 @@ fn dispatch_form_data_event<'s>(
form_data: v8::Local<'s, v8::Object>,
) {
let Ok((runtime_ptr, form_handle)) =
crate::native_bridge::node_runtime_and_handle_from_object(scope, form)
node_runtime_and_handle_from_object_or_detached(scope, form)
else {
return;
};
@@ -7,10 +7,10 @@ use crate::dom::{
};
use crate::native_bridge::{
element::{
element_attribute_for_object, element_internals_form_value_for_target,
control_has_datalist_ancestor, element_internals_form_value_for_target,
form_control_is_effectively_disabled, form_data_control_elements, text_control_value,
},
node_runtime_and_handle_from_object,
node_runtime_and_handle_from_object_or_detached,
};
use moli_encoding::is_charset_sentinel_name;
use moli_webapi_declare::WebApiObject;
@@ -31,6 +31,9 @@ pub(super) fn serialize_form_data_controls<'s>(
let mut entries = Vec::new();
let controls = form_data_control_elements(unsafe { &*runtime_ptr }, form_handle);
for handle in controls {
if control_has_datalist_ancestor(unsafe { &*runtime_ptr }, handle) {
continue;
}
let Some(control) = form_data_control_object(scope, runtime_ptr, handle) else {
continue;
};
@@ -82,10 +85,6 @@ fn append_form_data_entries_for_control<'s>(
if control_is_effectively_disabled(scope, control) {
return;
}
if control_has_datalist_ancestor(scope, control) {
return;
}
let tag = object_string_property_defined(scope, control, "tagName")
.map(|tag| tag.to_ascii_lowercase())
.unwrap_or_default();
@@ -243,19 +242,28 @@ fn append_form_data_entries_for_control<'s>(
}
}
fn form_control_name(
scope: &mut v8::PinScope<'_, '_>,
control: v8::Local<'_, v8::Object>,
fn form_control_name<'s>(
scope: &mut v8::PinScope<'s, '_>,
control: v8::Local<'s, v8::Object>,
) -> String {
element_attribute_for_object(scope, control, "name").unwrap_or_default()
let Ok((runtime_ptr, handle)) = node_runtime_and_handle_from_object_or_detached(scope, control)
else {
return String::new();
};
unsafe { &*runtime_ptr }
.dom_host()
.node(handle)
.and_then(Node::as_element)
.and_then(|element| element.attribute_ns("", "name"))
.unwrap_or_default()
.to_owned()
}
fn image_submitter_coordinates(
scope: &mut v8::PinScope<'_, '_>,
control: v8::Local<'_, v8::Object>,
fn image_submitter_coordinates<'s>(
scope: &mut v8::PinScope<'s, '_>,
control: v8::Local<'s, v8::Object>,
) -> (u32, u32) {
let Ok((runtime_ptr, handle)) =
crate::native_bridge::node_runtime_and_handle_from_object(scope, control)
let Ok((runtime_ptr, handle)) = node_runtime_and_handle_from_object_or_detached(scope, control)
else {
return (0, 0);
};
@@ -381,9 +389,9 @@ fn push_string_form_data_entry(
push_form_data_entry(entries, name, v8::Global::new(scope, value));
}
fn form_control_value(
scope: &mut v8::PinScope<'_, '_>,
control: v8::Local<'_, v8::Object>,
fn form_control_value<'s>(
scope: &mut v8::PinScope<'s, '_>,
control: v8::Local<'s, v8::Object>,
default: &str,
) -> String {
if let Some(value) = native_form_control_value(scope, control) {
@@ -392,11 +400,12 @@ fn form_control_value(
object_string_property_defined(scope, control, "value").unwrap_or_else(|| default.to_owned())
}
fn native_form_control_value(
scope: &mut v8::PinScope<'_, '_>,
control: v8::Local<'_, v8::Object>,
fn native_form_control_value<'s>(
scope: &mut v8::PinScope<'s, '_>,
control: v8::Local<'s, v8::Object>,
) -> Option<String> {
let (runtime_ptr, handle) = node_runtime_and_handle_from_object(scope, control).ok()?;
let (runtime_ptr, handle) =
node_runtime_and_handle_from_object_or_detached(scope, control).ok()?;
let runtime = unsafe { &*runtime_ptr };
let element = runtime.dom_host().node(handle).and_then(Node::as_element)?;
if element.is_html_input() {
@@ -427,7 +436,9 @@ fn control_is_effectively_disabled<'s>(
scope: &mut v8::PinScope<'s, '_>,
control: v8::Local<'s, v8::Object>,
) -> bool {
if let Ok((runtime_ptr, handle)) = node_runtime_and_handle_from_object(scope, control) {
if let Ok((runtime_ptr, handle)) =
node_runtime_and_handle_from_object_or_detached(scope, control)
{
return form_control_is_effectively_disabled(unsafe { &*runtime_ptr }, handle);
}
if object_bool_property(scope, control, "disabled").unwrap_or(false) {
@@ -453,7 +464,8 @@ fn native_form_associated_custom_element<'s>(
scope: &mut v8::PinScope<'s, '_>,
control: v8::Local<'s, v8::Object>,
) -> bool {
let Ok((runtime_ptr, handle)) = node_runtime_and_handle_from_object(scope, control) else {
let Ok((runtime_ptr, handle)) = node_runtime_and_handle_from_object_or_detached(scope, control)
else {
return false;
};
is_form_associated_custom_element_handle(unsafe { &*runtime_ptr }, handle)
@@ -488,23 +500,6 @@ fn control_is_in_first_legend<'s>(
false
}
fn control_has_datalist_ancestor<'s>(
scope: &mut v8::PinScope<'s, '_>,
control: v8::Local<'s, v8::Object>,
) -> bool {
let mut current = object_property_as_object(scope, control, "parentElement");
while let Some(element) = current {
let tag = object_string_property_defined(scope, element, "tagName")
.map(|tag| tag.to_ascii_lowercase())
.unwrap_or_default();
if tag == "datalist" {
return true;
}
current = object_property_as_object(scope, element, "parentElement");
}
false
}
fn option_is_disabled(scope: &mut v8::PinScope<'_, '_>, option: v8::Local<'_, v8::Object>) -> bool {
let mut current = Some(option);
while let Some(element) = current {
@@ -105,9 +105,9 @@ use trusted_types::{
};
pub(crate) use forms::{
autocomplete_field_name, autofill_related_form_control_elements, form_associated_form_owner,
form_control_elements, form_data_control_elements, is_valid_submit_button,
submit_form_with_submit_event,
autocomplete_field_name, autofill_related_form_control_elements, control_has_datalist_ancestor,
form_associated_form_owner, form_control_elements, form_data_control_elements,
is_valid_submit_button, submit_form_with_submit_event,
};
pub(in crate::native_bridge) use forms::{form_named_control_matches, form_named_image_matches};
#[cfg(test)]
@@ -245,6 +245,7 @@ pub(in crate::native_bridge) use self::text_control::{
text_control_set_selection_range_callback, textarea_value_getter_function,
textarea_value_setter_function,
};
pub(crate) use self::validation::control_has_datalist_ancestor;
pub(crate) use self::validation::v8_pattern_is_usable;
pub(in crate::native_bridge) use self::validation::{
control_check_validity_callback, control_matches_validity_pseudo,
@@ -488,7 +488,7 @@ fn required_value_missing_is_suppressed_for_immutable_control(
|| (element.is_html_input() && element.input_type().supports_readonly()))
}
fn control_has_datalist_ancestor(runtime: &JsContextHost, handle: DomHandle) -> bool {
pub(crate) fn control_has_datalist_ancestor(runtime: &JsContextHost, handle: DomHandle) -> bool {
let mut current = runtime.dom_host().parent_node(handle);
while let Some(parent) = current {
let Some(parent_element) = runtime.dom_host().node(parent).and_then(Node::as_element)
@@ -0,0 +1,84 @@
(() => {
const globals = [window, document.getElementById('child').contentWindow];
const rows = [], errors = [];
for (let realm = 0; realm < globals.length; ++realm) {
const g = globals[realm], other = globals[1 - realm];
for (const kind of ['live', 'windowless', 'parsed']) {
const label = `${realm}/${kind}`;
try {
const d = kind === 'live' ? g.document : kind === 'windowless' ?
g.document.implementation.createHTMLDocument('') :
new g.DOMParser().parseFromString('<!doctype html><body>', 'text/html');
const form = d.createElement('form'), input = d.createElement('input');
input.setAttribute('name', 'input-name'); input.setAttribute('value', 'input-value');
form.appendChild(input);
const select = d.createElement('select'), option = d.createElement('option');
select.setAttribute('name', 'select-name'); option.setAttribute('value', 'option-value');
select.appendChild(option); form.appendChild(select);
const button = d.createElement('button');
button.setAttribute('type', 'submit'); button.setAttribute('name', 'send'); button.setAttribute('value', 'button');
form.appendChild(button);
let tagReads = 0;
Object.defineProperty(form, 'tagName', {configurable: true, get() { ++tagReads; throw new Error('author tag getter'); }});
let valueReads = 0;
Object.defineProperty(input, 'value', {configurable: true, get() { ++valueReads; throw new Error('author value getter'); }});
let eventCount = 0, eventRealm = true, eventTarget = true;
form.addEventListener('formdata', event => {
++eventCount;
eventRealm &&= event instanceof g.FormDataEvent && event.formData instanceof g.FormData;
eventTarget &&= event.target === form;
event.formData.append('from-handler', 'listener');
});
const data = new other.FormData(form);
const checks = {constructorRealm: Object.getPrototypeOf(data) === other.FormData.prototype,
input: data.get('input-name') === 'input-value',
option: data.get('select-name') === 'option-value',
event: eventCount === 1 && eventRealm && eventTarget,
handlerMutation: data.get('from-handler') === 'listener',
nativeProperties: tagReads === 0 && valueReads === 0,
omitSubmitter: !data.has('send')};
const withSubmitter = new other.FormData(form, button);
checks.submitter = withSubmitter.get('send') === 'button' && eventCount === 2;
let traps = 0;
const author = new g.Proxy(form, {get() { ++traps; return undefined; }, getPrototypeOf() { ++traps; return null; }});
const revoked = g.Proxy.revocable(form, {}); revoked.revoke();
checks.invalidForms = true;
for (const invalid of [null, {}, g.Object.create(form), author, revoked.proxy,
d.createElement('div'), d.createElementNS('http://www.w3.org/2000/svg', 'form')]) {
let error;
try { new other.FormData(invalid); } catch (caught) { error = caught; }
checks.invalidForms &&= error instanceof other.TypeError;
}
checks.invalidSubmitters = true;
for (const invalid of [{}, g.Object.create(button), new g.Proxy(button, {get() { ++traps; }}),
d.createElement('div'), other.document.createElement('div')]) {
let error;
try { new other.FormData(form, invalid); } catch (caught) { error = caught; }
checks.invalidSubmitters &&= error instanceof other.TypeError;
}
const foreign = d.createElement('button'); foreign.type = 'submit';
const crossDocument = other.document.createElement('button'); crossDocument.type = 'submit';
checks.foreignSubmitter = true;
for (const submitter of [foreign, crossDocument]) {
let notFound;
try { new other.FormData(form, submitter); } catch (caught) { notFound = caught; }
checks.foreignSubmitter &&= notFound instanceof other.DOMException && notFound.name === 'NotFoundError';
}
let omittedSubmitterError;
try { new other.FormData(undefined, {}); }
catch (error) { omittedSubmitterError = error; }
checks.omittedFormStillConvertsSubmitter = omittedSubmitterError instanceof other.TypeError;
checks.noPublicReads = traps === 0 && tagReads === 0 && valueReads === 0;
other.document.adoptNode(form);
const adoptedData = new other.FormData(form);
checks.adoptedFormRealm = eventCount === 3 && eventRealm && eventTarget &&
Object.getPrototypeOf(adoptedData) === other.FormData.prototype &&
adoptedData.get('from-handler') === 'listener';
rows.push({label, checks});
} catch (error) { errors.push({label, message: String(error)}); }
}
}
globalThis.__uiEventResults = {rows, errors,
passed: errors.length === 0 && rows.every(row => Object.values(row.checks).every(value => value === true))};
return __uiEventResults.passed;
})()
@@ -0,0 +1,33 @@
use super::*;
#[tokio::test]
async fn form_data_constructor_accepts_native_forms_across_document_realms() {
let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap();
let mut vm = new_storage_page_task_executor_test_vm_with_loader(
"https://form-data-brands.test/",
&loader,
);
vm.eval(
r#"
if (!document.documentElement) document.appendChild(document.createElement('html'));
if (!document.body) document.documentElement.appendChild(document.createElement('body'));
const frame=document.body.appendChild(document.createElement('iframe'));
frame.id='child'; frame.srcdoc='<body></body>'; void frame.contentWindow;
'ready'
"#,
)
.unwrap();
assert!(
vm.run_one_child_frame_task_executor_turn(
ChildFrameSemanticTurnKind::RealmMaterialization,
&loader,
)
.await
.unwrap()
);
vm.drain_ready_page_task_executor_turns_for_setup(&loader, 128)
.await
.unwrap();
assert_eq!(vm.eval(include_str!("form_data_brands.js")).unwrap(), "true", "{}",
vm.eval("JSON.stringify({errors:__uiEventResults.errors,failures:__uiEventResults.rows.filter(row=>Object.values(row.checks).some(value=>value!==true))})").unwrap());
}
@@ -5,6 +5,7 @@ mod custom_elements;
mod detached;
mod dom_surface;
mod focus;
mod form_data_brands;
mod live_document;
mod text_controls;
mod wrapper_identity;