mirror of
https://github.com/lexmount/moli.git
synced 2026-10-08 08:01:01 +00:00
fix(fetch): enforce integrity before response handover
Preserve integrity through Window and worker fetch continuations and wait for complete response bytes before applying the shared SRI validator. Cover local, network, intercepted, and Service Worker responses, retaining null-body state and abort reasons. Add regressions for digest selection, body completion, and null versus empty responses; preserve non-ASCII whitespace in integrity metadata parsing.
This commit is contained in:
@@ -551,6 +551,7 @@ impl JsContextHost {
|
||||
fetch_context: super::WindowFetchContext,
|
||||
resolver: v8::Global<v8::PromiseResolver>,
|
||||
keepalive: bool,
|
||||
integrity: String,
|
||||
connect_policy: crate::document_runtime::DocumentConnectPolicySnapshot,
|
||||
csp_report_context: crate::network_host::WindowCspReportRequestContext,
|
||||
credentials_mode: moli_fetch::RequestCredentialsMode,
|
||||
@@ -594,6 +595,7 @@ impl JsContextHost {
|
||||
connect_policy,
|
||||
csp_report_context,
|
||||
redirect_mode,
|
||||
integrity,
|
||||
),
|
||||
),
|
||||
deferred_request_started: false,
|
||||
@@ -837,6 +839,7 @@ impl JsContextHost {
|
||||
fetch_context: super::WindowFetchContext,
|
||||
resolver: v8::Global<v8::PromiseResolver>,
|
||||
keepalive: bool,
|
||||
integrity: String,
|
||||
connect_policy: crate::document_runtime::DocumentConnectPolicySnapshot,
|
||||
csp_report_context: crate::network_host::WindowCspReportRequestContext,
|
||||
cancel_handle: Option<moli_fetch::FetchCancelHandle>,
|
||||
@@ -884,6 +887,7 @@ impl JsContextHost {
|
||||
connect_policy,
|
||||
csp_report_context,
|
||||
redirect_mode,
|
||||
integrity,
|
||||
),
|
||||
),
|
||||
deferred_request_started: defer_request_started,
|
||||
|
||||
@@ -84,7 +84,9 @@ pub(crate) use self::event_source::{
|
||||
fail_event_source_connection, install_event_source_bindings, open_event_source_connection,
|
||||
update_event_source_stream_state,
|
||||
};
|
||||
pub(crate) use self::fetch::validate_no_cors_http_redirect_mode;
|
||||
pub(crate) use self::fetch::{
|
||||
validate_fetch_response_integrity, validate_no_cors_http_redirect_mode,
|
||||
};
|
||||
pub(in crate::network_host) use self::fetch_surface::{
|
||||
REQUEST_BODY_SLOT, REQUEST_BODY_USED_SLOT, REQUEST_CACHE_SLOT, REQUEST_CREDENTIALS_SLOT,
|
||||
REQUEST_DESTINATION_SLOT, REQUEST_DUPLEX_SLOT, REQUEST_HEADERS_SLOT, REQUEST_INTEGRITY_SLOT,
|
||||
|
||||
@@ -737,7 +737,16 @@ pub(crate) fn spawn_async_subresource_fetch(
|
||||
});
|
||||
return;
|
||||
}
|
||||
if auth_requires_buffered_transport || !can_stream_subresource_body {
|
||||
// Integrity applies before response handover, including metadata that
|
||||
// the SRI parser later ignores. Never resolve fetch at the headers.
|
||||
let integrity_requires_full_body = request
|
||||
.subresource_request_metadata()
|
||||
.and_then(|metadata| metadata.integrity.as_deref())
|
||||
.is_some_and(|integrity| !integrity.is_empty());
|
||||
if auth_requires_buffered_transport
|
||||
|| !can_stream_subresource_body
|
||||
|| integrity_requires_full_body
|
||||
{
|
||||
let result = fetch_browser_subresource_with_preflight_headers_and_observer(
|
||||
loader,
|
||||
request,
|
||||
@@ -1777,6 +1786,70 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn fetch_integrity_waits_for_complete_network_body() -> Result<()> {
|
||||
for truncated in [false, true] {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let addr = listener.local_addr()?;
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
read_http_request_text(&mut stream).await.unwrap();
|
||||
stream
|
||||
.write_all(
|
||||
b"HTTP/1.1 200 OK\r\nContent-Length: 15\r\nConnection: close\r\n\r\nhello",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
if !truncated {
|
||||
stream.write_all(b" integrity").await.unwrap();
|
||||
}
|
||||
});
|
||||
let mut queue = RendererResourceCompletionTestHarness::new();
|
||||
let loader_owner = ResourceRequestClient::new(&FetchConfig::default())?;
|
||||
let url = Url::parse(&format!("http://{addr}/integrity"))?;
|
||||
let request = Request::get(url.as_str())?
|
||||
.with_request_origin(moli_url::WebOrigin::from_url(&url))
|
||||
.with_browser_request_metadata(BrowserRequestMetadata::Fetch)
|
||||
.with_script_fetch_metadata(moli_fetch::ScriptFetchRequestMetadata {
|
||||
integrity: Some("sha1-ignored".to_owned()),
|
||||
..Default::default()
|
||||
});
|
||||
spawn_async_subresource_fetch(
|
||||
crate::network::RendererResourceTaskRunner::from_current_tokio()?,
|
||||
queue.sender(),
|
||||
loader_owner.handle(),
|
||||
request,
|
||||
Some(FetchCancelHandle::new()),
|
||||
Vec::new(),
|
||||
42,
|
||||
AsyncSubresourceNetworkContext {
|
||||
frame_id: None,
|
||||
request_origin: moli_url::WebOrigin::from_url(&url),
|
||||
document_url: url.clone(),
|
||||
resource_type: SubresourceResourceType::Fetch,
|
||||
policy_context: Default::default(),
|
||||
},
|
||||
url,
|
||||
"GET".to_owned(),
|
||||
Vec::new(),
|
||||
None,
|
||||
);
|
||||
match next_async_subresource_event(&mut queue).await? {
|
||||
AsyncSubresourceFetchEvent::Completion(completion) => {
|
||||
if truncated {
|
||||
assert!(completion.result.is_err());
|
||||
} else {
|
||||
assert_eq!(completion.result.unwrap().body_bytes(), b"hello integrity");
|
||||
}
|
||||
}
|
||||
other => anyhow::bail!("integrity fetch exposed an incomplete body: {other:?}"),
|
||||
}
|
||||
server.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn xhr_subresource_uses_streaming_events_until_body_finish() -> Result<()> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
mod bindings;
|
||||
mod input;
|
||||
mod integrity;
|
||||
mod promise;
|
||||
|
||||
use super::request::parse_fetch_init;
|
||||
use super::*;
|
||||
|
||||
pub(crate) use self::bindings::window_fetch_callback;
|
||||
pub(crate) use self::integrity::validate_fetch_response_integrity;
|
||||
|
||||
/// Select the HTTP no-cors fetch path using the logical request URL, before
|
||||
/// Service Worker or DevTools interception. A DevTools transport URL rewrite
|
||||
|
||||
@@ -23,6 +23,7 @@ pub(super) fn record_intercepted_fetch(
|
||||
prepared.fetch_context,
|
||||
v8::Global::new(scope, resolver),
|
||||
prepared.keepalive,
|
||||
prepared.integrity.clone(),
|
||||
prepared.connect_policy,
|
||||
prepared.csp_report_context,
|
||||
prepared.credentials_mode,
|
||||
@@ -153,24 +154,48 @@ pub(super) fn resolve_local_fetch(
|
||||
) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let response = result.map_err(|message| {
|
||||
let message = if prepared.resolved_url.scheme() == "blob" && prepared.method == "GET" {
|
||||
FILE_NOT_FOUND_ERROR_TEXT.to_owned()
|
||||
} else {
|
||||
message
|
||||
};
|
||||
host.record_subresource_network(SubresourceNetworkRecord::failure(
|
||||
prepared.frame_id.clone(),
|
||||
prepared.document_url.clone(),
|
||||
prepared.resolved_url.clone(),
|
||||
prepared.method.clone(),
|
||||
prepared.request_headers.clone(),
|
||||
request_body_text(&prepared.body),
|
||||
SubresourceResourceType::Fetch,
|
||||
message.clone(),
|
||||
));
|
||||
message
|
||||
})?;
|
||||
let response = result
|
||||
.map_err(|message| {
|
||||
if prepared.resolved_url.scheme() == "blob" && prepared.method == "GET" {
|
||||
FILE_NOT_FOUND_ERROR_TEXT.to_owned()
|
||||
} else {
|
||||
message
|
||||
}
|
||||
})
|
||||
.and_then(|response| {
|
||||
if !prepared.integrity.is_empty() {
|
||||
let filter = FetchResponseRequest {
|
||||
method: &prepared.method,
|
||||
mode: prepared.request_mode,
|
||||
redirect_mode: prepared.redirect_mode,
|
||||
}
|
||||
.network_response_filter(
|
||||
&prepared.request_origin,
|
||||
&response.head(),
|
||||
prepared.credentials_mode,
|
||||
);
|
||||
validate_fetch_response_integrity(
|
||||
&prepared.integrity,
|
||||
&prepared.method,
|
||||
response.status,
|
||||
&filter,
|
||||
response.body_bytes(),
|
||||
)?;
|
||||
}
|
||||
Ok(response)
|
||||
})
|
||||
.inspect_err(|message| {
|
||||
host.record_subresource_network(SubresourceNetworkRecord::failure(
|
||||
prepared.frame_id.clone(),
|
||||
prepared.document_url.clone(),
|
||||
prepared.resolved_url.clone(),
|
||||
prepared.method.clone(),
|
||||
prepared.request_headers.clone(),
|
||||
request_body_text(&prepared.body),
|
||||
SubresourceResourceType::Fetch,
|
||||
message.clone(),
|
||||
));
|
||||
})?;
|
||||
host.record_subresource_network(
|
||||
SubresourceNetworkRecord::success_with_body(
|
||||
prepared.frame_id.clone(),
|
||||
@@ -260,6 +285,7 @@ pub(super) fn spawn_network_fetch(
|
||||
prepared.fetch_context,
|
||||
v8::Global::new(scope, resolver),
|
||||
prepared.keepalive,
|
||||
prepared.integrity.clone(),
|
||||
prepared.connect_policy,
|
||||
prepared.csp_report_context,
|
||||
Some(cancel_handle.clone()),
|
||||
|
||||
@@ -58,6 +58,7 @@ pub(super) fn dispatch_service_worker_fetch(
|
||||
prepared.fetch_context.duplicate(scope),
|
||||
v8::Global::new(scope, resolver),
|
||||
prepared.keepalive,
|
||||
prepared.integrity.clone(),
|
||||
prepared.connect_policy.clone(),
|
||||
prepared.csp_report_context.clone(),
|
||||
Some(cancel_handle.clone()),
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
use super::*;
|
||||
|
||||
/// Main fetch checks the filtered response's body before applying SRI's
|
||||
/// metadata parser. Even ignored metadata cannot authorize a null body.
|
||||
pub(crate) fn validate_fetch_response_integrity(
|
||||
integrity: &str,
|
||||
method: &str,
|
||||
status: u16,
|
||||
filter: &crate::types::AsyncSubresourceFetchResponseFilter,
|
||||
bytes: &[u8],
|
||||
) -> Result<(), String> {
|
||||
if integrity.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if !filter.is_readable() || response_has_null_body(method, status) {
|
||||
return Err("fetch: integrity metadata requires a non-null response body".to_owned());
|
||||
}
|
||||
if !crate::subresource_integrity::response_matches_subresource_integrity_metadata(
|
||||
bytes,
|
||||
Some(integrity),
|
||||
true,
|
||||
) {
|
||||
return Err("fetch: response body does not match its integrity metadata".to_owned());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -35,6 +35,40 @@ use crate::types::{
|
||||
};
|
||||
use crate::util::v8_string;
|
||||
|
||||
fn validate_pending_window_fetch_integrity(
|
||||
pending: &PendingSubresourceFetchState,
|
||||
method: &str,
|
||||
response: &crate::protocol_types::NavigationResponse,
|
||||
response_filter: Option<&AsyncSubresourceFetchResponseFilter>,
|
||||
) -> std::result::Result<(), String> {
|
||||
let Some(fetch) = pending
|
||||
.continuation
|
||||
.window_fetch()
|
||||
.filter(|fetch| !fetch.integrity().is_empty())
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let filter = response_filter.cloned().unwrap_or_else(|| {
|
||||
crate::network_host::FetchResponseRequest {
|
||||
method,
|
||||
mode: pending.request_mode,
|
||||
redirect_mode: fetch.redirect_mode(),
|
||||
}
|
||||
.network_response_filter(
|
||||
&pending.request_origin,
|
||||
&response.head(),
|
||||
pending.credentials_mode,
|
||||
)
|
||||
});
|
||||
crate::network_host::validate_fetch_response_integrity(
|
||||
fetch.integrity(),
|
||||
method,
|
||||
response.status,
|
||||
&filter,
|
||||
response.body_bytes(),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum WorkerOwnedFetchTarget {
|
||||
Dedicated {
|
||||
@@ -3237,6 +3271,7 @@ impl ScriptVm {
|
||||
request_body: Option<String>,
|
||||
response_status_text: Option<String>,
|
||||
skip_fetch_security_validation: bool,
|
||||
response_filter: Option<AsyncSubresourceFetchResponseFilter>,
|
||||
network_error_text: Option<String>,
|
||||
result: std::result::Result<crate::protocol_types::NavigationResponse, String>,
|
||||
) -> Result<()> {
|
||||
@@ -3270,6 +3305,12 @@ impl ScriptVm {
|
||||
pending.policy_context,
|
||||
)?;
|
||||
}
|
||||
validate_pending_window_fetch_integrity(
|
||||
&pending,
|
||||
&request_method,
|
||||
&response,
|
||||
response_filter.as_ref(),
|
||||
)?;
|
||||
Ok(response)
|
||||
})
|
||||
} else {
|
||||
@@ -3664,6 +3705,7 @@ impl ScriptVm {
|
||||
request_body,
|
||||
response_status_text,
|
||||
skip_fetch_security_validation,
|
||||
response_filter,
|
||||
network_error_text,
|
||||
result,
|
||||
)
|
||||
@@ -3780,6 +3822,9 @@ impl ScriptVm {
|
||||
Err(violation) => return Err(violation.into_message()),
|
||||
}
|
||||
}
|
||||
validate_pending_window_fetch_integrity(
|
||||
&pending, &request_method, &response, response_filter.as_ref(),
|
||||
)?;
|
||||
Ok(response)
|
||||
});
|
||||
trace_async_subresource_stage(
|
||||
|
||||
@@ -14435,7 +14435,8 @@ async fn navigator_service_worker_fetch_event_request_preserves_window_fetch_pol
|
||||
cache: "reload",
|
||||
referrer: "./referrer.html",
|
||||
referrerPolicy: "origin",
|
||||
integrity: "sha256-test",
|
||||
// Ignore this algorithm when hashing the response that reflects it.
|
||||
integrity: "sha1-test",
|
||||
keepalive: true,
|
||||
priority: "low"
|
||||
});
|
||||
@@ -14456,7 +14457,7 @@ async fn navigator_service_worker_fetch_event_request_preserves_window_fetch_pol
|
||||
&loader,
|
||||
"String(globalThis.__serviceWorkerFetchRequestPolicyMetadataProbe)",
|
||||
&format!(
|
||||
"200|cache=reload|referrer={base_url}/app/referrer.html|referrerPolicy=origin|integrity=sha256-test|keepalive=true"
|
||||
"200|cache=reload|referrer={base_url}/app/referrer.html|referrerPolicy=origin|integrity=sha1-test|keepalive=true"
|
||||
),
|
||||
)
|
||||
.await;
|
||||
@@ -14705,7 +14706,8 @@ async fn navigator_service_worker_fetch_event_request_preserves_worker_fetch_pol
|
||||
cache: "reload",
|
||||
referrer: "./worker-referrer.html",
|
||||
referrerPolicy: "origin",
|
||||
integrity: "sha256-test",
|
||||
// Ignore this algorithm when hashing the response that reflects it.
|
||||
integrity: "sha1-test",
|
||||
keepalive: true,
|
||||
priority: "high"
|
||||
});
|
||||
@@ -14758,7 +14760,7 @@ async fn navigator_service_worker_fetch_event_request_preserves_worker_fetch_pol
|
||||
&loader,
|
||||
"String(globalThis.__serviceWorkerWorkerFetchRequestPolicyMetadataProbe)",
|
||||
&format!(
|
||||
"200|basic|from-worker|cache=reload|referrer={base_url}/app/worker-referrer.html|referrerPolicy=origin|integrity=sha256-test|keepalive=true"
|
||||
"200|basic|from-worker|cache=reload|referrer={base_url}/app/worker-referrer.html|referrerPolicy=origin|integrity=sha1-test|keepalive=true"
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -222,6 +222,7 @@ fn pending_fetch_continuation<'s>(
|
||||
)
|
||||
.expect("test Fetch should capture its CSP report context"),
|
||||
moli_fetch::RequestRedirectMode::Follow,
|
||||
String::new(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
use super::*;
|
||||
|
||||
async fn run_integrity_probe(
|
||||
page_url: &str,
|
||||
worker: bool,
|
||||
expression: &str,
|
||||
expected_checks: usize,
|
||||
) {
|
||||
let loader = static_http_loader([]);
|
||||
let mut vm = new_page_task_executor_test_vm_with_loader(page_url, &loader);
|
||||
vm.eval("globalThis.integrityResult = null;").unwrap();
|
||||
let script = if worker {
|
||||
let source = format!(
|
||||
"{expression}.then(postMessage, error => postMessage({{error:String(error.stack || error)}}));"
|
||||
);
|
||||
format!(
|
||||
"const workerUrl = URL.createObjectURL(new Blob([{}], {{type:'text/javascript'}})); const worker = new Worker(workerUrl); worker.onmessage = event => {{ integrityResult = event.data; worker.terminate(); URL.revokeObjectURL(workerUrl); }}; worker.onerror = event => {{ integrityResult = {{error:event.message}}; event.preventDefault(); }};",
|
||||
serde_json::to_string(&source).unwrap()
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{expression}.then(value => {{ integrityResult = value; }}, error => {{ integrityResult = {{error:String(error.stack || error)}}; }});"
|
||||
)
|
||||
};
|
||||
vm.eval(&script).unwrap();
|
||||
advance_page_task_executor_until_eval_equals(
|
||||
&mut vm,
|
||||
&loader,
|
||||
"String(integrityResult !== null)",
|
||||
"true",
|
||||
"Fetch integrity probe should finish",
|
||||
)
|
||||
.await;
|
||||
let result: serde_json::Value =
|
||||
serde_json::from_str(&vm.eval("JSON.stringify(integrityResult)").unwrap()).unwrap();
|
||||
let checks = result["checks"]
|
||||
.as_array()
|
||||
.unwrap_or_else(|| panic!("worker={worker}: {result}"));
|
||||
let failures: Vec<_> = checks
|
||||
.iter()
|
||||
.filter(|check| check["pass"] != true)
|
||||
.collect();
|
||||
assert_eq!(result["state"], "pass", "worker={worker}: {failures:?}");
|
||||
assert_eq!(checks.len(), expected_checks, "worker={worker}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn fetch_integrity_validates_local_bytes_in_window_and_worker() {
|
||||
let fixture = include_str!("../../../tests/fixtures/fetch-integrity.js");
|
||||
let expression =
|
||||
format!("(async () => {{ {fixture}\nreturn fetchIntegrityProbe('', true); }})()");
|
||||
for worker in [false, true] {
|
||||
run_integrity_probe("https://fetch-integrity.test/", worker, &expression, 296).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn fetch_integrity_validates_network_responses_in_window_and_worker() {
|
||||
for worker in [false, true] {
|
||||
let server =
|
||||
StaticHttpServer::spawn_with_bodies(vec!["hello integrity".to_owned(); 8]).await;
|
||||
let expression = format!(
|
||||
r#"(async () => {{
|
||||
const checks = [];
|
||||
const check = (label, actual, wanted) => checks.push({{label, actual, wanted, pass:actual === wanted}});
|
||||
const valid = 'sha256-9pyxsrnsacWVDvpoeZHDpEDkdnPx2ySEsclLyHWyL6A=';
|
||||
const wrong = 'sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';
|
||||
for (const [label, integrity, method, accepted] of [
|
||||
['valid', valid, 'GET', true],
|
||||
['invalid', wrong, 'GET', false],
|
||||
['stronger-invalid', valid + ' sha512-AA==', 'GET', false],
|
||||
['same-level-valid', wrong + ' ' + valid, 'GET', true],
|
||||
['ignored', 'sha1-ignored', 'GET', true],
|
||||
['whitespace', ' ', 'GET', true],
|
||||
['no-integrity', '', 'GET', true],
|
||||
['null-body', 'sha1-ignored', 'HEAD', false],
|
||||
]) {{
|
||||
try {{
|
||||
const response = await fetch({} + label, {{integrity, method}});
|
||||
check(label + '/accepted', true, accepted);
|
||||
if (accepted) check(label + '/body', await response.text(), 'hello integrity');
|
||||
}} catch (error) {{ check(label + '/accepted', error instanceof TypeError ? false : String(error), accepted); }}
|
||||
}}
|
||||
return {{state:checks.every(check => check.pass) ? 'pass' : 'fail', checks}};
|
||||
}})()"#,
|
||||
serde_json::to_string(server.base_url().as_str()).unwrap(),
|
||||
);
|
||||
run_integrity_probe(server.base_url().as_str(), worker, &expression, 13).await;
|
||||
assert_eq!(server.finish().await.len(), 8);
|
||||
}
|
||||
}
|
||||
@@ -304,6 +304,7 @@ fn register_pending_window_fetch_for_test(
|
||||
fetch_context,
|
||||
v8::Global::new(scope, resolver),
|
||||
keepalive,
|
||||
String::new(),
|
||||
connect_policy,
|
||||
csp_report_context,
|
||||
Some(cancel_handle.clone()),
|
||||
@@ -469,6 +470,7 @@ fn register_pending_window_fetch_with_connect_policy_for_test(
|
||||
fetch_context,
|
||||
v8::Global::new(scope, resolver),
|
||||
keepalive,
|
||||
String::new(),
|
||||
crate::document_runtime::DocumentConnectPolicySnapshot::from_policy_container(&policy),
|
||||
csp_report_context,
|
||||
Some(cancel_handle.clone()),
|
||||
@@ -15826,6 +15828,7 @@ mod canvas_webgl;
|
||||
mod close_watchers;
|
||||
mod dom_elements;
|
||||
mod dom_xhr;
|
||||
mod fetch_integrity;
|
||||
mod fetch_referrer;
|
||||
mod fetch_request_guard;
|
||||
mod http_fixture;
|
||||
|
||||
@@ -636,6 +636,7 @@ pub(crate) struct ServiceWorkerFetchResponse {
|
||||
pub(crate) status: u16,
|
||||
pub(crate) status_text: String,
|
||||
pub(crate) headers: Vec<(String, String)>,
|
||||
pub(crate) body_is_null: bool,
|
||||
pub(crate) body: Vec<u8>,
|
||||
}
|
||||
|
||||
|
||||
@@ -8965,6 +8965,7 @@ self.addEventListener("message", event => {
|
||||
event_id,
|
||||
owner: test_run_owner(version_id, &run),
|
||||
result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse {
|
||||
body_is_null: false,
|
||||
cors_exposed_header_names: None,
|
||||
status: 200,
|
||||
status_text: "OK".to_owned(),
|
||||
@@ -9267,6 +9268,7 @@ self.addEventListener("message", event => {
|
||||
event_id,
|
||||
owner: test_run_owner(version_id, &run),
|
||||
result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse {
|
||||
body_is_null: false,
|
||||
cors_exposed_header_names: None,
|
||||
status: 200,
|
||||
status_text: "OK".to_owned(),
|
||||
|
||||
@@ -136,6 +136,7 @@ fn service_worker_fetch_can_forward_stream(
|
||||
&response_head.headers,
|
||||
)?;
|
||||
Ok(job.direct_completion_tx.is_none()
|
||||
&& job.metadata.integrity.is_empty()
|
||||
&& !matches!(
|
||||
job.network_context.resource_type,
|
||||
crate::types::SubresourceResourceType::Audio
|
||||
@@ -617,6 +618,13 @@ impl ServiceWorkerRuntimeService {
|
||||
self.complete_fetch_with_failure(job, message);
|
||||
return;
|
||||
}
|
||||
if response.body_is_null && !job.metadata.integrity.is_empty() {
|
||||
self.complete_fetch_with_failure(
|
||||
job,
|
||||
"fetch: integrity metadata requires a non-null response body".to_owned(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let response_filter =
|
||||
service_worker_fetch_response_filter(&response, job.request.redirect_mode);
|
||||
let navigation_response = crate::protocol_types::NavigationResponse::from_head_and_body(
|
||||
@@ -1254,6 +1262,7 @@ mod tests {
|
||||
event_id,
|
||||
owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run),
|
||||
result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse {
|
||||
body_is_null: false,
|
||||
cors_exposed_header_names: None,
|
||||
final_url: Some(final_url.clone()),
|
||||
response_type: "default".to_owned(),
|
||||
@@ -1288,6 +1297,122 @@ mod tests {
|
||||
assert_eq!(response.body_text(), "service-worker-body");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_integrity_rejects_synthetic_null_bodies_but_preserves_empty_bodies() {
|
||||
for integrity in ["", "sha1-ignored", " "] {
|
||||
for body_is_null in [false, true] {
|
||||
let service = new_service_worker_runtime_service();
|
||||
let event_id = ServiceWorkerEventId(21);
|
||||
let version_id = ServiceWorkerVersionId(1);
|
||||
let run = RendererServiceWorkerRunIdentity::fresh();
|
||||
let mut queue =
|
||||
crate::page_task_queue::RendererResourceCompletionTestHarness::new();
|
||||
let final_url = insert_active_fetch_job(
|
||||
&service,
|
||||
event_id,
|
||||
version_id,
|
||||
&run,
|
||||
301,
|
||||
queue.sender(),
|
||||
);
|
||||
service
|
||||
.inner
|
||||
.state
|
||||
.lock()
|
||||
.pending_fetch_jobs
|
||||
.get_mut(&event_id)
|
||||
.unwrap()
|
||||
.metadata
|
||||
.integrity = integrity.to_owned();
|
||||
service.finish_fetch_event_completed(ServiceWorkerFetchCompletion {
|
||||
event_id,
|
||||
owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(
|
||||
version_id, run,
|
||||
),
|
||||
result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse {
|
||||
body_is_null,
|
||||
cors_exposed_header_names: None,
|
||||
final_url: Some(final_url),
|
||||
response_type: "default".to_owned(),
|
||||
redirected: false,
|
||||
status: 200,
|
||||
status_text: "OK".to_owned(),
|
||||
headers: Vec::new(),
|
||||
body: Vec::new(),
|
||||
}),
|
||||
});
|
||||
let completion = pop_async_subresource_completion(&mut queue);
|
||||
assert_eq!(
|
||||
completion.result.is_ok(),
|
||||
integrity.is_empty() || !body_is_null,
|
||||
"integrity={integrity:?}, body_is_null={body_is_null}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_integrity_defers_service_worker_stream_handover() {
|
||||
let service = new_service_worker_runtime_service();
|
||||
let event_id = ServiceWorkerEventId(21);
|
||||
let version_id = ServiceWorkerVersionId(1);
|
||||
let run = RendererServiceWorkerRunIdentity::fresh();
|
||||
let mut queue = crate::page_task_queue::RendererResourceCompletionTestHarness::new();
|
||||
let final_url =
|
||||
insert_active_fetch_job(&service, event_id, version_id, &run, 301, queue.sender());
|
||||
service
|
||||
.inner
|
||||
.state
|
||||
.lock()
|
||||
.pending_fetch_jobs
|
||||
.get_mut(&event_id)
|
||||
.unwrap()
|
||||
.metadata
|
||||
.integrity = "sha1-ignored".to_owned();
|
||||
service.finish_fetch_stream_started(ServiceWorkerFetchStreamStarted {
|
||||
event_id,
|
||||
owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(
|
||||
version_id,
|
||||
run.clone(),
|
||||
),
|
||||
body_source_id: 77,
|
||||
response_head: MaterializedServiceWorkerFetchResponseHead {
|
||||
cors_exposed_header_names: None,
|
||||
final_url: Some(final_url.clone()),
|
||||
response_type: "default".to_owned(),
|
||||
redirected: false,
|
||||
status: 200,
|
||||
status_text: "OK".to_owned(),
|
||||
headers: Vec::new(),
|
||||
},
|
||||
});
|
||||
assert!(queue.pop_next_async_subresource_event().is_none());
|
||||
service.finish_fetch_stream_chunk(ServiceWorkerFetchStreamChunk {
|
||||
event_id,
|
||||
body_source_id: 77,
|
||||
bytes: b"hello".to_vec(),
|
||||
});
|
||||
assert!(queue.pop_next_async_subresource_event().is_none());
|
||||
service.finish_fetch_event_completed(ServiceWorkerFetchCompletion {
|
||||
event_id,
|
||||
owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run),
|
||||
result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse {
|
||||
body_is_null: false,
|
||||
cors_exposed_header_names: None,
|
||||
final_url: Some(final_url),
|
||||
response_type: "default".to_owned(),
|
||||
redirected: false,
|
||||
status: 200,
|
||||
status_text: "OK".to_owned(),
|
||||
headers: Vec::new(),
|
||||
body: b"hello integrity".to_vec(),
|
||||
}),
|
||||
});
|
||||
let completion = pop_async_subresource_completion(&mut queue);
|
||||
assert_eq!(completion.result.unwrap().body_bytes(), b"hello integrity");
|
||||
assert!(queue.pop_next_async_subresource_event().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xhr_upload_listener_preflight_survives_service_worker_network_fallback() {
|
||||
let service = new_service_worker_runtime_service();
|
||||
@@ -1397,6 +1522,7 @@ mod tests {
|
||||
event_id,
|
||||
owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run),
|
||||
result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse {
|
||||
body_is_null: false,
|
||||
cors_exposed_header_names: None,
|
||||
final_url: Some(final_url),
|
||||
response_type: "default".to_owned(),
|
||||
@@ -1523,6 +1649,7 @@ mod tests {
|
||||
version_id, run,
|
||||
),
|
||||
result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse {
|
||||
body_is_null: false,
|
||||
cors_exposed_header_names: None,
|
||||
final_url: None,
|
||||
response_type: "default".to_owned(),
|
||||
@@ -1588,6 +1715,7 @@ mod tests {
|
||||
event_id,
|
||||
owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run),
|
||||
result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse {
|
||||
body_is_null: false,
|
||||
cors_exposed_header_names: None,
|
||||
final_url: None,
|
||||
response_type: "opaque".to_owned(),
|
||||
@@ -1634,6 +1762,7 @@ mod tests {
|
||||
event_id,
|
||||
owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run),
|
||||
result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse {
|
||||
body_is_null: false,
|
||||
cors_exposed_header_names: None,
|
||||
final_url: None,
|
||||
response_type: "opaqueredirect".to_owned(),
|
||||
@@ -1679,6 +1808,7 @@ mod tests {
|
||||
event_id,
|
||||
owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run),
|
||||
result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse {
|
||||
body_is_null: false,
|
||||
cors_exposed_header_names: None,
|
||||
final_url: Some(final_url.clone()),
|
||||
response_type: "default".to_owned(),
|
||||
@@ -1723,6 +1853,7 @@ mod tests {
|
||||
event_id,
|
||||
owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run),
|
||||
result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse {
|
||||
body_is_null: false,
|
||||
cors_exposed_header_names: None,
|
||||
final_url: Some(url("https://example.test/app/manual-final.txt")),
|
||||
response_type: "default".to_owned(),
|
||||
@@ -1772,6 +1903,7 @@ mod tests {
|
||||
event_id,
|
||||
owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run),
|
||||
result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse {
|
||||
body_is_null: false,
|
||||
cors_exposed_header_names: None,
|
||||
final_url: None,
|
||||
response_type: "default".to_owned(),
|
||||
@@ -1896,6 +2028,7 @@ mod tests {
|
||||
event_id,
|
||||
owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run),
|
||||
result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse {
|
||||
body_is_null: false,
|
||||
cors_exposed_header_names: None,
|
||||
final_url: None,
|
||||
response_type: "default".to_owned(),
|
||||
@@ -1958,6 +2091,7 @@ mod tests {
|
||||
event_id,
|
||||
owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run),
|
||||
result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse {
|
||||
body_is_null: false,
|
||||
cors_exposed_header_names: None,
|
||||
final_url: None,
|
||||
response_type: "default".to_owned(),
|
||||
@@ -2041,6 +2175,7 @@ mod tests {
|
||||
}
|
||||
}));
|
||||
let redirect = || ServiceWorkerFetchResponse {
|
||||
body_is_null: false,
|
||||
cors_exposed_header_names: None,
|
||||
final_url: None,
|
||||
response_type: "default".to_owned(),
|
||||
@@ -2126,6 +2261,7 @@ mod tests {
|
||||
.without_inferred_referrer()
|
||||
.with_fetch_priority_hint(Some(moli_fetch::FetchPriorityHint::High));
|
||||
let response = ServiceWorkerFetchResponse {
|
||||
body_is_null: false,
|
||||
cors_exposed_header_names: None,
|
||||
final_url: Some(request_url.clone()),
|
||||
response_type: "default".to_owned(),
|
||||
@@ -2201,6 +2337,7 @@ mod tests {
|
||||
event_id,
|
||||
owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run),
|
||||
result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse {
|
||||
body_is_null: false,
|
||||
cors_exposed_header_names: None,
|
||||
final_url: None,
|
||||
response_type: "default".to_owned(),
|
||||
@@ -2249,6 +2386,7 @@ mod tests {
|
||||
event_id,
|
||||
owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run),
|
||||
result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse {
|
||||
body_is_null: false,
|
||||
cors_exposed_header_names: None,
|
||||
final_url: Some(response_url.clone()),
|
||||
response_type: "default".to_owned(),
|
||||
@@ -2317,6 +2455,7 @@ mod tests {
|
||||
event_id,
|
||||
owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run),
|
||||
result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse {
|
||||
body_is_null: false,
|
||||
cors_exposed_header_names: None,
|
||||
final_url: None,
|
||||
response_type: "error".to_owned(),
|
||||
@@ -2365,6 +2504,7 @@ mod tests {
|
||||
event_id,
|
||||
owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run),
|
||||
result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse {
|
||||
body_is_null: false,
|
||||
cors_exposed_header_names: None,
|
||||
final_url: Some(url("https://cross-origin.test/data.txt")),
|
||||
response_type: "cors".to_owned(),
|
||||
@@ -2412,6 +2552,7 @@ mod tests {
|
||||
event_id,
|
||||
owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run),
|
||||
result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse {
|
||||
body_is_null: false,
|
||||
cors_exposed_header_names: None,
|
||||
final_url: None,
|
||||
response_type: "opaque".to_owned(),
|
||||
@@ -2475,6 +2616,7 @@ mod tests {
|
||||
run.clone(),
|
||||
),
|
||||
result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse {
|
||||
body_is_null: false,
|
||||
cors_exposed_header_names: None,
|
||||
final_url: None,
|
||||
response_type: "opaque".to_owned(),
|
||||
@@ -2523,6 +2665,7 @@ mod tests {
|
||||
event_id,
|
||||
owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run),
|
||||
result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse {
|
||||
body_is_null: false,
|
||||
cors_exposed_header_names: None,
|
||||
final_url: Some(url("https://cdn.example.test/app/image.png")),
|
||||
response_type: "default".to_owned(),
|
||||
@@ -2577,6 +2720,7 @@ mod tests {
|
||||
event_id,
|
||||
owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run),
|
||||
result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse {
|
||||
body_is_null: false,
|
||||
cors_exposed_header_names: None,
|
||||
final_url: Some(url("https://cdn.example.test/app/image.png")),
|
||||
response_type: "default".to_owned(),
|
||||
@@ -2641,6 +2785,7 @@ mod tests {
|
||||
event_id,
|
||||
owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run),
|
||||
result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse {
|
||||
body_is_null: false,
|
||||
cors_exposed_header_names: None,
|
||||
final_url: Some(url("https://cdn.example.test/app/pixel.png")),
|
||||
response_type: "default".to_owned(),
|
||||
@@ -2701,6 +2846,7 @@ mod tests {
|
||||
event_id,
|
||||
owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run),
|
||||
result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse {
|
||||
body_is_null: false,
|
||||
cors_exposed_header_names: None,
|
||||
final_url: Some(url("https://cdn.example.test/app/pixel.png")),
|
||||
response_type: "default".to_owned(),
|
||||
@@ -2760,6 +2906,7 @@ mod tests {
|
||||
event_id,
|
||||
owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run),
|
||||
result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse {
|
||||
body_is_null: false,
|
||||
cors_exposed_header_names: None,
|
||||
final_url: Some(url("https://cdn.example.test/app/pixel.png")),
|
||||
response_type: "default".to_owned(),
|
||||
@@ -2819,6 +2966,7 @@ mod tests {
|
||||
event_id,
|
||||
owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run),
|
||||
result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse {
|
||||
body_is_null: false,
|
||||
cors_exposed_header_names: None,
|
||||
final_url: Some(url("https://cdn.example.test/app/pixel.png")),
|
||||
response_type: "default".to_owned(),
|
||||
@@ -2879,6 +3027,7 @@ mod tests {
|
||||
event_id,
|
||||
owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run),
|
||||
result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse {
|
||||
body_is_null: false,
|
||||
cors_exposed_header_names: None,
|
||||
final_url: Some(url("https://cdn.example.test/app/pixel.png")),
|
||||
response_type: "default".to_owned(),
|
||||
@@ -2933,6 +3082,7 @@ mod tests {
|
||||
event_id,
|
||||
owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run),
|
||||
result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse {
|
||||
body_is_null: false,
|
||||
cors_exposed_header_names: None,
|
||||
final_url: Some(url("https://cdn.example.test/app/pixel.png")),
|
||||
response_type: "default".to_owned(),
|
||||
@@ -2992,6 +3142,7 @@ mod tests {
|
||||
event_id,
|
||||
owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run),
|
||||
result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse {
|
||||
body_is_null: false,
|
||||
cors_exposed_header_names: None,
|
||||
final_url: Some(url("https://cdn.example.test/app/pixel.png")),
|
||||
response_type: "default".to_owned(),
|
||||
@@ -3050,6 +3201,7 @@ mod tests {
|
||||
event_id,
|
||||
owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run),
|
||||
result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse {
|
||||
body_is_null: false,
|
||||
cors_exposed_header_names: None,
|
||||
final_url: Some(url("https://cdn.example.test/app/pixel.png")),
|
||||
response_type: "default".to_owned(),
|
||||
@@ -3102,6 +3254,7 @@ mod tests {
|
||||
event_id,
|
||||
owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run),
|
||||
result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse {
|
||||
body_is_null: false,
|
||||
cors_exposed_header_names: None,
|
||||
final_url: Some(url("https://cdn.example.test/app/pixel.png")),
|
||||
response_type: "default".to_owned(),
|
||||
@@ -3349,6 +3502,7 @@ mod tests {
|
||||
event_id,
|
||||
owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run),
|
||||
result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse {
|
||||
body_is_null: false,
|
||||
cors_exposed_header_names: None,
|
||||
final_url: None,
|
||||
response_type: "default".to_owned(),
|
||||
@@ -3398,6 +3552,7 @@ mod tests {
|
||||
event_id,
|
||||
owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run),
|
||||
result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse {
|
||||
body_is_null: false,
|
||||
cors_exposed_header_names: None,
|
||||
final_url: Some(url("https://cdn.example.test/app/data.json")),
|
||||
response_type: "default".to_owned(),
|
||||
@@ -3446,6 +3601,7 @@ mod tests {
|
||||
event_id,
|
||||
owner: crate::service_worker_runtime::ServiceWorkerRunOwner::new(version_id, run),
|
||||
result: ServiceWorkerFetchResult::Response(ServiceWorkerFetchResponse {
|
||||
body_is_null: false,
|
||||
cors_exposed_header_names: None,
|
||||
final_url: None,
|
||||
response_type: "opaqueredirect".to_owned(),
|
||||
|
||||
@@ -23,10 +23,7 @@ pub(crate) fn response_matches_subresource_integrity_metadata(
|
||||
integrity: Option<&str>,
|
||||
response_is_eligible: bool,
|
||||
) -> bool {
|
||||
let Some(integrity) = integrity
|
||||
.map(str::trim)
|
||||
.filter(|integrity| !integrity.is_empty())
|
||||
else {
|
||||
let Some(integrity) = integrity.filter(|integrity| !integrity.is_empty()) else {
|
||||
return true;
|
||||
};
|
||||
let metadata = parse_integrity_metadata(integrity);
|
||||
|
||||
@@ -247,6 +247,7 @@ pub(super) struct PendingWindowFetchContinuation {
|
||||
csp_report_context: crate::network_host::WindowCspReportRequestContext,
|
||||
redirect_csp_state: crate::network_host::FetchCspRedirectState,
|
||||
redirect_mode: moli_fetch::RequestRedirectMode,
|
||||
integrity: String,
|
||||
}
|
||||
|
||||
enum PendingWindowFetchPromise {
|
||||
@@ -261,6 +262,7 @@ impl PendingWindowFetchContinuation {
|
||||
connect_policy: crate::document_runtime::DocumentConnectPolicySnapshot,
|
||||
csp_report_context: crate::network_host::WindowCspReportRequestContext,
|
||||
redirect_mode: moli_fetch::RequestRedirectMode,
|
||||
integrity: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
promise: PendingWindowFetchPromise::Active(resolver),
|
||||
@@ -269,6 +271,7 @@ impl PendingWindowFetchContinuation {
|
||||
connect_policy,
|
||||
csp_report_context,
|
||||
redirect_mode,
|
||||
integrity,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,6 +320,10 @@ impl PendingWindowFetchContinuation {
|
||||
pub(super) fn redirect_mode(&self) -> moli_fetch::RequestRedirectMode {
|
||||
self.redirect_mode
|
||||
}
|
||||
|
||||
pub(super) fn integrity(&self) -> &str {
|
||||
&self.integrity
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
|
||||
@@ -257,7 +257,7 @@ pub(in crate::worker) fn spawn_worker_fetch_network(
|
||||
}
|
||||
Err(error) => (Err(format!("fetch: {error}")), None),
|
||||
}
|
||||
} else if !allow_headers_first {
|
||||
} else if !allow_headers_first || !request_metadata.integrity.is_empty() {
|
||||
// Worker fetch still resolves after the full response, but ordinary
|
||||
// transfers can keep their body in the same chunked/pooled carrier that
|
||||
// CDP Network capture uses instead of forcing a single buffered Response.
|
||||
@@ -3197,6 +3197,49 @@ pub(in crate::worker) fn drain_worker_fetch_completion_result(
|
||||
return;
|
||||
}
|
||||
};
|
||||
let response_request = crate::network_host::FetchResponseRequest {
|
||||
method: &pending.request_method,
|
||||
mode: pending.request_mode,
|
||||
redirect_mode: pending.redirect_mode,
|
||||
};
|
||||
let response_filter = completion.response_filter.or_else(|| {
|
||||
Some(response_request.network_response_filter(
|
||||
&request_origin,
|
||||
&response_head,
|
||||
pending.credentials_mode,
|
||||
))
|
||||
});
|
||||
if !pending.request_metadata.integrity.is_empty() {
|
||||
let body = match &response {
|
||||
WorkerFetchResponse::Materialized(response) => {
|
||||
Ok(std::borrow::Cow::Borrowed(response.body_bytes()))
|
||||
}
|
||||
WorkerFetchResponse::Streamed { body, .. } => {
|
||||
body.try_bytes().map_err(|error| {
|
||||
format!("fetch: failed to read integrity response body: {error}")
|
||||
})
|
||||
}
|
||||
};
|
||||
let validation = body.and_then(|bytes| {
|
||||
crate::network_host::validate_fetch_response_integrity(
|
||||
&pending.request_metadata.integrity,
|
||||
&pending.request_method,
|
||||
response_head.status,
|
||||
response_filter
|
||||
.as_ref()
|
||||
.expect("Fetch response filter was selected"),
|
||||
&bytes,
|
||||
)
|
||||
});
|
||||
if let Err(message) = validation {
|
||||
record_worker_fetch_failure(&state.borrow(), &pending, message.clone());
|
||||
let reason = v8_string(scope, &message)
|
||||
.map(|message| v8::Exception::type_error(scope, message))
|
||||
.unwrap_or_else(|| v8::undefined(scope).into());
|
||||
let _ = resolver.reject(scope, reason);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if opaque_response_blocked {
|
||||
record_worker_fetch_failure(
|
||||
&state.borrow(),
|
||||
@@ -3225,18 +3268,6 @@ pub(in crate::worker) fn drain_worker_fetch_completion_result(
|
||||
},
|
||||
));
|
||||
}
|
||||
let response_request = crate::network_host::FetchResponseRequest {
|
||||
method: &pending.request_method,
|
||||
mode: pending.request_mode,
|
||||
redirect_mode: pending.redirect_mode,
|
||||
};
|
||||
let response_filter = completion.response_filter.or_else(|| {
|
||||
Some(response_request.network_response_filter(
|
||||
&request_origin,
|
||||
&response_head,
|
||||
pending.credentials_mode,
|
||||
))
|
||||
});
|
||||
let response_obj = match response.into_fetch_parts() {
|
||||
WorkerFetchResponseParts::Materialized { head, body } => {
|
||||
let body = if opaque_response_blocked {
|
||||
|
||||
@@ -3070,6 +3070,8 @@ fn service_worker_respond_with_settled<'s>(
|
||||
);
|
||||
return;
|
||||
}
|
||||
let body_is_null =
|
||||
crate::network_host::body_stream_object(scope, response).is_none();
|
||||
let body_source_id = new_network_body_source_id();
|
||||
let (body, stream_cancel_handle) =
|
||||
build_service_worker_respond_with_stream_chunk_callback(
|
||||
@@ -3097,7 +3099,7 @@ fn service_worker_respond_with_settled<'s>(
|
||||
});
|
||||
match body {
|
||||
MaterializedResponseBody::Ready(body) => ServiceWorkerFetchResult::Response(
|
||||
service_worker_fetch_response_from_materialized(head, body),
|
||||
service_worker_fetch_response_from_materialized(head, body, body_is_null),
|
||||
),
|
||||
MaterializedResponseBody::Pending(promise) => {
|
||||
let stream_body = stream_cancel_handle.is_some();
|
||||
@@ -3231,9 +3233,11 @@ fn service_worker_respond_with_lifetime_settled_for_event(
|
||||
fn service_worker_fetch_response_from_materialized(
|
||||
head: MaterializedResponseHead,
|
||||
body: Vec<u8>,
|
||||
body_is_null: bool,
|
||||
) -> ServiceWorkerFetchResponse {
|
||||
let response = head.with_body(body);
|
||||
ServiceWorkerFetchResponse {
|
||||
body_is_null,
|
||||
cors_exposed_header_names: response.cors_exposed_header_names,
|
||||
final_url: response.final_url,
|
||||
response_type: response.response_type,
|
||||
@@ -3750,7 +3754,7 @@ fn service_worker_respond_with_body_settled_for_event(
|
||||
pending.pending_respond_with_stream_body_source_id = None;
|
||||
pending.pending_respond_with_stream_cancel_handle = None;
|
||||
ServiceWorkerFetchResult::Response(service_worker_fetch_response_from_materialized(
|
||||
head, body,
|
||||
head, body, false,
|
||||
))
|
||||
}
|
||||
Err(error) => {
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
async function fetchIntegrityProbe(baseUrl, localOnly = false) {
|
||||
const resources = [{"name": "text", "bytes": [104, 101, 108, 108, 111, 32, 105, 110, 116, 101, 103, 114, 105, 116, 121], "base64": "aGVsbG8gaW50ZWdyaXR5", "sha256": "sha256-9pyxsrnsacWVDvpoeZHDpEDkdnPx2ySEsclLyHWyL6A=", "sha384": "sha384-CCi8AQnAx6lR9HsMyFzQjILv5ia8wub93wfmdoOS4L7a++MzT++Fu7fSP7kYTmWF", "sha512": "sha512-mXVi95TF+mUwM+gHNM32LCn1SWYf95zOfK1yAG1GIuN0/bZwRhGvOtN/7CHf7whlbimiebfCZwlROrdR5NgQqw=="}, {"name": "empty", "bytes": [], "base64": "", "sha256": "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=", "sha384": "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb", "sha512": "sha512-z4PhNX7vuL3xVChQ1m2AB9Yg5AULVxXcg/SpIdNs6c5H0NE8XYXysP+DGNKHfuwvY7kxvUdBeoGlODJ6+SfaPg=="}, {"name": "binary", "bytes": [0, 255, 128, 1, 2, 65], "base64": "AP+AAQJB", "sha256": "sha256-AUPFNQOnS+WsR2eYg0rb6qoEp35s40QZHzqIjaQb3Cg=", "sha384": "sha384-CTICUISiEC8kw5GcbXJIzxYr8ks0/3gSUBfe8Hg0YLLHkBLQpuiU9J7fDDm3IzDj", "sha512": "sha512-k41a7eyRLajjPtfRMt7ek2wfdA4Zo0wCQbBe/DpXCRMLP9oF8gTJutkQXJOsViyCOtMxhbex8hVEO+IgHdJpHA=="}, {"name": "unicode", "bytes": [240, 157, 140, 134, 195, 169, 239, 191, 189], "base64": "8J2MhsOp77+9", "sha256": "sha256-gwOIYBj9iyRwb5o4SO40WH4GhpQVcsyVoSPCr+AcL+k=", "sha384": "sha384-AlLyVKhsauH8U6ot4I3tmF5IQlBxrn9XKoU83Xk/rYZQlXXqTLA1oW+STAuA0Wbw", "sha512": "sha512-7NrPqei7Xlo2hwgwyz1/Nebsgd7yqgP4Q8DCwXLRl5rpuhvy2YE/7k6g57s8Zp2s9fg1WAbvVmDk0Q8FEfLDhQ=="}];
|
||||
|
||||
const checks = [];
|
||||
const check = (label, actual, wanted) => checks.push({label, actual, wanted,
|
||||
pass: JSON.stringify(actual) === JSON.stringify(wanted)});
|
||||
const wrong256 = 'sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=';
|
||||
const wrong512 = 'sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==';
|
||||
async function attempt(label, input, init, wanted, expectedBytes, expectedType) {
|
||||
try {
|
||||
const response = await fetch(input, init);
|
||||
check(label + '/accepted', true, wanted);
|
||||
if (wanted) {
|
||||
check(label + '/bytes', Array.from(new Uint8Array(await response.arrayBuffer())), expectedBytes);
|
||||
if (expectedType) check(label + '/type', response.type, expectedType);
|
||||
} else if (response.body) { await response.body.cancel(); }
|
||||
} catch (error) { check(label + '/accepted', error instanceof TypeError ? false : String(error), wanted); }
|
||||
}
|
||||
for (const data of resources) {
|
||||
const metadata = [
|
||||
['empty', '', true], ['sha256', data.sha256, true], ['sha384', data.sha384, true],
|
||||
['sha512', data.sha512, true], ['unpadded', data.sha512.replace(/=+$/, ''), true],
|
||||
['base64url', data.sha512.replace(/\+/g, '-').replace(/\//g, '_'), true],
|
||||
['invalid', wrong256, false], ['stronger-invalid', data.sha256 + ' ' + wrong512, false],
|
||||
['stronger-valid', wrong256 + ' ' + data.sha384, true],
|
||||
['same-level-valid', wrong512 + ' ' + data.sha512, true],
|
||||
['both-invalid', wrong256 + ' ' + wrong512, false],
|
||||
['unsupported', 'sha1-ignored', true], ['invalid-syntax', 'sha256-***', true],
|
||||
['whitespace', ' \t\n', true], ['leading-trailing', '\t' + data.sha256 + '\n', true],
|
||||
['unicode-prefix', '\u00a0' + wrong256, true], ['unicode-suffix', wrong256 + '\u00a0', true],
|
||||
];
|
||||
const blobUrl = URL.createObjectURL(new Blob([new Uint8Array(data.bytes)]));
|
||||
try {
|
||||
const urls = [['data', 'data:application/octet-stream;base64,' + data.base64], ['blob', blobUrl]];
|
||||
if (!localOnly) urls.push(['network', baseUrl + '/body?body=' + data.name]);
|
||||
for (const [source, url] of urls) {
|
||||
for (const [kind, integrity, wanted] of metadata) {
|
||||
await attempt(data.name + '/' + source + '/' + kind, url, {integrity}, wanted, data.bytes);
|
||||
}
|
||||
const original = new Request(url, {integrity: wrong256});
|
||||
await attempt(data.name + '/' + source + '/request', original, undefined, false, []);
|
||||
await attempt(data.name + '/' + source + '/clone', original.clone(), undefined, false, []);
|
||||
await attempt(data.name + '/' + source + '/override', original, {integrity: data.sha256}, true, data.bytes);
|
||||
await attempt(data.name + '/' + source + '/clear', original, {integrity: ''}, true, data.bytes);
|
||||
}
|
||||
} finally { URL.revokeObjectURL(blobUrl); }
|
||||
}
|
||||
if (!localOnly) {
|
||||
const text = resources[0];
|
||||
const cross = baseUrl.replace('127.0.0.1', 'localhost');
|
||||
await attempt('cors-valid', cross + '/body?body=text&cors=1', {integrity: text.sha256}, true, text.bytes, 'cors');
|
||||
await attempt('cors-invalid', cross + '/body?body=text&cors=1', {integrity: wrong256}, false, []);
|
||||
await attempt('cors-denied', cross + '/body?body=text', {integrity: text.sha256}, false, []);
|
||||
for (const integrity of ['', text.sha256, 'sha1-ignored', ' ', 'sha256-***']) {
|
||||
await attempt('opaque/' + integrity, cross + '/body?body=text', {integrity, mode: 'no-cors'}, !integrity, [], 'opaque');
|
||||
}
|
||||
for (const [label, method, status] of [['head', 'HEAD', 200], ['204', 'GET', 204], ['205', 'GET', 205], ['304', 'GET', 304]]) {
|
||||
for (const [kind, integrity] of [['empty', ''], ['valid-empty', resources[1].sha256], ['unsupported', 'sha1-ignored'], ['whitespace', ' ']]) {
|
||||
await attempt('null/' + label + '/' + kind, baseUrl + '/body?body=empty&status=' + status, {method, integrity}, !integrity, []);
|
||||
}
|
||||
}
|
||||
await attempt('http-error-valid', baseUrl + '/body?body=text&status=404', {integrity:text.sha256}, true, text.bytes);
|
||||
await attempt('follow-valid', baseUrl + '/redirect', {integrity:text.sha256}, true, text.bytes);
|
||||
await attempt('follow-invalid', baseUrl + '/redirect', {integrity:wrong256}, false, []);
|
||||
await attempt('manual-valid', baseUrl + '/redirect', {integrity:text.sha256,redirect:'manual'}, false, []);
|
||||
await attempt('manual-unsupported', baseUrl + '/redirect', {integrity:'sha1-ignored',redirect:'manual'}, false, []);
|
||||
for (const [kind, integrity, wanted, truncate, abort] of [
|
||||
['unvalidated', '', true, false, false],
|
||||
['valid', text.sha256, true, false, false],
|
||||
['invalid', wrong256, false, false, false],
|
||||
['unsupported', 'sha1-ignored', true, false, false],
|
||||
['truncated', text.sha256, false, true, false],
|
||||
['abort', text.sha256, false, false, true],
|
||||
]) {
|
||||
const id = Math.random().toString(36).slice(2);
|
||||
const controller = new AbortController();
|
||||
const reason = {aborted: true};
|
||||
let settled = false;
|
||||
const outcome = fetch(baseUrl + '/gated?id=' + id + (truncate ? '&truncate=1' : ''), {integrity, signal:controller.signal})
|
||||
.then(response => { settled = true; return {response}; }, error => { settled = true; return {error}; });
|
||||
try {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
if (await (await fetch(baseUrl + '/progress?id=' + id)).json()) break;
|
||||
if (i === 99) throw new Error('gated server did not start');
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 60));
|
||||
check('gated/' + kind + '/before-eof', settled, !integrity);
|
||||
if (abort) controller.abort(reason);
|
||||
} finally { await fetch(baseUrl + '/release?id=' + id); }
|
||||
const result = await outcome;
|
||||
if (abort) check('gated/' + kind + '/reason', result.error === reason, true);
|
||||
else if (result.error) check('gated/' + kind + '/accepted', result.error instanceof TypeError ? false : String(result.error), wanted);
|
||||
else {
|
||||
check('gated/' + kind + '/accepted', true, wanted);
|
||||
try {
|
||||
const bytes = Array.from(new Uint8Array(await result.response.arrayBuffer()));
|
||||
if (wanted) check('gated/' + kind + '/bytes', bytes, text.bytes);
|
||||
} catch (error) { if (wanted) check('gated/' + kind + '/body-error', String(error), null); }
|
||||
}
|
||||
}
|
||||
}
|
||||
return {state: checks.every(check => check.pass) ? 'pass' : 'fail', checks};
|
||||
}
|
||||
Reference in New Issue
Block a user