diff --git a/Cargo.lock b/Cargo.lock index 1706fd27..3e162067 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2824,6 +2824,7 @@ dependencies = [ "log", "lol_html", "matchit", + "mime", "pin-project-lite", "rand", "regex", diff --git a/Cargo.toml b/Cargo.toml index c3a35154..4a62fd6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,6 +75,7 @@ log = "0.4.29" log-fastly = "0.11.12" lol_html = "2.7.2" matchit = "0.9" +mime = "0.3" pin-project-lite = "0.2" rand = "0.8" regex = "1.12.3" diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 6b81637a..27e90516 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -5,13 +5,13 @@ use fastly::{Error, Request, Response}; use trusted_server_core::auction::endpoints::handle_auction; use trusted_server_core::auction::{build_orchestrator, AuctionOrchestrator}; use trusted_server_core::auth::enforce_basic_auth; +use trusted_server_core::compat; use trusted_server_core::constants::{ ENV_FASTLY_IS_STAGING, ENV_FASTLY_SERVICE_VERSION, HEADER_X_GEO_INFO_AVAILABLE, HEADER_X_TS_ENV, HEADER_X_TS_VERSION, }; use trusted_server_core::error::TrustedServerError; use trusted_server_core::geo::GeoInfo; -use trusted_server_core::http_util::sanitize_forwarded_headers; use trusted_server_core::integrations::IntegrationRegistry; use trusted_server_core::platform::RuntimeServices; use trusted_server_core::proxy::{ @@ -106,7 +106,7 @@ async fn route_request( // Strip client-spoofable forwarded headers at the edge. // On Fastly this service IS the first proxy — these headers from // clients are untrusted and can hijack URL rewriting (see #409). - sanitize_forwarded_headers(&mut req); + compat::sanitize_fastly_forwarded_headers(&mut req); // Look up geo info via the platform abstraction using the client IP // already captured in RuntimeServices at the entry point. @@ -121,8 +121,10 @@ async fn route_request( // `get_settings()` should already have rejected invalid handler regexes. // Keep this fallback so manually-constructed or otherwise unprepared // settings still become an error response instead of panicking. - match enforce_basic_auth(settings, &req) { - Ok(Some(mut response)) => { + let auth_req = compat::from_fastly_request_ref(&req); + match enforce_basic_auth(settings, &auth_req) { + Ok(Some(response)) => { + let mut response = compat::to_fastly_response(response); finalize_response(settings, geo_info.as_ref(), &mut response); return Ok(response); } diff --git a/crates/trusted-server-core/Cargo.toml b/crates/trusted-server-core/Cargo.toml index ddc8e2e2..a85261d7 100644 --- a/crates/trusted-server-core/Cargo.toml +++ b/crates/trusted-server-core/Cargo.toml @@ -32,6 +32,7 @@ http = { workspace = true } iab_gpp = { workspace = true } jose-jwk = { workspace = true } log = { workspace = true } +mime = { workspace = true } rand = { workspace = true } lol_html = { workspace = true } matchit = { workspace = true } diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index c0576fd2..22a48b57 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -4,6 +4,7 @@ use error_stack::{Report, ResultExt}; use fastly::{Request, Response}; use crate::auction::formats::AdRequest; +use crate::compat; use crate::consent; use crate::cookies::handle_request_cookies; use crate::error::TrustedServerError; @@ -46,16 +47,18 @@ pub async fn handle_auction( body.ad_units.len() ); + let http_req = compat::from_fastly_request_ref(&req); + // Generate synthetic ID early so the consent pipeline can use it for // KV Store fallback/write operations. - let synthetic_id = get_or_generate_synthetic_id(settings, services, &req).change_context( + let synthetic_id = get_or_generate_synthetic_id(settings, services, &http_req).change_context( TrustedServerError::Auction { message: "Failed to generate synthetic ID".to_string(), }, )?; // Extract consent from request cookies, headers, and geo. - let cookie_jar = handle_request_cookies(&req)?; + let cookie_jar = handle_request_cookies(&http_req)?; let geo = services .geo() .lookup(services.client_info.client_ip) @@ -65,7 +68,7 @@ pub async fn handle_auction( }); let consent_context = consent::build_consent_context(&consent::ConsentPipelineInput { jar: cookie_jar.as_ref(), - req: &req, + req: &http_req, config: &settings.consent, geo: geo.as_ref(), synthetic_id: Some(synthetic_id.as_str()), diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index fa6ebf11..950ad233 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -13,6 +13,7 @@ use std::collections::HashMap; use uuid::Uuid; use crate::auction::context::ContextValue; +use crate::compat; use crate::consent::ConsentContext; use crate::creative; use crate::error::TrustedServerError; @@ -89,7 +90,8 @@ pub fn convert_tsjs_to_auction_request( geo: Option, ) -> Result> { let synthetic_id = synthetic_id.to_owned(); - let fresh_id = generate_synthetic_id(settings, services, req).change_context( + let http_req = compat::from_fastly_request_ref(req); + let fresh_id = generate_synthetic_id(settings, services, &http_req).change_context( TrustedServerError::Auction { message: "Failed to generate fresh ID".to_string(), }, diff --git a/crates/trusted-server-core/src/auth.rs b/crates/trusted-server-core/src/auth.rs index 547784df..fa882044 100644 --- a/crates/trusted-server-core/src/auth.rs +++ b/crates/trusted-server-core/src/auth.rs @@ -1,7 +1,8 @@ use base64::{engine::general_purpose::STANDARD, Engine as _}; +use edgezero_core::body::Body as EdgeBody; use error_stack::Report; -use fastly::http::{header, StatusCode}; -use fastly::{Request, Response}; +use http::header; +use http::{Request, Response, StatusCode}; use sha2::{Digest as _, Sha256}; use subtle::ConstantTimeEq as _; @@ -27,9 +28,9 @@ const BASIC_AUTH_REALM: &str = r#"Basic realm="Trusted Server""#; /// un-compilable path regex. pub fn enforce_basic_auth( settings: &Settings, - req: &Request, -) -> Result, Report> { - let Some(handler) = settings.handler_for_path(req.get_path())? else { + req: &Request, +) -> Result>, Report> { + let Some(handler) = settings.handler_for_path(req.uri().path())? else { return Ok(None); }; @@ -53,14 +54,15 @@ pub fn enforce_basic_auth( if bool::from(username_match & password_match) { Ok(None) } else { - log::warn!("Basic auth failed for path: {}", req.get_path()); + log::warn!("Basic auth failed for path: {}", req.uri().path()); Ok(Some(unauthorized_response())) } } -fn extract_credentials(req: &Request) -> Option<(String, String)> { +fn extract_credentials(req: &Request) -> Option<(String, String)> { let header_value = req - .get_header(header::AUTHORIZATION) + .headers() + .get(header::AUTHORIZATION) .and_then(|value| value.to_str().ok())?; let mut parts = header_value.splitn(2, ' '); @@ -84,25 +86,42 @@ fn extract_credentials(req: &Request) -> Option<(String, String)> { Some((username, password)) } -fn unauthorized_response() -> Response { - Response::from_status(StatusCode::UNAUTHORIZED) - .with_header(header::WWW_AUTHENTICATE, BASIC_AUTH_REALM) - .with_header(header::CONTENT_TYPE, "text/plain; charset=utf-8") - .with_body_text_plain("Unauthorized") +fn unauthorized_response() -> Response { + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header(header::WWW_AUTHENTICATE, BASIC_AUTH_REALM) + .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") + .body(EdgeBody::from(b"Unauthorized".as_ref())) + .expect("should build unauthorized response") } #[cfg(test)] mod tests { use super::*; use base64::engine::general_purpose::STANDARD; - use fastly::http::{header, Method}; + use http::{header, HeaderValue, Method}; use crate::test_support::tests::{crate_test_settings_str, create_test_settings}; + fn build_request(method: Method, uri: &str) -> Request { + Request::builder() + .method(method) + .uri(uri) + .body(EdgeBody::empty()) + .expect("should build request") + } + + fn set_authorization(req: &mut Request, value: &str) { + req.headers_mut().insert( + header::AUTHORIZATION, + HeaderValue::from_str(value).expect("should build authorization header"), + ); + } + #[test] fn no_challenge_for_non_protected_path() { let settings = create_test_settings(); - let req = Request::new(Method::GET, "https://example.com/open"); + let req = build_request(Method::GET, "https://example.com/open"); assert!(enforce_basic_auth(&settings, &req) .expect("should evaluate auth") @@ -112,14 +131,15 @@ mod tests { #[test] fn challenge_when_missing_credentials() { let settings = create_test_settings(); - let req = Request::new(Method::GET, "https://example.com/secure"); + let req = build_request(Method::GET, "https://example.com/secure"); let response = enforce_basic_auth(&settings, &req) .expect("should evaluate auth") .expect("should challenge"); - assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); let realm = response - .get_header(header::WWW_AUTHENTICATE) + .headers() + .get(header::WWW_AUTHENTICATE) .expect("should have WWW-Authenticate header"); assert_eq!(realm, BASIC_AUTH_REALM); } @@ -127,9 +147,9 @@ mod tests { #[test] fn allow_when_credentials_match() { let settings = create_test_settings(); - let mut req = Request::new(Method::GET, "https://example.com/secure/data"); + let mut req = build_request(Method::GET, "https://example.com/secure/data"); let token = STANDARD.encode("user:pass"); - req.set_header(header::AUTHORIZATION, format!("Basic {token}")); + set_authorization(&mut req, &format!("Basic {token}")); assert!(enforce_basic_auth(&settings, &req) .expect("should evaluate auth") @@ -139,29 +159,29 @@ mod tests { #[test] fn challenge_when_both_credentials_wrong() { let settings = create_test_settings(); - let mut req = Request::new(Method::GET, "https://example.com/secure/data"); + let mut req = build_request(Method::GET, "https://example.com/secure/data"); let token = STANDARD.encode("wrong:wrong"); - req.set_header(header::AUTHORIZATION, format!("Basic {token}")); + set_authorization(&mut req, &format!("Basic {token}")); let response = enforce_basic_auth(&settings, &req) .expect("should evaluate auth") .expect("should challenge"); - assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[test] fn challenge_when_username_wrong_password_correct() { // Validates that both fields are always evaluated — no short-circuit username oracle. let settings = create_test_settings(); - let mut req = Request::new(Method::GET, "https://example.com/secure/data"); + let mut req = build_request(Method::GET, "https://example.com/secure/data"); let token = STANDARD.encode("wrong-user:pass"); - req.set_header(header::AUTHORIZATION, format!("Basic {token}")); + set_authorization(&mut req, &format!("Basic {token}")); let response = enforce_basic_auth(&settings, &req) .expect("should evaluate auth") .expect("should challenge"); assert_eq!( - response.get_status(), + response.status(), StatusCode::UNAUTHORIZED, "should reject wrong username even with correct password" ); @@ -170,15 +190,15 @@ mod tests { #[test] fn challenge_when_username_correct_password_wrong() { let settings = create_test_settings(); - let mut req = Request::new(Method::GET, "https://example.com/secure/data"); + let mut req = build_request(Method::GET, "https://example.com/secure/data"); let token = STANDARD.encode("user:wrong-pass"); - req.set_header(header::AUTHORIZATION, format!("Basic {token}")); + set_authorization(&mut req, &format!("Basic {token}")); let response = enforce_basic_auth(&settings, &req) .expect("should evaluate auth") .expect("should challenge"); assert_eq!( - response.get_status(), + response.status(), StatusCode::UNAUTHORIZED, "should reject correct username with wrong password" ); @@ -187,13 +207,13 @@ mod tests { #[test] fn challenge_when_scheme_is_not_basic() { let settings = create_test_settings(); - let mut req = Request::new(Method::GET, "https://example.com/secure"); - req.set_header(header::AUTHORIZATION, "Bearer token"); + let mut req = build_request(Method::GET, "https://example.com/secure"); + set_authorization(&mut req, "Bearer token"); let response = enforce_basic_auth(&settings, &req) .expect("should evaluate auth") .expect("should challenge"); - assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[test] @@ -210,9 +230,9 @@ mod tests { #[test] fn allow_admin_path_with_valid_credentials() { let settings = create_test_settings(); - let mut req = Request::new(Method::POST, "https://example.com/admin/keys/rotate"); + let mut req = build_request(Method::POST, "https://example.com/admin/keys/rotate"); let token = STANDARD.encode("admin:admin-pass"); - req.set_header(header::AUTHORIZATION, format!("Basic {token}")); + set_authorization(&mut req, &format!("Basic {token}")); assert!( enforce_basic_auth(&settings, &req) @@ -225,24 +245,24 @@ mod tests { #[test] fn challenge_admin_path_with_wrong_credentials() { let settings = create_test_settings(); - let mut req = Request::new(Method::POST, "https://example.com/admin/keys/rotate"); + let mut req = build_request(Method::POST, "https://example.com/admin/keys/rotate"); let token = STANDARD.encode("admin:wrong"); - req.set_header(header::AUTHORIZATION, format!("Basic {token}")); + set_authorization(&mut req, &format!("Basic {token}")); let response = enforce_basic_auth(&settings, &req) .expect("should evaluate auth") .expect("should challenge admin path with wrong credentials"); - assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[test] fn challenge_admin_path_with_missing_credentials() { let settings = create_test_settings(); - let req = Request::new(Method::POST, "https://example.com/admin/keys/rotate"); + let req = build_request(Method::POST, "https://example.com/admin/keys/rotate"); let response = enforce_basic_auth(&settings, &req) .expect("should evaluate auth") .expect("should challenge admin path with missing credentials"); - assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } } diff --git a/crates/trusted-server-core/src/compat.rs b/crates/trusted-server-core/src/compat.rs new file mode 100644 index 00000000..69bf1b6e --- /dev/null +++ b/crates/trusted-server-core/src/compat.rs @@ -0,0 +1,536 @@ +//! Compatibility bridge between `fastly` SDK types and `http` crate types. +//! +//! All items in this module are temporary scaffolding created in PR 11 and +//! scheduled for deletion in PR 15. Do not add new callers after PR 13. +//! +//! # PR 15 removal target + +use edgezero_core::body::Body as EdgeBody; +use fastly::http::header; + +use crate::constants::INTERNAL_HEADERS; +use crate::http_util::SPOOFABLE_FORWARDED_HEADERS; + +fn build_http_request(req: &fastly::Request, body: EdgeBody) -> http::Request { + let uri: http::Uri = req + .get_url_str() + .parse() + .expect("should parse fastly request URL as URI"); + + let mut builder = http::Request::builder() + .method(req.get_method().clone()) + .uri(uri); + + for (name, value) in req.get_headers() { + builder = builder.header(name.as_str(), value.as_bytes()); + } + + builder + .body(body) + .expect("should build http request from fastly request") +} + +/// Convert an owned `fastly::Request` into an `http::Request`. +/// +/// # PR 15 removal target +/// +/// # Panics +/// +/// Panics if the Fastly request URL cannot be parsed as an `http::Uri`. +pub fn from_fastly_request(mut req: fastly::Request) -> http::Request { + let body = EdgeBody::from(req.take_body_bytes()); + build_http_request(&req, body) +} + +/// Convert a borrowed `fastly::Request` into an `http::Request` for reading. +/// +/// Headers are copied; the body is empty. +/// +/// # PR 15 removal target +/// +/// # Panics +/// +/// Panics if the Fastly request URL cannot be parsed as an `http::Uri`. +pub fn from_fastly_request_ref(req: &fastly::Request) -> http::Request { + build_http_request(req, EdgeBody::empty()) +} + +/// Convert an `http::Request` into a `fastly::Request`. +/// +/// # PR 15 removal target +pub fn to_fastly_request(req: http::Request) -> fastly::Request { + let (parts, body) = req.into_parts(); + let mut fastly_req = fastly::Request::new(parts.method, parts.uri.to_string()); + for (name, value) in &parts.headers { + fastly_req.append_header(name.as_str(), value.as_bytes()); + } + + match body { + EdgeBody::Once(bytes) => { + if !bytes.is_empty() { + fastly_req.set_body(bytes.to_vec()); + } + } + EdgeBody::Stream(_) => { + log::warn!("streaming body in compat::to_fastly_request; body will be empty"); + } + } + + fastly_req +} + +/// Convert a `fastly::Response` into an `http::Response`. +/// +/// # PR 15 removal target +/// +/// # Panics +/// +/// Panics if the copied Fastly response parts cannot form a valid +/// `http::Response`. +pub fn from_fastly_response(mut resp: fastly::Response) -> http::Response { + let status = resp.get_status(); + let mut builder = http::Response::builder().status(status); + for (name, value) in resp.get_headers() { + builder = builder.header(name.as_str(), value.as_bytes()); + } + + builder + .body(EdgeBody::from(resp.take_body_bytes())) + .expect("should build http response from fastly response") +} + +/// Convert an `http::Response` into a `fastly::Response`. +/// +/// # PR 15 removal target +pub fn to_fastly_response(resp: http::Response) -> fastly::Response { + let (parts, body) = resp.into_parts(); + let mut fastly_resp = fastly::Response::from_status(parts.status.as_u16()); + for (name, value) in &parts.headers { + fastly_resp.append_header(name.as_str(), value.as_bytes()); + } + + match body { + EdgeBody::Once(bytes) => { + if !bytes.is_empty() { + fastly_resp.set_body(bytes.to_vec()); + } + } + EdgeBody::Stream(_) => { + log::warn!("streaming body in compat::to_fastly_response; body will be empty"); + } + } + + fastly_resp +} + +/// Sanitize forwarded headers on a `fastly::Request`. +/// +/// # PR 15 removal target +pub fn sanitize_fastly_forwarded_headers(req: &mut fastly::Request) { + for &name in SPOOFABLE_FORWARDED_HEADERS { + if req.get_header(name).is_some() { + log::debug!("Stripped spoofable header: {name}"); + req.remove_header(name); + } + } +} + +/// Copy `X-*` custom headers between two `fastly::Request` values. +/// +/// # PR 15 removal target +pub fn copy_fastly_custom_headers(from: &fastly::Request, to: &mut fastly::Request) { + for (name, value) in from.get_headers() { + let name_str = name.as_str(); + if (name_str.starts_with("x-") || name_str.starts_with("X-")) + && !INTERNAL_HEADERS.contains(&name_str) + { + to.append_header(name_str, value); + } + } +} + +/// Forward the `Cookie` header from one `fastly::Request` to another. +/// +/// # PR 15 removal target +pub fn forward_fastly_cookie_header( + from: &fastly::Request, + to: &mut fastly::Request, + strip_consent: bool, +) { + use crate::cookies::{strip_cookies, CONSENT_COOKIE_NAMES}; + + let Some(cookie_value) = from.get_header(header::COOKIE) else { + return; + }; + + if !strip_consent { + to.set_header(header::COOKIE, cookie_value); + return; + } + + match cookie_value.to_str() { + Ok(value) => { + let stripped = strip_cookies(value, CONSENT_COOKIE_NAMES); + if !stripped.is_empty() { + to.set_header(header::COOKIE, &stripped); + } + } + Err(_) => { + to.set_header(header::COOKIE, cookie_value); + } + } +} + +/// Set the synthetic ID cookie on a `fastly::Response`. +/// +/// # PR 15 removal target +pub fn set_fastly_synthetic_cookie( + settings: &crate::settings::Settings, + response: &mut fastly::Response, + synthetic_id: &str, +) { + if !crate::cookies::synthetic_id_cookie_value_is_safe(synthetic_id) { + log::warn!( + "Rejecting synthetic_id for Set-Cookie: value of {} bytes contains characters illegal in a cookie value", + synthetic_id.len() + ); + return; + } + + response.append_header( + header::SET_COOKIE, + crate::cookies::create_synthetic_cookie(settings, synthetic_id), + ); +} + +/// Expire the synthetic ID cookie on a `fastly::Response`. +/// +/// # PR 15 removal target +pub fn expire_fastly_synthetic_cookie( + settings: &crate::settings::Settings, + response: &mut fastly::Response, +) { + response.append_header( + header::SET_COOKIE, + crate::cookies::create_synthetic_id_expiry_cookie(settings), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_once_body_eq(body: EdgeBody, expected: &[u8]) { + match body { + EdgeBody::Once(bytes) => assert_eq!(bytes.as_ref(), expected, "should copy body bytes"), + EdgeBody::Stream(_) => panic!("expected non-streaming body"), + } + } + + #[test] + fn from_fastly_request_copies_body() { + let mut fastly_req = + fastly::Request::new(fastly::http::Method::POST, "https://example.com/path"); + fastly_req.set_header("content-type", "application/json"); + fastly_req.set_body(r#"{"ok":true}"#); + + let http_req = from_fastly_request(fastly_req); + let (parts, body) = http_req.into_parts(); + + assert_eq!(parts.method, http::Method::POST, "should copy method"); + assert_eq!(parts.uri.path(), "/path", "should copy uri path"); + assert_eq!( + parts + .headers + .get("content-type") + .and_then(|v| v.to_str().ok()), + Some("application/json"), + "should copy headers" + ); + assert_once_body_eq(body, br#"{"ok":true}"#); + } + + #[test] + fn from_fastly_request_ref_copies_headers() { + let mut fastly_req = + fastly::Request::new(fastly::http::Method::GET, "https://example.com/path"); + fastly_req.set_header("x-custom", "value"); + + let http_req = from_fastly_request_ref(&fastly_req); + + assert_eq!(http_req.uri().path(), "/path", "should copy path"); + assert_eq!( + http_req + .headers() + .get("x-custom") + .and_then(|v| v.to_str().ok()), + Some("value"), + "should copy custom header" + ); + } + + #[test] + fn from_fastly_request_ref_preserves_duplicate_headers() { + let mut fastly_req = + fastly::Request::new(fastly::http::Method::GET, "https://example.com/path"); + fastly_req.append_header("x-custom", "first"); + fastly_req.append_header("x-custom", "second"); + + let http_req = from_fastly_request_ref(&fastly_req); + let values: Vec<_> = http_req + .headers() + .get_all("x-custom") + .iter() + .map(|value| value.to_str().expect("should be valid utf8")) + .collect(); + + assert_eq!( + values, + vec!["first", "second"], + "should preserve duplicates" + ); + } + + #[test] + fn from_fastly_request_ref_body_is_empty() { + let fastly_req = fastly::Request::new(fastly::http::Method::POST, "https://example.com/"); + + let http_req = from_fastly_request_ref(&fastly_req); + + assert_eq!(http_req.method(), http::Method::POST, "should copy method"); + assert_once_body_eq(http_req.into_body(), b""); + } + + #[test] + fn to_fastly_request_copies_headers_and_body() { + let http_req = http::Request::builder() + .method(http::Method::POST) + .uri("https://example.com/submit") + .header("x-custom", "value") + .body(EdgeBody::from(b"payload".as_ref())) + .expect("should build request"); + + let mut fastly_req = to_fastly_request(http_req); + + assert_eq!( + fastly_req.get_method(), + &fastly::http::Method::POST, + "should copy method" + ); + assert_eq!( + fastly_req + .get_header("x-custom") + .and_then(|v| v.to_str().ok()), + Some("value"), + "should copy headers" + ); + assert_eq!( + fastly_req.take_body_bytes().as_slice(), + b"payload", + "should copy body bytes" + ); + } + + #[test] + fn to_fastly_request_preserves_duplicate_headers() { + let http_req = http::Request::builder() + .method(http::Method::GET) + .uri("https://example.com/") + .header("x-custom", "first") + .header("x-custom", "second") + .body(EdgeBody::empty()) + .expect("should build request"); + + let fastly_req = to_fastly_request(http_req); + + let values: Vec<_> = fastly_req + .get_headers() + .filter(|(name, _)| name.as_str() == "x-custom") + .map(|(_, value)| value.to_str().expect("should be valid utf8")) + .collect(); + assert_eq!( + values, + vec!["first", "second"], + "should preserve duplicate headers" + ); + } + + #[test] + fn from_fastly_response_copies_status_headers_and_body() { + let mut fastly_resp = fastly::Response::from_status(202); + fastly_resp.set_header("content-type", "application/json"); + fastly_resp.set_body(r#"{"ok":true}"#); + + let http_resp = from_fastly_response(fastly_resp); + let (parts, body) = http_resp.into_parts(); + + assert_eq!(parts.status.as_u16(), 202, "should copy status"); + assert_eq!( + parts + .headers + .get("content-type") + .and_then(|v| v.to_str().ok()), + Some("application/json"), + "should copy headers" + ); + assert_once_body_eq(body, br#"{"ok":true}"#); + } + + #[test] + fn to_fastly_response_copies_status_and_headers() { + let http_resp = http::Response::builder() + .status(201) + .header("content-type", "application/json") + .body(EdgeBody::from(b"{}".as_ref())) + .expect("should build response"); + + let fastly_resp = to_fastly_response(http_resp); + + assert_eq!(fastly_resp.get_status().as_u16(), 201, "should copy status"); + assert!( + fastly_resp.get_header("content-type").is_some(), + "should copy content-type header" + ); + } + + #[test] + fn sanitize_fastly_forwarded_headers_strips_spoofable() { + let mut req = fastly::Request::new(fastly::http::Method::GET, "https://example.com"); + req.set_header("forwarded", "host=evil.com"); + req.set_header("x-forwarded-host", "evil.com"); + req.set_header("x-forwarded-proto", "https"); + req.set_header("fastly-ssl", "1"); + req.set_header("host", "legit.example.com"); + + sanitize_fastly_forwarded_headers(&mut req); + + assert!( + req.get_header("forwarded").is_none(), + "should strip Forwarded" + ); + assert!( + req.get_header("x-forwarded-host").is_none(), + "should strip X-Forwarded-Host" + ); + assert!( + req.get_header("x-forwarded-proto").is_none(), + "should strip X-Forwarded-Proto" + ); + assert!( + req.get_header("fastly-ssl").is_none(), + "should strip Fastly-SSL" + ); + assert_eq!( + req.get_header("host").and_then(|v| v.to_str().ok()), + Some("legit.example.com"), + "should preserve Host" + ); + } + + #[test] + fn forward_fastly_cookie_header_strips_consent() { + let mut from_req = fastly::Request::new(fastly::http::Method::GET, "https://example.com"); + from_req.set_header(header::COOKIE, "euconsent-v2=BOE; session=abc"); + let mut to_req = fastly::Request::new(fastly::http::Method::GET, "https://partner.com"); + + forward_fastly_cookie_header(&from_req, &mut to_req, true); + + let forwarded = to_req + .get_header(header::COOKIE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!( + !forwarded.contains("euconsent-v2"), + "should strip consent cookie" + ); + assert!( + forwarded.contains("session=abc"), + "should keep non-consent cookie" + ); + } + + #[test] + fn copy_fastly_custom_headers_filters_internal() { + let mut from_req = fastly::Request::new(fastly::http::Method::GET, "https://example.com"); + from_req.set_header("x-custom-data", "present"); + from_req.set_header("x-synthetic-id", "should-not-copy"); + let mut to_req = fastly::Request::new(fastly::http::Method::GET, "https://partner.com"); + + copy_fastly_custom_headers(&from_req, &mut to_req); + + assert_eq!( + to_req + .get_header("x-custom-data") + .and_then(|v| v.to_str().ok()), + Some("present"), + "should copy arbitrary x-header" + ); + assert!( + to_req.get_header("x-synthetic-id").is_none(), + "should not copy internal header" + ); + } + + #[test] + fn copy_fastly_custom_headers_preserves_duplicate_values() { + let mut from_req = fastly::Request::new(fastly::http::Method::GET, "https://example.com"); + from_req.append_header("x-custom-data", "first"); + from_req.append_header("x-custom-data", "second"); + let mut to_req = fastly::Request::new(fastly::http::Method::GET, "https://partner.com"); + + copy_fastly_custom_headers(&from_req, &mut to_req); + + let values: Vec<_> = to_req + .get_headers() + .filter(|(name, _)| name.as_str() == "x-custom-data") + .map(|(_, value)| value.to_str().expect("should be valid utf8")) + .collect(); + assert_eq!( + values, + vec!["first", "second"], + "should preserve duplicates" + ); + } + + #[test] + fn set_fastly_synthetic_cookie_sets_cookie_header() { + let settings = crate::test_support::tests::create_test_settings(); + let mut response = fastly::Response::new(); + + set_fastly_synthetic_cookie(&settings, &mut response, "abc123.XyZ789"); + + let cookie = response + .get_header(header::SET_COOKIE) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + assert_eq!( + cookie, + Some(format!( + "synthetic_id=abc123.XyZ789; Domain={}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=31536000", + settings.publisher.cookie_domain + )), + "should set expected synthetic cookie" + ); + } + + #[test] + fn expire_fastly_synthetic_cookie_sets_expiry_cookie() { + let settings = crate::test_support::tests::create_test_settings(); + let mut response = fastly::Response::new(); + + expire_fastly_synthetic_cookie(&settings, &mut response); + + let cookie = response + .get_header(header::SET_COOKIE) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + assert_eq!( + cookie, + Some(format!( + "synthetic_id=; Domain={}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0", + settings.publisher.cookie_domain + )), + "should set expected expiry cookie" + ); + } +} diff --git a/crates/trusted-server-core/src/consent/extraction.rs b/crates/trusted-server-core/src/consent/extraction.rs index d5b420bf..98633baa 100644 --- a/crates/trusted-server-core/src/consent/extraction.rs +++ b/crates/trusted-server-core/src/consent/extraction.rs @@ -5,7 +5,8 @@ //! pipeline described in the [Consent Forwarding Architecture Design]. use cookie::CookieJar; -use fastly::Request; +use edgezero_core::body::Body as EdgeBody; +use http::Request; use crate::constants::{ COOKIE_EUCONSENT_V2, COOKIE_GPP, COOKIE_GPP_SID, COOKIE_US_PRIVACY, HEADER_SEC_GPC, @@ -24,7 +25,10 @@ use super::types::RawConsentSignals; /// Also reads the `Sec-GPC` header for Global Privacy Control. /// /// No decoding or validation is performed — values are captured as-is. -pub fn extract_consent_signals(jar: Option<&CookieJar>, req: &Request) -> RawConsentSignals { +pub fn extract_consent_signals( + jar: Option<&CookieJar>, + req: &Request, +) -> RawConsentSignals { let raw_tc_string = jar .and_then(|j| j.get(COOKIE_EUCONSENT_V2)) .map(|c| c.value().to_owned()); @@ -42,7 +46,8 @@ pub fn extract_consent_signals(jar: Option<&CookieJar>, req: &Request) -> RawCon .map(|c| c.value().to_owned()); let gpc = req - .get_header(HEADER_SEC_GPC) + .headers() + .get(HEADER_SEC_GPC) .and_then(|v| v.to_str().ok()) .map(|v| v.trim() == "1") .unwrap_or(false); @@ -61,9 +66,19 @@ mod tests { use super::*; use crate::cookies::parse_cookies_to_jar; + fn build_request(gpc: Option<&str>) -> Request { + let mut builder = Request::builder().method("GET").uri("https://example.com"); + if let Some(value) = gpc { + builder = builder.header("sec-gpc", value); + } + builder + .body(EdgeBody::empty()) + .expect("should build request") + } + #[test] fn no_cookies_no_headers() { - let req = Request::get("https://example.com"); + let req = build_request(None); let signals = extract_consent_signals(None, &req); assert!(signals.is_empty(), "should produce empty signals"); } @@ -71,7 +86,7 @@ mod tests { #[test] fn extracts_euconsent_v2() { let jar = parse_cookies_to_jar("euconsent-v2=CPXxGfAPXxGfAAHABBENBCCsAP_AAH_AAAAAHftf"); - let req = Request::get("https://example.com"); + let req = build_request(None); let signals = extract_consent_signals(Some(&jar), &req); assert_eq!( @@ -84,7 +99,7 @@ mod tests { #[test] fn extracts_gpp_cookies() { let jar = parse_cookies_to_jar("__gpp=DBACNYA~CPXxGfA; __gpp_sid=2,6"); - let req = Request::get("https://example.com"); + let req = build_request(None); let signals = extract_consent_signals(Some(&jar), &req); assert_eq!( @@ -102,7 +117,7 @@ mod tests { #[test] fn extracts_us_privacy() { let jar = parse_cookies_to_jar("us_privacy=1YNN"); - let req = Request::get("https://example.com"); + let req = build_request(None); let signals = extract_consent_signals(Some(&jar), &req); assert_eq!( @@ -114,7 +129,7 @@ mod tests { #[test] fn extracts_sec_gpc_header() { - let req = Request::get("https://example.com").with_header("sec-gpc", "1"); + let req = build_request(Some("1")); let signals = extract_consent_signals(None, &req); assert!(signals.gpc, "should detect Sec-GPC: 1 header"); @@ -122,7 +137,7 @@ mod tests { #[test] fn sec_gpc_absent_when_not_set() { - let req = Request::get("https://example.com"); + let req = build_request(None); let signals = extract_consent_signals(None, &req); assert!(!signals.gpc, "should default gpc to false"); @@ -130,7 +145,7 @@ mod tests { #[test] fn sec_gpc_absent_when_not_one() { - let req = Request::get("https://example.com").with_header("sec-gpc", "0"); + let req = build_request(Some("0")); let signals = extract_consent_signals(None, &req); assert!(!signals.gpc, "should not treat Sec-GPC: 0 as opt-out"); @@ -140,7 +155,7 @@ mod tests { fn extracts_all_signals() { let jar = parse_cookies_to_jar("euconsent-v2=CPXxGf; __gpp=DBAC; __gpp_sid=2,6; us_privacy=1YNN"); - let req = Request::get("https://example.com").with_header("sec-gpc", "1"); + let req = build_request(Some("1")); let signals = extract_consent_signals(Some(&jar), &req); assert!(signals.raw_tc_string.is_some(), "should have tc_string"); @@ -153,7 +168,7 @@ mod tests { #[test] fn empty_jar_produces_no_cookie_signals() { let jar = parse_cookies_to_jar(""); - let req = Request::get("https://example.com"); + let req = build_request(None); let signals = extract_consent_signals(Some(&jar), &req); assert!( @@ -169,7 +184,7 @@ mod tests { #[test] fn unrelated_cookies_ignored() { let jar = parse_cookies_to_jar("session_id=abc123; theme=dark"); - let req = Request::get("https://example.com"); + let req = build_request(None); let signals = extract_consent_signals(Some(&jar), &req); assert!( diff --git a/crates/trusted-server-core/src/consent/mod.rs b/crates/trusted-server-core/src/consent/mod.rs index e4e3bda4..f0c6807d 100644 --- a/crates/trusted-server-core/src/consent/mod.rs +++ b/crates/trusted-server-core/src/consent/mod.rs @@ -42,7 +42,8 @@ pub use types::{ use std::time::{SystemTime, UNIX_EPOCH}; use cookie::CookieJar; -use fastly::Request; +use edgezero_core::body::Body as EdgeBody; +use http::Request; use crate::consent_config::{ConflictMode, ConsentConfig, ConsentMode}; use crate::geo::GeoInfo; @@ -61,7 +62,7 @@ pub struct ConsentPipelineInput<'a> { /// Parsed cookie jar from the incoming request. pub jar: Option<&'a CookieJar>, /// The incoming HTTP request (for header access). - pub req: &'a Request, + pub req: &'a Request, /// Publisher consent configuration. pub config: &'a ConsentConfig, /// Geolocation data from the request (for jurisdiction detection). @@ -612,7 +613,8 @@ fn log_consent_context(ctx: &ConsentContext) { #[cfg(test)] mod tests { - use fastly::Request; + use edgezero_core::body::Body as EdgeBody; + use http::Request; use super::{ allows_ssc_creation, apply_expiration_check, apply_tcf_conflict_resolution, @@ -626,6 +628,14 @@ mod tests { use crate::consent_config::{ConflictMode, ConsentConfig, ConsentMode}; use crate::cookies::parse_cookies_to_jar; + fn build_request() -> Request { + Request::builder() + .method("GET") + .uri("https://example.com") + .body(EdgeBody::empty()) + .expect("should build consent test request") + } + /// Builder for [`TcfConsent`] test fixtures with sensible defaults. /// /// All purposes default to `false`, timestamps to `0`, and vendor lists @@ -739,7 +749,7 @@ mod tests { #[test] fn proxy_mode_marks_gdpr_when_raw_tc_exists() { let jar = parse_cookies_to_jar("euconsent-v2=CPXxGfAPXxGfA"); - let req = Request::get("https://example.com"); + let req = build_request(); let config = ConsentConfig { mode: ConsentMode::Proxy, ..ConsentConfig::default() @@ -768,7 +778,7 @@ mod tests { #[test] fn proxy_mode_marks_gdpr_when_gpp_sid_contains_tcf_section() { let jar = parse_cookies_to_jar("__gpp_sid=2,6"); - let req = Request::get("https://example.com"); + let req = build_request(); let config = ConsentConfig { mode: ConsentMode::Proxy, ..ConsentConfig::default() diff --git a/crates/trusted-server-core/src/cookies.rs b/crates/trusted-server-core/src/cookies.rs index a5daae24..cc517265 100644 --- a/crates/trusted-server-core/src/cookies.rs +++ b/crates/trusted-server-core/src/cookies.rs @@ -6,9 +6,11 @@ use std::borrow::Cow; use cookie::{Cookie, CookieJar}; +use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; -use fastly::http::header; -use fastly::Request; +use http::header; +use http::Request; +use http::Response; use crate::constants::{ COOKIE_EUCONSENT_V2, COOKIE_GPP, COOKIE_GPP_SID, COOKIE_SYNTHETIC_ID, COOKIE_US_PRIVACY, @@ -97,9 +99,9 @@ pub fn parse_cookies_to_jar(s: &str) -> CookieJar { /// /// - [`TrustedServerError::InvalidHeaderValue`] if the Cookie header contains invalid UTF-8 pub fn handle_request_cookies( - req: &Request, + req: &Request, ) -> Result, Report> { - match req.get_header(header::COOKIE) { + match req.headers().get(header::COOKIE) { Some(header_value) => { let header_value_str = header_value @@ -146,13 +148,23 @@ pub fn strip_cookies(cookie_header: &str, cookie_names: &[&str]) -> String { /// When `strip_consent` is `true`, cookies listed in [`CONSENT_COOKIE_NAMES`] /// are removed before forwarding. If stripping leaves no cookies, the header /// is omitted entirely. Non-UTF-8 cookie headers are forwarded unchanged. -pub fn forward_cookie_header(from: &Request, to: &mut Request, strip_consent: bool) { - let Some(cookie_value) = from.get_header(header::COOKIE) else { +/// +/// # Panics +/// +/// Panics if the stripped cookie string cannot be converted into a valid HTTP +/// `Cookie` header value. +pub fn forward_cookie_header( + from: &Request, + to: &mut Request, + strip_consent: bool, +) { + let Some(cookie_value) = from.headers().get(header::COOKIE) else { return; }; if !strip_consent { - to.set_header(header::COOKIE, cookie_value); + to.headers_mut() + .insert(header::COOKIE, cookie_value.clone()); return; } @@ -160,12 +172,17 @@ pub fn forward_cookie_header(from: &Request, to: &mut Request, strip_consent: bo Ok(s) => { let stripped = strip_cookies(s, CONSENT_COOKIE_NAMES); if !stripped.is_empty() { - to.set_header(header::COOKIE, &stripped); + to.headers_mut().insert( + header::COOKIE, + http::HeaderValue::from_str(&stripped) + .expect("should build stripped Cookie header value"), + ); } } Err(_) => { // Non-UTF-8 Cookie header — forward as-is - to.set_header(header::COOKIE, cookie_value); + to.headers_mut() + .insert(header::COOKIE, cookie_value.clone()); } } } @@ -181,7 +198,7 @@ pub fn forward_cookie_header(from: &Request, to: &mut Request, strip_consent: bo /// Non-ASCII characters (multi-byte UTF-8) are always rejected because their /// byte values exceed `0x7E`. #[must_use] -fn is_safe_cookie_value(value: &str) -> bool { +pub(crate) fn synthetic_id_cookie_value_is_safe(value: &str) -> bool { // RFC 6265 §4.1.1 cookie-octet: // 0x21 — '!' // 0x23–0x2B — '#' through '+' (excludes 0x22 DQUOTE) @@ -246,21 +263,27 @@ pub fn create_synthetic_cookie(settings: &Settings, synthetic_id: &str) -> Strin /// from injecting spurious cookie attributes via a controlled ID value. /// /// `cookie_domain` comes from operator configuration and is considered trusted. +/// +/// # Panics +/// +/// Panics if the generated `Set-Cookie` header cannot be represented as a +/// valid HTTP header value. pub fn set_synthetic_cookie( settings: &Settings, - response: &mut fastly::Response, + response: &mut Response, synthetic_id: &str, ) { - if !is_safe_cookie_value(synthetic_id) { + if !synthetic_id_cookie_value_is_safe(synthetic_id) { log::warn!( "Rejecting synthetic_id for Set-Cookie: value of {} bytes contains characters illegal in a cookie value", synthetic_id.len() ); return; } - response.append_header( + response.headers_mut().append( header::SET_COOKIE, - create_synthetic_cookie(settings, synthetic_id), + http::HeaderValue::from_str(&create_synthetic_cookie(settings, synthetic_id)) + .expect("should build Set-Cookie header value"), ); } @@ -268,13 +291,26 @@ pub fn set_synthetic_cookie( /// /// Used when a user revokes consent — the browser will delete the cookie /// on receipt of this header. -pub fn expire_synthetic_cookie(settings: &Settings, response: &mut fastly::Response) { - let cookie = format!( +pub(crate) fn create_synthetic_id_expiry_cookie(settings: &Settings) -> String { + format!( "{}=; {}", COOKIE_SYNTHETIC_ID, synthetic_cookie_attributes(settings, 0), + ) +} + +/// Appends an expiry `Set-Cookie` header that clears the synthetic ID cookie. +/// +/// # Panics +/// +/// Panics if the generated expiry `Set-Cookie` header cannot be represented as +/// a valid HTTP header value. +pub fn expire_synthetic_cookie(settings: &Settings, response: &mut Response) { + response.headers_mut().append( + header::SET_COOKIE, + http::HeaderValue::from_str(&create_synthetic_id_expiry_cookie(settings)) + .expect("should build expiry Set-Cookie header value"), ); - response.append_header(header::SET_COOKIE, cookie); } #[cfg(test)] @@ -283,6 +319,23 @@ mod tests { use super::*; + fn build_request(cookie_header: Option<&str>) -> Request { + let mut builder = Request::builder().method("GET").uri("http://example.com"); + if let Some(cookie_header) = cookie_header { + builder = builder.header(header::COOKIE, cookie_header); + } + builder + .body(EdgeBody::empty()) + .expect("should build test request") + } + + fn build_response() -> Response { + Response::builder() + .status(200) + .body(EdgeBody::empty()) + .expect("should build test response") + } + #[test] fn test_parse_cookies_to_jar() { let header_value = "c1=v1; c2=v2"; @@ -320,7 +373,7 @@ mod tests { #[test] fn test_handle_request_cookies() { - let req = Request::get("http://example.com").with_header(header::COOKIE, "c1=v1;c2=v2"); + let req = build_request(Some("c1=v1;c2=v2")); let jar = handle_request_cookies(&req) .expect("should parse cookies") .expect("should have cookie jar"); @@ -332,7 +385,7 @@ mod tests { #[test] fn test_handle_request_cookies_with_empty_cookie() { - let req = Request::get("http://example.com").with_header(header::COOKIE, ""); + let req = build_request(Some("")); let jar = handle_request_cookies(&req) .expect("should parse cookies") .expect("should have cookie jar"); @@ -342,7 +395,7 @@ mod tests { #[test] fn test_handle_request_cookies_no_cookie_header() { - let req: Request = Request::get("https://example.com"); + let req = build_request(None); let jar = handle_request_cookies(&req).expect("should handle missing cookie header"); assert!(jar.is_none()); @@ -350,7 +403,7 @@ mod tests { #[test] fn test_handle_request_cookies_invalid_cookie_header() { - let req = Request::get("http://example.com").with_header(header::COOKIE, "invalid"); + let req = build_request(Some("invalid")); let jar = handle_request_cookies(&req) .expect("should parse cookies") .expect("should have cookie jar"); @@ -361,11 +414,12 @@ mod tests { #[test] fn test_set_synthetic_cookie() { let settings = create_test_settings(); - let mut response = fastly::Response::new(); + let mut response = build_response(); set_synthetic_cookie(&settings, &mut response, "abc123.XyZ789"); let cookie_str = response - .get_header(header::SET_COOKIE) + .headers() + .get(header::SET_COOKIE) .expect("Set-Cookie header should be present") .to_str() .expect("header should be valid UTF-8"); @@ -414,11 +468,11 @@ mod tests { #[test] fn test_set_synthetic_cookie_rejects_semicolon() { let settings = create_test_settings(); - let mut response = fastly::Response::new(); + let mut response = build_response(); set_synthetic_cookie(&settings, &mut response, "evil; Domain=.attacker.com"); assert!( - response.get_header(header::SET_COOKIE).is_none(), + response.headers().get(header::SET_COOKIE).is_none(), "Set-Cookie should not be set when value contains a semicolon" ); } @@ -426,11 +480,11 @@ mod tests { #[test] fn test_set_synthetic_cookie_rejects_crlf() { let settings = create_test_settings(); - let mut response = fastly::Response::new(); + let mut response = build_response(); set_synthetic_cookie(&settings, &mut response, "evil\r\nX-Injected: header"); assert!( - response.get_header(header::SET_COOKIE).is_none(), + response.headers().get(header::SET_COOKIE).is_none(), "Set-Cookie should not be set when value contains CRLF" ); } @@ -438,25 +492,28 @@ mod tests { #[test] fn test_set_synthetic_cookie_rejects_space() { let settings = create_test_settings(); - let mut response = fastly::Response::new(); + let mut response = build_response(); set_synthetic_cookie(&settings, &mut response, "bad value"); assert!( - response.get_header(header::SET_COOKIE).is_none(), + response.headers().get(header::SET_COOKIE).is_none(), "Set-Cookie should not be set when value contains whitespace" ); } #[test] fn test_is_safe_cookie_value_rejects_empty_string() { - assert!(!is_safe_cookie_value(""), "should reject empty string"); + assert!( + !synthetic_id_cookie_value_is_safe(""), + "should reject empty string" + ); } #[test] fn test_is_safe_cookie_value_accepts_valid_synthetic_id_characters() { // Hex digits, dot separator, alphanumeric suffix — the full synthetic ID character set assert!( - is_safe_cookie_value("abcdef0123456789.ABCDEFabcdef"), + synthetic_id_cookie_value_is_safe("abcdef0123456789.ABCDEFabcdef"), "should accept hex digits, dots, and alphanumeric characters" ); } @@ -464,27 +521,39 @@ mod tests { #[test] fn test_is_safe_cookie_value_rejects_non_ascii() { assert!( - !is_safe_cookie_value("valüe"), + !synthetic_id_cookie_value_is_safe("valüe"), "should reject non-ASCII UTF-8 characters" ); } #[test] fn test_is_safe_cookie_value_rejects_illegal_characters() { - assert!(!is_safe_cookie_value("val;ue"), "should reject semicolon"); - assert!(!is_safe_cookie_value("val,ue"), "should reject comma"); assert!( - !is_safe_cookie_value("val\"ue"), + !synthetic_id_cookie_value_is_safe("val;ue"), + "should reject semicolon" + ); + assert!( + !synthetic_id_cookie_value_is_safe("val,ue"), + "should reject comma" + ); + assert!( + !synthetic_id_cookie_value_is_safe("val\"ue"), "should reject double-quote" ); - assert!(!is_safe_cookie_value("val\\ue"), "should reject backslash"); - assert!(!is_safe_cookie_value("val ue"), "should reject space"); assert!( - !is_safe_cookie_value("val\x00ue"), + !synthetic_id_cookie_value_is_safe("val\\ue"), + "should reject backslash" + ); + assert!( + !synthetic_id_cookie_value_is_safe("val ue"), + "should reject space" + ); + assert!( + !synthetic_id_cookie_value_is_safe("val\x00ue"), "should reject null byte" ); assert!( - !is_safe_cookie_value("val\x7fue"), + !synthetic_id_cookie_value_is_safe("val\x7fue"), "should reject DEL character" ); } @@ -492,12 +561,13 @@ mod tests { #[test] fn test_expire_synthetic_cookie_matches_security_attributes() { let settings = create_test_settings(); - let mut response = fastly::Response::new(); + let mut response = build_response(); expire_synthetic_cookie(&settings, &mut response); let cookie_header = response - .get_header(header::SET_COOKIE) + .headers() + .get(header::SET_COOKIE) .expect("Set-Cookie header should be present"); let cookie_str = cookie_header .to_str() diff --git a/crates/trusted-server-core/src/http_util.rs b/crates/trusted-server-core/src/http_util.rs index 8b27faac..55f239fe 100644 --- a/crates/trusted-server-core/src/http_util.rs +++ b/crates/trusted-server-core/src/http_util.rs @@ -1,7 +1,7 @@ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use chacha20poly1305::{aead::Aead, aead::KeyInit, XChaCha20Poly1305, XNonce}; -use fastly::http::{header, StatusCode}; -use fastly::{Request, Response}; +use edgezero_core::body::Body as EdgeBody; +use http::{header, Request, Response, StatusCode}; use sha2::{Digest, Sha256}; use subtle::ConstantTimeEq as _; @@ -15,15 +15,11 @@ use crate::settings::Settings; /// internal identity, geo-enrichment, and debugging data to downstream third-party /// services. Integrations that forward custom headers should use this utility /// instead of manually iterating over header names. -pub fn copy_custom_headers(from: &Request, to: &mut Request) { - for header_name in from.get_header_names() { +pub fn copy_custom_headers(from: &Request, to: &mut Request) { + for (header_name, value) in from.headers() { let name_str = header_name.as_str(); - if (name_str.starts_with("x-") || name_str.starts_with("X-")) - && !INTERNAL_HEADERS.contains(&name_str) - { - if let Some(value) = from.get_header(header_name) { - to.set_header(header_name, value); - } + if name_str.starts_with("x-") && !INTERNAL_HEADERS.contains(&name_str) { + to.headers_mut().append(header_name.clone(), value.clone()); } } } @@ -33,7 +29,7 @@ pub fn copy_custom_headers(from: &Request, to: &mut Request) { /// On Fastly Compute the service is the edge - there is no upstream proxy that /// legitimately sets these. Stripping them forces [`RequestInfo::from_request`] /// to fall back to the trustworthy `Host` header and [`ClientInfo`] TLS detection. -const SPOOFABLE_FORWARDED_HEADERS: &[&str] = &[ +pub(crate) const SPOOFABLE_FORWARDED_HEADERS: &[&str] = &[ "forwarded", "x-forwarded-host", "x-forwarded-proto", @@ -45,11 +41,11 @@ const SPOOFABLE_FORWARDED_HEADERS: &[&str] = &[ /// Call this at the edge entry point (before routing) to prevent /// `X-Forwarded-Host: evil.com` from hijacking all URL rewriting. /// See . -pub fn sanitize_forwarded_headers(req: &mut Request) { +pub fn sanitize_forwarded_headers(req: &mut Request) { for header in SPOOFABLE_FORWARDED_HEADERS { - if req.get_header(*header).is_some() { + if req.headers().contains_key(*header) { log::debug!("Stripped spoofable header: {}", header); - req.remove_header(*header); + req.headers_mut().remove(*header); } } } @@ -88,7 +84,7 @@ impl RequestInfo { /// In production the forwarded headers are stripped by /// [`sanitize_forwarded_headers`] at the edge, so `Host` and /// [`ClientInfo`] TLS detection are the only sources that fire. - pub fn from_request(req: &Request, client_info: &ClientInfo) -> Self { + pub fn from_request(req: &Request, client_info: &ClientInfo) -> Self { let host = extract_request_host(req); let scheme = detect_request_scheme( req, @@ -100,16 +96,22 @@ impl RequestInfo { } } -fn extract_request_host(req: &Request) -> String { - req.get_header("forwarded") +fn extract_request_host(req: &Request) -> String { + req.headers() + .get("forwarded") .and_then(|h| h.to_str().ok()) .and_then(|value| parse_forwarded_param(value, "host")) .or_else(|| { - req.get_header("x-forwarded-host") + req.headers() + .get("x-forwarded-host") .and_then(|h| h.to_str().ok()) .and_then(parse_list_header_value) }) - .or_else(|| req.get_header(header::HOST).and_then(|h| h.to_str().ok())) + .or_else(|| { + req.headers() + .get(header::HOST) + .and_then(|h| h.to_str().ok()) + }) .unwrap_or_default() .to_string() } @@ -170,7 +172,7 @@ fn normalize_scheme(value: &str) -> Option { /// 4. Fastly-SSL header (least reliable, can be spoofed) /// 5. Default to HTTP fn detect_request_scheme( - req: &Request, + req: &Request, tls_protocol: Option<&str>, tls_cipher: Option<&str>, ) -> String { @@ -187,7 +189,7 @@ fn detect_request_scheme( } // 2. Try the Forwarded header (RFC 7239) - if let Some(forwarded) = req.get_header("forwarded") { + if let Some(forwarded) = req.headers().get("forwarded") { if let Ok(forwarded_str) = forwarded.to_str() { if let Some(proto) = parse_forwarded_param(forwarded_str, "proto") { if let Some(scheme) = normalize_scheme(proto) { @@ -198,7 +200,7 @@ fn detect_request_scheme( } // 3. Try X-Forwarded-Proto header - if let Some(proto) = req.get_header("x-forwarded-proto") { + if let Some(proto) = req.headers().get("x-forwarded-proto") { if let Ok(proto_str) = proto.to_str() { if let Some(value) = parse_list_header_value(proto_str) { if let Some(scheme) = normalize_scheme(value) { @@ -209,7 +211,7 @@ fn detect_request_scheme( } // 4. Check Fastly-SSL header (can be spoofed by clients, use as last resort) - if let Some(ssl) = req.get_header("fastly-ssl") { + if let Some(ssl) = req.headers().get("fastly-ssl") { if let Ok(ssl_str) = ssl.to_str() { if ssl_str == "1" || ssl_str.to_lowercase() == "true" { return "https".to_string(); @@ -223,38 +225,53 @@ fn detect_request_scheme( /// Build a static text response with strong `ETag` and standard caching headers. /// Handles If-None-Match to return 304 when appropriate. -pub fn serve_static_with_etag(body: &str, req: &Request, content_type: &str) -> Response { +/// +/// # Panics +/// +/// Panics if the generated response headers cannot be represented in an +/// `http::Response`. +pub fn serve_static_with_etag( + body: &str, + req: &Request, + content_type: &str, +) -> Response { // Compute ETag for conditional caching let hash = Sha256::digest(body.as_bytes()); let etag = format!("\"sha256-{}\"", hex::encode(hash)); // If-None-Match handling for 304 responses if let Some(if_none_match) = req - .get_header(header::IF_NONE_MATCH) + .headers() + .get(header::IF_NONE_MATCH) .and_then(|h| h.to_str().ok()) { if if_none_match == etag { - return Response::from_status(StatusCode::NOT_MODIFIED) - .with_header(header::ETAG, &etag) - .with_header( + return Response::builder() + .status(StatusCode::NOT_MODIFIED) + .header(header::ETAG, &etag) + .header( header::CACHE_CONTROL, "public, max-age=300, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400", ) - .with_header("surrogate-control", "max-age=300") - .with_header(header::VARY, "Accept-Encoding"); + .header("surrogate-control", "max-age=300") + .header(header::VARY, "Accept-Encoding") + .body(EdgeBody::empty()) + .expect("should build 304 static response"); } } - Response::from_status(StatusCode::OK) - .with_header(header::CONTENT_TYPE, content_type) - .with_header( + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, content_type) + .header( header::CACHE_CONTROL, "public, max-age=300, s-maxage=300, stale-while-revalidate=60, stale-if-error=86400", ) - .with_header("surrogate-control", "max-age=300") - .with_header(header::ETAG, &etag) - .with_header(header::VARY, "Accept-Encoding") - .with_body(body) + .header("surrogate-control", "max-age=300") + .header(header::ETAG, &etag) + .header(header::VARY, "Accept-Encoding") + .body(EdgeBody::from(body.as_bytes())) + .expect("should build static response") } /// Encrypts a URL using XChaCha20-Poly1305 with a key derived from the publisher `proxy_secret`. @@ -386,6 +403,22 @@ pub fn compute_encrypted_sha256_token(settings: &Settings, full_url: &str) -> St mod tests { use super::*; use crate::platform::ClientInfo; + use http::{HeaderName, HeaderValue, Method}; + + fn build_request(method: Method, uri: &str) -> Request { + Request::builder() + .method(method) + .uri(uri) + .body(EdgeBody::empty()) + .expect("should build request") + } + + fn set_header(req: &mut Request, name: &str, value: &str) { + req.headers_mut().insert( + HeaderName::from_bytes(name.as_bytes()).expect("should build header name"), + HeaderValue::from_str(value).expect("should build header value"), + ); + } #[test] fn encode_decode_roundtrip() { @@ -454,8 +487,8 @@ mod tests { #[test] fn test_request_info_from_host_header() { - let mut req = Request::new(fastly::http::Method::GET, "https://test.example.com/page"); - req.set_header("host", "test.example.com"); + let mut req = build_request(Method::GET, "https://test.example.com/page"); + set_header(&mut req, "host", "test.example.com"); let info = RequestInfo::from_request( &req, @@ -478,9 +511,13 @@ mod tests { #[test] fn test_request_info_x_forwarded_host_precedence() { - let mut req = Request::new(fastly::http::Method::GET, "https://test.example.com/page"); - req.set_header("host", "internal-proxy.local"); - req.set_header("x-forwarded-host", "public.example.com, proxy.local"); + let mut req = build_request(Method::GET, "https://test.example.com/page"); + set_header(&mut req, "host", "internal-proxy.local"); + set_header( + &mut req, + "x-forwarded-host", + "public.example.com, proxy.local", + ); let info = RequestInfo::from_request( &req, @@ -498,9 +535,9 @@ mod tests { #[test] fn test_request_info_scheme_from_x_forwarded_proto() { - let mut req = Request::new(fastly::http::Method::GET, "https://test.example.com/page"); - req.set_header("host", "test.example.com"); - req.set_header("x-forwarded-proto", "https, http"); + let mut req = build_request(Method::GET, "https://test.example.com/page"); + set_header(&mut req, "host", "test.example.com"); + set_header(&mut req, "x-forwarded-proto", "https, http"); let info = RequestInfo::from_request( &req, @@ -516,9 +553,9 @@ mod tests { ); // Test HTTP - let mut req = Request::new(fastly::http::Method::GET, "http://test.example.com/page"); - req.set_header("host", "test.example.com"); - req.set_header("x-forwarded-proto", "http"); + let mut req = build_request(Method::GET, "http://test.example.com/page"); + set_header(&mut req, "host", "test.example.com"); + set_header(&mut req, "x-forwarded-proto", "http"); let info = RequestInfo::from_request( &req, @@ -537,14 +574,15 @@ mod tests { #[test] fn request_info_forwarded_header_precedence() { // Forwarded header takes precedence over X-Forwarded-Proto - let mut req = Request::new(fastly::http::Method::GET, "https://test.example.com/page"); - req.set_header( + let mut req = build_request(Method::GET, "https://test.example.com/page"); + set_header( + &mut req, "forwarded", "for=192.0.2.60;proto=\"HTTPS\";host=\"public.example.com:443\"", ); - req.set_header("host", "internal-proxy.local"); - req.set_header("x-forwarded-host", "proxy.local"); - req.set_header("x-forwarded-proto", "http"); + set_header(&mut req, "host", "internal-proxy.local"); + set_header(&mut req, "x-forwarded-host", "proxy.local"); + set_header(&mut req, "x-forwarded-proto", "http"); let info = RequestInfo::from_request( &req, @@ -566,8 +604,8 @@ mod tests { #[test] fn test_request_info_scheme_from_fastly_ssl() { - let mut req = Request::new(fastly::http::Method::GET, "https://test.example.com/page"); - req.set_header("fastly-ssl", "1"); + let mut req = build_request(Method::GET, "https://test.example.com/page"); + set_header(&mut req, "fastly-ssl", "1"); let info = RequestInfo::from_request( &req, @@ -587,13 +625,10 @@ mod tests { fn test_request_info_chained_proxy_scenario() { // Simulate: Client (HTTPS) -> Proxy A -> Trusted Server (HTTP internally) // Proxy A sets X-Forwarded-Host and X-Forwarded-Proto - let mut req = Request::new( - fastly::http::Method::GET, - "http://trusted-server.internal/page", - ); - req.set_header("host", "trusted-server.internal"); - req.set_header("x-forwarded-host", "public.example.com"); - req.set_header("x-forwarded-proto", "https"); + let mut req = build_request(Method::GET, "http://trusted-server.internal/page"); + set_header(&mut req, "host", "trusted-server.internal"); + set_header(&mut req, "x-forwarded-host", "public.example.com"); + set_header(&mut req, "x-forwarded-proto", "https"); let info = RequestInfo::from_request( &req, @@ -617,33 +652,34 @@ mod tests { #[test] fn sanitize_removes_all_spoofable_headers() { - let mut req = Request::new(fastly::http::Method::GET, "https://example.com/page"); - req.set_header("host", "legit.example.com"); - req.set_header("forwarded", "host=evil.com;proto=https"); - req.set_header("x-forwarded-host", "evil.com"); - req.set_header("x-forwarded-proto", "https"); - req.set_header("fastly-ssl", "1"); + let mut req = build_request(Method::GET, "https://example.com/page"); + set_header(&mut req, "host", "legit.example.com"); + set_header(&mut req, "forwarded", "host=evil.com;proto=https"); + set_header(&mut req, "x-forwarded-host", "evil.com"); + set_header(&mut req, "x-forwarded-proto", "https"); + set_header(&mut req, "fastly-ssl", "1"); sanitize_forwarded_headers(&mut req); assert!( - req.get_header("forwarded").is_none(), + req.headers().get("forwarded").is_none(), "should strip Forwarded header" ); assert!( - req.get_header("x-forwarded-host").is_none(), + req.headers().get("x-forwarded-host").is_none(), "should strip X-Forwarded-Host header" ); assert!( - req.get_header("x-forwarded-proto").is_none(), + req.headers().get("x-forwarded-proto").is_none(), "should strip X-Forwarded-Proto header" ); assert!( - req.get_header("fastly-ssl").is_none(), + req.headers().get("fastly-ssl").is_none(), "should strip Fastly-SSL header" ); assert_eq!( - req.get_header("host") + req.headers() + .get("host") .expect("should have Host header") .to_str() .expect("should be valid UTF-8"), @@ -654,10 +690,10 @@ mod tests { #[test] fn sanitize_then_request_info_falls_back_to_host() { - let mut req = Request::new(fastly::http::Method::GET, "https://example.com/page"); - req.set_header("host", "legit.example.com"); - req.set_header("x-forwarded-host", "evil.com"); - req.set_header("x-forwarded-proto", "http"); + let mut req = build_request(Method::GET, "https://example.com/page"); + set_header(&mut req, "host", "legit.example.com"); + set_header(&mut req, "x-forwarded-host", "evil.com"); + set_header(&mut req, "x-forwarded-proto", "http"); sanitize_forwarded_headers(&mut req); let info = RequestInfo::from_request( @@ -699,39 +735,77 @@ mod tests { #[test] fn test_copy_custom_headers_filters_internal() { - let mut req = Request::new(fastly::http::Method::GET, "https://example.com"); - req.set_header("x-custom-1", "value1"); - // HeaderName is case-insensitive and always lowercase, but set_header accepts strings - req.set_header("X-Custom-2", "value2"); - req.set_header("x-synthetic-id", "should not copy"); - req.set_header("x-geo-country", "US"); - - let mut target = Request::new(fastly::http::Method::GET, "https://target.com"); + let mut req = build_request(Method::GET, "https://example.com"); + set_header(&mut req, "x-custom-1", "value1"); + // HeaderName is case-insensitive and normalized by `http`. + set_header(&mut req, "X-Custom-2", "value2"); + set_header(&mut req, "x-synthetic-id", "should not copy"); + set_header(&mut req, "x-geo-country", "US"); + + let mut target = build_request(Method::GET, "https://target.com"); copy_custom_headers(&req, &mut target); assert_eq!( - target.get_header("x-custom-1").unwrap().to_str().unwrap(), + target + .headers() + .get("x-custom-1") + .unwrap() + .to_str() + .unwrap(), "value1", "Should copy arbitrary x-header" ); assert_eq!( - target.get_header("x-custom-2").unwrap().to_str().unwrap(), + target + .headers() + .get("x-custom-2") + .unwrap() + .to_str() + .unwrap(), "value2", "Should copy arbitrary X-header (case insensitive)" ); assert!( - target.get_header("x-synthetic-id").is_none(), + target.headers().get("x-synthetic-id").is_none(), "Should filter x-synthetic-id" ); assert!( - target.get_header("x-geo-country").is_none(), + target.headers().get("x-geo-country").is_none(), "Should filter x-geo-country" ); } + #[test] + fn copy_custom_headers_preserves_duplicate_values() { + let mut from = build_request(Method::GET, "https://example.com"); + from.headers_mut().append( + HeaderName::from_bytes(b"x-custom-data").expect("should build header name"), + HeaderValue::from_str("first").expect("should build header value"), + ); + from.headers_mut().append( + HeaderName::from_bytes(b"x-custom-data").expect("should build header name"), + HeaderValue::from_str("second").expect("should build header value"), + ); + + let mut target = build_request(Method::GET, "https://target.com"); + copy_custom_headers(&from, &mut target); + + let values: Vec<_> = target + .headers() + .get_all("x-custom-data") + .iter() + .map(|v| v.to_str().expect("should be valid utf8")) + .collect(); + assert_eq!( + values, + vec!["first", "second"], + "should preserve duplicate x-header values" + ); + } + #[test] fn request_info_https_from_client_info_tls_protocol() { - let req = Request::new(fastly::http::Method::GET, "https://test.example.com/page"); + let req = build_request(Method::GET, "https://test.example.com/page"); let client_info = ClientInfo { client_ip: None, tls_protocol: Some("TLSv1.3".to_string()), @@ -748,7 +822,7 @@ mod tests { #[test] fn request_info_https_from_client_info_tls_cipher() { - let req = Request::new(fastly::http::Method::GET, "https://test.example.com/page"); + let req = build_request(Method::GET, "https://test.example.com/page"); let client_info = ClientInfo { client_ip: None, tls_protocol: None, diff --git a/crates/trusted-server-core/src/integrations/lockr.rs b/crates/trusted-server-core/src/integrations/lockr.rs index ad009130..8e63345f 100644 --- a/crates/trusted-server-core/src/integrations/lockr.rs +++ b/crates/trusted-server-core/src/integrations/lockr.rs @@ -17,9 +17,8 @@ use serde::Deserialize; use validator::Validate; use crate::backend::BackendConfig; -use crate::cookies::forward_cookie_header; +use crate::compat; use crate::error::TrustedServerError; -use crate::http_util::copy_custom_headers; use crate::integrations::{ AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter, IntegrationEndpoint, IntegrationProxy, IntegrationRegistration, @@ -236,7 +235,7 @@ impl LockrIntegration { } // Always strip consent cookies — consent travels through the OpenRTB body - forward_cookie_header(from, to, true); + compat::forward_fastly_cookie_header(from, to, true); // Use origin override if configured, otherwise forward original let origin = self @@ -248,7 +247,7 @@ impl LockrIntegration { to.set_header(header::ORIGIN, origin); } - copy_custom_headers(from, to); + compat::copy_fastly_custom_headers(from, to); } } diff --git a/crates/trusted-server-core/src/integrations/permutive.rs b/crates/trusted-server-core/src/integrations/permutive.rs index 0946a54a..41d7e3bf 100644 --- a/crates/trusted-server-core/src/integrations/permutive.rs +++ b/crates/trusted-server-core/src/integrations/permutive.rs @@ -13,8 +13,8 @@ use serde::Deserialize; use validator::Validate; use crate::backend::BackendConfig; +use crate::compat; use crate::error::TrustedServerError; -use crate::http_util::copy_custom_headers; use crate::integrations::{ AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter, IntegrationEndpoint, IntegrationProxy, IntegrationRegistration, @@ -495,7 +495,7 @@ impl PermutiveIntegration { } // Copy any X-* custom headers, skipping TS-internal headers - copy_custom_headers(from, to); + compat::copy_fastly_custom_headers(from, to); } } diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index 87278d55..5d39e775 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -15,8 +15,8 @@ use crate::auction::types::{ AuctionContext, AuctionRequest, AuctionResponse, Bid as AuctionBid, MediaType, }; use crate::backend::BackendConfig; +use crate::compat; use crate::consent_config::ConsentForwardingMode; -use crate::cookies::forward_cookie_header; use crate::error::TrustedServerError; use crate::http_util::RequestInfo; use crate::integrations::{ @@ -443,7 +443,7 @@ fn copy_request_headers( } } - forward_cookie_header(from, to, consent_forwarding.strips_consent_cookies()); + compat::forward_fastly_cookie_header(from, to, consent_forwarding.strips_consent_cookies()); } /// Appends query parameters to a URL, handling both URLs with and without existing query strings. @@ -710,7 +710,8 @@ impl PrebidAuctionProvider { let regs = Self::build_regs(consent_ctx); // Build ext object - let request_info = RequestInfo::from_request(context.request, context.client_info); + let http_req = compat::from_fastly_request_ref(context.request); + let request_info = RequestInfo::from_request(&http_req, context.client_info); let (version, signature, kid, ts) = signer .map(|(s, sig, params)| { ( @@ -1008,7 +1009,8 @@ impl AuctionProvider for PrebidAuctionProvider { &context.settings.request_signing { if request_signing_config.enabled { - let request_info = RequestInfo::from_request(context.request, context.client_info); + let http_req = compat::from_fastly_request_ref(context.request); + let request_info = RequestInfo::from_request(&http_req, context.client_info); let signer = RequestSigner::from_services(context.services)?; let params = SigningParams::new(request.id.clone(), request_info.host, request_info.scheme); diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index a9236c8b..8d6da6b6 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -8,8 +8,8 @@ use fastly::http::Method; use fastly::{Request, Response}; use matchit::Router; +use crate::compat; use crate::constants::HEADER_X_SYNTHETIC_ID; -use crate::cookies::set_synthetic_cookie; use crate::error::TrustedServerError; use crate::platform::RuntimeServices; use crate::settings::Settings; @@ -659,7 +659,8 @@ impl IntegrationRegistry { ) -> Option>> { if let Some((proxy, _)) = self.find_route(method, path) { // Generate synthetic ID before consuming request - let synthetic_id_result = get_or_generate_synthetic_id(settings, services, &req); + let http_req = compat::from_fastly_request_ref(&req); + let synthetic_id_result = get_or_generate_synthetic_id(settings, services, &http_req); // Set synthetic ID header on the request so integrations can read it. // Header injection: Fastly's HeaderValue API rejects values containing \r, \n, or \0, @@ -681,7 +682,11 @@ impl IntegrationRegistry { // Cookie is intentionally not set when synthetic_id contains RFC 6265-illegal // characters (e.g. a crafted x-synthetic-id header value). The response header // is still emitted; only cookie persistence is skipped. - set_synthetic_cookie(settings, response, synthetic_id.as_str()); + compat::set_fastly_synthetic_cookie( + settings, + response, + synthetic_id.as_str(), + ); } Err(ref err) => { log::warn!( diff --git a/crates/trusted-server-core/src/integrations/testlight.rs b/crates/trusted-server-core/src/integrations/testlight.rs index e7385464..2123458b 100644 --- a/crates/trusted-server-core/src/integrations/testlight.rs +++ b/crates/trusted-server-core/src/integrations/testlight.rs @@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use validator::Validate; +use crate::compat; use crate::error::TrustedServerError; use crate::integrations::{ AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter, @@ -151,7 +152,8 @@ impl IntegrationProxy for TestlightIntegration { .map_err(|err| Report::new(Self::error(format!("Invalid request payload: {err}"))))?; // Read synthetic ID from header (set by registry) or cookie - let synthetic_id = get_synthetic_id(&req) + let http_req = compat::from_fastly_request_ref(&req); + let synthetic_id = get_synthetic_id(&http_req) .change_context(Self::error("Failed to read synthetic ID"))? .ok_or_else(|| { Report::new(Self::error( diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index e09f7ef8..b4757ee9 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -36,6 +36,7 @@ pub mod auction; pub mod auction_config_types; pub mod auth; pub mod backend; +pub mod compat; pub mod consent; pub mod consent_config; pub mod constants; @@ -63,3 +64,6 @@ pub mod streaming_replacer; pub mod synthetic; pub mod test_support; pub mod tsjs; + +#[cfg(test)] +mod migration_guards; diff --git a/crates/trusted-server-core/src/migration_guards.rs b/crates/trusted-server-core/src/migration_guards.rs new file mode 100644 index 00000000..370ce5a0 --- /dev/null +++ b/crates/trusted-server-core/src/migration_guards.rs @@ -0,0 +1,55 @@ +// Strips lines whose first non-whitespace token is `//`. +// +// Known limitations (both produce false positives, never false negatives): +// - String literals: `"fastly::Request"` in a test assertion would trigger a +// spurious failure even though it is not a real Fastly dependency. +// - Block comments: `/* fastly::Request */` is not stripped. A banned pattern +// inside a block comment causes a spurious failure; one hidden *outside* a +// block comment is still caught by the non-comment portions of the line. +// +// False positives are safe for a guard test — they cause a noisy failure that +// forces investigation rather than letting a real regression slip through +// silently. False negatives are not possible with the current banned-pattern +// list because none of the migrated files use block comments in practice. +fn strip_line_comments(source: &str) -> String { + source + .lines() + .filter(|line| { + let trimmed = line.trim_start(); + !trimmed.starts_with("//") + }) + .collect::>() + .join("\n") +} + +#[test] +fn migrated_utility_modules_do_not_depend_on_fastly_request_response_types() { + let sources = [ + ("auth.rs", include_str!("auth.rs")), + ("cookies.rs", include_str!("cookies.rs")), + ("synthetic.rs", include_str!("synthetic.rs")), + ("http_util.rs", include_str!("http_util.rs")), + ( + "consent/extraction.rs", + include_str!("consent/extraction.rs"), + ), + ("consent/mod.rs", include_str!("consent/mod.rs")), + ]; + let banned_patterns = [ + "fastly::Request", + "fastly::Response", + "fastly::http::Method", + "fastly::http::StatusCode", + "fastly::mime::APPLICATION_JSON", + ]; + + for (path, source) in sources { + let uncommented = strip_line_comments(source); + for banned in banned_patterns { + assert!( + !uncommented.contains(banned), + "{path} should not reference `{banned}` after PR11 migration" + ); + } + } +} diff --git a/crates/trusted-server-core/src/proxy.rs b/crates/trusted-server-core/src/proxy.rs index d711b098..225deb3f 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -1,3 +1,4 @@ +use crate::compat; use crate::http_util::{compute_encrypted_sha256_token, ct_str_eq}; use edgezero_core::body::Body as EdgeBody; use edgezero_core::http::{request_builder as edge_request_builder, Uri as EdgeUri}; @@ -485,7 +486,8 @@ pub async fn proxy_request( } fn append_synthetic_id(req: &Request, target_url_parsed: &mut url::Url) { - let synthetic_id_param = match get_synthetic_id(req) { + let http_req = compat::from_fastly_request_ref(req); + let synthetic_id_param = match get_synthetic_id(&http_req) { Ok(id) => id, Err(e) => { log::warn!("failed to extract synthetic ID for forwarding: {:?}", e); @@ -800,7 +802,8 @@ pub async fn handle_first_party_click( had_params, } = reconstruct_and_validate_signed_target(settings, req.get_url_str())?; - let synthetic_id = match get_synthetic_id(&req) { + let http_req = compat::from_fastly_request_ref(&req); + let synthetic_id = match get_synthetic_id(&http_req) { Ok(id) => id, Err(e) => { log::warn!("failed to extract synthetic ID for forwarding: {:?}", e); diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 6550b0d2..d306df15 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -3,9 +3,10 @@ use fastly::http::{header, StatusCode}; use fastly::{Body, Request, Response}; use crate::backend::BackendConfig; +use crate::compat; use crate::consent::{allows_ssc_creation, build_consent_context, ConsentPipelineInput}; use crate::constants::{COOKIE_SYNTHETIC_ID, HEADER_X_COMPRESS_HINT, HEADER_X_SYNTHETIC_ID}; -use crate::cookies::{expire_synthetic_cookie, handle_request_cookies, set_synthetic_cookie}; +use crate::cookies::handle_request_cookies; use crate::error::TrustedServerError; use crate::http_util::{serve_static_with_etag, RequestInfo}; use crate::integrations::IntegrationRegistry; @@ -115,12 +116,15 @@ pub fn handle_tsjs_dynamic( return Ok(Response::from_status(StatusCode::NOT_FOUND).with_body("Not Found")); } let filename = &path[PREFIX.len()..]; + let http_req = compat::from_fastly_request_ref(req); if UNIFIED_FILENAMES.contains(&filename) { // Serve core + immediate modules (excludes deferred like prebid) let module_ids = integration_registry.js_module_ids_immediate(); let body = trusted_server_js::concatenate_modules(&module_ids); - let mut resp = serve_static_with_etag(&body, req, "application/javascript; charset=utf-8"); + let http_resp = + serve_static_with_etag(&body, &http_req, "application/javascript; charset=utf-8"); + let mut resp = compat::to_fastly_response(http_resp); resp.set_header(HEADER_X_COMPRESS_HINT, "on"); return Ok(resp); } @@ -132,8 +136,9 @@ pub fn handle_tsjs_dynamic( return Ok(Response::from_status(StatusCode::NOT_FOUND).with_body("Not Found")); } if let Some(content) = trusted_server_js::module_bundle(module_id) { - let mut resp = - serve_static_with_etag(content, req, "application/javascript; charset=utf-8"); + let http_resp = + serve_static_with_etag(content, &http_req, "application/javascript; charset=utf-8"); + let mut resp = compat::to_fastly_response(http_resp); resp.set_header(HEADER_X_COMPRESS_HINT, "on"); return Ok(resp); } @@ -299,8 +304,10 @@ pub fn handle_publisher_request( // Prebid.js requests are not intercepted here anymore. The HTML processor removes // publisher-supplied Prebid scripts; the unified TSJS bundle includes Prebid.js when enabled. + let http_req = compat::from_fastly_request_ref(&req); + // Extract request host and scheme (uses Host header and TLS detection after edge sanitization) - let request_info = RequestInfo::from_request(&req, &services.client_info); + let request_info = RequestInfo::from_request(&http_req, &services.client_info); let request_host = &request_info.host; let request_scheme = &request_info.scheme; @@ -314,7 +321,7 @@ pub fn handle_publisher_request( ); // Parse cookies once for reuse by both consent extraction and synthetic ID logic. - let cookie_jar = handle_request_cookies(&req)?; + let cookie_jar = handle_request_cookies(&http_req)?; // Capture the current SSC cookie value for revocation handling. // This must come from the cookie itself (not the x-synthetic-id header) @@ -327,7 +334,7 @@ pub fn handle_publisher_request( // Generate synthetic identifiers before the request body is consumed. // Always generated for internal use (KV lookups, logging) even when // consent is absent — the cookie is only *set* when consent allows it. - let synthetic_id = get_or_generate_synthetic_id(settings, services, &req)?; + let synthetic_id = get_or_generate_synthetic_id(settings, services, &http_req)?; // Extract, decode, and log consent signals (TCF, GPP, US Privacy, GPC) // from the incoming request. The ConsentContext carries both raw strings @@ -343,7 +350,7 @@ pub fn handle_publisher_request( }); let consent_context = build_consent_context(&ConsentPipelineInput { jar: cookie_jar.as_ref(), - req: &req, + req: &http_req, config: &settings.consent, geo: geo.as_ref(), synthetic_id: Some(synthetic_id.as_str()), @@ -461,11 +468,11 @@ pub fn handle_publisher_request( response.set_header(HEADER_X_SYNTHETIC_ID, synthetic_id.as_str()); // Cookie persistence is skipped if the synthetic ID contains RFC 6265-illegal // characters. The header is still emitted when consent allows it. - set_synthetic_cookie(settings, &mut response, synthetic_id.as_str()); + compat::set_fastly_synthetic_cookie(settings, &mut response, synthetic_id.as_str()); } else if let Some(cookie_synthetic_id) = existing_ssc_cookie.as_deref() { // Always expire the cookie — consent is withdrawn regardless of whether the // stored value is well-formed. - expire_synthetic_cookie(settings, &mut response); + compat::expire_fastly_synthetic_cookie(settings, &mut response); if is_valid_synthetic_id(cookie_synthetic_id) { log::info!( "SSC revoked: consent withdrawn (jurisdiction={})", @@ -693,14 +700,16 @@ mod tests { format!("synthetic_id={cookie_synthetic_id}; other=value"), ); - let cookie_jar = handle_request_cookies(&req).expect("should parse cookies"); + let http_req = compat::from_fastly_request_ref(&req); + let cookie_jar = handle_request_cookies(&http_req).expect("should parse cookies"); let existing_ssc_cookie = cookie_jar .as_ref() .and_then(|jar| jar.get(COOKIE_SYNTHETIC_ID)) .map(|cookie| cookie.value().to_owned()); - let resolved_synthetic_id = get_or_generate_synthetic_id(&settings, &noop_services(), &req) - .expect("should resolve synthetic id"); + let resolved_synthetic_id = + get_or_generate_synthetic_id(&settings, &noop_services(), &http_req) + .expect("should resolve synthetic id"); assert_eq!( existing_ssc_cookie.as_deref(), diff --git a/crates/trusted-server-core/src/request_signing/endpoints.rs b/crates/trusted-server-core/src/request_signing/endpoints.rs index 5d37c8ca..949ba9e5 100644 --- a/crates/trusted-server-core/src/request_signing/endpoints.rs +++ b/crates/trusted-server-core/src/request_signing/endpoints.rs @@ -48,7 +48,7 @@ pub fn handle_trusted_server_discovery( )?; Ok(Response::from_status(200) - .with_content_type(fastly::mime::APPLICATION_JSON) + .with_content_type(mime::APPLICATION_JSON) .with_body(json)) } @@ -120,7 +120,7 @@ pub fn handle_verify_signature( })?; Ok(Response::from_status(200) - .with_content_type(fastly::mime::APPLICATION_JSON) + .with_content_type(mime::APPLICATION_JSON) .with_body(response_json)) } @@ -198,7 +198,7 @@ pub fn handle_rotate_key( })?; Ok(Response::from_status(200) - .with_content_type(fastly::mime::APPLICATION_JSON) + .with_content_type(mime::APPLICATION_JSON) .with_body(response_json)) } Err(e) => { @@ -219,7 +219,7 @@ pub fn handle_rotate_key( })?; Ok(Response::from_status(500) - .with_content_type(fastly::mime::APPLICATION_JSON) + .with_content_type(mime::APPLICATION_JSON) .with_body(response_json)) } } @@ -304,7 +304,7 @@ pub fn handle_deactivate_key( })?; Ok(Response::from_status(200) - .with_content_type(fastly::mime::APPLICATION_JSON) + .with_content_type(mime::APPLICATION_JSON) .with_body(response_json)) } Err(e) => { @@ -328,7 +328,7 @@ pub fn handle_deactivate_key( })?; Ok(Response::from_status(500) - .with_content_type(fastly::mime::APPLICATION_JSON) + .with_content_type(mime::APPLICATION_JSON) .with_body(response_json)) } } @@ -462,7 +462,7 @@ mod tests { assert_eq!(resp.get_status(), StatusCode::OK); assert_eq!( resp.get_content_type(), - Some(fastly::mime::APPLICATION_JSON), + Some(mime::APPLICATION_JSON), "should return application/json content type" ); @@ -502,7 +502,7 @@ mod tests { assert_eq!(resp.get_status(), StatusCode::OK); assert_eq!( resp.get_content_type(), - Some(fastly::mime::APPLICATION_JSON), + Some(mime::APPLICATION_JSON), "should return application/json content type" ); @@ -690,7 +690,7 @@ mod tests { assert_eq!(resp.get_status(), StatusCode::OK); assert_eq!( resp.get_content_type(), - Some(fastly::mime::APPLICATION_JSON), + Some(mime::APPLICATION_JSON), "should return application/json content type" ); let body = resp.take_body_str(); diff --git a/crates/trusted-server-core/src/synthetic.rs b/crates/trusted-server-core/src/synthetic.rs index bb8eb006..a2d41093 100644 --- a/crates/trusted-server-core/src/synthetic.rs +++ b/crates/trusted-server-core/src/synthetic.rs @@ -5,11 +5,12 @@ use std::net::IpAddr; +use edgezero_core::body::Body as EdgeBody; use error_stack::{Report, ResultExt}; -use fastly::http::header; -use fastly::Request; use handlebars::Handlebars; use hmac::{Hmac, Mac}; +use http::header; +use http::Request; use rand::Rng; use serde_json::json; use sha2::Sha256; @@ -97,18 +98,21 @@ fn generate_random_suffix(length: usize) -> String { pub fn generate_synthetic_id( settings: &Settings, services: &RuntimeServices, - req: &Request, + req: &Request, ) -> Result> { let client_ip = services.client_info.client_ip.map(normalize_ip); let user_agent = req - .get_header(header::USER_AGENT) + .headers() + .get(header::USER_AGENT) .map(|h| h.to_str().unwrap_or("unknown")); let accept_language = req - .get_header(header::ACCEPT_LANGUAGE) + .headers() + .get(header::ACCEPT_LANGUAGE) .and_then(|h| h.to_str().ok()) .map(|lang| lang.split(',').next().unwrap_or("unknown")); let accept_encoding = req - .get_header(header::ACCEPT_ENCODING) + .headers() + .get(header::ACCEPT_ENCODING) .and_then(|h| h.to_str().ok()); let random_uuid = Uuid::new_v4().to_string(); @@ -167,9 +171,12 @@ pub fn generate_synthetic_id( /// # Errors /// /// - [`TrustedServerError::InvalidHeaderValue`] if the Cookie header contains invalid UTF-8 -pub fn get_synthetic_id(req: &Request) -> Result, Report> { +pub fn get_synthetic_id( + req: &Request, +) -> Result, Report> { if let Some(raw) = req - .get_header(HEADER_X_SYNTHETIC_ID) + .headers() + .get(HEADER_X_SYNTHETIC_ID) .and_then(|h| h.to_str().ok()) { if is_valid_synthetic_id(raw) { @@ -218,7 +225,7 @@ pub fn get_synthetic_id(req: &Request) -> Result, Report, ) -> Result> { if let Some(id) = get_synthetic_id(req)? { return Ok(id); @@ -233,7 +240,7 @@ pub fn get_or_generate_synthetic_id( #[cfg(test)] mod tests { use super::*; - use fastly::http::{HeaderName, HeaderValue}; + use http::{HeaderName, HeaderValue}; use std::net::{Ipv4Addr, Ipv6Addr}; use crate::platform::test_support::{noop_services, noop_services_with_client_ip}; @@ -269,10 +276,14 @@ mod tests { assert_eq!(normalize_ip(ipv6_a), "2001:db8:abcd:1::"); } - fn create_test_request(headers: Vec<(HeaderName, &str)>) -> Request { - let mut req = Request::new("GET", "http://example.com"); + fn create_test_request(headers: Vec<(HeaderName, &str)>) -> Request { + let mut req = Request::builder() + .method("GET") + .uri("http://example.com") + .body(EdgeBody::empty()) + .expect("should build test request"); for (key, value) in headers { - req.set_header( + req.headers_mut().insert( key, HeaderValue::from_str(value).expect("should create valid header value"), );