fix(scripts): validate response eligibility before SRI

Preserve response filtering through script and service worker loading, validate CORS authorization across redirects, and reject opaque responses before computing supported integrity digests. Keep empty and unsupported metadata behavior unchanged.

Add parser, dynamic script, module, and service worker regressions covering 39 scenarios. Validated with cargo fmt --all, full workspace Clippy, and cargo nextest run --no-fail-fast (17457 passed, 13 skipped).
This commit is contained in:
ldm0
2026-09-12 07:20:44 +08:00
parent 6f686b88f9
commit 59e1af954e
11 changed files with 486 additions and 42 deletions
+2
View File
@@ -1,5 +1,7 @@
#[path = "scripts/child_script_text.rs"]
mod child_script_text;
#[path = "scripts/integrity.rs"]
mod integrity;
use moli_test_support as support;
+303
View File
@@ -0,0 +1,303 @@
use super::*;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
task::{JoinHandle, JoinSet},
};
const SCRIPT: &str = "globalThis.sriExecutions = (globalThis.sriExecutions || 0) + 1;";
const INTEGRITY: &str = "sha384-T7tuz8k7Hz0eBaWUPKiAEECRmaKHLJ1eRz7NF4VdK1fN++IaKD3hEk0SETOP+8aJ";
struct IntegrityServers {
origin: String,
cross_origin: String,
tasks: Vec<JoinHandle<()>>,
}
impl Drop for IntegrityServers {
fn drop(&mut self) {
for task in &self.tasks {
task.abort();
}
}
}
impl IntegrityServers {
async fn spawn() -> Result<Self> {
let main = TcpListener::bind("127.0.0.1:0").await?;
let cross = TcpListener::bind("127.0.0.1:0").await?;
let origin = format!("http://{}", main.local_addr()?);
let cross_origin = format!("http://{}", cross.local_addr()?);
let tasks = [main, cross]
.into_iter()
.map(|listener| {
let origin = origin.clone();
let cross_origin = cross_origin.clone();
tokio::spawn(async move {
let mut connections = JoinSet::new();
loop {
tokio::select! {
accepted = listener.accept() => {
let Ok((mut stream, _)) = accepted else { break };
let origin = origin.clone();
let cross_origin = cross_origin.clone();
connections.spawn(async move {
let mut request = Vec::new();
while !request.ends_with(b"\r\n\r\n") && request.len() < 16384 {
let Ok(byte) = stream.read_u8().await else { return };
request.push(byte);
}
let request = String::from_utf8_lossy(&request);
let path = request.split_whitespace().nth(1).unwrap_or("/");
let path = path.split('?').next().unwrap_or(path);
let (status, headers, body) = fixture_response(path, &origin, &cross_origin);
let response = format!(
"HTTP/1.1 {status}\r\n{headers}Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len(),
);
let _ = stream.write_all(response.as_bytes()).await;
});
}
_ = connections.join_next(), if !connections.is_empty() => {}
}
}
})
})
.collect();
Ok(Self {
origin,
cross_origin,
tasks,
})
}
}
fn network_cases(origin: &str, cross: &str) -> serde_json::Value {
serde_json::json!([
{"name": "same-origin", "src": format!("{origin}/script.js"), "integrity": INTEGRITY, "expected": "load"},
{"name": "cross-no-cors", "src": format!("{cross}/script.js"), "integrity": INTEGRITY, "expected": "error"},
{"name": "cross-anonymous", "src": format!("{cross}/cors.js"), "integrity": INTEGRITY, "crossOrigin": "anonymous", "expected": "load"},
{"name": "cross-no-acao", "src": format!("{cross}/script.js"), "integrity": INTEGRITY, "crossOrigin": "anonymous", "expected": "error"},
{"name": "redirect-cross", "src": format!("{origin}/redirect.js"), "integrity": INTEGRITY, "expected": "error"},
{"name": "redirect-home", "src": format!("{origin}/roundtrip.js"), "integrity": INTEGRITY, "expected": "error"},
{"name": "cors-redirect", "src": format!("{origin}/cors-redirect.js"), "integrity": INTEGRITY, "crossOrigin": "anonymous", "expected": "load"},
{"name": "cors-unapproved-hop", "src": format!("{cross}/unapproved-redirect.js"), "integrity": INTEGRITY, "crossOrigin": "anonymous", "expected": "error"},
{"name": "cors-null-origin", "src": format!("{cross}/cors-redirect-null.js"), "integrity": INTEGRITY, "crossOrigin": "anonymous", "expected": "load"},
{"name": "cors-old-origin", "src": format!("{cross}/cors-redirect-origin.js"), "integrity": INTEGRITY, "crossOrigin": "anonymous", "expected": "error"},
{"name": "absent-integrity", "src": format!("{cross}/script.js"), "expected": "load"},
{"name": "empty-integrity", "src": format!("{cross}/script.js"), "integrity": "", "expected": "load"},
{"name": "unsupported-integrity", "src": format!("{cross}/script.js"), "integrity": "sha1-ignored", "expected": "load"},
{"name": "mismatch", "src": format!("{origin}/script.js"), "integrity": "sha384-foobar", "expected": "error"}
])
}
fn fixture_response(path: &str, origin: &str, cross: &str) -> (&'static str, String, String) {
let javascript = "Content-Type: text/javascript\r\n";
match path {
"/script.js" => ("200 OK", javascript.to_owned(), SCRIPT.to_owned()),
"/cors.js" | "/cors-null.js" | "/cors-origin.js" => (
"200 OK",
format!(
"{javascript}Access-Control-Allow-Origin: {}\r\n",
match path {
"/cors-null.js" => "null",
"/cors-origin.js" => origin,
_ => "*",
}
),
SCRIPT.to_owned(),
),
"/redirect.js"
| "/roundtrip.js"
| "/redirect-home.js"
| "/cors-redirect.js"
| "/unapproved-redirect.js"
| "/cors-redirect-null.js"
| "/cors-redirect-origin.js" => {
let target = match path {
"/redirect.js" => format!("{cross}/script.js"),
"/roundtrip.js" => format!("{cross}/redirect-home.js"),
"/cors-redirect.js" => format!("{cross}/cors.js"),
"/unapproved-redirect.js" => format!("{origin}/cors.js"),
"/cors-redirect-null.js" => format!("{origin}/cors-null.js"),
"/cors-redirect-origin.js" => format!("{origin}/cors-origin.js"),
_ => format!("{origin}/script.js"),
};
let acao = if path.starts_with("/cors-redirect") {
"Access-Control-Allow-Origin: *\r\n"
} else {
""
};
(
"302 Found",
format!("Location: {target}\r\n{acao}"),
String::new(),
)
}
"/worker.js" => {
let body = format!(
r#"
self.addEventListener('install', event => event.waitUntil(self.skipWaiting()));
self.addEventListener('activate', event => event.waitUntil(clients.claim()));
self.addEventListener('fetch', event => {{
const path = new URL(event.request.url).pathname;
if (path === '/sw-opaque.js')
event.respondWith(fetch('{cross}/script.js', {{mode: 'no-cors'}}));
else if (path === '/sw-basic.js')
event.respondWith(fetch('{origin}/script.js'));
else if (path === '/sw-cors.js')
event.respondWith(fetch('{cross}/cors.js'));
else if (path === '/sw-default.js')
event.respondWith(new Response({script}, {{headers: {{'Content-Type': 'text/javascript'}}}}));
else if (path === '/sw-redirect.js')
event.respondWith(Response.redirect('{cross}/script.js'));
else if (path === '/sw-roundtrip.js')
event.respondWith(Response.redirect('{cross}/redirect-home.js'));
}});
"#,
script = serde_json::to_string(SCRIPT).unwrap()
);
("200 OK", javascript.to_owned(), body)
}
"/page.html" | "/parser.html" => {
let mut html = "<!doctype html><body><script>globalThis.sriEvents = {}; globalThis.sriExecutions = 0;</script>".to_owned();
if path == "/parser.html" {
for case in network_cases(origin, cross).as_array().unwrap() {
let name = case["name"].as_str().unwrap();
html.push_str(&format!("<script src=\"{}\" onload=\"sriEvents['{name}']='load'\" onerror=\"sriEvents['{name}']='error'\"", case["src"].as_str().unwrap()));
for attribute in ["integrity", "crossOrigin"] {
if let Some(value) = case[attribute].as_str() {
html.push_str(&format!(" {attribute}=\"{value}\""));
}
}
html.push_str("></script>");
}
}
("200 OK", "Content-Type: text/html\r\n".to_owned(), html)
}
_ => (
"404 Not Found",
"Content-Type: text/plain\r\n".to_owned(),
"not found".to_owned(),
),
}
}
fn assert_integrity_results(result: serde_json::Value, cases: &serde_json::Value) {
let result: serde_json::Value =
serde_json::from_str(result["value"].as_str().expect("integrity result JSON")).unwrap();
let mut executions = 0;
for case in cases.as_array().unwrap() {
let name = case["name"].as_str().unwrap();
assert_eq!(result["events"][name], case["expected"], "{name}: {result}");
executions += usize::from(case["expected"] == "load");
}
assert_eq!(
result["executions"], executions,
"rejected scripts must not execute"
);
}
fn dynamic_probe(cases: &serde_json::Value, service_worker: bool) -> String {
format!(
r#"(async () => {{
if ({service_worker}) {{
const controlled = navigator.serviceWorker.controller ? Promise.resolve() :
new Promise(resolve => navigator.serviceWorker.addEventListener('controllerchange', resolve, {{once: true}}));
await navigator.serviceWorker.register('/worker.js', {{scope: '/'}});
await navigator.serviceWorker.ready;
await controlled;
}}
for (const test of {cases}) {{
sriEvents[test.name] = await new Promise(resolve => {{
const script = document.createElement('script');
script.src = test.src;
if ('type' in test) script.type = test.type;
if ('integrity' in test) script.integrity = test.integrity;
if ('crossOrigin' in test) script.crossOrigin = test.crossOrigin;
script.onload = () => resolve('load');
script.onerror = () => resolve('error');
document.body.append(script);
}});
}}
return JSON.stringify({{events: sriEvents, executions: sriExecutions}});
}})()"#
)
}
#[tokio::test(flavor = "multi_thread")]
async fn parser_script_integrity_requires_readable_response() -> Result<()> {
let servers = IntegrityServers::spawn().await?;
let browser = Browser::new(AppConfig::default())?;
let mut page = browser
.fetch(&format!("{}/parser.html", servers.origin))
.await?;
let result = page
.evaluate_runtime_expression_async(
"JSON.stringify({events: sriEvents, executions: sriExecutions})",
)
.await?;
assert_integrity_results(
result,
&network_cases(&servers.origin, &servers.cross_origin),
);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn dynamic_script_integrity_requires_readable_response() -> Result<()> {
let servers = IntegrityServers::spawn().await?;
let browser = Browser::new(AppConfig::default())?;
let mut page = browser
.fetch(&format!("{}/page.html", servers.origin))
.await?;
let cases = network_cases(&servers.origin, &servers.cross_origin);
let result = page
.evaluate_runtime_expression_with_await_async(&dynamic_probe(&cases, false), true)
.await?;
assert_integrity_results(result, &cases);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn module_script_integrity_requires_readable_response() -> Result<()> {
let servers = IntegrityServers::spawn().await?;
let browser = Browser::new(AppConfig::default())?;
let mut page = browser
.fetch(&format!("{}/page.html", servers.origin))
.await?;
let cases = serde_json::json!([
{"name": "same-origin", "src": "/script.js", "type": "module", "integrity": INTEGRITY, "expected": "load"},
{"name": "cors", "src": format!("{}/cors.js", servers.cross_origin), "type": "module", "integrity": INTEGRITY, "expected": "load"},
{"name": "no-acao", "src": format!("{}/script.js", servers.cross_origin), "type": "module", "integrity": INTEGRITY, "expected": "error"}
]);
let result = page
.evaluate_runtime_expression_with_await_async(&dynamic_probe(&cases, false), true)
.await?;
assert_integrity_results(result, &cases);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn service_worker_script_integrity_preserves_response_filter() -> Result<()> {
let servers = IntegrityServers::spawn().await?;
let browser = Browser::new(AppConfig::default())?;
let mut page = browser
.fetch(&format!("{}/page.html", servers.origin))
.await?;
let cases = serde_json::json!([
{"name": "opaque", "src": "/sw-opaque.js", "integrity": INTEGRITY, "expected": "error"},
{"name": "basic", "src": "/sw-basic.js", "integrity": INTEGRITY, "expected": "load"},
{"name": "cors", "src": "/sw-cors.js", "integrity": INTEGRITY, "expected": "load"},
{"name": "default", "src": "/sw-default.js", "integrity": INTEGRITY, "expected": "load"},
{"name": "redirect-cross", "src": "/sw-redirect.js", "integrity": INTEGRITY, "expected": "error"},
{"name": "redirect-home", "src": "/sw-roundtrip.js", "integrity": INTEGRITY, "expected": "error"},
{"name": "opaque-without-integrity", "src": "/sw-opaque.js", "expected": "load"},
{"name": "opaque-empty-integrity", "src": "/sw-opaque.js", "integrity": "", "expected": "load"}
]);
let result = page
.evaluate_runtime_expression_with_await_async(&dynamic_probe(&cases, true), true)
.await?;
assert_integrity_results(result, &cases);
Ok(())
}
+14 -1
View File
@@ -424,6 +424,9 @@ impl NativeModuleGraphFetchRequest {
let kind = self.kind;
let integrity = self.fetch_metadata.request_metadata.integrity.clone();
let request = self.request()?;
let initiator_url = self.initiator_url.clone();
let request_mode = request.request_mode;
let credentials_mode = request.credentials_mode;
let completion = move |response: anyhow::Result<moli_fetch::Response>| {
let mut network_result: Option<SharedNavigationResponseResult> = None;
let result = response
@@ -465,9 +468,19 @@ impl NativeModuleGraphFetchRequest {
&response.headers,
);
let (head, _, body_bytes) = response.into_parts();
if !crate::subresource_integrity::response_body_matches_subresource_integrity_metadata(
let response_is_eligible = crate::network_host::network_response_filter(
&initiator_url,
&head,
request_mode,
).is_none() && crate::network_host::validate_cors_response_chain(
&initiator_url,
&head,
credentials_mode,
).is_ok();
if !crate::subresource_integrity::response_matches_subresource_integrity_metadata(
&body_bytes,
integrity.as_deref(),
response_is_eligible,
) {
return Err(ModuleLoadError::new(
ModuleLoadStage::Fetch,
+2 -2
View File
@@ -152,8 +152,8 @@ pub(crate) use self::response::{
is_cors_policy_failure_message, materialize_response_object_body,
materialize_response_object_body_with_chunk_callback, materialize_response_object_head,
materialize_response_object_head_for_service_worker_respond_with,
materialized_body_bytes_from_value, response_constructor_callback,
validate_cors_preflight_response, validate_cors_response,
materialized_body_bytes_from_value, network_response_filter, response_constructor_callback,
validate_cors_preflight_response, validate_cors_response, validate_cors_response_chain,
validate_cross_origin_embedder_and_document_isolation_policy,
validate_cross_origin_resource_policy, validate_fetch_response_security_policy,
validate_fetch_response_security_policy_with_body,
@@ -15,7 +15,7 @@ pub(super) use self::body_methods::install_response_body_methods;
pub(crate) use self::cors::{
FetchResponseSecurityViolation, cors_preflight_request_headers,
filter_cors_exposed_response_headers, is_cors_policy_failure_message,
validate_cors_preflight_response, validate_cors_response,
validate_cors_preflight_response, validate_cors_response, validate_cors_response_chain,
validate_cross_origin_embedder_and_document_isolation_policy,
validate_cross_origin_resource_policy, validate_fetch_response_security_policy,
validate_fetch_response_security_policy_with_body,
@@ -35,5 +35,5 @@ pub(crate) use self::materialize::{
materialize_response_object_body, materialize_response_object_body_with_chunk_callback,
materialize_response_object_head,
materialize_response_object_head_for_service_worker_respond_with,
materialized_body_bytes_from_value,
materialized_body_bytes_from_value, network_response_filter,
};
@@ -50,6 +50,41 @@ pub(crate) fn validate_cors_response(
}
let origin = origin_ascii_serialization(document_url);
validate_cors_response_for_origin(&origin, response_headers, credentials_mode)
}
/// Validates an already fetched network response, including each redirect.
/// Once CORS-tainted, a redirect across origins changes the request origin to
/// null, even when the final URL returns to the initiating document's origin.
pub(crate) fn validate_cors_response_chain(
document_url: &url::Url,
head: &moli_fetch::ResponseHead,
credentials_mode: RequestCredentialsMode,
) -> Result<(), String> {
let mut origin = origin_ascii_serialization(document_url);
let mut cors_tainted = false;
for redirect in &head.redirect_chain {
cors_tainted |= !same_origin(document_url, &redirect.from_url);
if cors_tainted {
validate_cors_response_for_origin(&origin, &redirect.headers, credentials_mode)?;
if !same_origin(&redirect.from_url, &redirect.to_url) {
origin = "null".to_owned();
}
}
}
if matches!(head.final_url.scheme(), "http" | "https")
&& (cors_tainted || !same_origin(document_url, &head.final_url))
{
validate_cors_response_for_origin(&origin, &head.headers, credentials_mode)?;
}
Ok(())
}
fn validate_cors_response_for_origin(
origin: &str,
response_headers: &[(String, String)],
credentials_mode: RequestCredentialsMode,
) -> Result<(), String> {
let Some(allow_origin) = response_header_value(response_headers, "access-control-allow-origin")
else {
return Err(format!(
@@ -69,13 +69,28 @@ fn response_filter(
head: &moli_fetch::ResponseHead,
request_mode: RequestMode,
) -> FetchResponseFilter {
network_response_filter(document_url, head, request_mode)
.map_or(FetchResponseFilter::None, Into::into)
}
/// Classifies a network response using the whole redirect chain. A no-cors
/// response stays opaque even when a cross-origin hop redirects back home.
/// Service worker responses must retain their own filter instead: their URL
/// does not determine whether the worker returned a readable response.
pub(crate) fn network_response_filter(
document_url: &url::Url,
head: &moli_fetch::ResponseHead,
request_mode: RequestMode,
) -> Option<crate::types::AsyncSubresourceFetchResponseFilter> {
use crate::types::AsyncSubresourceFetchResponseFilter;
if is_redirect_status(head.status) {
FetchResponseFilter::OpaqueRedirect
Some(AsyncSubresourceFetchResponseFilter::OpaqueRedirect)
} else if request_mode == RequestMode::NoCors && no_cors_response_is_opaque(document_url, head)
{
FetchResponseFilter::Opaque
Some(AsyncSubresourceFetchResponseFilter::Opaque)
} else {
FetchResponseFilter::None
None
}
}
+35 -9
View File
@@ -377,7 +377,7 @@ pub(crate) async fn load_service_worker_aware_external_script_source_outcome(
}
let request = external_script_request(script, request_resource_type);
match browser_context_runtime
.fetch_service_worker_subresource_for_client(
.fetch_service_worker_subresource_for_client_with_metadata(
service_worker_client_id,
document_url,
&request,
@@ -390,9 +390,10 @@ pub(crate) async fn load_service_worker_aware_external_script_source_outcome(
{
Ok(Some(response)) => external_script_source_load_outcome_from_response_inner(
script,
response,
*response.response,
document_character_set,
true,
response.response_filter,
response.response_filter.is_none(),
),
Ok(None) => {
load_prepared_script_source_outcome_with_document_character_set(
@@ -501,11 +502,24 @@ pub(crate) fn external_script_source_load_outcome_from_response(
response: crate::protocol_types::NavigationResponse,
document_character_set: Option<&str>,
) -> PreparedScriptSourceLoadOutcome {
let request_mode = external_script_request_mode(script.kind, &script.fetch_metadata);
let head = response.head();
let response_filter =
crate::network_host::network_response_filter(&script.initiator_url, &head, request_mode);
let response_is_eligible = response_filter.is_none()
&& (request_mode == RequestMode::NoCors
|| crate::network_host::validate_cors_response_chain(
&script.initiator_url,
&head,
external_script_credentials_mode(script.kind, &script.fetch_metadata),
)
.is_ok());
external_script_source_load_outcome_from_response_inner(
script,
response,
document_character_set,
false,
response_filter,
response_is_eligible,
)
}
@@ -528,10 +542,13 @@ fn external_script_source_load_outcome_from_response_inner(
script: &PreparedScript,
response: crate::protocol_types::NavigationResponse,
document_character_set: Option<&str>,
allow_opaque_status_zero: bool,
response_filter: Option<crate::types::AsyncSubresourceFetchResponseFilter>,
response_is_eligible: bool,
) -> PreparedScriptSourceLoadOutcome {
let response_bytes = response.body_bytes().to_vec();
let opaque_status_zero = allow_opaque_status_zero && response.status == 0;
let opaque_status_zero = response_filter
== Some(crate::types::AsyncSubresourceFetchResponseFilter::Opaque)
&& response.status == 0;
let source_result = if !(opaque_status_zero || (200..=299).contains(&response.status)) {
Err(format!(
"script request `{}` returned HTTP {}",
@@ -542,9 +559,10 @@ fn external_script_source_load_outcome_from_response_inner(
validate_external_script_response_mime(&script.url, script.kind, &response)
{
Err(error)
} else if !crate::subresource_integrity::response_body_matches_subresource_integrity_metadata(
} else if !crate::subresource_integrity::response_matches_subresource_integrity_metadata(
&response_bytes,
script.fetch_metadata.integrity.as_deref(),
response_is_eligible && response_filter.is_none(),
) {
Err(format!(
"script request `{}` failed its integrity check",
@@ -617,6 +635,9 @@ pub(crate) fn external_script_credentials_mode(
) -> RequestCredentialsMode {
match kind {
ScriptKind::Module => module_script_credentials_mode(metadata.cross_origin.as_deref()),
ScriptKind::Classic if metadata.cross_origin.is_some() => {
module_script_credentials_mode(metadata.cross_origin.as_deref())
}
ScriptKind::Classic | ScriptKind::ImportMap | ScriptKind::DataBlock => {
RequestCredentialsMode::Include
}
@@ -1284,8 +1305,13 @@ mod tests {
source.as_bytes().to_vec(),
);
let outcome =
external_script_source_load_outcome_from_response_inner(&script, response, None, true);
let outcome = external_script_source_load_outcome_from_response_inner(
&script,
response,
None,
Some(crate::types::AsyncSubresourceFetchResponseFilter::Opaque),
false,
);
assert_eq!(outcome.source_result.expect("opaque script source"), source);
assert_eq!(
@@ -249,29 +249,6 @@ impl RendererBrowserContextRuntime {
}
}
pub(crate) async fn fetch_service_worker_subresource_for_client(
&self,
client_id: ServiceWorkerClientId,
document_url: Url,
request: &Request,
request_client: &ResourceRequestClient,
resource_task_runner: RendererResourceTaskRunner,
destination: ServiceWorkerRequestDestination,
resource_type: SubresourceResourceType,
) -> Result<Option<crate::protocol_types::NavigationResponse>> {
self.fetch_service_worker_subresource_for_client_with_metadata(
client_id,
document_url,
request,
request_client,
resource_task_runner,
destination,
resource_type,
)
.await
.map(|response| response.map(|response| *response.response))
}
pub(crate) async fn fetch_service_worker_subresource_for_client_with_metadata(
&self,
client_id: ServiceWorkerClientId,
@@ -333,6 +333,9 @@ impl ServiceWorkerRuntimeService {
};
let cancel_handle = job.cancel_handle.clone();
let redirect_chain = job.redirect_chain.clone();
let document_url = job.network_context.document_url.clone();
let request_mode = job.request_mode;
let credentials_mode = job.credentials_mode;
job.resource_task_runner.spawn(async move {
let result = match request_client
.fetch_raw_stream_with_cancel(request, cancel_handle)
@@ -350,6 +353,25 @@ impl ServiceWorkerRuntimeService {
head.redirect_chain = combined_redirect_chain;
head.redirected = true;
}
// This is a network response after a synthetic redirect,
// not a worker-produced readable Response. Authorize CORS
// before the direct consumer can trust an absent filter.
if request_mode == moli_fetch::RequestMode::Cors
&& let Err(message) = crate::network_host::validate_cors_response_chain(
&document_url,
&head,
credentials_mode,
)
{
let _ =
completion_tx.send(ServiceWorkerDirectFetchResult::Failure(message));
return;
}
let response_filter = crate::network_host::network_response_filter(
&document_url,
&head,
request_mode,
);
let body = response.clone_body_bytes();
let navigation_response =
crate::protocol_types::NavigationResponse::from_head_and_body(
@@ -359,7 +381,7 @@ impl ServiceWorkerRuntimeService {
);
ServiceWorkerDirectFetchResult::Response(ServiceWorkerDirectFetchResponse {
response: Box::new(navigation_response),
response_filter: None,
response_filter,
})
}
Err(error) => ServiceWorkerDirectFetchResult::Failure(error.to_string()),
+52 -1
View File
@@ -31,9 +31,13 @@ struct ParsedIntegrityToken {
expected_digest: Option<Vec<u8>>,
}
pub(crate) fn response_body_matches_subresource_integrity_metadata(
/// HTML scripts ignore empty or unsupported metadata. With supported metadata,
/// check response eligibility before hashing: observing a digest match against
/// opaque internal bytes would expose a cross-origin content oracle.
pub(crate) fn response_matches_subresource_integrity_metadata(
body: &[u8],
integrity: Option<&str>,
response_is_eligible: bool,
) -> bool {
let Some(integrity) = integrity
.map(str::trim)
@@ -50,6 +54,9 @@ pub(crate) fn response_body_matches_subresource_integrity_metadata(
else {
return true;
};
if !response_is_eligible {
return false;
}
let actual_digest = strongest_algorithm.digest_algorithm().digest_bytes(body);
metadata.tokens.iter().any(|token| {
token.algorithm == strongest_algorithm
@@ -143,6 +150,50 @@ fn decode_integrity_digest(digest: &str) -> Option<Vec<u8>> {
mod tests {
use super::*;
fn response_body_matches_subresource_integrity_metadata(
body: &[u8],
integrity: Option<&str>,
) -> bool {
response_matches_subresource_integrity_metadata(body, integrity, true)
}
#[test]
fn integrity_rejects_ineligible_responses_even_with_matching_metadata() {
let body = b"console.log('opaque body')";
let digest = base64::engine::general_purpose::STANDARD
.encode(DigestAlgorithm::Sha384.digest_bytes(body));
let matching = format!("sha384-{digest}");
assert!(response_matches_subresource_integrity_metadata(
body,
Some(&matching),
true
));
assert!(!response_matches_subresource_integrity_metadata(
body,
Some(&matching),
false
));
}
#[test]
fn empty_or_ignored_integrity_does_not_require_a_readable_response() {
for integrity in [
None,
Some(""),
Some(" \t\n"),
Some("sha384-***"),
Some("sha1-ignored"),
] {
for response_is_eligible in [true, false] {
assert!(response_matches_subresource_integrity_metadata(
b"opaque body",
integrity,
response_is_eligible,
));
}
}
}
#[test]
fn script_integrity_metadata_parses_matching_supported_hash() {
let body = b"console.log('integrity ok')";