fix(fetch): retain parsed blob URL entries across revocation

Capture shared blob bytes when Request is constructed or XHR is opened,
and retain the entry through cloning, Window/Worker fetches, and request
interception. Fresh URLs and reopened XHRs still observe revocation.

Private isolate-scoped carriers release their references on GC or isolate
teardown, while requests survive cleanup of the creating realm.

Validation: cargo fmt, workspace Clippy with warnings denied, and nextest
(17986 passed, 13 skipped). Fixes seven upstream WPT failures; 58 cases
show no regressions. Additional contract checks improve from 332 to 440
out of 448; eight pre-existing getter-exception failures remain separate.
This commit is contained in:
ldm0
2026-09-14 17:43:46 +08:00
parent 0ee4db49df
commit 731c2dd2a9
24 changed files with 585 additions and 23 deletions
+53 -8
View File
@@ -207,15 +207,19 @@ where
/// Return object URL bytes and MIME type, excluding its fragment.
pub fn object_url_bytes_and_type(&self, url: &str) -> Option<(Vec<u8>, String)> {
let (bytes, mime_type) = self.object_url_shared_bytes_and_type(url)?;
Some((bytes.to_vec(), mime_type))
}
/// Capture an object URL entry without copying its immutable body. The
/// captured entry remains usable after revocation or creator teardown.
pub fn object_url_shared_bytes_and_type(&self, url: &str) -> Option<(Arc<[u8]>, String)> {
let url = url.split_once('#').map_or(url, |(url, _)| url);
let blob_id = self
.object_urls
.lock()
.get(url)
.map(|state| state.blob_id)?;
let bytes = self.blob_bytes(blob_id)?;
let mime_type = self.blob_mime_type(blob_id).unwrap_or_default();
Some((bytes, mime_type))
let object_urls = self.object_urls.lock();
let blob_id = object_urls.get(url)?.blob_id;
let blobs = self.blobs.lock();
let blob = blobs.by_id.get(&blob_id)?;
Some((blob.bytes.clone(), blob.mime_type.clone()))
}
/// Return object URL body decoded lossily as text plus MIME type.
@@ -348,6 +352,47 @@ fn random_uuid() -> String {
mod tests {
use super::*;
#[test]
fn captured_object_url_entries_outlive_revocation_and_creator_cleanup() {
for cleanup in ["revoke", "owner", "lifetime"] {
let store = BlobStore::<u64, u64>::default();
let blob = store.create_blob(
Some(1),
Some(10),
vec![0, 128, 255],
"application/example".to_owned(),
);
let url = store
.create_object_url_with_lifetime(Some(1), Some(101), blob, "https://example.test")
.unwrap();
let entry = store
.object_url_shared_bytes_and_type(&format!("{url}#fragment"))
.unwrap();
let clone = store.object_url_shared_bytes_and_type(&url).unwrap();
assert!(Arc::ptr_eq(&entry.0, &clone.0));
let weak = Arc::downgrade(&entry.0);
store.release_blob_wrapper_ref(blob);
match cleanup {
"revoke" => {
assert!(store.revoke_object_url(&url));
}
"owner" => store.cleanup_owner_resources(1),
"lifetime" => {
assert_eq!(store.cleanup_object_url_lifetime(1, 101), 1);
}
_ => unreachable!(),
}
assert!(store.object_url_shared_bytes_and_type(&url).is_none());
assert!(store.blob_bytes(blob).is_none());
assert_eq!(&*entry.0, &[0, 128, 255]);
assert_eq!(entry.1, "application/example");
drop(entry);
assert!(weak.upgrade().is_some());
drop(clone);
assert!(weak.upgrade().is_none());
}
}
#[test]
fn object_url_retains_blob_until_revoked() {
let store = BlobStore::<u64, u64>::default();
+4
View File
@@ -411,6 +411,10 @@ pub(super) fn object_url_bytes_and_type(url: &str) -> Option<(Vec<u8>, String)>
blob_store().object_url_bytes_and_type(url)
}
pub(super) fn object_url_shared_bytes_and_type(url: &str) -> Option<(Arc<[u8]>, String)> {
blob_store().object_url_shared_bytes_and_type(url)
}
pub(super) fn collect_blob_bytes_and_type<'s>(
scope: &mut v8::PinScope<'s, '_>,
parts_value: v8::Local<'s, v8::Value>,
@@ -558,6 +558,7 @@ impl JsContextHost {
request_origin: moli_url::WebOrigin,
network_partition_key: Option<String>,
policy_context: crate::types::SubresourcePolicyContext,
blob_url_entry: Option<crate::network_host::CapturedBlobUrl>,
mut info: PendingSubresourceFetchInfo,
) {
self.assign_pending_subresource_fetch_identity(&mut info);
@@ -593,6 +594,7 @@ impl JsContextHost {
),
),
deferred_request_started: false,
blob_url_entry,
},
);
self.note_subresource_activity();
@@ -630,6 +632,7 @@ impl JsContextHost {
fetch_id,
},
deferred_request_started: false,
blob_url_entry: None,
},
);
self.note_subresource_activity();
@@ -663,6 +666,7 @@ impl JsContextHost {
policy_context: Default::default(),
continuation: PendingSubresourceContinuation::WorkerXhr { worker_id, xhr_id },
deferred_request_started: false,
blob_url_entry: None,
},
);
self.note_subresource_activity();
@@ -700,6 +704,7 @@ impl JsContextHost {
report_id,
},
deferred_request_started: false,
blob_url_entry: None,
},
);
self.note_subresource_activity();
@@ -737,6 +742,7 @@ impl JsContextHost {
fetch_id,
},
deferred_request_started: false,
blob_url_entry: None,
},
);
self.note_subresource_activity();
@@ -773,6 +779,7 @@ impl JsContextHost {
xhr_id,
},
deferred_request_started: false,
blob_url_entry: None,
},
);
self.note_subresource_activity();
@@ -810,6 +817,7 @@ impl JsContextHost {
report_id,
},
deferred_request_started: false,
blob_url_entry: None,
},
);
self.note_subresource_activity();
@@ -867,6 +875,7 @@ impl JsContextHost {
),
),
deferred_request_started: defer_request_started,
blob_url_entry: None,
},
);
self.note_subresource_activity();
@@ -914,6 +923,7 @@ impl JsContextHost {
policy_context,
continuation: PendingSubresourceContinuation::EventSource(event_source),
deferred_request_started: false,
blob_url_entry: None,
},
);
self.note_subresource_activity();
@@ -970,6 +980,7 @@ impl JsContextHost {
sequence,
},
deferred_request_started: false,
blob_url_entry: None,
},
);
self.note_subresource_activity();
@@ -1041,6 +1052,7 @@ impl JsContextHost {
request_initiator_type,
},
deferred_request_started: false,
blob_url_entry: None,
},
);
self.note_subresource_activity();
@@ -1156,6 +1168,7 @@ impl JsContextHost {
sequence,
},
deferred_request_started: false,
blob_url_entry: None,
},
);
self.note_subresource_activity();
@@ -1212,6 +1225,7 @@ impl JsContextHost {
css_image,
},
deferred_request_started: false,
blob_url_entry: None,
},
);
self.note_subresource_activity();
@@ -1249,6 +1263,7 @@ impl JsContextHost {
policy_context: Default::default(),
continuation: PendingSubresourceContinuation::Beacon,
deferred_request_started: false,
blob_url_entry: None,
},
);
self.note_subresource_activity();
@@ -1289,6 +1304,7 @@ impl JsContextHost {
policy_context,
continuation: PendingSubresourceContinuation::CspReport { client_id },
deferred_request_started: false,
blob_url_entry: None,
},
);
self.note_subresource_activity();
@@ -1325,6 +1341,7 @@ impl JsContextHost {
policy_context: Default::default(),
continuation: PendingSubresourceContinuation::Beacon,
deferred_request_started: false,
blob_url_entry: None,
},
);
self.note_subresource_activity();
@@ -1365,6 +1382,7 @@ impl JsContextHost {
policy_context,
continuation: PendingSubresourceContinuation::CspReport { client_id },
deferred_request_started: false,
blob_url_entry: None,
},
);
self.note_subresource_activity();
@@ -1378,6 +1396,7 @@ impl JsContextHost {
credentials_mode: moli_fetch::RequestCredentialsMode,
network_partition_key: Option<String>,
policy_context: crate::types::SubresourcePolicyContext,
blob_url_entry: Option<crate::network_host::CapturedBlobUrl>,
mut info: PendingSubresourceFetchInfo,
) -> u64 {
self.assign_pending_subresource_fetch_identity(&mut info);
@@ -1402,6 +1421,7 @@ impl JsContextHost {
policy_context,
continuation: PendingSubresourceContinuation::Xhr(xhr),
deferred_request_started: false,
blob_url_entry,
},
);
self.note_subresource_activity();
@@ -1437,6 +1457,7 @@ impl JsContextHost {
policy_context: Default::default(),
continuation: PendingSubresourceContinuation::WebSocket(connection),
deferred_request_started: false,
blob_url_entry: None,
},
);
self.note_subresource_activity();
@@ -1475,6 +1496,7 @@ impl JsContextHost {
policy_context,
continuation: PendingSubresourceContinuation::Xhr(xhr),
deferred_request_started: false,
blob_url_entry: None,
},
);
self.note_subresource_activity();
+7 -1
View File
@@ -1,6 +1,7 @@
mod async_fetch;
mod beacon;
mod bindings;
mod blob_url_entry;
mod body;
mod body_source;
mod browser_response;
@@ -27,6 +28,9 @@ use moli_webapi_declare::WebApiObject;
use crate::network::ResourceRequestClient;
pub(in crate::network_host) use self::blob_url_entry::BLOB_URL_ENTRY_SLOT;
pub(crate) use self::blob_url_entry::{CapturedBlobUrl, blob_url_entry, set_blob_url_entry};
pub(crate) use self::async_fetch::{
browser_request_needs_manual_preflight_redirects,
fetch_browser_subresource_raw_stream_with_preflight_headers_and_network_metadata,
@@ -64,7 +68,9 @@ pub(crate) use self::body_source::{
pending_network_body_stream, release_pending_opaque_response_body,
};
pub(in crate::network_host) use self::browser_response::http_status_text;
pub(crate) use self::browser_response::{local_url_response, local_url_response_result};
pub(crate) use self::browser_response::{
local_url_response, local_url_response_result, local_url_response_with_blob_entry,
};
pub(crate) use self::csp_reports::{
WindowCspReportRequestContext, capture_window_csp_report_request_context,
send_content_security_policy_reports_for_lightweight_popup,
@@ -0,0 +1,217 @@
//! URL parsing captures the blob entry, including a failed lookup. Revoking
//! the URL must not invalidate an existing Request or a successfully opened
//! XHR. Private carriers retain shared bytes until their last JS owner is
//! collected; the isolate store also releases them on isolate teardown.
use std::{cell::RefCell, collections::HashMap, rc::Rc, sync::Arc};
use crate::util::{get_private_value, set_private_value};
pub(in crate::network_host) const BLOB_URL_ENTRY_SLOT: &str = "__lmBlobUrlEntry";
const ID_SLOT: &str = "__lmBlobUrlEntryId";
#[derive(Clone)]
pub(crate) struct CapturedBlobUrl {
url: url::Url,
data: Option<(Arc<[u8]>, String)>,
}
impl CapturedBlobUrl {
pub(crate) fn capture(url: &url::Url) -> Option<Self> {
if url.scheme() != "blob" {
return None;
}
let mut url = url.clone();
url.set_fragment(None);
let data = crate::blob::object_url_shared_bytes_and_type(url.as_str());
Some(Self { url, data })
}
pub(super) fn response(&self, url: &url::Url) -> Option<super::Response> {
let (body, mime_type) = self.data.as_ref()?;
Some(super::browser_response::blob_response(
url,
body.to_vec(),
mime_type.clone(),
))
}
pub(super) fn matches(&self, url: &url::Url) -> bool {
self.url.as_str()
== url
.as_str()
.split_once('#')
.map_or(url.as_str(), |(url, _)| url)
}
}
type Store = Rc<RefCell<Entries>>;
#[derive(Default)]
struct Entries {
next_id: u64,
values: HashMap<u64, (v8::Weak<v8::Object>, CapturedBlobUrl)>,
}
pub(crate) fn set_blob_url_entry<'s>(
scope: &mut v8::PinScope<'s, '_>,
owner: v8::Local<'s, v8::Object>,
entry: Option<CapturedBlobUrl>,
) {
let Some(entry) = entry else {
set_private_value(
scope,
owner,
BLOB_URL_ENTRY_SLOT,
v8::undefined(scope).into(),
);
return;
};
let store = if let Some(store) = scope.get_slot::<Store>() {
store.clone()
} else {
let store = Store::default();
scope.set_slot(store.clone());
store
};
let id = {
let mut store = store.borrow_mut();
store.next_id = store
.next_id
.checked_add(1)
.expect("blob entry identity exhausted");
store.next_id
};
let carrier = v8::Object::new(scope);
let weak_store = Rc::downgrade(&store);
let weak = v8::Weak::with_finalizer(
scope,
carrier,
Box::new(move |_| {
if let Some(store) = weak_store.upgrade() {
store.borrow_mut().values.remove(&id);
}
}),
);
store.borrow_mut().values.insert(id, (weak, entry));
set_private_value(
scope,
carrier,
ID_SLOT,
v8::BigInt::new_from_u64(scope, id).into(),
);
set_private_value(scope, owner, BLOB_URL_ENTRY_SLOT, carrier.into());
}
pub(crate) fn blob_url_entry<'s>(
scope: &mut v8::PinScope<'s, '_>,
owner: v8::Local<'s, v8::Object>,
) -> Option<CapturedBlobUrl> {
let carrier = get_private_value(scope, owner, BLOB_URL_ENTRY_SLOT)
.and_then(|value| v8::Local::<v8::Object>::try_from(value).ok())?;
let id = get_private_value(scope, carrier, ID_SLOT)
.and_then(|value| v8::Local::<v8::BigInt>::try_from(value).ok())?
.u64_value()
.0;
scope
.get_slot::<Store>()?
.borrow()
.values
.get(&id)
.map(|(_, entry)| entry.clone())
}
#[cfg(test)]
mod tests {
use super::*;
fn entry() -> CapturedBlobUrl {
CapturedBlobUrl {
url: url::Url::parse("blob:https://example.test/captured").unwrap(),
data: Some((Arc::from([0, 128, 255]), "application/example".to_owned())),
}
}
#[test]
fn private_entries_follow_the_last_owner_across_realms_gc_and_isolate_drop() {
moli_v8_test_util::ensure_v8();
let weak = {
let mut isolate = v8::Isolate::new(Default::default());
let (weak, surviving_owner) = {
let scope = std::pin::pin!(v8::HandleScope::new(&mut isolate));
let scope = &mut scope.init();
let first = v8::Context::new(scope, Default::default());
let scope = &mut v8::ContextScope::new(scope, first);
let owner = v8::Object::new(scope);
let entry = entry();
let weak = Arc::downgrade(&entry.data.as_ref().unwrap().0);
set_blob_url_entry(scope, owner, Some(entry));
let second = v8::Context::new(scope, Default::default());
let scope = &mut v8::ContextScope::new(scope, second);
let other = v8::Object::new(scope);
let carrier = get_private_value(scope, owner, BLOB_URL_ENTRY_SLOT).unwrap();
set_private_value(scope, other, BLOB_URL_ENTRY_SLOT, carrier);
let captured = blob_url_entry(scope, other).unwrap();
assert!(Arc::ptr_eq(
&captured.data.unwrap().0,
&weak.upgrade().unwrap()
));
(weak, v8::Global::new(scope, other))
};
isolate.low_memory_notification();
assert!(weak.upgrade().is_some());
drop(surviving_owner);
isolate.low_memory_notification();
assert!(weak.upgrade().is_none());
assert!(
isolate
.get_slot::<Store>()
.unwrap()
.borrow()
.values
.is_empty()
);
let scope = std::pin::pin!(v8::HandleScope::new(&mut isolate));
let scope = &mut scope.init();
let context = v8::Context::new(scope, Default::default());
let scope = &mut v8::ContextScope::new(scope, context);
let owner = v8::Object::new(scope);
let entry = entry();
let weak = Arc::downgrade(&entry.data.as_ref().unwrap().0);
set_blob_url_entry(scope, owner, Some(entry));
weak
};
assert!(weak.upgrade().is_none());
}
#[test]
fn captured_entries_match_fragments_but_do_not_override_other_urls_or_methods() {
let captured = entry();
let mut url = captured.url.clone();
url.set_fragment(Some("fragment"));
let response =
super::super::local_url_response_with_blob_entry(&url, "GET", Some(&captured))
.unwrap()
.unwrap();
assert_eq!(response.head().status, 200);
let (_, body) = response.into_body();
assert_eq!(
body.try_into_materialized_bytes().unwrap(),
vec![0, 128, 255]
);
assert!(
super::super::local_url_response_with_blob_entry(&url, "POST", Some(&captured))
.unwrap()
.is_err()
);
let other = url::Url::parse("blob:https://example.test/other").unwrap();
assert!(
super::super::local_url_response_with_blob_entry(&other, "GET", Some(&captured))
.unwrap()
.is_err()
);
let missing = CapturedBlobUrl { url, data: None };
assert!(missing.response(&missing.url).is_none());
}
}
@@ -10,11 +10,15 @@ pub(in crate::network_host) fn http_status_text(status: u16) -> &'static str {
pub(crate) fn blob_url_response(url: &url::Url) -> Option<Response> {
let (body_bytes, mime_type) = blob::object_url_bytes_and_type(url.as_str())?;
Some(blob_response(url, body_bytes, mime_type))
}
pub(super) fn blob_response(url: &url::Url, body_bytes: Vec<u8>, mime_type: String) -> Response {
let mut headers = Vec::new();
if !mime_type.is_empty() {
headers.push(("Content-Type".to_owned(), mime_type));
}
Some(Response::from_head_and_lossy_body_bytes(
Response::from_head_and_lossy_body_bytes(
moli_fetch::ResponseHead {
final_url: url.clone(),
status: 200,
@@ -27,7 +31,7 @@ pub(crate) fn blob_url_response(url: &url::Url) -> Option<Response> {
negotiated_http_version: None,
},
body_bytes,
))
)
}
pub(crate) fn data_url_response(url: &url::Url) -> Option<Response> {
@@ -63,13 +67,25 @@ pub(crate) fn local_url_response(url: &url::Url) -> Option<Response> {
pub(crate) fn local_url_response_result(
url: &url::Url,
method: &str,
) -> Option<Result<Response, String>> {
local_url_response_with_blob_entry(url, method, None)
}
pub(crate) fn local_url_response_with_blob_entry(
url: &url::Url,
method: &str,
entry: Option<&CapturedBlobUrl>,
) -> Option<Result<Response, String>> {
match url.scheme() {
"blob" if method != "GET" => {
Some(Err(format!("blob URL fetch requires GET, got `{method}`")))
}
"blob" => {
Some(blob_url_response(url).ok_or_else(|| format!("blob URL `{url}` is unavailable")))
let response = match entry.filter(|entry| entry.matches(url)) {
Some(entry) => entry.response(url),
None => blob_url_response(url),
};
Some(response.ok_or_else(|| format!("blob URL `{url}` is unavailable")))
}
"data" => {
Some(data_url_response(url).ok_or_else(|| format!("data URL `{url}` is invalid")))
@@ -30,6 +30,7 @@ pub(super) fn record_intercepted_fetch(
prepared.request_origin.clone(),
prepared.network_partition_key,
prepared.policy_context,
prepared.blob_url_entry,
PendingSubresourceFetchInfo {
internal_id: 0,
network_request_handle: None,
@@ -144,7 +145,11 @@ pub(super) fn resolve_local_fetch(
host: &mut JsContextHost,
prepared: &PreparedWindowFetchRequest,
) -> Result<Option<Response>, String> {
let Some(result) = local_url_response_result(&prepared.resolved_url, &prepared.method) else {
let Some(result) = local_url_response_with_blob_entry(
&prepared.resolved_url,
&prepared.method,
prepared.blob_url_entry.as_ref(),
) else {
return Ok(None);
};
let response = result.map_err(|message| {
@@ -13,6 +13,7 @@ pub(super) struct PreparedWindowFetchRequest {
pub(super) document_referrer_policy: Option<String>,
pub(super) policy_context: crate::types::SubresourcePolicyContext,
pub(super) resolved_url: url::Url,
pub(super) blob_url_entry: Option<CapturedBlobUrl>,
pub(super) method: String,
pub(super) request_headers: Vec<(String, String)>,
pub(super) cors_preflight_request_headers: Vec<(String, String)>,
@@ -82,6 +83,9 @@ pub(super) fn prepare_window_fetch_request<'s>(
.validate(scope, parsed.request_mode.as_ref(), &parsed.cache)?
.unwrap_or(parsed.referrer);
let blob_url_entry = parsed
.blob_url_entry
.or_else(|| CapturedBlobUrl::capture(&resolved_url));
Ok(PreparedWindowFetchRequest {
frame_id,
fetch_context,
@@ -94,6 +98,7 @@ pub(super) fn prepare_window_fetch_request<'s>(
document_referrer_policy,
policy_context,
resolved_url,
blob_url_entry,
method: parsed.method,
request_headers,
cors_preflight_request_headers,
@@ -4,6 +4,7 @@ use std::str::FromStr;
pub(super) struct ParsedWindowFetchInput {
pub(super) url: String,
pub(super) blob_url_entry: Option<CapturedBlobUrl>,
pub(super) method: String,
pub(super) body: Option<Vec<u8>>,
pub(super) body_stream: Option<v8::Global<v8::Object>>,
@@ -95,6 +96,7 @@ pub(super) fn parse_window_fetch_input<'s>(
});
Ok(ParsedWindowFetchInput {
url,
blob_url_entry: inherited.blob_url_entry,
method,
body,
body_stream,
@@ -138,6 +140,7 @@ pub(super) fn parse_window_fetch_input<'s>(
.unwrap_or(moli_fetch::RequestRedirectMode::Follow);
Ok(ParsedWindowFetchInput {
url,
blob_url_entry: None,
method: init.method,
body: init.body,
body_stream: init.body_stream,
@@ -817,6 +817,7 @@ fn request_clone_callback<'s>(
.bind(scope)
.expect("Request clone shell declaration should bind");
for slot in [
BLOB_URL_ENTRY_SLOT,
REQUEST_METHOD_SLOT,
REQUEST_URL_SLOT,
REQUEST_DESTINATION_SLOT,
@@ -90,6 +90,14 @@ pub(crate) fn request_constructor_callback<'s>(
return;
}
// Fresh URL entry lookup follows RequestInit conversion. Inherited URL
// entries, including unavailable entries, survive getters that revoke it.
let blob_url_entry = state.blob_url_entry.or_else(|| {
url::Url::parse(&state.url_resolved)
.ok()
.and_then(|url| CapturedBlobUrl::capture(&url))
});
set_blob_url_entry(scope, obj, blob_url_entry);
append_default_body_content_type(&mut state.headers, state.body_content_type.as_deref());
let body_buffer = state
.body
@@ -12,6 +12,7 @@ use moli_fetch::RequestRedirectMode;
pub(super) struct RequestConstructionState {
pub(super) url_resolved: String,
pub(super) blob_url_entry: Option<CapturedBlobUrl>,
pub(super) method: String,
pub(super) mode: String,
pub(super) cache: String,
@@ -121,10 +122,14 @@ pub(super) fn request_initial_state<'s>(
.as_ref()
.map(|snapshot| snapshot.headers.clone())
.unwrap_or_default();
let blob_url_entry = inherited
.as_ref()
.and_then(|snapshot| snapshot.blob_url_entry.clone());
let signal = inherited.and_then(|snapshot| snapshot.signal);
Ok(RequestConstructionState {
url_resolved,
blob_url_entry,
method,
mode,
cache,
@@ -64,6 +64,7 @@ pub(super) fn request_headers_guard_for_mode(mode: &str) -> HeadersGuard {
pub(crate) struct RequestInputSnapshot {
pub(crate) url: String,
pub(crate) blob_url_entry: Option<CapturedBlobUrl>,
pub(crate) method: String,
pub(crate) mode: String,
pub(crate) cache: String,
@@ -175,6 +176,7 @@ fn request_input_snapshot_inner<'s>(
let signal = request_signal_snapshot_from_property(scope, object)?;
Ok(Some(RequestInputSnapshot {
url,
blob_url_entry: None,
method,
mode,
cache,
@@ -251,6 +253,7 @@ fn request_input_snapshot_from_private_slots<'s>(
.flatten();
Ok(RequestInputSnapshot {
url,
blob_url_entry: blob_url_entry(scope, object),
method,
mode,
cache,
@@ -72,6 +72,7 @@ pub(super) fn xhr_open_callback<'s>(
set_xhr_state_number(scope, xhr, XHR_OPEN_GENERATION_SLOT, open_generation + 1.0);
set_xhr_state_string(scope, xhr, XHR_METHOD_SLOT, &method);
set_xhr_state_string(scope, xhr, XHR_URL_SLOT, request_url.as_str());
set_blob_url_entry(scope, xhr, CapturedBlobUrl::capture(&request_url));
set_xhr_state_string(scope, xhr, XHR_REQUEST_HEADERS_SLOT, "[]");
set_xhr_state_bool(scope, xhr, XHR_ASYNC_SLOT, parsed.async_request);
set_xhr_state_number(scope, xhr, XHR_READY_STATE_SLOT, 1.0);
@@ -192,7 +192,11 @@ pub(super) fn xhr_send_callback<'s>(
return;
}
if let Some(result) = local_url_response_result(&prepared.resolved_url, &prepared.method) {
if let Some(result) = local_url_response_with_blob_entry(
&prepared.resolved_url,
&prepared.method,
prepared.blob_url_entry.as_ref(),
) {
match result {
Ok(response) if async_request => {
queue_local_xhr_response(scope, host, xhr, prepared, response);
@@ -29,6 +29,7 @@ pub(super) fn record_intercepted_xhr(
prepared.credentials_mode,
prepared.network_partition_key,
prepared.policy_context,
prepared.blob_url_entry,
PendingSubresourceFetchInfo {
internal_id: 0,
network_request_handle: None,
@@ -21,6 +21,7 @@ pub(super) struct PreparedXhrSendRequest {
pub(super) network_partition_key: Option<String>,
pub(super) policy_context: crate::types::SubresourcePolicyContext,
pub(super) resolved_url: url::Url,
pub(super) blob_url_entry: Option<CapturedBlobUrl>,
pub(super) method: String,
pub(super) request_headers: Vec<(String, String)>,
pub(super) cors_preflight_request_headers: Vec<(String, String)>,
@@ -55,7 +56,7 @@ pub(super) fn xhr_dom_debugger_request_url<'s>(
pub(super) fn prepare_xhr_send_request<'s>(
scope: &mut v8::PinScope<'s, '_>,
host: &JsContextHost,
xhr: v8::Local<'_, v8::Object>,
xhr: v8::Local<'s, v8::Object>,
method: String,
prepared_body: PreparedXhrSendBody,
) -> Result<PreparedXhrSendRequest, XhrSendPrepareError> {
@@ -99,6 +100,7 @@ pub(super) fn prepare_xhr_send_request<'s>(
network_partition_key,
policy_context,
resolved_url,
blob_url_entry: blob_url_entry(scope, xhr),
method,
request_headers,
cors_preflight_request_headers,
@@ -4355,6 +4355,168 @@ async fn blob_fetch_and_xhr_reject_non_get_methods_in_window_and_worker() {
.await;
}
#[tokio::test]
async fn blob_url_entries_survive_request_cloning_and_xhr_open_in_window_and_worker() {
run_page_vm_async_test(async move {
for worker in [false, true] {
let mut page_vm = test_page_vm();
let local_executor = page_vm.local_executor.clone();
let probe = r#"(async () => {
const check = (value, message) => { if (!value) throw new Error(message); };
const make = () => URL.createObjectURL(new Blob(['payload'], {type: 'text/plain'}));
const read = async (request) => {
const response = await fetch(request);
check(response.status === 200 && !response.url.includes('#'), 'response metadata');
check(await response.text() === 'payload', 'captured payload');
};
const rejects = async input => {
let failure;
try { await fetch(input); } catch (error) { failure = error; }
check(failure instanceof TypeError, 'fresh revoked URL must reject');
};
for (const fragment of ['', '#fragment']) {
const url = make();
const request = new Request(url + fragment);
const before = request.clone();
URL.revokeObjectURL(url);
for (const copy of [request, before, request.clone(), new Request(request)]) await read(copy);
await rejects(url + fragment);
await rejects(new Request(url + fragment));
for (const inherited of [false, true]) {
for (const member of ['method', 'headers', 'signal']) {
const getterUrl = make();
const input = inherited ? new Request(getterUrl + fragment) : getterUrl + fragment;
const init = {};
Object.defineProperty(init, member, {get() {
URL.revokeObjectURL(getterUrl);
return member === 'method' ? 'GET' : member === 'headers' ? [] : null;
}});
const copy = new Request(input, init);
if (inherited) await read(copy); else await rejects(copy);
}
}
const immediateUrl = make();
const pending = fetch(immediateUrl + fragment);
URL.revokeObjectURL(immediateUrl);
check(await (await pending).text() === 'payload', 'fetch must capture before returning');
for (const async of [false, true]) {
for (const reopen of [false, true]) {
const xhrUrl = make();
const xhr = new XMLHttpRequest();
xhr.open('GET', xhrUrl + fragment, async);
URL.revokeObjectURL(xhrUrl);
try { xhr.open('GET', 'http://['); } catch (error) {
check(error.name === 'SyntaxError', 'failed open must preserve previous request');
}
if (reopen) xhr.open('GET', xhrUrl + fragment, async);
if (async) {
await new Promise(resolve => { xhr.onloadend = resolve; xhr.send(); });
} else {
let failure;
try { xhr.send(); } catch (error) { failure = error; }
check(reopen ? failure?.name === 'NetworkError' : !failure, 'synchronous outcome');
}
check(xhr.status === (reopen ? 0 : 200), 'XHR reopened entry status');
check(xhr.responseText === (reopen ? '' : 'payload'), 'XHR captured body');
}
}
}
return 'ok';
})()"#;
let script = if worker {
let source = serde_json::to_string(&format!(
"{probe}.then(postMessage, error => postMessage(String(error)))"
)).unwrap();
format!(r#"globalThis.__snapshotResult = 'pending';
const source = URL.createObjectURL(new Blob([{source}]));
const worker = new Worker(source);
worker.onmessage = e => {{ globalThis.__snapshotResult = e.data; worker.terminate(); URL.revokeObjectURL(source); }};
worker.onerror = e => {{ globalThis.__snapshotResult = e.message; }};"#)
} else {
format!("globalThis.__snapshotResult = 'pending'; {probe}.then(value => {{ globalThis.__snapshotResult = value; }}, error => {{ globalThis.__snapshotResult = String(error); }})")
};
let result = local_executor.run(async move {
page_vm.vm_mut().eval(&script)?;
drive_websocket_until_done(&mut page_vm, "String(globalThis.__snapshotResult !== 'pending')", "blob entry lifetime checks should finish").await?;
page_vm.vm_mut().eval("globalThis.__snapshotResult")
}).await.expect("blob entry probe should run on owner lane");
assert_eq!(result, "ok", "worker={worker}");
}
}).await;
}
#[tokio::test]
async fn intercepted_blob_url_requests_keep_their_entry_and_respect_url_and_method_overrides() {
run_page_vm_async_test(async move {
for (worker, xhr) in [(false, false), (false, true), (true, false)] {
for change in ["none", "fragment", "url", "method"] {
let mut page_vm = test_page_vm();
let local_executor = page_vm.local_executor.clone();
let result = local_executor.run(async move {
page_vm.vm_mut().set_fetch_subresource_interception(true, Some(if xhr {
SubresourceResourceType::Xhr
} else {
SubresourceResourceType::Fetch
}));
let probe = format!(r#"(() => {{
const original = URL.createObjectURL(new Blob(['payload']));
const other = URL.createObjectURL(new Blob(['replacement']));
const finish = value => {{
URL.revokeObjectURL(other);
{finish}
}};
if ({xhr}) {{
const xhr = new XMLHttpRequest();
xhr.open('GET', original + '#original');
URL.revokeObjectURL(original);
xhr.onload = () => finish(xhr.responseText);
xhr.onerror = () => finish('error');
xhr.send();
}} else {{
const request = new Request(original + '#original');
URL.revokeObjectURL(original);
fetch(request).then(r => r.text()).then(finish, () => finish('error'));
}}
{ready}
}})()"#,
finish = if worker { "postMessage({value});" } else { "globalThis.__snapshotResult = value;" },
ready = if worker { "postMessage({ready: true, other});" } else { "globalThis.__snapshotReady = true; globalThis.__snapshotOther = other;" },
);
let script = if worker {
let source = serde_json::to_string(&probe).unwrap();
format!(r#"globalThis.__snapshotResult = 'pending';
const source = URL.createObjectURL(new Blob([{source}]));
const worker = new Worker(source);
worker.onmessage = e => {{
if (e.data.ready) {{ globalThis.__snapshotReady = true; globalThis.__snapshotOther = e.data.other; }}
else {{ globalThis.__snapshotResult = e.data.value; worker.terminate(); URL.revokeObjectURL(source); }}
}};"#)
} else {
format!("globalThis.__snapshotResult = 'pending'; {probe}")
};
page_vm.vm_mut().eval(&script)?;
drive_websocket_until_done(&mut page_vm, "String(globalThis.__snapshotReady === true)", "blob request should reach interception").await?;
let pending = page_vm.vm_mut().take_pending_subresource_fetch_infos();
assert_eq!(pending.len(), 1, "worker={worker}, xhr={xhr}, change={change}");
let pending = &pending[0];
let url = match change {
"fragment" => { let mut url = pending.url.clone(); url.set_fragment(Some("new")); Some(url) }
"url" => Some(Url::parse(&page_vm.vm_mut().eval("globalThis.__snapshotOther")?)?),
_ => None,
};
let method = (change == "method").then(|| "POST".to_owned());
page_vm.continue_pending_subresource_fetch(pending.internal_id, url, method, None, None, false, false)?;
drive_websocket_until_done(&mut page_vm, "String(globalThis.__snapshotResult !== 'pending')", "continued blob request should complete").await?;
page_vm.vm_mut().eval("globalThis.__snapshotResult")
}).await.expect("intercepted blob request should run on owner lane");
assert_eq!(result, match change { "url" => "replacement", "method" => "error", _ => "payload" }, "worker={worker}, xhr={xhr}, change={change}");
}
}
}).await;
}
#[tokio::test]
async fn window_fetch_revoked_blob_url_records_file_not_found_failure() {
run_page_vm_async_test(async move {
@@ -1126,6 +1126,7 @@ impl ScriptVm {
policy_context,
continuation,
deferred_request_started,
blob_url_entry,
} = pending;
let pending = match continuation {
PendingSubresourceContinuation::WebSocket(connection) => {
@@ -1189,6 +1190,7 @@ impl ScriptVm {
policy_context,
continuation: target.continuation(),
deferred_request_started,
blob_url_entry,
},
request_url,
request_method,
@@ -1244,6 +1246,7 @@ impl ScriptVm {
policy_context,
continuation: target.continuation(),
deferred_request_started,
blob_url_entry,
},
request_url,
request_method,
@@ -1308,6 +1311,7 @@ impl ScriptVm {
policy_context,
continuation: PendingSubresourceContinuation::CspReport { client_id },
deferred_request_started,
blob_url_entry,
};
if !self._context_host.borrow().network_offline() {
let maybe_pending = self.continue_csp_report_via_service_worker(
@@ -1355,6 +1359,7 @@ impl ScriptVm {
policy_context,
continuation,
deferred_request_started,
blob_url_entry,
},
};
let request_url = url.unwrap_or_else(|| pending.info.url.clone());
@@ -1943,6 +1948,7 @@ impl ScriptVm {
policy_context,
continuation,
deferred_request_started,
blob_url_entry,
} = pending;
let pending = match continuation {
PendingSubresourceContinuation::WebSocket(connection) => {
@@ -2095,6 +2101,7 @@ impl ScriptVm {
policy_context,
continuation,
deferred_request_started,
blob_url_entry,
},
};
let info = pending.info.clone();
@@ -2136,6 +2143,7 @@ impl ScriptVm {
policy_context,
continuation,
deferred_request_started,
blob_url_entry,
} = pending;
// Request-stage fulfillment has no followed redirects yet. Reuse this
// complete head for validation and response materialization.
@@ -2460,6 +2468,7 @@ impl ScriptVm {
policy_context,
continuation,
deferred_request_started,
blob_url_entry,
},
};
let info = pending.info.clone();
@@ -4009,13 +4018,20 @@ impl ScriptVm {
let request_headers = state.request_headers.clone();
let request_body = state.request_body.clone();
let completion_tx = self._context_host.borrow().resource_completion_sender();
let local_response = crate::network_host::local_url_response_with_blob_entry(
&request.url,
&request.method,
state.pending.blob_url_entry.as_ref(),
);
{
let mut host = self._context_host.borrow_mut();
host.begin_active_subresource_request();
host.record_running_subresource_fetch(state);
}
task_runner.spawn(async move {
let result =
let result = if let Some(result) = local_response {
result.map(crate::protocol_types::NavigationResponse::from)
} else {
crate::network_host::fetch_browser_subresource_with_preflight_and_network_metadata(
request_client,
request,
@@ -4028,7 +4044,8 @@ impl ScriptVm {
.with_network_request_headers(
request_observation.map(|observation| observation.into_headers()),
)
});
})
};
let _ = completion_tx.send_async_subresource(AsyncSubresourceFetchCompletion {
internal_id,
request_url,
@@ -2265,6 +2265,7 @@ async fn streaming_subresource_finish_preserves_response_head_cache_state() {
None,
),
deferred_request_started: false,
blob_url_entry: None,
},
request_url: request_url.clone(),
request_method: "GET".to_owned(),
@@ -2412,6 +2413,7 @@ async fn async_subresource_failure_network_error_override_preserves_fetch_reject
None,
),
deferred_request_started: false,
blob_url_entry: None,
},
);
Ok(())
@@ -2553,6 +2555,7 @@ async fn streaming_fetch_body_error_records_response_started_then_body_failed()
None,
),
deferred_request_started: false,
blob_url_entry: None,
},
request_url: request_url.clone(),
request_method: "GET".to_owned(),
@@ -2710,6 +2713,7 @@ fn install_streaming_fetch_response_fixture(
Some(cancel_handle),
),
deferred_request_started: false,
blob_url_entry: None,
},
request_url: request_url.clone(),
request_method: "GET".to_owned(),
@@ -2818,6 +2822,7 @@ async fn streaming_fetch_body_cancel_aborts_streaming_subresource() {
Some(cancel_handle_for_state),
),
deferred_request_started: false,
blob_url_entry: None,
},
request_url: request_url.clone(),
request_method: "GET".to_owned(),
@@ -3448,6 +3453,7 @@ async fn streaming_xhr_materialization_failure_errors_body_source_before_close()
None,
),
deferred_request_started: false,
blob_url_entry: None,
},
request_url: request_url.clone(),
request_method: "GET".to_owned(),
+1
View File
@@ -532,6 +532,7 @@ pub(super) struct PendingSubresourceFetchState {
// Window fetches that need CORS preflight emit the actual request-start
// after the preflight record, not when the pending fetch is registered.
pub(super) deferred_request_started: bool,
pub(super) blob_url_entry: Option<crate::network_host::CapturedBlobUrl>,
}
impl PendingSubresourceFetchState {
@@ -1,4 +1,5 @@
use super::*;
use crate::network_host::{CapturedBlobUrl, local_url_response_with_blob_entry};
use crate::service_worker_runtime::{
ServiceWorkerClientId, ServiceWorkerDirectFetchResult, ServiceWorkerFetchDispatch,
ServiceWorkerFetchRequest, ServiceWorkerFetchRequestMetadata, ServiceWorkerRequestDestination,
@@ -147,6 +148,7 @@ pub(in crate::worker) fn spawn_worker_fetch_network(
referrer_policy: Option<String>,
network_partition_key: Option<String>,
resolved_url: Url,
blob_url_entry: Option<CapturedBlobUrl>,
method: String,
body: Option<Vec<u8>>,
headers: Vec<(String, String)>,
@@ -158,6 +160,10 @@ pub(in crate::worker) fn spawn_worker_fetch_network(
auth: Option<crate::protocol_types::SubresourceAuthCredentials>,
allow_headers_first: bool,
) {
// Resolve local URLs before scheduling: fetch(url) already parsed and
// captured its entry when JavaScript regains control and may revoke it.
let local_response =
local_url_response_with_blob_entry(&resolved_url, &method, blob_url_entry.as_ref());
tokio::task::spawn_local(async move {
let loader = load.request_client();
let (result, network_request_headers) = if let Err(message) =
@@ -165,7 +171,7 @@ pub(in crate::worker) fn spawn_worker_fetch_network(
.validate_request_mode(request_mode, &moli_url::WebOrigin::from_url(&document_url))
{
(Err(message), None)
} else if let Some(result) = local_url_response_result(&resolved_url, &method) {
} else if let Some(result) = local_response {
(
result
.map(|response| WorkerFetchResponse::Materialized(Box::new(response)))
@@ -445,6 +451,7 @@ fn spawn_worker_fetch_service_worker(
referrer_policy,
network_partition_key,
resolved_url,
None,
method,
body,
headers,
@@ -617,6 +624,7 @@ pub(in crate::worker) fn continue_pending_worker_fetch(
network_partition_key,
fetch_id,
resolved_url,
blob_url_entry,
method,
body,
headers,
@@ -658,6 +666,7 @@ pub(in crate::worker) fn continue_pending_worker_fetch(
network_partition_key,
request.fetch_id,
request.url,
pending.blob_url_entry.clone(),
request.method,
request.body,
request.headers,
@@ -676,6 +685,7 @@ pub(in crate::worker) fn continue_pending_worker_fetch(
state.borrow().referrer_policy.clone(),
network_partition_key,
resolved_url,
blob_url_entry,
method,
body.map(|body| body.into_bytes()),
headers,
@@ -1739,6 +1749,7 @@ pub(in crate::worker) fn worker_fetch_signal_option<'s>(
pub(in crate::worker) struct ResolvedWorkerFetchInput<'s> {
resolved_url: Url,
blob_url_entry: Option<CapturedBlobUrl>,
method: String,
body: Option<Vec<u8>>,
body_stream: Option<v8::Global<v8::Object>>,
@@ -1768,6 +1779,9 @@ pub(in crate::worker) fn resolve_worker_fetch_input<'s>(
let body_stream;
let has_stream_body;
let inherited = request_input_snapshot(scope, arg0).map_err(|error| error.to_string())?;
let blob_url_entry = inherited
.as_ref()
.and_then(|request| request.blob_url_entry.clone());
let (
url_input,
method,
@@ -1914,11 +1928,13 @@ pub(in crate::worker) fn resolve_worker_fetch_input<'s>(
metadata.referrer = referrer;
}
let signal = worker_fetch_signal_option(scope, args, request_like)?;
let blob_url_entry = blob_url_entry.or_else(|| CapturedBlobUrl::capture(&resolved_url));
if consumes_request_body && let Some(request_like) = request_like {
crate::network_host::mark_request_input_body_used_for_fetch(scope, request_like);
}
Ok(ResolvedWorkerFetchInput {
resolved_url,
blob_url_entry,
method,
body,
body_stream,
@@ -1983,6 +1999,7 @@ pub(in crate::worker) fn worker_fetch_callback<'s>(
let ResolvedWorkerFetchInput {
resolved_url,
blob_url_entry,
method,
body,
body_stream,
@@ -2147,6 +2164,7 @@ pub(in crate::worker) fn worker_fetch_callback<'s>(
signal_id,
load: load.clone(),
request_url: resolved_url.clone(),
blob_url_entry: blob_url_entry.clone(),
request_method: method.clone(),
request_headers: headers.clone(),
request_body: request_body.clone(),
@@ -2245,6 +2263,7 @@ pub(in crate::worker) fn worker_fetch_callback<'s>(
signal_id,
load: load.clone(),
request_url: resolved_url.clone(),
blob_url_entry: blob_url_entry.clone(),
request_method: method.clone(),
request_headers: headers.clone(),
request_body,
@@ -2300,6 +2319,7 @@ pub(in crate::worker) fn worker_fetch_callback<'s>(
referrer_policy,
network_partition_key,
resolved_url,
blob_url_entry,
method,
body,
headers,
@@ -83,9 +83,9 @@ use crate::network_host::{
fetch_browser_subresource_raw_stream_with_preflight_headers_and_network_metadata,
fetch_browser_subresource_with_preflight_headers_and_network_metadata,
filter_cors_exposed_response_headers, filter_headers_for_guard, is_cors_policy_failure_message,
local_url_response_result, parse_fetch_init, prepare_xhr_send_body_from_args,
request_input_snapshot, request_object_credentials_mode, reset_xhr_response_for_request_error,
resolve_context_url, set_xhr_state_bool, set_xhr_state_number, throw_synchronous_xhr_failure,
parse_fetch_init, prepare_xhr_send_body_from_args, request_input_snapshot,
request_object_credentials_mode, reset_xhr_response_for_request_error, resolve_context_url,
set_xhr_state_bool, set_xhr_state_number, throw_synchronous_xhr_failure,
validate_fetch_response_headers, validate_fetch_response_security_policy,
validate_fetch_response_security_policy_with_body_classified, xhr_author_request_headers,
xhr_dispatch_progress_event, xhr_ensure_send_allowed, xhr_state_bool_property,
@@ -1094,6 +1094,7 @@ pub(super) struct PendingWorkerFetch {
pub(super) signal_id: Option<u32>,
pub(super) load: ResourceLoadLease,
pub(super) request_url: Url,
pub(super) blob_url_entry: Option<crate::network_host::CapturedBlobUrl>,
pub(super) request_method: String,
pub(super) request_headers: Vec<(String, String)>,
pub(super) request_body: Option<String>,
@@ -1,4 +1,5 @@
use super::*;
use crate::network_host::{CapturedBlobUrl, blob_url_entry, local_url_response_with_blob_entry};
use crossbeam_channel::{after, bounded, never, select};
use moli_webapi_declare::WebApiObject;
use std::thread;
@@ -7,6 +8,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
pub(in crate::worker) struct PreparedWorkerXhrSendRequest {
document_url: Url,
resolved_url: Url,
blob_url_entry: Option<CapturedBlobUrl>,
method: String,
request_headers: Vec<(String, String)>,
send_body: Option<Vec<u8>>,
@@ -408,7 +410,11 @@ pub(crate) fn try_worker_xhr_send_callback<'s>(
return true;
}
let local_response = local_url_response_result(&prepared.resolved_url, &prepared.method);
let local_response = local_url_response_with_blob_entry(
&prepared.resolved_url,
&prepared.method,
prepared.blob_url_entry.as_ref(),
);
if !async_request && let Some(result) = local_response {
match result {
Ok(response) => apply_xhr_response(scope, xhr, response),
@@ -1166,7 +1172,7 @@ pub(in crate::worker) fn drain_worker_xhr_completion(
pub(in crate::worker) fn prepare_worker_xhr_send_request<'s>(
scope: &mut v8::PinScope<'s, '_>,
state: &Rc<RefCell<WorkerGlobalState>>,
xhr: v8::Local<'_, v8::Object>,
xhr: v8::Local<'s, v8::Object>,
method: String,
prepared_body: PreparedXhrSendBody,
) -> Result<PreparedWorkerXhrSendRequest, WorkerXhrSendPrepareError> {
@@ -1190,6 +1196,7 @@ pub(in crate::worker) fn prepare_worker_xhr_send_request<'s>(
Ok(PreparedWorkerXhrSendRequest {
document_url,
resolved_url,
blob_url_entry: blob_url_entry(scope, xhr),
method,
request_headers,
send_body: prepared_body.body,