fix(scripts): enforce client-origin CORS before script execution

Bind script loads and cache entries to the document client origin, authorize redirects before following them, and preserve redirect origin taint across Window and Worker Fetch/XHR validation and response filtering.

Check dynamic script CSP before transport, cover real HTTP and lifecycle regressions, and refresh the one newly passing WPT case after fixed-binary CLI/CDP comparisons.
This commit is contained in:
ldm0
2026-09-14 15:46:10 +08:00
parent f6944b3d5a
commit 8a1f39d33c
17 changed files with 1003 additions and 54 deletions
@@ -2969,7 +2969,6 @@ html/semantics/scripting-1/the-script-element/json-module/json-module-service-wo
html/semantics/scripting-1/the-script-element/microtasks/checkpoint-after-window-onerror-module.html
html/semantics/scripting-1/the-script-element/microtasks/checkpoint-after-window-onerror.html
html/semantics/scripting-1/the-script-element/microtasks/checkpoint-after-workerglobalscope-onerror.html
html/semantics/scripting-1/the-script-element/module/crossorigin.html
html/semantics/scripting-1/the-script-element/module/dynamic-import/alpha/base-url-worker-importScripts.html
html/semantics/scripting-1/the-script-element/module/dynamic-import/code-cache-base-url.html
html/semantics/scripting-1/the-script-element/module/inline-async-execorder.html
@@ -6489,6 +6489,7 @@ html/semantics/scripting-1/the-script-element/module/choice-of-error-2.html
html/semantics/scripting-1/the-script-element/module/choice-of-error-3.html
html/semantics/scripting-1/the-script-element/module/compilation-error-1.html
html/semantics/scripting-1/the-script-element/module/compilation-error-2.html
html/semantics/scripting-1/the-script-element/module/crossorigin.html
html/semantics/scripting-1/the-script-element/module/currentScript-null.html
html/semantics/scripting-1/the-script-element/module/custom-element-exception.html
html/semantics/scripting-1/the-script-element/module/duplicated-imports-1.html
+9 -1
View File
@@ -5269,7 +5269,9 @@ async fn module_wasm_csp_blocks_cross_origin_script_element_fetch() -> Result<()
.await?;
assert_eq!(
diagnostic_global(&page, "moduleWasmCspViolationCount"),
Some(&JsValueSnapshot::Number(1.0))
Some(&JsValueSnapshot::Number(1.0)),
"script report: {:#?}",
page.script_execution()
);
assert_eq!(
diagnostic_global(&page, "moduleWasmCspViolationText"),
@@ -5281,6 +5283,12 @@ async fn module_wasm_csp_blocks_cross_origin_script_element_fetch() -> Result<()
diagnostic_global(&page, "moduleWasmCspExecuted"),
Some(&JsValueSnapshot::String(String::new()))
);
assert!(
page.subresource_network_records()
.iter()
.all(|record| record.url().path() != "/assets/execute-start.wasm"),
"CSP must block the dynamic module before source transport starts"
);
server.shutdown().await;
Ok(())
+32 -3
View File
@@ -325,12 +325,41 @@ fn append_browser_subresource_headers(
request: &Request,
request_url: &Url,
) {
let Some(metadata) = request.browser_request_metadata() else {
return;
};
if !matches!(request_url.scheme(), "http" | "https") {
return;
}
let Some(metadata) = request.browser_request_metadata() else {
if matches!(
request.resource_type,
crate::RequestResourceType::Script
| crate::RequestResourceType::ParserBlockingScript
| crate::RequestResourceType::ClassicAsyncOrDeferScript
| crate::RequestResourceType::LatePreloadScript
) {
append_header_if_missing(outgoing, "Accept", "*/*".to_owned());
append_header_if_missing(
outgoing,
"Accept-Language",
config.browser_identity().accept_language().to_owned(),
);
append_header_if_missing(
outgoing,
"Sec-Fetch-Site",
request_sec_fetch_site(request, request_url),
);
append_header_if_missing(
outgoing,
"Sec-Fetch-Mode",
request.request_mode.as_ref().to_owned(),
);
append_header_if_missing(outgoing, "Sec-Fetch-Dest", "script".to_owned());
if let Some(origin) = request_origin_header_value(request, request_url) {
append_header_if_missing(outgoing, "Origin", origin);
}
append_browser_client_hints(outgoing, config);
}
return;
};
match metadata {
BrowserRequestMetadata::Audio
@@ -140,6 +140,11 @@ impl DocumentResourceLoader {
request_client = request_client.with_browser_site_context(browser_site_context);
}
let loads = ResourceLoadRegistry::new(task_runner);
// The Fetch client origin is independent of a script's referrer/base
// URL, including cross-origin dependencies and inherited/sandboxed Documents.
let request_client = request_client.with_script_request_origin(
moli_url::WebOrigin::from_ascii_serialization(context.origin()),
);
Self {
request_client,
authority: Arc::new(DocumentResourceLoaderAuthority {
@@ -213,6 +218,11 @@ impl DocumentResourceLoader {
if let Some(browser_site_context) = self.request_client.shared_browser_site_context() {
request_client = request_client.with_shared_browser_site_context(browser_site_context);
}
request_client = request_client.with_script_request_origin(
moli_url::WebOrigin::from_ascii_serialization(
self.authority.lifecycle.lock().context.origin(),
),
);
Self {
request_client,
authority: Arc::clone(&self.authority),
+71 -6
View File
@@ -33,6 +33,7 @@ pub struct ResourceRequestClient {
resource_runtime: BrowserResourceRuntime,
page_network_policy: PageNetworkPolicy,
browser_site_context: Option<Arc<BrowserCookieFacadeContext>>,
script_request_origin: Option<Arc<moli_url::WebOrigin>>,
}
/// Thread-affine lifetime root for a standalone resource request client.
@@ -104,6 +105,22 @@ impl ResourceRequestClient {
resource_runtime,
page_network_policy,
browser_site_context: None,
script_request_origin: None,
}
}
pub(crate) fn with_script_request_origin(mut self, origin: moli_url::WebOrigin) -> Self {
self.script_request_origin = Some(Arc::new(origin));
self
}
fn script_request_with_client_origin(&self, request: Request) -> Request {
if request.request_origin().is_none()
&& let Some(origin) = &self.script_request_origin
{
request.with_request_origin(origin.as_ref().clone())
} else {
request
}
}
@@ -141,6 +158,7 @@ impl ResourceRequestClient {
self.page_network_policy.frozen_request_view(),
);
client.browser_site_context = self.browser_site_context.clone();
client.script_request_origin = self.script_request_origin.clone();
client
}
@@ -281,7 +299,7 @@ impl ResourceRequestClient {
&self,
request: Request,
) -> Result<Response> {
let request = self.apply_network_policy(request)?;
let request = self.apply_network_policy(self.script_request_with_client_origin(request))?;
if let Some(result) = local_text_response(&request.url) {
return result;
}
@@ -299,7 +317,10 @@ impl ResourceRequestClient {
);
}
let result = self
.fetch_text_stream_with_cancel_after_policy(request, FetchCancelHandle::new())
.fetch_script_text_stream_with_cancel_after_policy(
request,
FetchCancelHandle::new(),
)
.await;
if let (Some(started), Some(url)) = (started, timing_url.as_deref()) {
let status = result.as_ref().ok().map(|response| response.status);
@@ -400,7 +421,7 @@ impl ResourceRequestClient {
}
let cache_request = request.clone();
let result = self
.fetch_text_stream_with_cancel_after_policy(request, FetchCancelHandle::new())
.fetch_script_text_stream_with_cancel_after_policy(request, FetchCancelHandle::new())
.await
.map_err(|error| format!("{error:#}"));
if let (Some(started), Some(url)) = (started, timing_url.as_deref()) {
@@ -445,7 +466,7 @@ impl ResourceRequestClient {
where
F: FnOnce(Result<Response>) + Send + 'static,
{
let request = self.apply_network_policy(request)?;
let request = self.apply_network_policy(self.script_request_with_client_origin(request))?;
if let Some(result) = local_text_response(&request.url) {
let task_runner = resource_load.task_runner();
task_runner.spawn(async move {
@@ -470,9 +491,10 @@ impl ResourceRequestClient {
let cancel_handle = FetchCancelHandle::new();
resource_load.attach_cancel_handle(cancel_handle.clone());
let callback_resource_load = resource_load.clone();
let started_fetch = self.fetch_text_callback_with_cancel_after_policy(
let started_fetch = self.fetch_script_text_callback_with_cancel_after_policy(
request,
cancel_handle,
resource_load.task_runner(),
move |result| {
callback_resource_load.finish();
if let (Some(started), Some(url)) = (started, timing_url.as_deref()) {
@@ -601,9 +623,10 @@ impl ResourceRequestClient {
let callback_key = key.clone();
let cancel_handle = FetchCancelHandle::new();
load.attach_transport_cancel(cancel_handle.clone());
if let Err(error) = self.fetch_text_callback_with_cancel_after_policy(
if let Err(error) = self.fetch_script_text_callback_with_cancel_after_policy(
request,
cancel_handle,
resource_load.task_runner(),
move |result| {
let result = result.map_err(|error| format!("{error:#}"));
request_client
@@ -629,6 +652,48 @@ impl ResourceRequestClient {
Ok(())
}
async fn fetch_script_text_stream_with_cancel_after_policy(
&self,
request: Request,
cancel_handle: FetchCancelHandle,
) -> Result<Response> {
if request.request_mode == moli_fetch::RequestMode::Cors
&& request.request_origin().is_some()
{
return crate::network_host::fetch_cors_script_text(self, request, cancel_handle)
.await
.map_err(anyhow::Error::msg);
}
self.fetch_text_stream_with_cancel_after_policy(request, cancel_handle)
.await
}
fn fetch_script_text_callback_with_cancel_after_policy<F>(
&self,
request: Request,
cancel_handle: FetchCancelHandle,
task_runner: super::RendererResourceTaskRunner,
callback: F,
) -> Result<()>
where
F: FnOnce(Result<Response>) + Send + 'static,
{
if request.request_mode == moli_fetch::RequestMode::Cors
&& request.request_origin().is_some()
{
let client = self.clone();
task_runner.spawn(async move {
callback(
client
.fetch_script_text_stream_with_cancel_after_policy(request, cancel_handle)
.await,
);
});
return Ok(());
}
self.fetch_text_callback_with_cancel_after_policy(request, cancel_handle, callback)
}
async fn fetch_text_stream_with_cancel_after_policy(
&self,
request: Request,
@@ -50,6 +50,7 @@ fn browser_client_rejects_http_requests_even_with_an_initiator() -> Result<()> {
owner.handle().apply_network_policy(browser)?;
Ok(())
}
mod script_cors;
#[test]
fn loader_clones_share_one_browser_resource_runtime() {
@@ -0,0 +1,486 @@
use super::*;
use crate::{
frame_owner_model::{DocumentId, FrameDocumentTaskOwner, FrameSchedulerLaneId, LocalWindowId},
native_bridge::WindowDocumentOwner,
network::{
RendererResourceTaskRunner,
context::{DocumentFetchContext, DocumentResourceLoader},
loads::{ResourceLoadDisposition, ResourceLoadKind},
},
};
use moli_fetch::{RequestMode, Response};
use parking_lot::Mutex;
struct ScriptServer {
url: Url,
requests: Arc<Mutex<Vec<String>>>,
task: tokio::task::JoinHandle<()>,
}
impl ScriptServer {
async fn new(status: u16, headers: &[(&str, &str)]) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = Url::parse(&format!(
"http://{}/script.mjs",
listener.local_addr().unwrap()
))
.unwrap();
let body = "export const value = 1;";
let extra_headers = headers
.iter()
.map(|(name, value)| format!("{name}: {value}\r\n"))
.collect::<String>();
let response = format!(
"HTTP/1.1 {status} Test\r\nContent-Type: application/javascript\r\nCache-Control: max-age=3600\r\nContent-Length: {}\r\nConnection: close\r\n{extra_headers}\r\n{body}",
body.len()
);
let requests = Arc::new(Mutex::new(Vec::new()));
let captured = Arc::clone(&requests);
let task = tokio::spawn(async move {
while let Ok((mut stream, _)) = listener.accept().await {
let request = read_http_request_text(&mut stream).await.unwrap();
captured.lock().push(request);
stream.write_all(response.as_bytes()).await.unwrap();
}
});
Self {
url,
requests,
task,
}
}
fn request_count(&self) -> usize {
self.requests.lock().len()
}
fn last_origin(&self) -> Option<String> {
self.last_header("origin")
}
fn last_header(&self, header: &str) -> Option<String> {
self.requests.lock().last().and_then(|request| {
request.lines().find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case(header)
.then(|| value.trim().to_owned())
})
})
}
}
impl Drop for ScriptServer {
fn drop(&mut self) {
self.task.abort();
}
}
fn document(loader: &ResourceRequestClient, url: &str, origin: &str) -> DocumentResourceLoader {
let url = Url::parse(url).unwrap();
DocumentResourceLoader::new(
loader.clone(),
RendererResourceTaskRunner::from_current_tokio().unwrap(),
DocumentFetchContext::new(
WindowDocumentOwner::Frame(FrameDocumentTaskOwner::new(
FrameSchedulerLaneId(7),
LocalWindowId(11),
DocumentId(1),
)),
url.clone(),
url,
origin,
),
)
}
fn script_request(url: &Url, mode: RequestMode, credentials: RequestCredentialsMode) -> Request {
Request::new("GET", url.as_str(), None, Vec::new())
.unwrap()
// A dependency's referrer can be same-origin with the script server.
// It must never replace the Document's origin for CORS or cookies.
.with_initiator_url(url)
.with_request_mode(mode)
.with_credentials_mode(credentials)
.with_script_fetch_metadata(ScriptFetchRequestMetadata::default())
}
async fn fetch_script(
document: &DocumentResourceLoader,
request: Request,
callback: bool,
) -> Result<Response> {
let load = document
.register_load(
ResourceLoadKind::Script,
ResourceLoadDisposition::Ordinary,
None,
)
.unwrap();
let client = load.request_client();
if callback {
let (tx, rx) = oneshot::channel();
client.fetch_cacheable_script_text_callback_with_load(request, load, move |result| {
let _ = tx.send(result);
})?;
timeout(Duration::from_secs(5), rx).await??
} else {
let result = timeout(
Duration::from_secs(5),
client.fetch_cacheable_script_text_stream(request),
)
.await?;
load.finish();
result
}
}
#[tokio::test]
async fn script_cors_checks_the_document_origin_and_credentials_in_both_fetch_paths() {
for callback in [false, true] {
for (headers, credentials, allowed) in [
(vec![], RequestCredentialsMode::SameOrigin, false),
(
vec![("Access-Control-Allow-Origin", "https://wrong.test")],
RequestCredentialsMode::SameOrigin,
false,
),
(
vec![("Access-Control-Allow-Origin", "https://page.test")],
RequestCredentialsMode::SameOrigin,
true,
),
(
vec![("Access-Control-Allow-Origin", "*")],
RequestCredentialsMode::SameOrigin,
true,
),
(
vec![("Access-Control-Allow-Origin", "*")],
RequestCredentialsMode::Include,
false,
),
(
vec![("Access-Control-Allow-Origin", "https://page.test")],
RequestCredentialsMode::Include,
false,
),
(
vec![
("Access-Control-Allow-Origin", "https://page.test"),
("Access-Control-Allow-Credentials", "true"),
],
RequestCredentialsMode::Include,
true,
),
] {
let server = ScriptServer::new(200, &headers).await;
let transport = ResourceRequestClient::new(&FetchConfig::default()).unwrap();
let document = document(&transport, "https://page.test/page", "https://page.test");
let result = fetch_script(
&document,
script_request(&server.url, RequestMode::Cors, credentials),
callback,
)
.await;
assert_eq!(
result.is_ok(),
allowed,
"callback={callback}, headers={headers:?}, credentials={credentials:?}: {result:?}"
);
assert_eq!(server.last_origin().as_deref(), Some("https://page.test"));
if let Err(error) = result {
assert!(error.to_string().contains("CORS check failed"), "{error}");
}
}
}
}
#[tokio::test]
async fn script_cors_preserves_same_origin_and_no_cors_loading() {
for callback in [false, true] {
for mode in [RequestMode::Cors, RequestMode::NoCors] {
let server = ScriptServer::new(200, &[]).await;
let transport = ResourceRequestClient::new(&FetchConfig::default()).unwrap();
let origin = if mode == RequestMode::Cors {
moli_url::origin_ascii_serialization(&server.url)
} else {
"https://page.test".to_owned()
};
let document = document(&transport, server.url.as_str(), &origin);
fetch_script(
&document,
script_request(&server.url, mode, RequestCredentialsMode::Include),
callback,
)
.await
.unwrap();
}
}
}
#[tokio::test]
async fn script_cors_cache_cannot_reuse_no_cors_or_another_documents_authorization() {
for callback in [false, true] {
let server = ScriptServer::new(
200,
&[
("Access-Control-Allow-Origin", "https://first.test"),
("Access-Control-Allow-Credentials", "true"),
],
)
.await;
let transport = ResourceRequestClient::new(&FetchConfig::default()).unwrap();
let first = document(&transport, "https://first.test/page", "https://first.test");
let second = first.fork_for_document(DocumentFetchContext::new(
WindowDocumentOwner::Frame(FrameDocumentTaskOwner::new(
FrameSchedulerLaneId(7),
LocalWindowId(11),
DocumentId(2),
)),
Url::parse("https://second.test/page").unwrap(),
Url::parse("https://second.test/base").unwrap(),
"https://second.test",
));
for _ in 0..2 {
fetch_script(
&first,
script_request(
&server.url,
RequestMode::Cors,
RequestCredentialsMode::Include,
),
callback,
)
.await
.unwrap();
}
assert_eq!(
server.request_count(),
1,
"same Document should reuse its authorized response"
);
let result = fetch_script(
&second,
script_request(
&server.url,
RequestMode::Cors,
RequestCredentialsMode::Include,
),
callback,
)
.await;
assert!(
result.is_err(),
"another Document must perform its own CORS check"
);
let server = ScriptServer::new(200, &[]).await;
fetch_script(
&first,
script_request(
&server.url,
RequestMode::NoCors,
RequestCredentialsMode::Include,
),
callback,
)
.await
.unwrap();
let result = fetch_script(
&first,
script_request(
&server.url,
RequestMode::Cors,
RequestCredentialsMode::Include,
),
callback,
)
.await;
assert!(
result.is_err(),
"a classic no-cors cache entry must not authorize a module"
);
}
}
#[tokio::test]
async fn script_cors_rejects_a_redirect_before_requesting_its_target() {
for callback in [false, true] {
let final_server = ScriptServer::new(200, &[("Access-Control-Allow-Origin", "*")]).await;
let redirect = ScriptServer::new(302, &[("Location", final_server.url.as_str())]).await;
let transport = ResourceRequestClient::new(&FetchConfig::default()).unwrap();
let document = document(&transport, "https://page.test/page", "https://page.test");
let result = fetch_script(
&document,
script_request(
&redirect.url,
RequestMode::Cors,
RequestCredentialsMode::SameOrigin,
),
callback,
)
.await;
assert!(
result.is_err(),
"missing CORS headers on an intermediate redirect must reject"
);
assert_eq!(
final_server.request_count(),
0,
"blocked redirect target must not be fetched"
);
}
}
#[tokio::test]
async fn script_cors_cross_origin_redirects_taint_the_origin() {
for callback in [false, true] {
for (allow_origin, allowed) in [("null", true), ("https://page.test", false)] {
let final_server =
ScriptServer::new(200, &[("Access-Control-Allow-Origin", allow_origin)]).await;
let redirect = ScriptServer::new(
302,
&[
("Location", final_server.url.as_str()),
("Access-Control-Allow-Origin", "https://page.test"),
],
)
.await;
let transport = ResourceRequestClient::new(&FetchConfig::default()).unwrap();
let document = document(&transport, "https://page.test/page", "https://page.test");
let result = fetch_script(
&document,
script_request(
&redirect.url,
RequestMode::Cors,
RequestCredentialsMode::SameOrigin,
),
callback,
)
.await;
assert_eq!(result.is_ok(), allowed, "{result:?}");
assert_eq!(redirect.last_origin().as_deref(), Some("https://page.test"));
assert_eq!(final_server.last_origin().as_deref(), Some("null"));
}
}
}
#[tokio::test]
async fn script_cors_redirects_drop_authorization_apply_referrer_policy_and_preserve_fragments() {
for callback in [false, true] {
let final_server = ScriptServer::new(200, &[("Access-Control-Allow-Origin", "*")]).await;
let redirect = ScriptServer::new(
302,
&[
("Location", final_server.url.as_str()),
("Referrer-Policy", "no-referrer"),
],
)
.await;
let transport = ResourceRequestClient::new(&FetchConfig::default()).unwrap();
let origin = moli_url::origin_ascii_serialization(&redirect.url);
let document = document(&transport, redirect.url.as_str(), &origin);
let mut request = script_request(
&redirect.url,
RequestMode::Cors,
RequestCredentialsMode::SameOrigin,
);
request.url.set_fragment(Some("module-fragment"));
request.request_headers = vec![
("Authorization".to_owned(), "Bearer test-only".to_owned()),
("X-Embedder".to_owned(), "preserved".to_owned()),
];
let response = fetch_script(&document, request, callback).await.unwrap();
assert_eq!(response.final_url.fragment(), Some("module-fragment"));
assert_eq!(redirect.request_count(), 1);
assert_eq!(
final_server.request_count(),
1,
"browser-added script headers must not trigger preflight"
);
assert_eq!(
redirect.last_header("authorization").as_deref(),
Some("Bearer test-only")
);
assert_eq!(final_server.last_header("authorization"), None);
assert_eq!(final_server.last_header("referer"), None);
assert_eq!(
final_server.last_header("x-embedder").as_deref(),
Some("preserved")
);
}
}
#[tokio::test]
async fn script_cors_redirects_reject_non_http_schemes_and_cross_origin_url_credentials() {
for callback in [false, true] {
for target in [
"data:text/javascript,export default 1",
"http://user:pass@other.test/module.js",
] {
let redirect = ScriptServer::new(302, &[("Location", target)]).await;
let transport = ResourceRequestClient::new(&FetchConfig::default()).unwrap();
let origin = moli_url::origin_ascii_serialization(&redirect.url);
let document = document(&transport, redirect.url.as_str(), &origin);
let result = fetch_script(
&document,
script_request(
&redirect.url,
RequestMode::Cors,
RequestCredentialsMode::SameOrigin,
),
callback,
)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("CORS redirect"));
}
}
}
#[tokio::test]
async fn script_cors_uses_inherited_and_opaque_document_origins_after_transport_replacement() {
for callback in [false, true] {
for origin in ["https://creator.test", "null"] {
let server = ScriptServer::new(200, &[("Access-Control-Allow-Origin", origin)]).await;
let transport = ResourceRequestClient::new(&FetchConfig::default()).unwrap();
let replacement = ResourceRequestClient::new(&FetchConfig::default()).unwrap();
let document = document(&transport, "about:srcdoc", origin)
.with_replacement_transport(replacement.handle());
fetch_script(
&document,
script_request(
&server.url,
RequestMode::Cors,
RequestCredentialsMode::SameOrigin,
),
callback,
)
.await
.unwrap();
assert_eq!(server.last_origin().as_deref(), Some(origin));
}
}
}
#[test]
fn script_cors_memory_cache_key_separates_origins_and_request_modes() {
let url = Url::parse("https://script.test/module.js").unwrap();
let first = script_request(&url, RequestMode::Cors, RequestCredentialsMode::Include)
.with_request_origin(moli_url::WebOrigin::from_ascii_serialization(
"https://first.test",
));
let second = first
.clone()
.with_request_origin(moli_url::WebOrigin::from_ascii_serialization(
"https://second.test",
));
assert_ne!(
super::super::script_text_cache_key(&first),
super::super::script_text_cache_key(&second)
);
assert_ne!(
super::super::script_text_cache_key(&first),
super::super::script_text_cache_key(&first.clone().with_request_mode(RequestMode::NoCors))
);
}
+1 -1
View File
@@ -32,7 +32,7 @@ pub(crate) use self::async_fetch::{
fetch_browser_subresource_raw_stream_with_preflight_headers_and_network_metadata,
fetch_browser_subresource_with_preflight_and_network_metadata,
fetch_browser_subresource_with_preflight_headers,
fetch_browser_subresource_with_preflight_headers_and_network_metadata,
fetch_browser_subresource_with_preflight_headers_and_network_metadata, fetch_cors_script_text,
spawn_async_subresource_fetch,
};
pub(crate) use self::beacon::{navigator_send_beacon_callback, send_link_audit_ping};
@@ -7,6 +7,28 @@ use moli_fetch::{
const MAX_MANUAL_CORS_REDIRECTS: usize = 20;
/// Script fetches need CORS authorization before following every redirect,
/// even though their browser-generated GET requests do not require preflight.
pub(crate) async fn fetch_cors_script_text(
loader: &ResourceRequestClient,
request: Request,
cancel_handle: FetchCancelHandle,
) -> Result<Response, String> {
let observed = fetch_browser_subresource_raw_stream_with_manual_preflight_redirects(
loader,
request,
Some(cancel_handle),
Vec::new(),
None,
)
.await?;
observed
.into_response()
.into_lossy_materialized_text_response()
.await
.map_err(format_network_error)
}
#[cfg(test)]
pub(crate) async fn fetch_browser_subresource_with_preflight(
loader: ResourceRequestClient,
@@ -200,7 +222,46 @@ impl ManualCorsRedirectState {
unreachable!("redirect modes were handled before the follow transition")
}
}
if !matches!(next_url.scheme(), "http" | "https") {
return Err(format!("CORS redirect requires an HTTP(S) URL: {next_url}"));
}
if self.request.request_mode == RequestMode::Cors
&& (!next_url.username().is_empty() || next_url.password().is_some())
&& self.request.request_origin().is_some_and(|origin| {
!origin.same_origin_url(&head.final_url) || !origin.same_origin_url(&next_url)
})
{
return Err("CORS redirect URL must not include credentials".to_owned());
}
if !moli_url::same_origin(&head.final_url, &next_url) {
self.request
.request_headers
.retain(|(name, _)| !name.eq_ignore_ascii_case("authorization"));
if self
.request
.auth()
.is_some_and(|auth| auth.target == moli_fetch::RequestAuthTarget::Server)
{
self.request.set_auth(None);
}
}
if let Some(policy) =
crate::referrer_policy::response_referrer_policy_from_headers(&head.headers)
{
let mut metadata = self
.request
.subresource_request_metadata()
.cloned()
.unwrap_or_default();
metadata.referrer_policy = Some(policy);
self.request = self
.request
.clone()
.with_subresource_request_metadata(metadata);
}
let redirect_status = head.status;
// Keep the original client origin for Fetch Metadata. Recording the hop
// makes Request origin serialization and CORS validation apply redirect taint.
self.request.record_redirect(RedirectInfo {
source: moli_fetch::RedirectSource::Network,
from_url: head.final_url,
@@ -218,7 +279,14 @@ impl ManualCorsRedirectState {
});
self.request.apply_redirect_status(redirect_status);
self.request.url = next_url;
self.preflight_request_headers = self.request.request_headers.clone();
// Only author headers participate in preflight. In particular, a
// redirected script must not acquire embedder/browser-added headers here.
self.preflight_request_headers.retain(|(name, _)| {
self.request
.request_headers
.iter()
.any(|(remaining, _)| name.eq_ignore_ascii_case(remaining))
});
Ok(ManualCorsRedirectTransition::FollowedRedirect)
}
@@ -340,7 +408,12 @@ fn next_redirect_url(
final_url
.join(location)
.or_else(|_| url::Url::parse(location))
.map(Some)
.map(|mut url| {
if !location.contains('#') {
url.set_fragment(final_url.fragment());
}
Some(url)
})
.map_err(|error| {
format!("failed to resolve redirect location `{location}` from {final_url}: {error}")
})
@@ -586,6 +586,62 @@ mod tests {
}
}
#[test]
fn cors_response_validation_uses_the_redirect_tainted_origin() {
let home = url("https://page.test/a");
let away = url("https://script.test/b");
let elsewhere = url("https://other.test/c");
let origin = WebOrigin::from_url(&home);
for (redirects, serialized_origin) in [
(vec![(&home, &away)], origin.ascii_serialization()),
(vec![(&away, &elsewhere)], "null"),
(vec![(&home, &away), (&away, &home)], "null"),
] {
let final_url = redirects.last().expect("redirect target").1.clone();
let mut head = header_response(
final_url,
vec![(
"Access-Control-Allow-Origin".to_owned(),
serialized_origin.to_owned(),
)],
);
head.redirected = true;
head.redirect_chain = redirects
.into_iter()
.map(|(from, to)| moli_fetch::RedirectInfo {
source: RedirectSource::Network,
from_url: from.clone(),
to_url: to.clone(),
status: 302,
headers: vec![(
"Access-Control-Allow-Origin".to_owned(),
origin.ascii_serialization().to_owned(),
)],
network_extra_info_available: false,
request_extra_info: None,
response_extra_info: None,
redirect_has_extra_info: false,
request_cookie_report: None,
cookie_set_reports: Vec::new(),
from_cache: false,
negotiated_http_version: None,
})
.collect();
assert_eq!(
head.url_list().serialized_origin(&origin),
serialized_origin
);
validate_cors_response_chain(&origin, &head, RequestCredentialsMode::SameOrigin)
.expect("each response must be authorized against its own request hop origin");
head.headers.clear();
assert!(
validate_cors_response_chain(&origin, &head, RequestCredentialsMode::SameOrigin)
.is_err(),
"returning to the initial origin must not bypass CORS"
);
}
}
#[test]
fn cors_preflight_request_headers_detect_unsafe_method_and_headers() {
let headers = vec![
+18
View File
@@ -1250,6 +1250,24 @@ mod tests {
);
}
#[test]
fn classic_cors_script_uses_the_crossorigin_credentials_mode() {
for (cross_origin, expected) in [
("anonymous", RequestCredentialsMode::SameOrigin),
("", RequestCredentialsMode::SameOrigin),
("use-credentials", RequestCredentialsMode::Include),
] {
let metadata = ScriptFetchMetadata {
cross_origin: Some(cross_origin.to_owned()),
..ScriptFetchMetadata::default()
};
assert_eq!(
external_script_credentials_mode(ScriptKind::Classic, &metadata),
expected
);
}
}
#[test]
fn external_script_request_sets_browser_fetch_mode() {
let classic =
+109 -38
View File
@@ -77,6 +77,7 @@ use moli_websocket::test_support::{
use serde_json::json;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use tokio::task::JoinHandle;
use tokio::time::sleep;
use url::Url;
@@ -4711,22 +4712,9 @@ async fn page_vm_child_defer_classic_source_failure_releases_parser_order_slot()
#[tokio::test]
async fn page_vm_moved_child_defer_disposes_in_flight_slot_before_later_module() {
run_page_vm_async_test(async move {
let (base_url, server) = spawn_path_response_http_server(vec![
(
"/moved-child-defer.js",
"HTTP/1.1 200 OK",
"parent.__movedChildDeferEvents.push('classic-ran');".to_owned(),
Duration::from_millis(80),
),
(
"/later-moved-module.js",
"HTTP/1.1 200 OK",
"parent.__movedChildDeferEvents.push('module-ran');".to_owned(),
Duration::ZERO,
),
])
.await;
let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader");
let (base_url, release_classic, server) = spawn_moved_child_defer_http_server().await;
let loader =
crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader");
let document_url = Url::parse(&format!("{base_url}/page")).expect("page url");
let page_vm = test_page_vm_with_loader_and_document_url(&loader, Vec::new(), document_url);
let local_executor = page_vm.local_executor.clone();
@@ -4771,7 +4759,10 @@ async fn page_vm_moved_child_defer_disposes_in_flight_slot_before_later_module()
))?;
for _ in 0..12 {
let Some(_) = page_vm.run_next_child_frame_task_source_for_semantic_test().await else {
let Some(_) = page_vm
.run_next_child_frame_task_source_for_semantic_test()
.await
else {
break;
};
}
@@ -4782,8 +4773,13 @@ async fn page_vm_moved_child_defer_disposes_in_flight_slot_before_later_module()
);
let mut classic_completion = None;
let mut module_completion = false;
let mut release_classic = Some(release_classic);
for _ in 0..4 {
if !page_vm.page_resource_completion_queue().has_ready_completion() {
if !page_vm
.page_resource_completion_queue()
.has_ready_completion()
{
tokio::time::timeout(
Duration::from_secs(2),
wait_for_typed_page_resource_completion(&mut page_vm),
@@ -4791,30 +4787,44 @@ async fn page_vm_moved_child_defer_disposes_in_flight_slot_before_later_module()
.await
.expect("moved child defer completion should arrive");
}
let completion =
run_next_resource_completion_as_typed_page_turn(&mut page_vm)?;
let completion = run_next_resource_completion_as_typed_page_turn(&mut page_vm)?;
if matches!(
completion.action.source(),
RendererOwnerResourceActivitySource::ChildClassicScript
) {
classic_completion = Some(completion);
} else {
assert!(
matches!(
completion.action.source(),
RendererOwnerResourceActivitySource::ModuleGraphFetch
),
"the only other completion is the later module root"
);
run_expected_child_module_script_terminal_turn(
&mut page_vm,
"module terminal retained behind the moved classic defer",
)
.await;
module_completion = true;
// Make the later module ready while the classic source
// is still pending, without relying on transport timing.
release_classic
.take()
.expect("one module completion")
.send(())
.expect("classic response should still be gated");
}
if classic_completion.is_some() && module_completion {
break;
}
assert!(
matches!(
completion.action.source(),
RendererOwnerResourceActivitySource::ModuleGraphFetch
),
"the only completion allowed ahead of the moved classic defer is its later module root"
);
run_expected_child_module_script_terminal_turn(
&mut page_vm,
"module terminal retained behind the moved classic defer",
)
.await;
}
classic_completion
.expect("classic source completion must arrive after retained module terminals");
.expect("classic source completion must arrive before the script is moved");
assert!(
module_completion,
"later module must be ready before testing defer-slot release"
);
page_vm.vm_mut().eval(
r#"
(() => {
@@ -4862,15 +4872,27 @@ async fn page_vm_moved_child_defer_disposes_in_flight_slot_before_later_module()
ChildFrameSemanticTurnKind::DocumentLifecycle,
"moved defer complete transition",
),
(ChildFrameSemanticTurnKind::HostLoad, "moved defer iframe load"),
(
ChildFrameSemanticTurnKind::HostLoad,
"moved defer iframe load",
),
] {
sources.push(
run_expected_child_frame_task_source_after_realm_prerequisite_for_wait(&mut page_vm, source, label)
.await,
run_expected_child_frame_task_source_after_realm_prerequisite_for_wait(
&mut page_vm,
source,
label,
)
.await,
);
}
let final_events = page_vm.vm_mut().eval("__movedChildDeferEvents.join('|')")?;
assert_eq!(page_vm.run_next_child_frame_task_source_for_semantic_test().await, None);
assert_eq!(
page_vm
.run_next_child_frame_task_source_for_semantic_test()
.await,
None
);
Ok::<_, anyhow::Error>((events_after_dispose, sources, final_events))
})
.await
@@ -4891,7 +4913,9 @@ async fn page_vm_moved_child_defer_disposes_in_flight_slot_before_later_module()
final_events,
"before|after|ready:interactive|moved|module-ran|module-load|dcl|ready:complete|load"
);
server.await.expect("moved child defer server should finish");
server
.await
.expect("moved child defer server should finish");
})
.await;
}
@@ -14548,6 +14572,53 @@ async fn spawn_path_response_http_server(
(format!("http://{addr}"), server)
}
async fn spawn_moved_child_defer_http_server() -> (String, oneshot::Sender<()>, JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind defer fixture");
let base_url = format!("http://{}", listener.local_addr().unwrap());
let (release_classic, classic_released) = oneshot::channel();
let task = tokio::spawn(async move {
let mut classic_released = Some(classic_released);
let mut responses = Vec::new();
for _ in 0..2 {
let (mut stream, _) = listener.accept().await.expect("accept script request");
let request = read_http_request_head(&mut stream).await.unwrap();
let path = request
.lines()
.next()
.unwrap()
.split_whitespace()
.nth(1)
.unwrap();
let (body, gate) = match path {
"/moved-child-defer.js" => (
"parent.__movedChildDeferEvents.push('classic-ran');",
Some(classic_released.take().expect("one classic request")),
),
"/later-moved-module.js" => {
("parent.__movedChildDeferEvents.push('module-ran');", None)
}
_ => panic!("unexpected script path: {path}"),
};
responses.push(tokio::spawn(async move {
if let Some(gate) = gate {
gate.await.expect("test should release classic response");
}
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/javascript\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
stream.write_all(response.as_bytes()).await.unwrap();
}));
}
for response in responses {
response.await.expect("script response task");
}
});
(base_url, release_classic, task)
}
async fn spawn_concurrent_path_response_http_server(
response_specs: Vec<(&'static str, &'static str, String, Duration)>,
) -> (String, JoinHandle<()>) {
+1 -1
View File
@@ -10212,7 +10212,7 @@ async fn spawn_child_external_parser_module_ready_lane_server() -> (
let body = r#"parent.__childExternalParserModuleEvents.push("module:" + (globalThis === self));
globalThis.__childExternalParserModuleValue = 188;"#;
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nAccess-Control-Allow-Origin: *\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
"HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nAccess-Control-Allow-Origin: http://child-parser-module-driver.test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
@@ -644,7 +644,7 @@ fn send_synchronous_worker_xhr(
};
match result {
Ok(response) => {
Ok(mut response) => {
let response_head = response.head();
let redirect_status = if response_head.redirect_chain.is_empty() {
crate::content_security_policy::ContentSecurityPolicyRedirectStatus::NoRedirect
@@ -715,6 +715,11 @@ fn send_synchronous_worker_xhr(
throw_synchronous_xhr_failure(scope, xhr, &request_url_text, "NetworkError");
return;
}
let observable_headers = filter_cors_exposed_response_headers(
&prepared.document_url,
&response_head,
prepared.credentials_mode,
);
record_worker_subresource_success(
&state.borrow(),
prepared.document_url,
@@ -726,6 +731,7 @@ fn send_synchronous_worker_xhr(
response_head,
SubresourceResponseBody::from_fetch_response(&response),
);
response.headers = observable_headers;
apply_xhr_response(scope, xhr, response);
}
Err(error) => {
@@ -0,0 +1,125 @@
use super::*;
use parking_lot::Mutex;
struct CorsServer {
url: String,
requests: Arc<Mutex<Vec<String>>>,
task: JoinHandle<()>,
}
impl CorsServer {
async fn new(redirect_to: Option<String>) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}/data", listener.local_addr().unwrap());
let requests = Arc::new(Mutex::new(Vec::new()));
let captured = Arc::clone(&requests);
let task = tokio::spawn(async move {
while let Ok((mut stream, _)) = listener.accept().await {
let request = read_http_request_head(&mut stream).await.unwrap();
let preflight = request.starts_with("OPTIONS ");
captured.lock().push(request);
let origin = if redirect_to.is_some() {
"http://worker.test"
} else {
"null"
};
let (status, location, body) = if preflight {
("204 No Content", String::new(), "")
} else if let Some(target) = &redirect_to {
(
"307 Temporary Redirect",
format!("Location: {target}\r\n"),
"",
)
} else {
("200 OK", String::new(), "allowed")
};
let response = format!(
"HTTP/1.1 {status}\r\nAccess-Control-Allow-Origin: {origin}\r\nAccess-Control-Allow-Methods: PUT\r\nAccess-Control-Allow-Headers: x-test\r\nContent-Type: text/plain\r\nX-Hidden: secret\r\nContent-Length: {}\r\nConnection: close\r\n{location}\r\n{body}",
body.len()
);
stream.write_all(response.as_bytes()).await.unwrap();
}
});
Self {
url,
requests,
task,
}
}
}
impl Drop for CorsServer {
fn drop(&mut self) {
self.task.abort();
}
}
#[tokio::test]
async fn worker_cors_redirects_validate_null_origin_in_fetch_and_xhr() {
ensure_v8();
for api in ["fetch", "xhr", "sync-xhr"] {
let target = CorsServer::new(None).await;
let source = CorsServer::new(Some(target.url.clone())).await;
let loader = ResourceRequestClient::new(&FetchConfig::default()).unwrap();
let mut worker = spawn_worker_with_request_client(
format!(
r#"
(async () => {{
try {{
const url = {url:?};
const api = {api:?};
if (api === "fetch") {{
const response = await fetch(url, {{ method: "PUT", headers: {{ "X-Test": "1" }} }});
postMessage([response.status, await response.text(), response.headers.get("X-Hidden")]);
}} else {{
const xhr = new XMLHttpRequest();
const done = new Promise(resolve => xhr.onloadend = resolve);
xhr.open(api === "sync-xhr" ? "GET" : "PUT", url, api !== "sync-xhr");
if (api !== "sync-xhr") xhr.setRequestHeader("X-Test", "1");
xhr.send();
if (api !== "sync-xhr") await done;
postMessage([xhr.status, xhr.responseText, xhr.getResponseHeader("X-Hidden")]);
}}
}} catch (error) {{
postMessage(String(error));
}} finally {{ close(); }}
}})();
"#,
url = source.url,
),
"http://worker.test/main.js".to_owned(),
loader,
);
let posted = timeout(TIMEOUT, async {
loop {
match worker.recv().await.expect("worker channel") {
WorkerToParentMessage::Post(payload) => break stringify_payload(&payload),
WorkerToParentMessage::SubresourceNetwork(_) => {}
message => panic!("unexpected worker message: {message:?}"),
}
}
})
.await
.expect("worker CORS redirect result");
assert_eq!(posted, r#"[200,"allowed",null]"#, "{api}");
let requests = target.requests.lock();
let expected_requests = if api == "sync-xhr" { 1 } else { 2 };
assert_eq!(requests.len(), expected_requests, "{api}");
assert!(
requests
.last()
.unwrap()
.starts_with(if api == "sync-xhr" { "GET " } else { "PUT " }),
"{api}: actual request must reach the target"
);
for request in requests.iter() {
assert!(
request
.lines()
.any(|line| line.eq_ignore_ascii_case("origin: null")),
"{api}: {request}"
);
}
}
}
@@ -1517,6 +1517,7 @@ fn service_worker_storage_apis_use_explicit_registration_storage_key() {
// ─── Basic tests ────────────────────────────────────────────────────
mod cors_redirects;
mod lazy_storage;
mod lifecycle;
mod modules;