From da20704a89e8b1b3473ede2779caee1e5b1ec6bc Mon Sep 17 00:00:00 2001 From: peg Date: Thu, 17 Sep 2026 08:24:46 +0200 Subject: [PATCH 1/2] Attestation provider server should default to loopback device --- crates/attestation-provider-server/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/attestation-provider-server/src/main.rs b/crates/attestation-provider-server/src/main.rs index 84b2436..c6d2fcd 100644 --- a/crates/attestation-provider-server/src/main.rs +++ b/crates/attestation-provider-server/src/main.rs @@ -35,7 +35,7 @@ struct Cli { enum CliCommand { Server { /// Socket address to listen on - #[arg(short, long, default_value = "0.0.0.0:0", env = "LISTEN_ADDR")] + #[arg(short, long, default_value = "127.0.0.1:0", env = "LISTEN_ADDR")] listen_addr: SocketAddr, /// Type of attestation to present (will attempt to detect if not /// given) From c4879d3af513f6d132335264b4c02e5820c3b43b Mon Sep 17 00:00:00 2001 From: peg Date: Thu, 17 Sep 2026 08:25:46 +0200 Subject: [PATCH 2/2] Disallow http redirects when fetching measurement from remote url --- crates/attestation/src/measurements.rs | 94 +++++++++++++++++++++++--- 1 file changed, 85 insertions(+), 9 deletions(-) diff --git a/crates/attestation/src/measurements.rs b/crates/attestation/src/measurements.rs index d75581c..971f9a9 100644 --- a/crates/attestation/src/measurements.rs +++ b/crates/attestation/src/measurements.rs @@ -540,20 +540,30 @@ impl MeasurementPolicy { } /// Given either a URL or the path to a file, parse the measurement - /// policy from JSON + /// policy from JSON. HTTP redirects are not followed. pub async fn from_file_or_url(file_or_url: String) -> Result { #[cfg(test)] crate::install_test_crypto_provider(); - if file_or_url.to_lowercase().trim_ascii().starts_with("https://") { - let measurements_json = reqwest::get(file_or_url).await?.bytes().await?; - Self::from_json_bytes(measurements_json.to_vec()) - } else if file_or_url.to_lowercase().trim_ascii().starts_with("http://") { - if !Self::is_loopback_http_url(&file_or_url)? { + let normalized_source = file_or_url.to_lowercase(); + let normalized_source = normalized_source.trim_ascii(); + let is_https = normalized_source.starts_with("https://"); + let is_http = normalized_source.starts_with("http://"); + if is_https || is_http { + if is_http && !Self::is_loopback_http_url(&file_or_url)? { return Err(MeasurementFormatError::InsecureHttpNotLoopback(file_or_url)); } - let measurements_json = reqwest::get(file_or_url).await?.bytes().await?; + let response = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build()? + .get(file_or_url) + .send() + .await?; + if response.status().is_redirection() { + return Err(MeasurementFormatError::RedirectNotAllowed(response.status().as_u16())); + } + let measurements_json = response.bytes().await?; Self::from_json_bytes(measurements_json.to_vec()) } else { Self::from_file(file_or_url.into()).await @@ -561,7 +571,7 @@ impl MeasurementPolicy { } /// Synchronously parse a measurement policy from either a URL or a file - /// path. + /// path. HTTP redirects are not followed. pub fn from_file_or_url_sync(file_or_url: String) -> Result { #[cfg(test)] crate::install_test_crypto_provider(); @@ -575,10 +585,16 @@ impl MeasurementPolicy { return Err(MeasurementFormatError::InsecureHttpNotLoopback(file_or_url)); } - let response = ureq::get(&file_or_url) + let response = ureq::AgentBuilder::new() + .redirects(0) + .build() + .get(&file_or_url) .timeout(Duration::from_secs(10)) .call() .map_err(|error| MeasurementFormatError::Ureq(Box::new(error)))?; + if (300..400).contains(&response.status()) { + return Err(MeasurementFormatError::RedirectNotAllowed(response.status())); + } let mut measurements_json = Vec::new(); response.into_reader().read_to_end(&mut measurements_json)?; Self::from_json_bytes(measurements_json) @@ -911,6 +927,8 @@ pub enum MeasurementFormatError { InvalidUri(#[from] InvalidUri), #[error("Refusing to load measurement policy over plain HTTP from non-loopback host: {0}")] InsecureHttpNotLoopback(String), + #[error("Refusing HTTP redirect when loading measurement policy (status {0})")] + RedirectNotAllowed(u16), #[error("Measurement entry for register '{0}' has both 'expected' and 'expected_any'")] BothExpectedAndExpectedAny(String), #[error("Measurement entry for register '{0}' has neither 'expected' nor 'expected_any'")] @@ -1985,6 +2003,64 @@ mod tests { )); } + /// The redirect body is itself a valid policy, so rejecting it also + /// checks that disabling redirects does not accidentally install it. + fn serve_policy_response(status: u16) -> (String, std::thread::JoinHandle<()>) { + use std::io::{Read, Write}; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/measurements.json", listener.local_addr().unwrap()); + let location = url.clone(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut request = [0u8; 4096]; + assert!(stream.read(&mut request).unwrap() > 0); + let body = r#"[{"attestation_type":"none"}]"#; + write!( + stream, + "HTTP/1.1 {status} Test\r\nLocation: {location}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len(), + ) + .unwrap(); + }); + (url, server) + } + + #[tokio::test] + async fn policy_loader_rejects_redirects() { + for status in [301, 302, 303, 307, 308] { + let (url, server) = serve_policy_response(status); + let result = MeasurementPolicy::from_file_or_url(url).await; + server.join().unwrap(); + assert!( + matches!(result, Err(MeasurementFormatError::RedirectNotAllowed(code)) if code == status) + ); + } + + let (url, server) = serve_policy_response(200); + let policy = MeasurementPolicy::from_file_or_url(url).await.unwrap(); + server.join().unwrap(); + assert!(!policy.has_remote_attestation()); + } + + #[test] + fn sync_policy_loader_rejects_redirects() { + for status in [301, 302, 303, 307, 308] { + let (url, server) = serve_policy_response(status); + let result = MeasurementPolicy::from_file_or_url_sync(url); + server.join().unwrap(); + assert!( + matches!(result, Err(MeasurementFormatError::RedirectNotAllowed(code)) if code == status) + ); + } + + let (url, server) = serve_policy_response(200); + let policy = MeasurementPolicy::from_file_or_url_sync(url).unwrap(); + server.join().unwrap(); + assert!(!policy.has_remote_attestation()); + } + #[tokio::test] async fn test_from_file_or_url_allows_http_localhost() { let result =