diff --git a/moli-benchmark/wpt-cross-current/failed-cases.txt b/moli-benchmark/wpt-cross-current/failed-cases.txt index 3c87454ab7..7807806c7b 100644 --- a/moli-benchmark/wpt-cross-current/failed-cases.txt +++ b/moli-benchmark/wpt-cross-current/failed-cases.txt @@ -2906,7 +2906,6 @@ fetch/api/policies/referrer-origin-when-cross-origin-worker.html fetch/api/policies/referrer-origin-worker.html fetch/api/policies/referrer-unsafe-url-worker.html fetch/nosniff/importscripts.html -fetch/security/dangling-markup/media.html fetch/security/dangling-markup/option.html fetch/security/dangling-markup/textarea.html focus/activeelement-after-focusing-different-site-iframe-contentwindow.html diff --git a/moli-benchmark/wpt-cross-current/passed-cases.txt b/moli-benchmark/wpt-cross-current/passed-cases.txt index b3c90fd45f..af7330509f 100644 --- a/moli-benchmark/wpt-cross-current/passed-cases.txt +++ b/moli-benchmark/wpt-cross-current/passed-cases.txt @@ -4906,6 +4906,7 @@ fetch/api/response/multi-globals/url-parsing.html fetch/api/response/response-body-read-task-handling.html fetch/api/response/response-form-data.html fetch/content-length/content-length.html +fetch/security/dangling-markup/media.html focus/activeelement-after-focusing-different-site-iframe-then-immediately-focusing-back.html focus/anchor-remove-href.html focus/focus-already-focused-iframe-deep-different-site.html diff --git a/moli-renderer-v8/src/native_bridge/element.rs b/moli-renderer-v8/src/native_bridge/element.rs index 91d9a249fc..065b729d6f 100644 --- a/moli-renderer-v8/src/native_bridge/element.rs +++ b/moli-renderer-v8/src/native_bridge/element.rs @@ -652,7 +652,7 @@ use url_attributes::{ iframe_has_inactive_child_context, iframe_is_in_own_child_document, iframe_is_inside_its_own_child_context_document, iframe_uses_detached_content_cache, normalize_url_default_port, parsed_url_like_attribute, resolve_url_like_attribute, - set_resolved_url_attribute, + set_resolved_url_attribute, should_block_dangling_markup_subresource, }; #[derive(WebApiFunctionTemplate)] diff --git a/moli-renderer-v8/src/native_bridge/element/media/attributes.rs b/moli-renderer-v8/src/native_bridge/element/media/attributes.rs index fb1e432284..2841fa136c 100644 --- a/moli-renderer-v8/src/native_bridge/element/media/attributes.rs +++ b/moli-renderer-v8/src/native_bridge/element/media/attributes.rs @@ -6,6 +6,7 @@ use super::super::{ html_media_element_setter_receiver, parsed_url_like_attribute, property_dom_string_value, property_usv_string_value, remove_reflected_attribute, resolve_url_like_attribute, set_reflected_attribute, set_reflected_boolean_attribute, + should_block_dangling_markup_subresource, }; use crate::document_runtime::DocumentSubresourceCspKind; use crate::native_bridge::{ @@ -444,7 +445,7 @@ fn start_media_load( scope: &mut v8::PinScope<'_, '_>, runtime_ptr: *mut JsContextHost, handle: crate::document_runtime::DomHandle, - selected_source: Option, + selected_source: Option, ) { let pending = { let runtime = unsafe { &mut *runtime_ptr }; @@ -472,7 +473,8 @@ fn start_media_load( } let start = selected_source .ok_or_else(|| "media resource selection found no supported URL".to_owned()) - .and_then(|request_url| { + .and_then(|selected_source| { + let request_url = selected_source.request_url; if unsafe { &mut *runtime_ptr } .check_top_document_subresource_csp( scope, @@ -485,6 +487,14 @@ fn start_media_load( successful: false, }); } + if should_block_dangling_markup_subresource( + &request_url, + &selected_source.original_input, + ) { + return Ok(crate::network_host::MediaElementResourceFetchStart::Local { + successful: false, + }); + } crate::network_host::start_media_element_resource_fetch( scope, unsafe { &mut *runtime_ptr }, @@ -760,10 +770,15 @@ fn media_load_target_for_source_change( .map(|_| parent) } +struct SelectedMediaSource { + request_url: url::Url, + original_input: String, +} + fn selected_media_source( runtime: &JsContextHost, handle: crate::document_runtime::DomHandle, -) -> Result, ()> { +) -> Result, ()> { let element = runtime .dom_host() .node(handle) @@ -774,7 +789,12 @@ fn selected_media_source( return Err(()); } return parsed_url_like_attribute(runtime, handle, "src") - .map(Some) + .map(|request_url| { + Some(SelectedMediaSource { + request_url, + original_input: src.to_owned(), + }) + }) .ok_or(()); } for child in runtime.dom_host().child_handles(handle) { @@ -793,7 +813,12 @@ fn selected_media_source( continue; } return parsed_url_like_attribute(runtime, child, "src") - .map(Some) + .map(|request_url| { + Some(SelectedMediaSource { + request_url, + original_input: src.to_owned(), + }) + }) .ok_or(()); } Ok(None) diff --git a/moli-renderer-v8/src/native_bridge/element/url_attributes.rs b/moli-renderer-v8/src/native_bridge/element/url_attributes.rs index bb3d2a4218..af018947ab 100644 --- a/moli-renderer-v8/src/native_bridge/element/url_attributes.rs +++ b/moli-renderer-v8/src/native_bridge/element/url_attributes.rs @@ -4,6 +4,7 @@ mod iframe; pub(super) use self::helpers::{ default_port_for_scheme, normalize_url_default_port, parsed_url_like_attribute, resolve_url_like_attribute, set_resolved_url_attribute, + should_block_dangling_markup_subresource, }; pub(in crate::native_bridge) use self::iframe::update_iframe_snapshot_navigation; pub(super) use self::iframe::{ diff --git a/moli-renderer-v8/src/native_bridge/element/url_attributes/helpers.rs b/moli-renderer-v8/src/native_bridge/element/url_attributes/helpers.rs index 7d57befd95..02d9e35d95 100644 --- a/moli-renderer-v8/src/native_bridge/element/url_attributes/helpers.rs +++ b/moli-renderer-v8/src/native_bridge/element/url_attributes/helpers.rs @@ -47,6 +47,20 @@ pub(in crate::native_bridge::element) fn parsed_url_like_attribute( parse_url_with_document_query_encoding(runtime, handle, &base, &value).ok() } +/// Reconstructs Chromium's URL-parser flag after `url::Url` has removed raw +/// URL whitespace and percent-encoded the `<` in its serialized result. +pub(in crate::native_bridge::element) fn should_block_dangling_markup_subresource( + request_url: &Url, + original_input: &str, +) -> bool { + matches!(request_url.scheme(), "http" | "https") + && original_input.as_bytes().contains(&b'<') + && original_input + .as_bytes() + .iter() + .any(|byte| matches!(byte, b'\r' | b'\n' | b'\t')) +} + fn parse_url_with_document_query_encoding( runtime: &JsContextHost, handle: DomHandle, @@ -130,3 +144,35 @@ pub(in crate::native_bridge::element) fn set_resolved_url_attribute( ) { set_reflected_attribute(scope, runtime_ptr, handle, name, url.as_ref()); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dangling_markup_subresource_gate_preserves_url_parser_flag_semantics() { + let http = Url::parse("https://example.test/resource").unwrap(); + assert!(should_block_dangling_markup_subresource( + &http, + "resource?\n<" + )); + assert!(should_block_dangling_markup_subresource( + &http, + "resource?<\tkey" + )); + assert!(!should_block_dangling_markup_subresource( + &http, + "resource?<" + )); + assert!(!should_block_dangling_markup_subresource( + &http, + "resource?\r%3C" + )); + + let data = Url::parse("data:text/plain,resource").unwrap(); + assert!(!should_block_dangling_markup_subresource( + &data, + "data:text/plain,resource\n<" + )); + } +} diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/media.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/media.rs index 97e08bd460..30ae07a0f4 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/media.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/media.rs @@ -209,6 +209,108 @@ async fn media_invalid_request_url_fails_before_load() { ); } +#[tokio::test] +async fn media_blocks_http_dangling_markup_without_blocking_safe_url_inputs() { + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("loader"); + let mut vm = new_storage_page_task_executor_test_vm_with_loader( + "https://media-dangling-markup.test/page.html", + &loader, + ); + + vm.eval( + r#" +(() => { + globalThis.__lmMediaDanglingMarkupEvents = []; + const probes = [ + ["audio", "blocked", "asset.mp3?\n<", false], + ["video", "blocked", "asset.mp4?<\tkey", false], + ["video", "child-source", "asset.mp4?\n<", true], + ["audio", "data", "data:audio/mp3,bytes\n<", false] + ]; + for (const [localName, name, source, useChildSource] of probes) { + const media = document.createElement(localName); + media.onerror = () => __lmMediaDanglingMarkupEvents.push(`${localName}:${name}:error`); + media.oncanplay = () => __lmMediaDanglingMarkupEvents.push(`${localName}:${name}:canplay`); + if (useChildSource) { + const sourceElement = document.createElement("source"); + sourceElement.src = source; + media.appendChild(sourceElement); + } else { + media.src = source; + } + (document.body || document.documentElement || document).appendChild(media); + } +})() +"#, + ) + .expect("media dangling markup setup should evaluate"); + + for turn in 0..10 { + run_next_page_media_element_event_for_test( + &mut vm, + &loader, + &format!("dangling-markup media event turn {turn}"), + ) + .await; + } + + assert_eq!( + vm.eval(r#"__lmMediaDanglingMarkupEvents.sort().join("|")"#) + .expect("media dangling markup events should evaluate"), + "audio:blocked:error|audio:data:canplay|video:blocked:error|video:child-source:error" + ); +} + +#[tokio::test] +async fn media_dangling_markup_still_dispatches_report_only_csp() { + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("loader"); + let mut vm = new_storage_page_task_executor_test_vm_with_loader( + "https://media-dangling-csp.test/page.html", + &loader, + ); + vm.set_response_content_security_report_only_policies(&["media-src 'none'".to_owned()]); + + vm.eval( + r#" +(() => { + globalThis.__lmMediaDanglingCspEvents = []; + document.addEventListener("securitypolicyviolation", event => { + __lmMediaDanglingCspEvents.push(`csp:${event.disposition}:${event.effectiveDirective}`); + }); + const video = document.createElement("video"); + video.onerror = () => __lmMediaDanglingCspEvents.push("error"); + video.oncanplay = () => __lmMediaDanglingCspEvents.push("canplay"); + video.src = "asset.mp4?\n<"; + (document.body || document.documentElement || document).appendChild(video); +})() +"#, + ) + .expect("media dangling markup CSP setup should evaluate"); + + assert_eq!( + drain_pre_domcontentloaded_non_script_page_tasks_for_test(&mut vm), + 1 + ); + run_next_page_media_element_event_for_test( + &mut vm, + &loader, + "report-only dangling-markup media loadstart turn", + ) + .await; + run_next_page_media_element_event_for_test( + &mut vm, + &loader, + "report-only dangling-markup media error turn", + ) + .await; + + assert_eq!( + vm.eval(r#"__lmMediaDanglingCspEvents.join("|")"#) + .expect("media dangling markup CSP events should evaluate"), + "csp:report:media-src|error" + ); +} + #[test] fn media_play_returns_a_fulfilled_promise() { let mut vm = new_storage_test_vm("https://media-play-promise.test/");