From daadb123309772d10d5e62a6b77d0aab23421d78 Mon Sep 17 00:00:00 2001 From: peg Date: Fri, 11 Sep 2026 12:31:02 +0200 Subject: [PATCH 1/3] Rm measurement headers from source --- src/lib.rs | 119 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 118 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 8b4a833..e912405 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -251,7 +251,8 @@ impl ProxyServer { update_header(headers, &X_FORWARDED_FOR, &new_x_forwarded_for); - // If we have measurements, from the remote peer, add them to the request header + // Discard untrusted measurements before inserting verified peer measurements. + headers.remove(MEASUREMENT_HEADER); let measurements = measurements.clone(); if let Some(measurements) = measurements { match measurements.to_header_format() { @@ -499,6 +500,8 @@ impl ProxyClient { // If we have measurements from the proxy-server, inject them into the // response header let headers = resp.headers_mut(); + // Never forward measurements supplied by the target service. + headers.remove(MEASUREMENT_HEADER); if let Some(measurements) = measurements.clone() { match measurements.to_header_format() { Ok(header_value) => { @@ -886,6 +889,120 @@ mod tests { assert_eq!(res_body, "No measurements"); } + #[tokio::test] + async fn http_proxy_sanitizes_measurement_headers() { + for protocol in [ALPN_HTTP11, ALPN_H2] { + for attested in [false, true] { + let app = axum::Router::new().route( + "/", + axum::routing::get(|headers: HeaderMap| async move { + let received: Vec = headers + .get_all(MEASUREMENT_HEADER) + .iter() + .map(|value| value.to_str().unwrap().to_owned()) + .collect(); + let mut headers = HeaderMap::new(); + headers.append( + MEASUREMENT_HEADER, + HeaderValue::from_static("forged-server"), + ); + headers.append( + MEASUREMENT_HEADER, + HeaderValue::from_static("another-forgery"), + ); + (headers, axum::Json(received)) + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let target_addr = listener.local_addr().unwrap(); + let backend = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + let (cert_chain, private_key) = + generate_certificate_chain("127.0.0.1".parse().unwrap()); + let (mut server_config, mut client_config) = + generate_tls_config(cert_chain.clone(), private_key); + server_config.alpn_protocols = vec![protocol.to_vec()]; + client_config.alpn_protocols = vec![protocol.to_vec()]; + + let (generator, verifier, expected) = if attested { + ( + AttestationGenerator::new(AttestationType::DcapTdx, None).unwrap(), + AttestationVerifier::mock(), + vec![ + serde_json::from_str::( + mock_dcap_measurements() + .to_header_format() + .unwrap() + .to_str() + .unwrap(), + ) + .unwrap(), + ], + ) + } else { + ( + AttestationGenerator::with_no_attestation(), + AttestationVerifier::expect_none(), + Vec::new(), + ) + }; + let server = ProxyServer::new_with_tls_config( + cert_chain, + server_config, + "127.0.0.1:0", + target_addr.to_string(), + generator.clone(), + verifier.clone(), + ) + .await + .unwrap(); + let server_addr = server.local_addr().unwrap(); + tokio::spawn(async move { server.accept().await.unwrap() }); + + let client = ProxyClient::new_with_tls_config( + client_config, + "127.0.0.1:0", + server_addr.to_string(), + generator, + verifier, + None, + ) + .await + .unwrap(); + let client_addr = client.local_addr().unwrap(); + tokio::spawn(async move { client.accept().await.unwrap() }); + + let response = reqwest::Client::new() + .get(format!("http://{client_addr}/")) + .header(MEASUREMENT_HEADER, "forged-client") + .header(MEASUREMENT_HEADER, "another-forgery") + .send() + .await + .unwrap(); + assert_eq!(response.status(), http::StatusCode::OK); + let response_measurements: Vec<_> = response + .headers() + .get_all(MEASUREMENT_HEADER) + .iter() + .map(|value| { + serde_json::from_str::(value.to_str().unwrap()).unwrap() + }) + .collect(); + assert_eq!(response_measurements, expected); + let request_measurements: Vec = + serde_json::from_slice(&response.bytes().await.unwrap()).unwrap(); + let request_measurements: Vec<_> = request_measurements + .iter() + .map(|value| serde_json::from_str::(value).unwrap()) + .collect(); + assert_eq!(request_measurements, expected); + backend.abort(); + } + } + } + // Server has mock DCAP, client has no attestation and no client auth #[tokio::test] async fn http_proxy_with_server_attestation() { From 4464ddf996c2dc9e4524a48dcf272447d99796c5 Mon Sep 17 00:00:00 2001 From: peg Date: Fri, 11 Sep 2026 12:36:52 +0200 Subject: [PATCH 2/3] Always check measurement policy, even no attestation configured --- attested-tls/src/lib.rs | 67 ++++++++++++++++++++++++++++++++++------- 1 file changed, 56 insertions(+), 11 deletions(-) diff --git a/attested-tls/src/lib.rs b/attested-tls/src/lib.rs index 1021950..7cc68c7 100644 --- a/attested-tls/src/lib.rs +++ b/attested-tls/src/lib.rs @@ -197,17 +197,14 @@ impl AttestedTlsServer { let remote_attestation_message = AttestationExchangeMessage::decode(&mut &buf[..])?; let remote_attestation_type = remote_attestation_message.attestation_type(); - // If we expect an attestaion from the client, verify it and get measurements - let measurements = if self.attestation_verifier.has_remote_attestation() { - let remote_input_data = compute_report_input(remote_cert_chain.as_deref(), exporter)?; - - self.attestation_verifier - .verify_attestation(remote_attestation_message, remote_input_data) - .await? - .map(|verified| verified.measurements) - } else { - None - }; + // Validate every exchange, including policies that only accept no attestation, + // before exposing the peer's attestation type to callers. + let remote_input_data = compute_report_input(remote_cert_chain.as_deref(), exporter)?; + let measurements = self + .attestation_verifier + .verify_attestation(remote_attestation_message, remote_input_data) + .await? + .map(|verified| verified.measurements); Ok((tls_stream, measurements, remote_attestation_type)) } @@ -672,6 +669,54 @@ mod tests { client.connect_tcp(&server_addr.to_string()).await.unwrap(); } + #[tokio::test] + async fn server_enforces_no_attestation_policy() { + for client_type in [AttestationType::None, AttestationType::DcapTdx] { + let (cert_chain, private_key) = + generate_certificate_chain("127.0.0.1".parse().unwrap()); + let (server_config, client_config) = + generate_tls_config(cert_chain.clone(), private_key); + let server = AttestedTlsServer::new_with_tls_config( + cert_chain, + server_config, + AttestationGenerator::with_no_attestation(), + AttestationVerifier::expect_none(), + ) + .unwrap(); + let client = AttestedTlsClient::new_with_tls_config( + client_config, + AttestationGenerator::new(client_type, None).unwrap(), + AttestationVerifier::expect_none(), + None, + ) + .unwrap(); + let (server_io, client_io) = tokio::io::duplex(128 * 1024); + let (server_result, client_result) = + tokio::time::timeout(std::time::Duration::from_secs(5), async { + tokio::join!( + server.handle_connection(server_io), + client.connect("127.0.0.1", client_io), + ) + }) + .await + .unwrap(); + // The client finishes sending before the server checks its evidence. + let _client_connection = client_result.unwrap(); + if client_type == AttestationType::None { + let (_stream, measurements, attestation_type) = server_result.unwrap(); + assert!(measurements.is_none()); + assert_eq!(attestation_type, AttestationType::None); + } else { + assert!(matches!( + server_result, + Err(AttestedTlsError::Attestation( + AttestationError::MeasurementsNotAccepted + )) + )); + } + } + } + // Negative test - server does not provide attestation but client requires it // Server has no attestation, client has no attestation and no client auth #[tokio::test] From 00a5800d85ee3f7b47fa9213aafce4460d941d06 Mon Sep 17 00:00:00 2001 From: peg Date: Fri, 11 Sep 2026 12:44:24 +0200 Subject: [PATCH 3/3] attested-get should disallow redirects --- README.md | 1 + src/attested_get.rs | 25 ++++---- src/lib.rs | 2 + src/main.rs | 6 ++ tests/attested_get_redirect.rs | 104 +++++++++++++++++++++++++++++++++ 5 files changed, 127 insertions(+), 11 deletions(-) create mode 100644 tests/attested_get_redirect.rs diff --git a/README.md b/README.md index 7d97492..562ea79 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ These are the attestation type names used in the HTTP headers, and the measureme - `client`, `get-tls-cert`, and `attested-get` accept `--allow-self-signed` to permit a self-signed remote TLS certificate. - `client` and `server` accept `--listen-addr-healthcheck` to start a separate HTTP health-check listener. - `get-tls-cert --out-measurements ` writes the verified remote measurements as JSON in addition to writing the certificate chain to standard output. +- `attested-get` does not follow HTTP redirects and exits with an error on 3xx responses. Its library API returns the original response for inspection. The loopback request ignores environment proxy settings. - If `server` is started without `--tls-private-key-path` and `--tls-certificate-path`, it generates a self-signed certificate for its listening IP address. ## Protocol Specification diff --git a/src/attested_get.rs b/src/attested_get.rs index 008cb1c..df6ef1e 100644 --- a/src/attested_get.rs +++ b/src/attested_get.rs @@ -23,6 +23,8 @@ pub fn split_target_and_path(target: &str) -> (String, Option) { /// Start a proxy-client, send a single HTTP GET request to the given path and return the /// [reqwest::Response] +/// +/// Redirects are returned without following them, so requests stay on the attested channel. pub async fn attested_get( target_addr: String, url_path: &str, @@ -62,7 +64,17 @@ async fn attested_get_with_client( proxy_client: ProxyClient, url_path: &str, ) -> Result { - let proxy_client_addr = proxy_client.local_addr().unwrap(); + let proxy_client_addr = proxy_client.local_addr()?; + + // Keep the request on the local proxy and return redirects without following them. + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .build()?; + let url_path = url_path.strip_prefix("/").unwrap_or(url_path); + let request = client + .get(format!("http://{proxy_client_addr}/{url_path}")) + .build()?; // Accept a single connection in a separate task tokio::spawn(async move { @@ -71,16 +83,7 @@ async fn attested_get_with_client( } }); - // Remove leading '/' if present - let url_path = url_path.strip_prefix("/").unwrap_or(url_path); - - // Make a GET request - let request = reqwest::Request::new( - reqwest::Method::GET, - reqwest::Url::parse(&format!("http://{proxy_client_addr}/{url_path}")).unwrap(), - ); - let client = reqwest::Client::new(); - let response = client.execute(request).await.unwrap(); + let response = client.execute(request).await?; Ok(response) } diff --git a/src/lib.rs b/src/lib.rs index e912405..219610a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -752,6 +752,8 @@ pub enum ProxyError { BadDnsName(#[from] tokio_rustls::rustls::pki_types::InvalidDnsNameError), #[error("HTTP: {0}")] Hyper(#[from] hyper::Error), + #[error("HTTP request: {0}")] + Reqwest(#[from] reqwest::Error), #[error("JSON: {0}")] Json(#[from] serde_json::Error), #[error("Could not forward response - sender was dropped")] diff --git a/src/main.rs b/src/main.rs index 7cef2f0..884ca23 100644 --- a/src/main.rs +++ b/src/main.rs @@ -449,6 +449,12 @@ async fn main() -> anyhow::Result<()> { ) .await?; + ensure!( + !response.status().is_redirection(), + "Attested GET returned {}; redirects are not followed because the destination has not been attested", + response.status() + ); + // Write response body to standard output let mut stdout = tokio::io::stdout(); diff --git a/tests/attested_get_redirect.rs b/tests/attested_get_redirect.rs new file mode 100644 index 0000000..9d30e53 --- /dev/null +++ b/tests/attested_get_redirect.rs @@ -0,0 +1,104 @@ +use std::time::Duration; + +use attested_tls_proxy::{ + AttestationGenerator, ProxyServer, attestation::AttestationVerifier, + attested_get::attested_get, self_signed::generate_self_signed_cert, +}; +use axum::{Router, routing::get}; +use tokio::{net::TcpListener, process::Command, time::timeout}; + +#[tokio::test] +async fn redirects_never_contact_the_destination_and_cli_rejects_them() { + let _ = tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().install_default(); + let destination = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let destination_url = format!("http://{}/", destination.local_addr().unwrap()); + let location = destination_url.clone(); + let app = Router::new().route( + "/", + get(move || { + let location = location.clone(); + async move { + ( + http::StatusCode::FOUND, + [(http::header::LOCATION, location)], + "redirect body", + ) + } + }), + ); + let target = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let target_addr = target.local_addr().unwrap(); + let backend = tokio::spawn(async move { axum::serve(target, app).await.unwrap() }); + let server = ProxyServer::new( + generate_self_signed_cert("127.0.0.1".parse().unwrap()).unwrap(), + "127.0.0.1:0", + target_addr.to_string(), + AttestationGenerator::with_no_attestation(), + AttestationVerifier::expect_none(), + false, + ) + .await + .unwrap(); + let server_addr = server.local_addr().unwrap().to_string(); + let proxy = tokio::spawn(async move { + loop { + server.accept().await.unwrap(); + } + }); + + let response = timeout( + Duration::from_secs(5), + attested_get( + server_addr.clone(), + "/", + AttestationVerifier::expect_none(), + None, + true, + ), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(response.status(), http::StatusCode::FOUND); + assert_eq!(response.headers()[http::header::LOCATION], destination_url); + assert_eq!(response.text().await.unwrap(), "redirect body"); + + // Configure an environment proxy in a subprocess to avoid mutating the test process. + let output = timeout( + Duration::from_secs(5), + Command::new(env!("CARGO_BIN_EXE_attested-tls-proxy")) + .args([ + "attested-get", + &server_addr, + "--allow-self-signed", + "--allowed-remote-attestation-type", + "none", + ]) + .env_remove("MEASUREMENTS_FILE") + .env("HTTP_PROXY", &destination_url) + .env("http_proxy", &destination_url) + .env("ALL_PROXY", &destination_url) + .env("all_proxy", &destination_url) + .env("NO_PROXY", "") + .env("no_proxy", "") + .kill_on_drop(true) + .output(), + ) + .await + .unwrap() + .unwrap(); + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains("302 Found"), "{stderr}"); + assert!(stderr.contains("redirects are not followed"), "{stderr}"); + + // Any connection attempt would remain queued on this listener. + assert!( + timeout(Duration::from_millis(100), destination.accept()) + .await + .is_err() + ); + proxy.abort(); + backend.abort(); +}