diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 72cae33ea..850faa657 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -5,14 +5,13 @@ use log_fastly::Logger; 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::{ @@ -97,7 +96,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_forwarded_headers_fastly(&mut req); // Look up geo info via the platform abstraction using the client IP // already captured in RuntimeServices at the entry point. @@ -112,7 +111,7 @@ 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) { + match compat::enforce_basic_auth_fastly(settings, &req) { Ok(Some(mut response)) => { finalize_response(settings, geo_info.as_ref(), &mut response); return Ok(response); diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 018b2b383..324c3f7de 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -4,9 +4,8 @@ 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::edge_cookie::get_or_generate_ec_id; use crate::error::TrustedServerError; use crate::geo::GeoInfo; use crate::platform::RuntimeServices; @@ -49,13 +48,14 @@ pub async fn handle_auction( // Generate EC ID early so the consent pipeline can use it for // KV Store fallback/write operations. - let ec_id = - get_or_generate_ec_id(settings, &req).change_context(TrustedServerError::Auction { + let ec_id = compat::get_or_generate_ec_id_fastly(settings, &req).change_context( + TrustedServerError::Auction { message: "Failed to generate EC ID".to_string(), - })?; + }, + )?; // Extract consent from request cookies, headers, and geo. - let cookie_jar = handle_request_cookies(&req)?; + let cookie_jar = compat::handle_request_cookies_fastly(&req)?; #[allow(deprecated)] let geo = GeoInfo::from_request(&req); let consent_context = consent::build_consent_context(&consent::ConsentPipelineInput { diff --git a/crates/trusted-server-core/src/auction/formats.rs b/crates/trusted-server-core/src/auction/formats.rs index 1f557a17e..af342c9d8 100644 --- a/crates/trusted-server-core/src/auction/formats.rs +++ b/crates/trusted-server-core/src/auction/formats.rs @@ -13,10 +13,10 @@ use std::collections::HashMap; use uuid::Uuid; use crate::auction::context::ContextValue; +use crate::compat; use crate::consent::ConsentContext; use crate::constants::{HEADER_X_TS_EC, HEADER_X_TS_EC_FRESH}; use crate::creative; -use crate::edge_cookie::generate_ec_id; use crate::error::TrustedServerError; use crate::geo::GeoInfo; use crate::openrtb::{to_openrtb_i32, OpenRtbBid, OpenRtbResponse, ResponseExt, SeatBid, ToExt}; @@ -88,9 +88,11 @@ pub fn convert_tsjs_to_auction_request( ec_id: &str, ) -> Result> { let ec_id = ec_id.to_owned(); - let fresh_id = generate_ec_id(settings, req).change_context(TrustedServerError::Auction { - message: "Failed to generate fresh EC ID".to_string(), - })?; + let fresh_id = compat::generate_ec_id_fastly(settings, req).change_context( + TrustedServerError::Auction { + message: "Failed to generate fresh EC ID".to_string(), + }, + )?; // Convert ad units to slots let mut slots = Vec::new(); diff --git a/crates/trusted-server-core/src/auth.rs b/crates/trusted-server-core/src/auth.rs index 547784dfe..68e292eda 100644 --- a/crates/trusted-server-core/src/auth.rs +++ b/crates/trusted-server-core/src/auth.rs @@ -1,7 +1,7 @@ use base64::{engine::general_purpose::STANDARD, Engine as _}; +use edgezero_core::body::Body; use error_stack::Report; -use fastly::http::{header, StatusCode}; -use fastly::{Request, Response}; +use http::{header, StatusCode}; use sha2::{Digest as _, Sha256}; use subtle::ConstantTimeEq as _; @@ -27,9 +27,10 @@ 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: &http::Request, +) -> Result>, Report> { + let path = req.uri().path(); + let Some(handler) = settings.handler_for_path(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: {}", path); Ok(Some(unauthorized_response())) } } -fn extract_credentials(req: &Request) -> Option<(String, String)> { +fn extract_credentials(req: &http::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,43 @@ 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() -> http::Response { + http::Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header(header::WWW_AUTHENTICATE, BASIC_AUTH_REALM) + .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") + .body(Body::from("Unauthorized")) + .expect("should build unauthorized response") } #[cfg(test)] mod tests { use super::*; use base64::engine::general_purpose::STANDARD; - use fastly::http::{header, Method}; use crate::test_support::tests::{crate_test_settings_str, create_test_settings}; + fn make_request(method: &str, uri: &str) -> http::Request { + http::Request::builder() + .method(method) + .uri(uri) + .body(Body::empty()) + .expect("should build test request") + } + + fn make_request_with_auth(method: &str, uri: &str, token: &str) -> http::Request { + http::Request::builder() + .method(method) + .uri(uri) + .header(header::AUTHORIZATION, format!("Basic {token}")) + .body(Body::empty()) + .expect("should build test request with auth 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 = make_request("GET", "https://example.com/open"); assert!(enforce_basic_auth(&settings, &req) .expect("should evaluate auth") @@ -112,14 +132,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 = make_request("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 +148,8 @@ 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 token = STANDARD.encode("user:pass"); - req.set_header(header::AUTHORIZATION, format!("Basic {token}")); + let req = make_request_with_auth("GET", "https://example.com/secure/data", &token); assert!(enforce_basic_auth(&settings, &req) .expect("should evaluate auth") @@ -139,29 +159,27 @@ 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 token = STANDARD.encode("wrong:wrong"); - req.set_header(header::AUTHORIZATION, format!("Basic {token}")); + let req = make_request_with_auth("GET", "https://example.com/secure/data", &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 token = STANDARD.encode("wrong-user:pass"); - req.set_header(header::AUTHORIZATION, format!("Basic {token}")); + let req = make_request_with_auth("GET", "https://example.com/secure/data", &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 +188,14 @@ 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 token = STANDARD.encode("user:wrong-pass"); - req.set_header(header::AUTHORIZATION, format!("Basic {token}")); + let req = make_request_with_auth("GET", "https://example.com/secure/data", &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 +204,17 @@ 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 req = http::Request::builder() + .method("GET") + .uri("https://example.com/secure") + .header(header::AUTHORIZATION, "Bearer token") + .body(Body::empty()) + .expect("should build test request"); 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 +231,8 @@ 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 token = STANDARD.encode("admin:admin-pass"); - req.set_header(header::AUTHORIZATION, format!("Basic {token}")); + let req = make_request_with_auth("POST", "https://example.com/admin/keys/rotate", &token); assert!( enforce_basic_auth(&settings, &req) @@ -225,24 +245,23 @@ 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 token = STANDARD.encode("admin:wrong"); - req.set_header(header::AUTHORIZATION, format!("Basic {token}")); + let req = make_request_with_auth("POST", "https://example.com/admin/keys/rotate", &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 = make_request("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 000000000..b79b1ed8b --- /dev/null +++ b/crates/trusted-server-core/src/compat.rs @@ -0,0 +1,634 @@ +//! Compatibility adapter bridging `fastly::Request/Response` with `http::Request/Response`. +//! +//! This module provides type conversion functions and request extension types used during the +//! Phase 2 of the platform migration. All items here are temporary bridges scheduled for removal +//! once the full type migration is complete. +//! +//! ## Usage pattern +//! +//! Handler-layer functions that still accept `fastly::Request` call these helpers at each +//! utility-function boundary: +//! +//! ```ignore +//! // read-only: borrow-based — no body copy +//! let http_req = compat::from_fastly_request_ref(&req); +//! let info = http_util::RequestInfo::from_request(&http_req); +//! +//! // mutable: build an http outgoing request, then convert back +//! let mut http_out = http::Request::builder().method(...).uri(...).body(Body::empty())...; +//! cookies::forward_cookie_header(&http_req, &mut http_out, true); +//! let fastly_out = compat::to_fastly_request(http_out); +//! ``` +//! +//! # TODO(PR15) +//! +//! Remove this entire module after handler-layer type migration is complete. + +use std::net::IpAddr; + +use edgezero_core::body::Body; +use http::{HeaderName, HeaderValue, StatusCode}; + +use crate::auth; +use crate::cookies; +use crate::edge_cookie; +use crate::http_util; +use crate::http_util::{RequestInfo, SPOOFABLE_FORWARDED_HEADERS}; + +// ── Request extension types ──────────────────────────────────────────────── + +/// TLS protocol string detected by the Fastly SDK. +/// +/// Stored as a [`http::Request`] extension by [`from_fastly_request_ref`] so that +/// `http_util::detect_request_scheme` can check it without Fastly SDK access. +/// +/// # TODO(PR15): Remove once handler-layer migration is complete. +#[derive(Debug, Clone)] +pub struct TlsProtocol(pub Option<&'static str>); + +/// TLS cipher string detected by the Fastly SDK. +/// +/// Stored as a [`http::Request`] extension by [`from_fastly_request_ref`] so that +/// `http_util::detect_request_scheme` can check it without Fastly SDK access. +/// +/// # TODO(PR15): Remove once handler-layer migration is complete. +#[derive(Debug, Clone)] +pub struct TlsCipher(pub Option<&'static str>); + +/// Client IP address captured from the Fastly SDK. +/// +/// Stored as a [`http::Request`] extension by [`from_fastly_request_ref`] so that +/// `edge_cookie::generate_ec_id` can read the client IP without Fastly SDK access. +/// +/// # TODO(PR15): Remove once handler-layer migration is complete. +#[derive(Debug, Clone)] +pub struct ClientIpExt(pub Option); + +// ── Request conversions ──────────────────────────────────────────────────── + +/// Create an `http::Request` from a `&fastly::Request` reference. +/// +/// Copies all headers and the URI from the Fastly request. The body is **not** copied — +/// the returned request carries an empty body. Fastly-specific TLS and client IP +/// information is preserved in the request's extensions. +/// +/// Prefer this over [`from_fastly_request`] for utility calls that only read +/// headers or the request path, so that the original `fastly::Request` body is +/// not consumed. +/// +/// # Panics +/// +/// Panics if the Fastly request URL cannot be parsed as an `http::Uri`. +/// +/// # TODO(PR15): Remove after handler-layer migration is complete. +pub fn from_fastly_request_ref(req: &fastly::Request) -> 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.clone(), value.clone()); + } + + let mut http_req = builder + .body(Body::empty()) + .expect("should build http request from fastly request reference"); + + http_req + .extensions_mut() + .insert(TlsProtocol(req.get_tls_protocol())); + http_req + .extensions_mut() + .insert(TlsCipher(req.get_tls_cipher_openssl_name())); + http_req + .extensions_mut() + .insert(ClientIpExt(req.get_client_ip_addr())); + + http_req +} + +/// Consume a `fastly::Request` and produce an `http::Request`. +/// +/// Moves the request body out of the Fastly request. Use this when the utility layer +/// needs to read or process the request body. Fastly-specific TLS and client IP +/// information is preserved in the request's extensions. +/// +/// # Panics +/// +/// Panics if the Fastly request URL cannot be parsed as an `http::Uri`. +/// +/// # TODO(PR15): Remove after handler-layer migration is complete. +pub fn from_fastly_request(mut req: fastly::Request) -> http::Request { + let uri: http::Uri = req + .get_url_str() + .parse() + .expect("should parse fastly request URL as URI"); + + let tls_protocol = req.get_tls_protocol(); + let tls_cipher = req.get_tls_cipher_openssl_name(); + let client_ip = req.get_client_ip_addr(); + + let mut builder = http::Request::builder() + .method(req.get_method().clone()) + .uri(uri); + + for (name, value) in req.get_headers() { + builder = builder.header(name.clone(), value.clone()); + } + + let body = Body::from_bytes(req.take_body_bytes()); + + let mut http_req = builder + .body(body) + .expect("should build http request from fastly request"); + + http_req.extensions_mut().insert(TlsProtocol(tls_protocol)); + http_req.extensions_mut().insert(TlsCipher(tls_cipher)); + http_req.extensions_mut().insert(ClientIpExt(client_ip)); + + http_req +} + +/// Convert an `http::Request` back to a `fastly::Request`. +/// +/// Used at integration layer boundaries where the utility layer produces an +/// `http::Request` (e.g. after header manipulation) but the downstream +/// operation still requires a `fastly::Request` for sending via the Fastly SDK. +/// +/// Body streaming is not supported — the body is materialised into bytes before +/// conversion. Avoid this function for large bodies until streaming support is added. +/// +/// # TODO(PR15): Remove after handler-layer migration is complete. +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.set_header(name, value); + } + + let body_bytes = body.into_bytes(); + if !body_bytes.is_empty() { + fastly_req.set_body_octet_stream(&body_bytes); + } + + fastly_req +} + +// ── Response conversions ─────────────────────────────────────────────────── + +/// Convert a `fastly::Response` to an `http::Response`. +/// +/// Body streaming is not supported — the body is materialised into bytes. +/// +/// # Panics +/// +/// Panics if the `http::Response` builder fails (unreachable in practice). +/// +/// # TODO(PR15): Remove after handler-layer migration is complete. +pub fn from_fastly_response(mut res: fastly::Response) -> http::Response { + let mut builder = http::Response::builder().status(res.get_status()); + + for (name, value) in res.get_headers() { + builder = builder.header(name.clone(), value.clone()); + } + + let body = Body::from_bytes(res.take_body_bytes()); + + builder + .body(body) + .expect("should build http response from fastly response") +} + +/// Convert an `http::Response` to a `fastly::Response`. +/// +/// Body streaming is not supported — the body is materialised into bytes. +/// +/// # TODO(PR15): Remove after handler-layer migration is complete. +pub fn to_fastly_response(res: http::Response) -> fastly::Response { + let (parts, body) = res.into_parts(); + + let mut fastly_res = fastly::Response::from_status(parts.status); + + for (name, value) in &parts.headers { + fastly_res.set_header(name, value); + } + + let body_bytes = body.into_bytes(); + if !body_bytes.is_empty() { + fastly_res.set_body_octet_stream(&body_bytes); + } + + fastly_res +} + +// ── Response builder helpers ─────────────────────────────────────────────── + +/// Build an `http::Response` with a given status and no body. +/// +/// # Panics +/// +/// Panics if the `http::Response` builder fails (unreachable in practice). +/// +/// # TODO(PR15): Remove — callers should use `http::Response::builder()` directly. +#[must_use] +pub fn response_from_status(status: StatusCode) -> http::Response { + http::Response::builder() + .status(status) + .body(Body::empty()) + .expect("should build response from status") +} + +/// Append a header to an `http::Response`, returning the response. +/// +/// Unlike [`http::Response::headers_mut().append()`], this is chainable. +/// +/// # TODO(PR15): Remove — callers should use `response.headers_mut().append()` directly. +pub fn append_header_to_response( + mut res: http::Response, + name: HeaderName, + value: HeaderValue, +) -> http::Response { + res.headers_mut().append(name, value); + res +} + +// ── Fastly-boundary bridge functions ────────────────────────────────────── +// +// These are handler/integration-layer shims that let code still holding +// `fastly::Request` / `fastly::Response` call into the migrated utility +// functions without converting the entire call stack in PR 11. They are +// removed in PR 15 once the handler and integration layers are fully migrated. + +/// Apply `http_util::sanitize_forwarded_headers` to a `fastly::Request`. +/// +/// Removes client-spoofable forwarded headers in place. +/// +/// # TODO(PR15): Remove once the handler layer migrates to `http` types. +pub fn sanitize_forwarded_headers_fastly(req: &mut fastly::Request) { + for header in SPOOFABLE_FORWARDED_HEADERS { + if req.get_header(*header).is_some() { + log::debug!("Stripped spoofable header: {}", header); + req.remove_header(*header); + } + } +} + +/// Apply `auth::enforce_basic_auth` with a `fastly::Request`, returning a `fastly::Response`. +/// +/// Converts the request to `http::Request` and the returned response to +/// `fastly::Response` for the adapter entry point. +/// +/// # Errors +/// +/// Returns an error when handler configuration is invalid. +/// +/// # TODO(PR15): Remove once the adapter entry point migrates to `http` types. +pub fn enforce_basic_auth_fastly( + settings: &crate::settings::Settings, + req: &fastly::Request, +) -> Result, error_stack::Report> { + let http_req = from_fastly_request_ref(req); + auth::enforce_basic_auth(settings, &http_req).map(|opt| opt.map(to_fastly_response)) +} + +/// Apply `http_util::serve_static_with_etag` with a `fastly::Request`, returning a +/// `fastly::Response`. +/// +/// # TODO(PR15): Remove once the handler layer migrates to `http` types. +pub fn serve_static_with_etag_fastly( + body: &str, + req: &fastly::Request, + content_type: &str, +) -> fastly::Response { + let http_req = from_fastly_request_ref(req); + let http_resp = http_util::serve_static_with_etag(body, &http_req, content_type); + to_fastly_response(http_resp) +} + +/// Apply `cookies::set_ec_cookie` to a `fastly::Response`. +/// +/// Creates a temporary `http::Response` to collect the Set-Cookie header, +/// then appends it to the Fastly response. +/// +/// # Panics +/// +/// Panics if the temporary `http::Response` builder fails (unreachable in practice). +/// +/// # TODO(PR15): Remove once publisher/registry migrate to `http` types. +pub fn set_ec_cookie_fastly( + settings: &crate::settings::Settings, + response: &mut fastly::Response, + ec_id: &str, +) { + let mut temp = http::Response::builder() + .status(200u16) + .body(Body::empty()) + .expect("should build temp response for cookie collection"); + cookies::set_ec_cookie(settings, &mut temp, ec_id); + for value in temp.headers().get_all(http::header::SET_COOKIE) { + response.append_header(http::header::SET_COOKIE, value); + } +} + +/// Apply `cookies::expire_ec_cookie` to a `fastly::Response`. +/// +/// # Panics +/// +/// Panics if the temporary `http::Response` builder fails (unreachable in practice). +/// +/// # TODO(PR15): Remove once publisher/registry migrate to `http` types. +pub fn expire_ec_cookie_fastly( + settings: &crate::settings::Settings, + response: &mut fastly::Response, +) { + let mut temp = http::Response::builder() + .status(200u16) + .body(Body::empty()) + .expect("should build temp response for cookie collection"); + cookies::expire_ec_cookie(settings, &mut temp); + for value in temp.headers().get_all(http::header::SET_COOKIE) { + response.append_header(http::header::SET_COOKIE, value); + } +} + +/// Apply `cookies::forward_cookie_header` with `fastly::Request` types. +/// +/// Converts `from` to `http::Request`, runs the migrated utility, then +/// applies any Cookie header changes back to `to`. +/// +/// # Panics +/// +/// Panics if the temporary `http::Request` builder fails (unreachable in practice). +/// +/// # TODO(PR15): Remove once integration layer migrates to `http` types. +pub fn forward_cookie_header_fastly( + from: &fastly::Request, + to: &mut fastly::Request, + strip_consent: bool, +) { + let http_from = from_fastly_request_ref(from); + let mut http_to = http::Request::builder() + .method("GET") + .uri("/") + .body(Body::empty()) + .expect("should build temp request for cookie forwarding"); + cookies::forward_cookie_header(&http_from, &mut http_to, strip_consent); + match http_to.headers().get(http::header::COOKIE) { + Some(cookie) => to.set_header(http::header::COOKIE, cookie), + None => { + to.remove_header(http::header::COOKIE); + } + } +} + +/// Apply `http_util::copy_custom_headers` with `fastly::Request` types. +/// +/// Converts `from` to `http::Request`, runs the migrated utility, then +/// copies all resulting X-* headers to `to`. +/// +/// # Panics +/// +/// Panics if the temporary `http::Request` builder fails (unreachable in practice). +/// +/// # TODO(PR15): Remove once integration layer migrates to `http` types. +pub fn copy_custom_headers_fastly(from: &fastly::Request, to: &mut fastly::Request) { + let http_from = from_fastly_request_ref(from); + let mut http_to = http::Request::builder() + .method("GET") + .uri("/") + .body(Body::empty()) + .expect("should build temp request for custom header copying"); + http_util::copy_custom_headers(&http_from, &mut http_to); + for (name, value) in http_to.headers() { + to.set_header(name, value); + } +} + +/// Apply `http_util::RequestInfo::from_request` with a `fastly::Request`. +/// +/// # TODO(PR15): Remove once the handler layer migrates to `http` types. +pub fn request_info_from_fastly(req: &fastly::Request) -> RequestInfo { + let http_req = from_fastly_request_ref(req); + RequestInfo::from_request(&http_req) +} + +/// Apply `edge_cookie::get_ec_id` with a `fastly::Request`. +/// +/// # Errors +/// +/// Returns an error if cookie parsing fails. +/// +/// # TODO(PR15): Remove once the handler layer migrates to `http` types. +pub fn get_ec_id_fastly( + req: &fastly::Request, +) -> Result, error_stack::Report> { + let http_req = from_fastly_request_ref(req); + edge_cookie::get_ec_id(&http_req) +} + +/// Apply `edge_cookie::get_or_generate_ec_id` with a `fastly::Request`. +/// +/// # Errors +/// +/// Returns an error if EC ID generation fails. +/// +/// # TODO(PR15): Remove once the handler layer migrates to `http` types. +pub fn get_or_generate_ec_id_fastly( + settings: &crate::settings::Settings, + req: &fastly::Request, +) -> Result> { + let http_req = from_fastly_request_ref(req); + edge_cookie::get_or_generate_ec_id(settings, &http_req) +} + +/// Apply `edge_cookie::generate_ec_id` with a `fastly::Request`. +/// +/// # Errors +/// +/// Returns an error if EC ID generation fails. +/// +/// # TODO(PR15): Remove once the handler layer migrates to `http` types. +pub fn generate_ec_id_fastly( + settings: &crate::settings::Settings, + req: &fastly::Request, +) -> Result> { + let http_req = from_fastly_request_ref(req); + edge_cookie::generate_ec_id(settings, &http_req) +} + +/// Apply `cookies::handle_request_cookies` with a `fastly::Request`. +/// +/// # Errors +/// +/// Returns an error if the Cookie header contains invalid UTF-8. +/// +/// # TODO(PR15): Remove once the handler layer migrates to `http` types. +pub fn handle_request_cookies_fastly( + req: &fastly::Request, +) -> Result, error_stack::Report> { + let http_req = from_fastly_request_ref(req); + cookies::handle_request_cookies(&http_req) +} + +#[cfg(test)] +mod tests { + use super::*; + use http::header; + + fn make_fastly_req_with_headers() -> fastly::Request { + let mut req = + fastly::Request::new(fastly::http::Method::GET, "https://example.com/path?q=1"); + req.set_header("x-custom", "value"); + req.set_header(header::HOST, "example.com"); + req + } + + #[test] + fn from_fastly_request_ref_copies_headers_and_uri() { + let fastly_req = make_fastly_req_with_headers(); + let http_req = from_fastly_request_ref(&fastly_req); + + assert_eq!( + http_req + .headers() + .get("x-custom") + .map(http::HeaderValue::as_bytes), + Some(b"value".as_ref()), + "should copy x-custom header" + ); + assert_eq!( + http_req.uri().path(), + "/path", + "should preserve request path" + ); + assert_eq!( + http_req.uri().query(), + Some("q=1"), + "should preserve query string" + ); + } + + #[test] + fn from_fastly_request_ref_has_empty_body() { + let fastly_req = make_fastly_req_with_headers(); + let http_req = from_fastly_request_ref(&fastly_req); + + assert!( + http_req.body().as_bytes().is_empty(), + "should have empty body when using reference conversion" + ); + } + + #[test] + fn from_fastly_request_ref_stores_client_ip_extension() { + let fastly_req = make_fastly_req_with_headers(); + let http_req = from_fastly_request_ref(&fastly_req); + + assert!( + http_req.extensions().get::().is_some(), + "should store ClientIpExt extension" + ); + } + + #[test] + fn from_fastly_request_ref_stores_tls_extensions() { + let fastly_req = make_fastly_req_with_headers(); + let http_req = from_fastly_request_ref(&fastly_req); + + assert!( + http_req.extensions().get::().is_some(), + "should store TlsProtocol extension" + ); + assert!( + http_req.extensions().get::().is_some(), + "should store TlsCipher extension" + ); + } + + #[test] + fn from_fastly_request_copies_body() { + let mut fastly_req = fastly::Request::post("https://example.com/api"); + fastly_req.set_body_octet_stream(b"hello body"); + + let http_req = from_fastly_request(fastly_req); + + assert_eq!( + http_req.body().as_bytes(), + b"hello body", + "should copy request body bytes" + ); + } + + #[test] + fn to_fastly_request_preserves_method_uri_headers() { + let http_req = http::Request::builder() + .method("POST") + .uri("https://api.example.com/submit") + .header("x-req-id", "abc-123") + .body(Body::empty()) + .expect("should build http request"); + + let fastly_req = to_fastly_request(http_req); + + assert_eq!( + fastly_req.get_method_str(), + "POST", + "should preserve HTTP method" + ); + assert!( + fastly_req.get_url_str().contains("api.example.com"), + "should preserve URL" + ); + assert!( + fastly_req.get_header("x-req-id").is_some(), + "should preserve request headers" + ); + } + + #[test] + fn response_roundtrip_preserves_status_and_headers() { + let mut fastly_res = fastly::Response::from_status(202); + fastly_res.set_header("x-resp-id", "xyz"); + + let http_res = from_fastly_response(fastly_res); + assert_eq!( + http_res.status().as_u16(), + 202, + "should preserve status code" + ); + assert_eq!( + http_res + .headers() + .get("x-resp-id") + .map(http::HeaderValue::as_bytes), + Some(b"xyz".as_ref()), + "should preserve response headers" + ); + + let fastly_res2 = to_fastly_response(http_res); + assert_eq!( + fastly_res2.get_status().as_u16(), + 202, + "should round-trip status code" + ); + assert!( + fastly_res2.get_header("x-resp-id").is_some(), + "should round-trip response headers" + ); + } + + #[test] + fn response_from_status_has_correct_status_and_empty_body() { + let res = response_from_status(StatusCode::NOT_FOUND); + + assert_eq!(res.status().as_u16(), 404, "should have 404 status"); + assert!(res.body().as_bytes().is_empty(), "should have empty body"); + } +} diff --git a/crates/trusted-server-core/src/cookies.rs b/crates/trusted-server-core/src/cookies.rs index 963bf1256..7fba2c456 100644 --- a/crates/trusted-server-core/src/cookies.rs +++ b/crates/trusted-server-core/src/cookies.rs @@ -6,9 +6,9 @@ use std::borrow::Cow; use cookie::{Cookie, CookieJar}; +use edgezero_core::body::Body; use error_stack::{Report, ResultExt}; -use fastly::http::header; -use fastly::Request; +use http::header; use crate::constants::{ COOKIE_EUCONSENT_V2, COOKIE_GPP, COOKIE_GPP_SID, COOKIE_TS_EC, COOKIE_US_PRIVACY, @@ -97,9 +97,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: &http::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 +146,18 @@ 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 { +pub fn forward_cookie_header( + from: &http::Request, + to: &mut http::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 +165,15 @@ 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); + if let Ok(val) = http::HeaderValue::from_str(&stripped) { + to.headers_mut().insert(header::COOKIE, val); + } } } 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()); } } } @@ -246,7 +254,7 @@ pub fn create_ec_cookie(settings: &Settings, ec_id: &str) -> String { /// from injecting spurious cookie attributes via a controlled ID value. /// /// `cookie_domain` comes from operator configuration and is considered trusted. -pub fn set_ec_cookie(settings: &Settings, response: &mut fastly::Response, ec_id: &str) { +pub fn set_ec_cookie(settings: &Settings, response: &mut http::Response, ec_id: &str) { if !is_safe_cookie_value(ec_id) { log::warn!( "Rejecting EC ID for Set-Cookie: value of {} bytes contains characters illegal in a cookie value", @@ -254,16 +262,21 @@ pub fn set_ec_cookie(settings: &Settings, response: &mut fastly::Response, ec_id ); return; } - response.append_header(header::SET_COOKIE, create_ec_cookie(settings, ec_id)); + let cookie_str = create_ec_cookie(settings, ec_id); + if let Ok(val) = http::HeaderValue::from_str(&cookie_str) { + response.headers_mut().append(header::SET_COOKIE, val); + } } /// Expires the EC cookie by setting `Max-Age=0`. /// /// Used when a user revokes consent — the browser will delete the cookie /// on receipt of this header. -pub fn expire_ec_cookie(settings: &Settings, response: &mut fastly::Response) { - let cookie = format!("{}=; {}", COOKIE_TS_EC, ec_cookie_attributes(settings, 0),); - response.append_header(header::SET_COOKIE, cookie); +pub fn expire_ec_cookie(settings: &Settings, response: &mut http::Response) { + let cookie = format!("{}=; {}", COOKIE_TS_EC, ec_cookie_attributes(settings, 0)); + if let Ok(val) = http::HeaderValue::from_str(&cookie) { + response.headers_mut().append(header::SET_COOKIE, val); + } } #[cfg(test)] @@ -272,6 +285,22 @@ mod tests { use super::*; + fn make_request_with_cookie(cookie_value: &str) -> http::Request { + http::Request::builder() + .method("GET") + .uri("http://example.com") + .header(header::COOKIE, cookie_value) + .body(Body::empty()) + .expect("should build test request with cookie") + } + + fn make_empty_response() -> http::Response { + http::Response::builder() + .status(200) + .body(Body::empty()) + .expect("should build empty response") + } + #[test] fn test_parse_cookies_to_jar() { let header_value = "c1=v1; c2=v2"; @@ -309,7 +338,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 = make_request_with_cookie("c1=v1;c2=v2"); let jar = handle_request_cookies(&req) .expect("should parse cookies") .expect("should have cookie jar"); @@ -321,7 +350,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 = make_request_with_cookie(""); let jar = handle_request_cookies(&req) .expect("should parse cookies") .expect("should have cookie jar"); @@ -331,7 +360,11 @@ mod tests { #[test] fn test_handle_request_cookies_no_cookie_header() { - let req: Request = Request::get("https://example.com"); + let req = http::Request::builder() + .method("GET") + .uri("https://example.com") + .body(Body::empty()) + .expect("should build request without cookie"); let jar = handle_request_cookies(&req).expect("should handle missing cookie header"); assert!(jar.is_none()); @@ -339,7 +372,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 = make_request_with_cookie("invalid"); let jar = handle_request_cookies(&req) .expect("should parse cookies") .expect("should have cookie jar"); @@ -350,11 +383,12 @@ mod tests { #[test] fn test_set_ec_cookie() { let settings = create_test_settings(); - let mut response = fastly::Response::new(); + let mut response = make_empty_response(); set_ec_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"); @@ -403,11 +437,11 @@ mod tests { #[test] fn test_set_ec_cookie_rejects_semicolon() { let settings = create_test_settings(); - let mut response = fastly::Response::new(); + let mut response = make_empty_response(); set_ec_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" ); } @@ -415,11 +449,11 @@ mod tests { #[test] fn test_set_ec_cookie_rejects_crlf() { let settings = create_test_settings(); - let mut response = fastly::Response::new(); + let mut response = make_empty_response(); set_ec_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" ); } @@ -427,11 +461,11 @@ mod tests { #[test] fn test_set_ec_cookie_rejects_space() { let settings = create_test_settings(); - let mut response = fastly::Response::new(); + let mut response = make_empty_response(); set_ec_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" ); } @@ -481,12 +515,13 @@ mod tests { #[test] fn test_expire_ec_cookie_matches_security_attributes() { let settings = create_test_settings(); - let mut response = fastly::Response::new(); + let mut response = make_empty_response(); expire_ec_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/edge_cookie.rs b/crates/trusted-server-core/src/edge_cookie.rs index 063c3fddc..57adc2cef 100644 --- a/crates/trusted-server-core/src/edge_cookie.rs +++ b/crates/trusted-server-core/src/edge_cookie.rs @@ -5,12 +5,13 @@ use std::net::IpAddr; +use edgezero_core::body::Body; use error_stack::{Report, ResultExt}; -use fastly::Request; use hmac::{Hmac, Mac}; use rand::Rng; use sha2::Sha256; +use crate::compat::ClientIpExt; use crate::constants::{COOKIE_TS_EC, HEADER_X_TS_EC}; use crate::cookies::{ec_id_has_only_allowed_chars, handle_request_cookies}; use crate::error::TrustedServerError; @@ -62,17 +63,23 @@ fn generate_random_suffix(length: usize) -> String { /// the client IP address, then appends a random suffix for additional /// uniqueness. The resulting format is `{64hex}.{6alnum}`. /// +/// The client IP is read from the [`ClientIpExt`] request extension, which is +/// populated by [`crate::compat::from_fastly_request_ref`] from the Fastly SDK. +/// Falls back to `"unknown"` when the extension is absent (e.g. in tests). +/// /// # Errors /// /// - [`TrustedServerError::Ec`] if HMAC generation fails pub fn generate_ec_id( settings: &Settings, - req: &Request, + req: &http::Request, ) -> Result> { - // Fallback to "unknown" when client IP is unavailable (e.g., local testing). - // All such requests share the same HMAC base; the random suffix provides uniqueness. + // Read client IP from the extension set by compat::from_fastly_request_ref. + // Falls back to "unknown" when absent (e.g. local testing or unit tests). let client_ip = req - .get_client_ip_addr() + .extensions() + .get::() + .and_then(|ext| ext.0) .map(normalize_ip) .unwrap_or_else(|| "unknown".to_string()); @@ -105,8 +112,12 @@ pub fn generate_ec_id( /// # Errors /// /// - [`TrustedServerError::InvalidHeaderValue`] if cookie parsing fails -pub fn get_ec_id(req: &Request) -> Result, Report> { - if let Some(ec_id) = req.get_header(HEADER_X_TS_EC).and_then(|h| h.to_str().ok()) { +pub fn get_ec_id(req: &http::Request) -> Result, Report> { + if let Some(ec_id) = req + .headers() + .get(HEADER_X_TS_EC) + .and_then(|h| h.to_str().ok()) + { if ec_id_has_only_allowed_chars(ec_id) { log::trace!("Using existing EC ID from header: {}", ec_id); return Ok(Some(ec_id.to_string())); @@ -146,7 +157,7 @@ pub fn get_ec_id(req: &Request) -> Result, Report, ) -> Result> { if let Some(id) = get_ec_id(req)? { return Ok(id); @@ -161,7 +172,7 @@ pub fn get_or_generate_ec_id( #[cfg(test)] mod tests { use super::*; - use fastly::http::{HeaderName, HeaderValue}; + use http::header; use std::net::{Ipv4Addr, Ipv6Addr}; use crate::test_support::tests::create_test_settings; @@ -196,16 +207,16 @@ 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"); - for (key, value) in headers { - req.set_header( - key, - HeaderValue::from_str(value).expect("should create valid header value"), - ); + fn create_test_request(headers: Vec<(&str, &str)>) -> http::Request { + let mut builder = http::Request::builder() + .method("GET") + .uri("http://example.com"); + for (name, value) in headers { + builder = builder.header(name, value); } - - req + builder + .body(Body::empty()) + .expect("should build test request") } fn is_ec_id_format(value: &str) -> bool { @@ -285,7 +296,7 @@ mod tests { #[test] fn test_get_ec_id_with_header() { let settings = create_test_settings(); - let req = create_test_request(vec![(HEADER_X_TS_EC, "existing_ec_id")]); + let req = create_test_request(vec![(HEADER_X_TS_EC.as_str(), "existing_ec_id")]); let ec_id = get_ec_id(&req).expect("should get EC ID"); assert_eq!(ec_id, Some("existing_ec_id".to_string())); @@ -298,7 +309,7 @@ mod tests { fn test_get_ec_id_with_cookie() { let settings = create_test_settings(); let req = create_test_request(vec![( - fastly::http::header::COOKIE, + header::COOKIE.as_str(), &format!("{}=existing_cookie_id", COOKIE_TS_EC), )]); @@ -328,9 +339,9 @@ mod tests { #[test] fn test_get_ec_id_rejects_invalid_header_and_falls_back_to_cookie() { let req = create_test_request(vec![ - (HEADER_X_TS_EC, "evil;injected"), + (HEADER_X_TS_EC.as_str(), "evil;injected"), ( - fastly::http::header::COOKIE, + header::COOKIE.as_str(), &format!("{}=valid_cookie_id", COOKIE_TS_EC), ), ]); @@ -346,7 +357,7 @@ mod tests { #[test] fn test_get_or_generate_ec_id_replaces_invalid_header() { let settings = create_test_settings(); - let req = create_test_request(vec![(HEADER_X_TS_EC, "evil;injected")]); + let req = create_test_request(vec![(HEADER_X_TS_EC.as_str(), "evil;injected")]); let ec_id = get_or_generate_ec_id(&settings, &req) .expect("should generate fresh ID on invalid header"); @@ -363,7 +374,7 @@ mod tests { #[test] fn test_get_ec_id_rejects_invalid_cookie() { let req = create_test_request(vec![( - fastly::http::header::COOKIE, + header::COOKIE.as_str(), &format!("{}=bad