diff --git a/moli-fetch/src/blocking/mod.rs b/moli-fetch/src/blocking/mod.rs index a6af751283..3ce85f0bc4 100644 --- a/moli-fetch/src/blocking/mod.rs +++ b/moli-fetch/src/blocking/mod.rs @@ -1149,6 +1149,29 @@ mod tests { ); } + #[test] + fn opaque_request_origin_is_cross_origin_without_hiding_referrer_url() { + let config = FetchConfig::default(); + let request_url = url("https://app.test/data"); + let request = Request::new("GET", request_url.as_str(), None, Vec::new()) + .unwrap() + .with_initiator_url(&url("https://app.test/sandboxed-frame")) + .with_request_origin(moli_url::WebOrigin::Opaque) + .with_browser_request_metadata(BrowserRequestMetadata::Fetch); + + let headers = outgoing_request_headers_for_url(&config, &request, &request_url, None); + + assert_eq!(header_value(&headers, "origin").as_deref(), Some("null")); + assert_eq!( + header_value(&headers, "sec-fetch-site").as_deref(), + Some("cross-site") + ); + assert_eq!( + header_value(&headers, "referer").as_deref(), + Some("https://app.test/sandboxed-frame") + ); + } + #[test] fn post_navigation_without_an_initiator_serializes_opaque_origin() { let config = FetchConfig::default(); diff --git a/moli-fetch/src/fetch_url_list.rs b/moli-fetch/src/fetch_url_list.rs index f046dca01a..0bb674f64b 100644 --- a/moli-fetch/src/fetch_url_list.rs +++ b/moli-fetch/src/fetch_url_list.rs @@ -28,7 +28,8 @@ impl<'a> FetchUrlList<'a> { } /// Returning to the initiating origin cannot restore basic response tainting. - pub fn has_cross_origin_url(self, origin: &WebOrigin) -> bool { + pub fn has_cross_origin_url(self, origin: impl Into) -> bool { + let origin = origin.into(); self.urls().any(|url| !origin.same_origin(&url.into())) } @@ -52,13 +53,22 @@ impl<'a> FetchUrlList<'a> { Ok(()) } + pub fn has_cross_origin_url_for_origin(self, origin: &WebOrigin) -> bool { + self.has_cross_origin_url(origin) + } + + pub fn serialized_origin_for_origin(self, origin: &WebOrigin) -> String { + self.serialized_origin(origin) + } + pub fn has_cross_site_url(self, origin: &Url) -> bool { self.urls().any(|url| !same_site_urls(origin, url, true)) } /// A first hop out of the initiating origin retains that origin. Crossing /// origins from an already cross-origin URL serializes the origin as null. - pub fn serialized_origin(self, origin: &WebOrigin) -> String { + pub fn serialized_origin(self, origin: impl Into) -> String { + let origin = origin.into(); if self.redirects.iter().any(|redirect| { !same_origin(&redirect.from_url, &redirect.to_url) && !origin.same_origin(&(&redirect.from_url).into()) diff --git a/moli-fetch/src/tests/cookie_context.rs b/moli-fetch/src/tests/cookie_context.rs index eebc897b4c..41ba1ab161 100644 --- a/moli-fetch/src/tests/cookie_context.rs +++ b/moli-fetch/src/tests/cookie_context.rs @@ -110,6 +110,19 @@ fn request_credentials_mode_controls_cross_origin_cookie_access() { assert!(!omit_request.allows_credentials_for_url(&same_origin_url)); } +#[test] +fn opaque_request_origin_disallows_same_origin_credentials_for_same_url_origin() { + let document_url = Url::parse("https://example.com/app/page.html").unwrap(); + let request_url = Url::parse("https://example.com/api/data").unwrap(); + let request = Request::new("GET", request_url.as_str(), None, vec![]) + .unwrap() + .with_initiator_url(&document_url) + .with_request_origin(moli_url::WebOrigin::Opaque) + .with_credentials_mode(RequestCredentialsMode::SameOrigin); + + assert!(!request.allows_credentials_for_url(&request_url)); +} + #[test] fn explicit_same_site_override_sets_both_site_context_tracks() { let context = NetworkCookieRequestContext::subresource("GET") diff --git a/moli-renderer-v8/src/network_host/response/cors.rs b/moli-renderer-v8/src/network_host/response/cors.rs index b5f940d79f..3121ac71fe 100644 --- a/moli-renderer-v8/src/network_host/response/cors.rs +++ b/moli-renderer-v8/src/network_host/response/cors.rs @@ -659,6 +659,34 @@ mod tests { ); } + #[test] + fn opaque_request_origin_requires_null_cors_opt_in_for_same_url_origin() { + let response_url = url("https://example.test/data"); + let response = header_response(response_url.clone(), Vec::new()); + + assert!( + validate_cors_response_chain( + &WebOrigin::Opaque, + &response, + RequestCredentialsMode::SameOrigin, + ) + .is_err() + ); + + let allowed_response = header_response( + response_url, + vec![("Access-Control-Allow-Origin".to_owned(), "null".to_owned())], + ); + assert!( + validate_cors_response_chain( + &WebOrigin::Opaque, + &allowed_response, + RequestCredentialsMode::SameOrigin, + ) + .is_ok() + ); + } + #[test] fn validate_cors_preflight_response_checks_method_and_headers() { let request_headers = vec![ diff --git a/moli-renderer-v8/src/script_vm/subresource_fetch.rs b/moli-renderer-v8/src/script_vm/subresource_fetch.rs index e0a4070f9e..5ca2fb9a1e 100644 --- a/moli-renderer-v8/src/script_vm/subresource_fetch.rs +++ b/moli-renderer-v8/src/script_vm/subresource_fetch.rs @@ -5857,6 +5857,7 @@ impl ScriptVm { trace_fields, record_started, ); + let request_origin = streaming.pending.request_origin(); if let PendingSubresourceContinuation::Xhr(xhr) = streaming.pending.continuation && let Some(response_body) = xhr_delivery_body @@ -5881,7 +5882,7 @@ impl ScriptVm { { observable_head.headers = crate::network_host::filter_cors_exposed_response_headers( - &streaming.pending.request_origin, + &request_origin, &observable_head, streaming.pending.credentials_mode, ); diff --git a/moli-renderer-v8/src/script_vm/tests/extracted/realms_and_teardown.rs b/moli-renderer-v8/src/script_vm/tests/extracted/realms_and_teardown.rs index ac11200d32..17e27bb41f 100644 --- a/moli-renderer-v8/src/script_vm/tests/extracted/realms_and_teardown.rs +++ b/moli-renderer-v8/src/script_vm/tests/extracted/realms_and_teardown.rs @@ -71,6 +71,21 @@ async fn opaque_child_isolated_world_projects_only_its_own_document() { .child_browsing_context_has_opaque_origin(child_handle), "sandbox without allow-same-origin must create an opaque child origin" ); + let child_request_origin = { + let host = vm._context_host.borrow(); + let owner = crate::native_bridge::OwnerDispatchScope::Child(child_handle); + let loader = host + .document_resource_loader_for_dispatch_scope(owner) + .expect("opaque child resource loader should exist"); + host.subresource_request_environment(&loader, owner) + .expect("opaque child request environment should exist") + .request_origin + }; + assert_eq!( + child_request_origin, + moli_url::WebOrigin::Opaque, + "sandboxed child subresource requests must use an opaque client origin" + ); assert_eq!( vm.eval("document.getElementById('opaque-isolated-frame').contentDocument === null") .expect("top opaque contentDocument visibility should evaluate"), diff --git a/moli-renderer-v8/src/types.rs b/moli-renderer-v8/src/types.rs index c8210e668f..0fa9708082 100644 --- a/moli-renderer-v8/src/types.rs +++ b/moli-renderer-v8/src/types.rs @@ -521,6 +521,10 @@ pub(super) struct PendingSubresourceFetchState { } impl PendingSubresourceFetchState { + pub(super) fn request_origin(&self) -> moli_url::WebOrigin { + self.request_origin.clone() + } + pub(super) fn detach_keepalive_window_fetch(&mut self) -> bool { let PendingSubresourceExecutionContext::WindowFetch(context) = &self.execution_context else { diff --git a/moli-url/src/origin.rs b/moli-url/src/origin.rs index b8ab25a8ee..2b748e4876 100644 --- a/moli-url/src/origin.rs +++ b/moli-url/src/origin.rs @@ -17,7 +17,7 @@ impl TupleOrigin { } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Eq, PartialEq)] pub enum WebOrigin { Tuple(TupleOrigin), Opaque, @@ -45,6 +45,12 @@ impl WebOrigin { } } + pub fn from_ascii_serialization(serialized: &str) -> Self { + Url::parse(serialized) + .ok() + .map_or(Self::Opaque, |url| Self::from_url(&url)) + } + pub fn is_opaque(&self) -> bool { matches!(self, Self::Opaque) } @@ -69,6 +75,10 @@ impl WebOrigin { _ => false, } } + + pub fn same_origin_url(&self, url: &Url) -> bool { + self.same_origin(&Self::from_url(url)) + } } impl From<&Url> for WebOrigin { @@ -206,6 +216,27 @@ mod tests { )); } + #[test] + fn web_origin_compares_directly_with_urls() { + let origin = WebOrigin::from_url(&url("https://example.test/document")); + + assert!(origin.same_origin_url(&url("https://example.test/resource"))); + assert!(!origin.same_origin_url(&url("https://other.test/resource"))); + assert!(!WebOrigin::Opaque.same_origin_url(&url("https://example.test/resource"))); + } + + #[test] + fn web_origin_rehydrates_tuple_serializations_and_keeps_null_opaque() { + assert_eq!( + WebOrigin::from_ascii_serialization("https://example.test:8443"), + WebOrigin::from_url(&url("https://example.test:8443/path")) + ); + assert_eq!( + WebOrigin::from_ascii_serialization("null"), + WebOrigin::Opaque + ); + } + #[test] fn blob_url_path_fallback_only_accepts_http_https_and_file_schemes() { let blob = url("blob:https://example.test/object-1");