fix(media): block dangling markup source URLs

This commit is contained in:
ldm0
2026-08-28 19:55:59 +08:00
parent 39d474864b
commit e32b30516d
7 changed files with 181 additions and 7 deletions
@@ -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
@@ -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
@@ -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)]
@@ -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<url::Url>,
selected_source: Option<SelectedMediaSource>,
) {
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<Option<url::Url>, ()> {
) -> Result<Option<SelectedMediaSource>, ()> {
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)
@@ -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::{
@@ -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<"
));
}
}
@@ -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/");