diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index 4b71d07ce..9a371f805 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -37,7 +37,7 @@ use trusted_server_core::settings_data::{ use trusted_server_core::platform::RuntimeServices; -use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; +use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware, SanitizeRequestMiddleware}; use crate::platform::{AxumPlatformConfigStore, build_runtime_services}; // --------------------------------------------------------------------------- @@ -600,6 +600,11 @@ fn build_router(state: &Arc) -> RouterService { let fallback = fallback_handler(Arc::clone(state)); let mut router = RouterService::builder() + // Outermost middleware: strips the configured trusted-client-IP + // headers before anything else sees the request. Must stay first — + // any middleware registered ahead of it would observe the + // shared-secret authentication header. + .middleware(SanitizeRequestMiddleware::new(Arc::clone(&state.settings))) .middleware(FinalizeResponseMiddleware::new(Arc::clone(&state.settings))) .middleware(AuthMiddleware::new(Arc::clone(&state.settings))); diff --git a/crates/trusted-server-adapter-axum/src/middleware.rs b/crates/trusted-server-adapter-axum/src/middleware.rs index 45cbedc2c..9f00f7614 100644 --- a/crates/trusted-server-adapter-axum/src/middleware.rs +++ b/crates/trusted-server-adapter-axum/src/middleware.rs @@ -7,20 +7,58 @@ use edgezero_core::http::{HeaderValue, Response}; use edgezero_core::middleware::{Middleware, Next}; use trusted_server_core::auth::enforce_basic_auth; use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; +use trusted_server_core::http_util::sanitize_trusted_client_ip_headers; use trusted_server_core::settings::Settings; +// --------------------------------------------------------------------------- +// SanitizeRequestMiddleware +// --------------------------------------------------------------------------- + +/// Outermost middleware: strips the configured client-IP trust headers from the +/// request before any inner middleware or handler observes them. +/// +/// Must stay the first middleware registered in [`crate::app`]. Registering +/// another middleware ahead of it would re-expose the shared-secret +/// authentication header to request handling. Only the Fastly adapter consumes +/// these headers for client-IP resolution; every other adapter removes them so +/// a shared configuration cannot leak the secret into publisher or integration +/// request handling. +pub struct SanitizeRequestMiddleware { + settings: Arc, +} + +impl SanitizeRequestMiddleware { + /// Creates a new [`SanitizeRequestMiddleware`] with the given settings. + #[must_use] + pub fn new(settings: Arc) -> Self { + Self { settings } + } +} + +#[async_trait(?Send)] +impl Middleware for SanitizeRequestMiddleware { + async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { + sanitize_trusted_client_ip_headers( + ctx.request_mut(), + self.settings.trusted_client_ip.as_ref(), + ); + next.run(ctx).await + } +} + // --------------------------------------------------------------------------- // FinalizeResponseMiddleware // --------------------------------------------------------------------------- -/// Outermost middleware: injects all standard TS response headers. +/// Response-finalization middleware: injects all standard TS response headers. /// /// Geo lookup is unavailable in the Axum dev server — `X-Geo-Info-Available: false` /// is always emitted. Fastly-specific headers (`X-TS-Version`, `X-TS-ENV`) are /// skipped because the corresponding env vars are not set in a local dev context. /// -/// Registered first in the middleware chain so that every outgoing response — -/// including auth-rejected ones — carries a consistent set of headers. +/// Registered directly inside [`SanitizeRequestMiddleware`] and ahead of +/// [`AuthMiddleware`] so that every outgoing response — including auth-rejected +/// ones — carries a consistent set of headers. pub struct FinalizeResponseMiddleware { settings: Arc, } @@ -111,8 +149,17 @@ pub(crate) fn apply_finalize_headers(settings: &Settings, response: &mut Respons mod tests { use super::*; + use std::collections::HashMap; + use std::sync::Mutex; + use edgezero_core::body::Body; - use edgezero_core::http::response_builder; + use edgezero_core::context::RequestContext; + use edgezero_core::http::{Method, request_builder, response_builder}; + use edgezero_core::middleware::Next; + use edgezero_core::params::PathParams; + use futures::executor::block_on; + use trusted_server_core::redacted::Redacted; + use trusted_server_core::settings::TrustedClientIpConfig; fn empty_response() -> Response { response_builder() @@ -120,6 +167,17 @@ mod tests { .expect("should build empty test response") } + fn empty_ctx() -> RequestContext { + let req = request_builder() + .method(Method::GET) + .uri("/test") + .header("x-reader-ip", "198.51.100.7") + .header("x-reader-ip-auth", "fictional-shared-secret-0123456789") + .body(Body::empty()) + .expect("should build test request"); + RequestContext::new(req, PathParams::new(HashMap::new())) + } + fn settings_with_response_headers(headers: Vec<(&str, &str)>) -> Settings { let mut s = Settings::from_toml( r#" @@ -197,4 +255,36 @@ mod tests { "should apply operator-configured response headers" ); } + + #[test] + fn sanitize_middleware_strips_configured_trust_headers_before_routing() { + let mut settings = settings_with_response_headers(vec![]); + settings.trusted_client_ip = Some(TrustedClientIpConfig { + ip_header: "x-reader-ip".to_owned(), + auth_header: "x-reader-ip-auth".to_owned(), + shared_secret: Redacted::new("fictional-shared-secret-0123456789".to_owned()), + }); + let middleware = SanitizeRequestMiddleware::new(Arc::new(settings)); + let observed = Arc::new(Mutex::new(None)); + let handler_observed = Arc::clone(&observed); + let handler = Arc::new(move |ctx: RequestContext| { + let handler_observed = Arc::clone(&handler_observed); + async move { + *handler_observed.lock().expect("should lock observation") = Some(( + ctx.request().headers().contains_key("x-reader-ip"), + ctx.request().headers().contains_key("x-reader-ip-auth"), + )); + Ok::(empty_response()) + } + }); + + block_on(middleware.handle(empty_ctx(), Next::new(&[], &*handler))) + .expect("should run middleware"); + + assert_eq!( + *observed.lock().expect("should lock observation"), + Some((false, false)), + "should remove both configured trust headers before the handler" + ); + } } diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 86ac86987..6ce0a5ee3 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -36,7 +36,7 @@ use trusted_server_core::request_signing::{ }; use trusted_server_core::settings::Settings; -use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware}; +use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware, SanitizeRequestMiddleware}; use crate::platform::build_runtime_services; // --------------------------------------------------------------------------- @@ -462,6 +462,11 @@ fn build_router(state: &Arc) -> RouterService { }; let mut router = RouterService::builder() + // Outermost middleware: strips the configured trusted-client-IP + // headers before anything else sees the request. Must stay first — + // any middleware registered ahead of it would observe the + // shared-secret authentication header. + .middleware(SanitizeRequestMiddleware::new(Arc::clone(&state.settings))) .middleware(FinalizeResponseMiddleware::new(Arc::clone(&state.settings))) .middleware(AuthMiddleware::new(Arc::clone(&state.settings))) .get( diff --git a/crates/trusted-server-adapter-cloudflare/src/middleware.rs b/crates/trusted-server-adapter-cloudflare/src/middleware.rs index 5b605bcff..f2b3374c4 100644 --- a/crates/trusted-server-adapter-cloudflare/src/middleware.rs +++ b/crates/trusted-server-adapter-cloudflare/src/middleware.rs @@ -7,20 +7,58 @@ use edgezero_core::http::{HeaderValue, Response}; use edgezero_core::middleware::{Middleware, Next}; use trusted_server_core::auth::enforce_basic_auth; use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; +use trusted_server_core::http_util::sanitize_trusted_client_ip_headers; use trusted_server_core::settings::Settings; +// --------------------------------------------------------------------------- +// SanitizeRequestMiddleware +// --------------------------------------------------------------------------- + +/// Outermost middleware: strips the configured client-IP trust headers from the +/// request before any inner middleware or handler observes them. +/// +/// Must stay the first middleware registered in [`crate::app`]. Registering +/// another middleware ahead of it would re-expose the shared-secret +/// authentication header to request handling. Only the Fastly adapter consumes +/// these headers for client-IP resolution; every other adapter removes them so +/// a shared configuration cannot leak the secret into publisher or integration +/// request handling. +pub struct SanitizeRequestMiddleware { + settings: Arc, +} + +impl SanitizeRequestMiddleware { + /// Creates a new [`SanitizeRequestMiddleware`] with the given settings. + #[must_use] + pub fn new(settings: Arc) -> Self { + Self { settings } + } +} + +#[async_trait(?Send)] +impl Middleware for SanitizeRequestMiddleware { + async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { + sanitize_trusted_client_ip_headers( + ctx.request_mut(), + self.settings.trusted_client_ip.as_ref(), + ); + next.run(ctx).await + } +} + // --------------------------------------------------------------------------- // FinalizeResponseMiddleware // --------------------------------------------------------------------------- -/// Outermost middleware: injects all standard TS response headers. +/// Response-finalization middleware: injects all standard TS response headers. /// /// Geo availability is determined by the presence of the `cf-ipcountry` header /// (injected by the Cloudflare Workers runtime). On the native host target the /// header is absent, so `X-Geo-Info-Available: false` is emitted. /// -/// Registered first in the middleware chain so that every outgoing response — -/// including auth-rejected ones — carries a consistent set of headers. +/// Registered directly inside [`SanitizeRequestMiddleware`] and ahead of +/// [`AuthMiddleware`] so that every outgoing response — including auth-rejected +/// ones — carries a consistent set of headers. pub struct FinalizeResponseMiddleware { settings: Arc, } @@ -124,8 +162,17 @@ pub(crate) fn apply_finalize_headers( mod tests { use super::*; + use std::collections::HashMap; + use std::sync::Mutex; + use edgezero_core::body::Body; - use edgezero_core::http::response_builder; + use edgezero_core::context::RequestContext; + use edgezero_core::http::{Method, request_builder, response_builder}; + use edgezero_core::middleware::Next; + use edgezero_core::params::PathParams; + use futures::executor::block_on; + use trusted_server_core::redacted::Redacted; + use trusted_server_core::settings::TrustedClientIpConfig; fn empty_response() -> Response { response_builder() @@ -133,6 +180,17 @@ mod tests { .expect("should build empty test response") } + fn empty_ctx() -> RequestContext { + let req = request_builder() + .method(Method::GET) + .uri("/test") + .header("x-reader-ip", "198.51.100.7") + .header("x-reader-ip-auth", "fictional-shared-secret-0123456789") + .body(Body::empty()) + .expect("should build test request"); + RequestContext::new(req, PathParams::new(HashMap::new())) + } + fn settings_with_response_headers(headers: Vec<(&str, &str)>) -> Settings { // Build from explicit test settings: the settings baked into the // binary contain placeholder secrets that `get_settings()` rejects @@ -230,4 +288,36 @@ mod tests { "should apply operator-configured response headers" ); } + + #[test] + fn sanitize_middleware_strips_configured_trust_headers_before_routing() { + let mut settings = settings_with_response_headers(vec![]); + settings.trusted_client_ip = Some(TrustedClientIpConfig { + ip_header: "x-reader-ip".to_owned(), + auth_header: "x-reader-ip-auth".to_owned(), + shared_secret: Redacted::new("fictional-shared-secret-0123456789".to_owned()), + }); + let middleware = SanitizeRequestMiddleware::new(Arc::new(settings)); + let observed = Arc::new(Mutex::new(None)); + let handler_observed = Arc::clone(&observed); + let handler = Arc::new(move |ctx: RequestContext| { + let handler_observed = Arc::clone(&handler_observed); + async move { + *handler_observed.lock().expect("should lock observation") = Some(( + ctx.request().headers().contains_key("x-reader-ip"), + ctx.request().headers().contains_key("x-reader-ip-auth"), + )); + Ok::(empty_response()) + } + }); + + block_on(middleware.handle(empty_ctx(), Next::new(&[], &*handler))) + .expect("should run middleware"); + + assert_eq!( + *observed.lock().expect("should lock observation"), + Some((false, false)), + "should remove both configured trust headers before the handler" + ); + } } diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 41e5e65ee..5e78a87ed 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -2195,13 +2195,27 @@ mod tests { server_region: Some("US-East".to_string()), }); - let _ = route(&router, req); + let response = route(&router, req); let observed = captured .lock() .expect("should lock captured client info") .clone() .expect("request filter should have observed the entry-point ClientInfo"); + assert_eq!( + observed.client_ip, + Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7))), + "request-scoped services should preserve the resolved client IP used by EC" + ); + let finalize = response + .extensions() + .get::() + .expect("fallback response should carry EC finalization state"); + assert_eq!( + finalize.ec_context.client_ip(), + Some("203.0.113.7"), + "EC should capture the resolved client IP from request-scoped services" + ); assert_eq!( observed.tls_protocol.as_deref(), Some("TLSv1.3"), diff --git a/crates/trusted-server-adapter-fastly/src/compat.rs b/crates/trusted-server-adapter-fastly/src/compat.rs index b46e8cc9b..5ccd9c423 100644 --- a/crates/trusted-server-adapter-fastly/src/compat.rs +++ b/crates/trusted-server-adapter-fastly/src/compat.rs @@ -1,8 +1,13 @@ //! Compatibility bridge between `fastly` SDK types and `http` crate types. +use std::net::IpAddr; + use edgezero_core::body::Body as EdgeBody; use edgezero_core::http::Response as HttpResponse; use trusted_server_core::http_util::SPOOFABLE_FORWARDED_HEADERS; +use trusted_server_core::settings::TrustedClientIpConfig; + +use crate::platform::resolve_client_ip; /// Convert an [`HttpResponse`] into a `fastly::Response`. pub(crate) fn to_fastly_response(resp: HttpResponse) -> fastly::Response { @@ -52,9 +57,18 @@ pub(crate) fn to_fastly_response_skeleton(resp: HttpResponse) -> fastly::Respons /// Sanitize forwarded headers on a `fastly::Request`. /// -/// Strips headers that clients can spoof before any request-derived context -/// is built or the request is converted to core HTTP types. -pub(crate) fn sanitize_fastly_forwarded_headers(req: &mut fastly::Request) { +/// Strips configured trust headers and headers that clients can spoof before +/// any request-derived context is built or the request is converted to core +/// HTTP types. +pub(crate) fn sanitize_fastly_forwarded_headers( + req: &mut fastly::Request, + config: Option<&TrustedClientIpConfig>, +) { + if let Some(config) = config { + req.remove_header(config.ip_header.as_str()); + req.remove_header(config.auth_header.as_str()); + } + for &name in SPOOFABLE_FORWARDED_HEADERS { if req.get_header(name).is_some() { log::debug!("Stripped spoofable header: {name}"); @@ -63,9 +77,32 @@ pub(crate) fn sanitize_fastly_forwarded_headers(req: &mut fastly::Request) { } } +/// Resolve the trusted client IP, then strip every trust and spoofable header. +/// +/// Resolution has to observe the trust headers *before* sanitization removes +/// them. Both steps live behind this one call so that ordering is structural +/// rather than a convention the entry point has to remember. +pub(crate) fn resolve_and_sanitize_client_ip( + req: &mut fastly::Request, + config: Option<&TrustedClientIpConfig>, +) -> Option { + let client_ip = resolve_client_ip(req, req.get_client_ip_addr(), config); + sanitize_fastly_forwarded_headers(req, config); + client_ip +} + #[cfg(test)] mod tests { use super::*; + use trusted_server_core::redacted::Redacted; + + fn trusted_client_ip_config(ip_header: &str) -> TrustedClientIpConfig { + TrustedClientIpConfig { + ip_header: ip_header.to_owned(), + auth_header: "x-trusted-client-auth".to_owned(), + shared_secret: Redacted::new("fictional-shared-secret-0123456789".to_owned()), + } + } #[test] fn sanitize_fastly_forwarded_headers_strips_spoofable() { @@ -74,9 +111,10 @@ mod tests { req.set_header("x-forwarded-host", "evil.example.com"); req.set_header("x-forwarded-proto", "http"); req.set_header("fastly-ssl", "1"); + req.set_header("fastly-client-ip", "198.51.100.7"); req.set_header("host", "example.com"); - sanitize_fastly_forwarded_headers(&mut req); + sanitize_fastly_forwarded_headers(&mut req, None); assert!( req.get_header("forwarded").is_none(), @@ -94,9 +132,86 @@ mod tests { req.get_header("fastly-ssl").is_none(), "should strip fastly-ssl" ); + assert!( + req.get_header("fastly-client-ip").is_none(), + "should strip fastly-client-ip" + ); + assert!(req.get_header("host").is_some(), "should preserve host"); + } + + #[test] + fn sanitize_fastly_forwarded_headers_strips_configured_headers() { + let config = trusted_client_ip_config("x-trusted-client-ip"); + let mut req = fastly::Request::get("https://example.com/"); + req.set_header("x-trusted-client-ip", "198.51.100.7"); + req.set_header( + "x-trusted-client-auth", + "fictional-shared-secret-0123456789", + ); + req.set_header("host", "example.com"); + + sanitize_fastly_forwarded_headers(&mut req, Some(&config)); + + assert!( + req.get_header("x-trusted-client-ip").is_none(), + "should strip the configured IP header" + ); + assert!( + req.get_header("x-trusted-client-auth").is_none(), + "should strip the configured auth header" + ); assert!(req.get_header("host").is_some(), "should preserve host"); } + #[test] + fn sanitize_fastly_forwarded_headers_allows_static_and_dynamic_overlap() { + let config = trusted_client_ip_config("fastly-client-ip"); + let mut req = fastly::Request::get("https://example.com/"); + req.set_header("fastly-client-ip", "198.51.100.7"); + req.set_header( + "x-trusted-client-auth", + "fictional-shared-secret-0123456789", + ); + + sanitize_fastly_forwarded_headers(&mut req, Some(&config)); + + assert!( + req.get_header("fastly-client-ip").is_none(), + "should tolerate removing fastly-client-ip twice" + ); + assert!( + req.get_header("x-trusted-client-auth").is_none(), + "should strip the configured auth header" + ); + } + + #[test] + fn resolve_and_sanitize_client_ip_reads_trust_headers_before_stripping_them() { + let config = trusted_client_ip_config("x-trusted-client-ip"); + let mut req = fastly::Request::get("https://example.com/"); + req.set_header("x-trusted-client-ip", "198.51.100.7"); + req.set_header( + "x-trusted-client-auth", + "fictional-shared-secret-0123456789", + ); + + let resolved = resolve_and_sanitize_client_ip(&mut req, Some(&config)); + + assert_eq!( + resolved, + Some(IpAddr::V4(std::net::Ipv4Addr::new(198, 51, 100, 7))), + "should resolve the forwarded IP before sanitization removes the headers" + ); + assert!( + req.get_header("x-trusted-client-ip").is_none(), + "should strip the configured IP header after resolving" + ); + assert!( + req.get_header("x-trusted-client-auth").is_none(), + "should strip the configured auth header after resolving" + ); + } + #[test] fn to_fastly_response_with_streaming_body_produces_empty_body() { use edgezero_core::http::StatusCode; diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index a19d0485d..90879c3ba 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -124,9 +124,14 @@ fn edgezero_main(mut req: FastlyRequest) { let (app, app_state) = TrustedServerApp::build_app_with_state(); let settings_snapshot = app_state.as_ref().map(|state| Arc::clone(&state.settings)); + let trusted_client_ip = settings_snapshot + .as_deref() + .and_then(|settings| settings.trusted_client_ip.as_ref()); - // Strip client-spoofable forwarded headers before dispatch. - compat::sanitize_fastly_forwarded_headers(&mut req); + // Resolve the trusted client IP, then strip client-spoofable forwarded + // headers before dispatch. One call keeps resolution ahead of the + // sanitization that removes the headers it reads. + let resolved_client_ip = compat::resolve_and_sanitize_client_ip(&mut req, trusted_client_ip); // Re-inject a trusted TLS scheme signal after sanitization has stripped any // client-sent fastly-ssl header. Setting it from Fastly's native TLS @@ -138,9 +143,6 @@ fn edgezero_main(mut req: FastlyRequest) { req.set_header("fastly-ssl", "1"); } - // Capture client IP before the request is consumed by dispatch. - let client_ip = req.get_client_ip_addr(); - // Strip any client-supplied x-ts-tls-* headers before injecting the trusted // values from the Fastly SDK. Must run after sanitize_fastly_forwarded_headers. req.remove_header("x-ts-tls-protocol"); @@ -160,7 +162,8 @@ fn edgezero_main(mut req: FastlyRequest) { // Capture metadata from the original FastlyRequest before conversion. These // accessors only return real values on the client request, so store them in // request extensions for build_per_request_services and EC bot classification. - let client_info = client_info_from_request(&req); + let client_info = client_info_from_request(&req, resolved_client_ip); + let client_ip = client_info.client_ip; let device_signals = derive_device_signals(&req); // Dispatch directly through the EdgeZero router without an intermediate diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index 153d90295..2345efafe 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -25,7 +25,7 @@ use trusted_server_core::constants::{ HEADER_X_TS_ENV, HEADER_X_TS_VERSION, }; use trusted_server_core::geo::GeoInfo; -use trusted_server_core::platform::PlatformGeo; +use trusted_server_core::platform::{ClientInfo, PlatformGeo}; use trusted_server_core::settings::Settings; pub(crate) const HEADER_X_TS_FINALIZED: &str = "x-ts-finalized"; @@ -67,7 +67,10 @@ impl FinalizeResponseMiddleware { #[async_trait(?Send)] impl Middleware for FinalizeResponseMiddleware { async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { - let client_ip = FastlyRequestContext::get(ctx.request()).and_then(|c| c.client_ip); + let client_ip = ctx.request().extensions().get::().map_or_else( + || FastlyRequestContext::get(ctx.request()).and_then(|c| c.client_ip), + |info| info.client_ip, + ); let mut response = match next.run(ctx).await { Ok(r) => r, @@ -249,7 +252,7 @@ mod tests { use std::collections::HashMap; use std::net::IpAddr; - use std::sync::Arc; + use std::sync::{Arc, Mutex}; use edgezero_core::body::Body; use edgezero_core::context::RequestContext; @@ -259,7 +262,7 @@ mod tests { use edgezero_core::params::PathParams; use error_stack::Report; use futures::executor::block_on; - use trusted_server_core::platform::{PlatformError, PlatformGeo}; + use trusted_server_core::platform::{ClientInfo, PlatformError, PlatformGeo}; fn empty_response() -> Response { response_builder() @@ -284,6 +287,23 @@ mod tests { } } + struct RecordingGeo { + lookups: Arc>>>, + } + + impl PlatformGeo for RecordingGeo { + fn lookup( + &self, + client_ip: Option, + ) -> Result, Report> { + self.lookups + .lock() + .expect("should lock recorded geo lookups") + .push(client_ip); + Ok(None) + } + } + fn test_settings() -> Settings { Settings::from_toml( r#" @@ -525,6 +545,110 @@ mod tests { // FinalizeResponseMiddleware::handle tests // --------------------------------------------------------------------------- + #[test] + fn finalize_handle_uses_client_info_ip_for_geo_lookup() { + let reader_ip = IpAddr::V4(std::net::Ipv4Addr::new(198, 51, 100, 7)); + let peer_ip = IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, 9)); + let settings = settings_with_response_headers(vec![]); + let lookups = Arc::new(Mutex::new(Vec::new())); + let middleware = FinalizeResponseMiddleware::new( + Arc::new(settings), + Arc::new(RecordingGeo { + lookups: Arc::clone(&lookups), + }), + ); + let mut ctx = empty_ctx(); + ctx.request_mut().extensions_mut().insert(ClientInfo { + client_ip: Some(reader_ip), + ..ClientInfo::default() + }); + FastlyRequestContext::insert( + ctx.request_mut(), + FastlyRequestContext { + client_ip: Some(peer_ip), + }, + ); + let handler = + Arc::new( + |_ctx: RequestContext| async move { Ok::(empty_response()) }, + ); + + block_on(middleware.handle(ctx, Next::new(&[], &*handler))).expect("should succeed"); + + assert_eq!( + *lookups.lock().expect("should lock recorded geo lookups"), + vec![Some(reader_ip)], + "should look up geo using the resolved ClientInfo IP" + ); + } + + #[test] + fn finalize_handle_preserves_none_from_present_client_info() { + let peer_ip = IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, 9)); + let settings = settings_with_response_headers(vec![]); + let lookups = Arc::new(Mutex::new(Vec::new())); + let middleware = FinalizeResponseMiddleware::new( + Arc::new(settings), + Arc::new(RecordingGeo { + lookups: Arc::clone(&lookups), + }), + ); + let mut ctx = empty_ctx(); + ctx.request_mut() + .extensions_mut() + .insert(ClientInfo::default()); + FastlyRequestContext::insert( + ctx.request_mut(), + FastlyRequestContext { + client_ip: Some(peer_ip), + }, + ); + let handler = + Arc::new( + |_ctx: RequestContext| async move { Ok::(empty_response()) }, + ); + + block_on(middleware.handle(ctx, Next::new(&[], &*handler))).expect("should succeed"); + + assert_eq!( + *lookups.lock().expect("should lock recorded geo lookups"), + vec![None], + "should preserve no IP from the authoritative ClientInfo" + ); + } + + #[test] + fn finalize_handle_uses_fastly_context_ip_when_client_info_is_absent() { + let peer_ip = IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, 9)); + let settings = settings_with_response_headers(vec![]); + let lookups = Arc::new(Mutex::new(Vec::new())); + let middleware = FinalizeResponseMiddleware::new( + Arc::new(settings), + Arc::new(RecordingGeo { + lookups: Arc::clone(&lookups), + }), + ); + let mut ctx = empty_ctx(); + FastlyRequestContext::insert( + ctx.request_mut(), + FastlyRequestContext { + client_ip: Some(peer_ip), + }, + ); + let handler = + Arc::new( + |_ctx: RequestContext| async move { Ok::(empty_response()) }, + ); + + block_on(middleware.handle(ctx, Next::new(&[], &*handler))).expect("should succeed"); + + assert_eq!( + *lookups.lock().expect("should lock recorded geo lookups"), + vec![Some(peer_ip)], + "should fall back to the Fastly request context IP" + ); + } + #[test] fn finalize_handle_injects_geo_unavailable_on_ok_response() { let settings = settings_with_response_headers(vec![]); diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index 9e7920e1c..638aed82b 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -21,6 +21,7 @@ use trusted_server_core::platform::{ PlatformImageOptimizerRegion, PlatformKvStore, PlatformPendingRequest, PlatformResponse, PlatformSecretStore, PlatformSelectResult, StoreId, StoreName, }; +use trusted_server_core::settings::TrustedClientIpConfig; // --------------------------------------------------------------------------- // FastlyPlatformConfigStore @@ -694,7 +695,53 @@ impl PlatformGeo for FastlyPlatformGeo { } } -/// Extract [`ClientInfo`] from the original Fastly request. +fn single_utf8_header<'a>(req: &'a Request, name: &str) -> Option<&'a str> { + let mut values = req.get_header_all(name); + let value = values.next()?; + if values.next().is_some() { + return None; + } + value.to_str().ok() +} + +/// Resolve the request's client IP from an authenticated forwarding header. +/// +/// When no trusted-client-IP configuration is present, or when either header +/// is missing, duplicated, malformed, or unauthenticated, this returns the +/// Fastly SDK peer address unchanged. +/// +/// Every fallback taken while a configuration *is* present logs at debug level +/// so a rotated secret or renamed header is diagnosable. Debug rather than warn +/// keeps a direct client from driving log volume by sending junk trust headers. +#[must_use] +pub(crate) fn resolve_client_ip( + req: &Request, + peer_ip: Option, + config: Option<&TrustedClientIpConfig>, +) -> Option { + let Some(config) = config else { + return peer_ip; + }; + let Some(auth_candidate) = single_utf8_header(req, &config.auth_header) else { + log::debug!("Trusted client IP: auth header is missing, duplicated, or not UTF-8"); + return peer_ip; + }; + if !config.authenticates(auth_candidate) { + log::debug!("Trusted client IP: auth header did not match the configured shared secret"); + return peer_ip; + } + let Some(ip_candidate) = single_utf8_header(req, &config.ip_header) else { + log::debug!("Trusted client IP: IP header is missing, duplicated, or not UTF-8"); + return peer_ip; + }; + + ip_candidate.parse::().ok().or_else(|| { + log::debug!("Trusted client IP: IP header is not a bare IPv4 or IPv6 address"); + peer_ip + }) +} + +/// Extract [`ClientInfo`] from the original Fastly request and resolved client IP. /// /// Fastly's TLS, JA4, and HTTP/2 fingerprint accessors only return real values /// on the client request before it is converted to platform HTTP types. This @@ -703,9 +750,9 @@ impl PlatformGeo for FastlyPlatformGeo { /// extensions so `build_per_request_services` can read back metadata the /// reconstructed request cannot expose. #[must_use] -pub fn client_info_from_request(req: &Request) -> ClientInfo { +pub fn client_info_from_request(req: &Request, client_ip: Option) -> ClientInfo { ClientInfo { - client_ip: req.get_client_ip_addr(), + client_ip, tls_protocol: req.get_tls_protocol().ok().flatten().map(str::to_string), tls_cipher: req .get_tls_cipher_openssl_name() @@ -741,6 +788,184 @@ mod tests { use super::*; use edgezero_core::body::Body; use edgezero_core::http::request_builder; + use fastly::http::HeaderValue; + use trusted_server_core::redacted::Redacted; + use trusted_server_core::settings::TrustedClientIpConfig; + + const PEER_IP: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, 9)); + + fn trusted_client_ip_config() -> TrustedClientIpConfig { + TrustedClientIpConfig { + ip_header: "fastly-client-ip".to_owned(), + auth_header: "x-trusted-client-auth".to_owned(), + shared_secret: Redacted::new("fictional-shared-secret-0123456789".to_owned()), + } + } + + fn authenticated_request(ip: impl AsRef<[u8]>) -> Request { + let mut req = Request::get("https://example.com/"); + req.set_header( + "x-trusted-client-auth", + "fictional-shared-secret-0123456789", + ); + req.set_header("fastly-client-ip", ip.as_ref()); + req + } + + #[test] + fn resolve_client_ip_uses_peer_when_config_is_absent() { + let req = authenticated_request("198.51.100.7"); + + let resolved = resolve_client_ip(&req, Some(PEER_IP), None); + + assert_eq!(resolved, Some(PEER_IP), "should preserve the peer IP"); + } + + #[test] + fn resolve_client_ip_accepts_authenticated_ipv4() { + let req = authenticated_request("198.51.100.7"); + + let resolved = resolve_client_ip(&req, Some(PEER_IP), Some(&trusted_client_ip_config())); + + assert_eq!( + resolved, + Some(IpAddr::V4(std::net::Ipv4Addr::new(198, 51, 100, 7))), + "should use the authenticated IPv4 address" + ); + } + + #[test] + fn resolve_client_ip_accepts_authenticated_ipv6() { + let req = authenticated_request("2001:db8::7"); + + let resolved = resolve_client_ip(&req, Some(PEER_IP), Some(&trusted_client_ip_config())); + + assert_eq!( + resolved, + Some(IpAddr::V6(std::net::Ipv6Addr::new( + 0x2001, 0xdb8, 0, 0, 0, 0, 0, 7, + ))), + "should use the authenticated IPv6 address" + ); + } + + #[test] + fn resolve_client_ip_uses_peer_when_auth_is_missing_empty_or_wrong() { + for auth_value in [None, Some(""), Some("fictional-wrong-secret")] { + let mut req = Request::get("https://example.com/"); + if let Some(auth_value) = auth_value { + req.set_header("x-trusted-client-auth", auth_value); + } + req.set_header("fastly-client-ip", "198.51.100.7"); + + let resolved = + resolve_client_ip(&req, Some(PEER_IP), Some(&trusted_client_ip_config())); + + assert_eq!( + resolved, + Some(PEER_IP), + "should fall back for auth value {auth_value:?}" + ); + } + } + + #[test] + fn resolve_client_ip_uses_peer_when_auth_is_duplicated() { + let mut req = authenticated_request("198.51.100.7"); + req.append_header( + "x-trusted-client-auth", + "fictional-shared-secret-0123456789", + ); + + let resolved = resolve_client_ip(&req, Some(PEER_IP), Some(&trusted_client_ip_config())); + + assert_eq!(resolved, Some(PEER_IP), "should reject duplicate auth"); + } + + #[test] + fn resolve_client_ip_uses_peer_when_auth_is_not_utf8() { + let mut req = Request::get("https://example.com/"); + req.set_header( + "x-trusted-client-auth", + HeaderValue::from_bytes(b"fictional-shared-secret-0123456789\xff") + .expect("should build non-UTF-8 auth header"), + ); + req.set_header("fastly-client-ip", "198.51.100.7"); + + let resolved = resolve_client_ip(&req, Some(PEER_IP), Some(&trusted_client_ip_config())); + + assert_eq!(resolved, Some(PEER_IP), "should reject non-UTF-8 auth"); + } + + #[test] + fn resolve_client_ip_uses_peer_when_ip_is_missing() { + let mut req = Request::get("https://example.com/"); + req.set_header( + "x-trusted-client-auth", + "fictional-shared-secret-0123456789", + ); + + let resolved = resolve_client_ip(&req, Some(PEER_IP), Some(&trusted_client_ip_config())); + + assert_eq!(resolved, Some(PEER_IP), "should require an IP header"); + } + + #[test] + fn resolve_client_ip_uses_peer_when_ip_text_is_invalid() { + for ip_value in [ + " 198.51.100.7", + "198.51.100.7 ", + "198.51.100.7:443", + "2001:db8::7%example0", + "198.51.100.7, 203.0.113.10", + "", + ] { + let req = authenticated_request(ip_value); + + let resolved = + resolve_client_ip(&req, Some(PEER_IP), Some(&trusted_client_ip_config())); + + assert_eq!( + resolved, + Some(PEER_IP), + "should reject invalid IP value {ip_value:?}" + ); + } + } + + #[test] + fn resolve_client_ip_uses_peer_when_ip_is_duplicated() { + let mut req = authenticated_request("198.51.100.7"); + req.append_header("fastly-client-ip", "203.0.113.10"); + + let resolved = resolve_client_ip(&req, Some(PEER_IP), Some(&trusted_client_ip_config())); + + assert_eq!(resolved, Some(PEER_IP), "should reject duplicate IP values"); + } + + #[test] + fn resolve_client_ip_uses_peer_when_ip_is_not_utf8() { + let req = authenticated_request( + HeaderValue::from_bytes(b"198.51.100.7\xff").expect("should build non-UTF-8 IP header"), + ); + + let resolved = resolve_client_ip(&req, Some(PEER_IP), Some(&trusted_client_ip_config())); + + assert_eq!(resolved, Some(PEER_IP), "should reject non-UTF-8 IP"); + } + + #[test] + fn client_info_from_request_preserves_supplied_client_ip() { + let req = Request::get("https://example.com/"); + let supplied_ip = Some(IpAddr::V4(std::net::Ipv4Addr::new(198, 51, 100, 7))); + + let client_info = client_info_from_request(&req, supplied_ip); + + assert_eq!( + client_info.client_ip, supplied_ip, + "should preserve the supplied client IP" + ); + } #[test] fn edge_request_to_fastly_replaces_url_derived_host_header() { diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 06bb1a15a..f24b5b717 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -35,7 +35,9 @@ use trusted_server_core::request_signing::{ }; use trusted_server_core::settings::Settings; -use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware, NormalizeMiddleware}; +use crate::middleware::{ + AuthMiddleware, FinalizeResponseMiddleware, NormalizeMiddleware, SanitizeRequestMiddleware, +}; use crate::platform::build_runtime_services; // --------------------------------------------------------------------------- @@ -766,6 +768,11 @@ fn build_router(state: &Arc) -> RouterService { |_ctx: RequestContext| async { Ok::(legacy_admin_alias_denied()) }; let mut builder = RouterService::builder() + // Outermost middleware: strips the configured trusted-client-IP + // headers before anything else sees the request. Must stay first — + // any middleware registered ahead of it would observe the + // shared-secret authentication header. + .middleware(SanitizeRequestMiddleware::new(Arc::clone(&state.settings))) .middleware(FinalizeResponseMiddleware::new(Arc::clone(&state.settings))) .middleware(AuthMiddleware::new(Arc::clone(&state.settings))) // Innermost middleware: normalize every routed request (strip diff --git a/crates/trusted-server-adapter-spin/src/middleware.rs b/crates/trusted-server-adapter-spin/src/middleware.rs index 1bcede1fc..d3005d510 100644 --- a/crates/trusted-server-adapter-spin/src/middleware.rs +++ b/crates/trusted-server-adapter-spin/src/middleware.rs @@ -7,19 +7,57 @@ use edgezero_core::http::{HeaderValue, Response}; use edgezero_core::middleware::{Middleware, Next}; use trusted_server_core::auth::enforce_basic_auth; use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; +use trusted_server_core::http_util::sanitize_trusted_client_ip_headers; use trusted_server_core::settings::Settings; +// --------------------------------------------------------------------------- +// SanitizeRequestMiddleware +// --------------------------------------------------------------------------- + +/// Outermost middleware: strips the configured client-IP trust headers from the +/// request before any inner middleware or handler observes them. +/// +/// Must stay the first middleware registered in [`crate::app`]. Registering +/// another middleware ahead of it would re-expose the shared-secret +/// authentication header to request handling. Only the Fastly adapter consumes +/// these headers for client-IP resolution; every other adapter removes them so +/// a shared configuration cannot leak the secret into publisher or integration +/// request handling. +pub struct SanitizeRequestMiddleware { + settings: Arc, +} + +impl SanitizeRequestMiddleware { + /// Creates a new [`SanitizeRequestMiddleware`] with the given settings. + #[must_use] + pub fn new(settings: Arc) -> Self { + Self { settings } + } +} + +#[async_trait(?Send)] +impl Middleware for SanitizeRequestMiddleware { + async fn handle(&self, mut ctx: RequestContext, next: Next<'_>) -> Result { + sanitize_trusted_client_ip_headers( + ctx.request_mut(), + self.settings.trusted_client_ip.as_ref(), + ); + next.run(ctx).await + } +} + // --------------------------------------------------------------------------- // FinalizeResponseMiddleware // --------------------------------------------------------------------------- -/// Outermost middleware: injects all standard TS response headers. +/// Response-finalization middleware: injects all standard TS response headers. /// /// Spin does not expose geo headers to the application, so /// `X-Geo-Info-Available: false` is emitted for every response. /// -/// Registered first in the middleware chain so that every outgoing response — -/// including auth-rejected ones — carries a consistent set of headers. +/// Registered directly inside [`SanitizeRequestMiddleware`] and ahead of +/// [`AuthMiddleware`] so that every outgoing response — including auth-rejected +/// ones — carries a consistent set of headers. pub struct FinalizeResponseMiddleware { settings: Arc, } @@ -151,8 +189,17 @@ pub(crate) fn apply_finalize_headers( mod tests { use super::*; + use std::collections::HashMap; + use std::sync::Mutex; + use edgezero_core::body::Body; - use edgezero_core::http::response_builder; + use edgezero_core::context::RequestContext; + use edgezero_core::http::{Method, request_builder, response_builder}; + use edgezero_core::middleware::Next; + use edgezero_core::params::PathParams; + use futures::executor::block_on; + use trusted_server_core::redacted::Redacted; + use trusted_server_core::settings::TrustedClientIpConfig; fn empty_response() -> Response { response_builder() @@ -160,6 +207,17 @@ mod tests { .expect("should build empty test response") } + fn empty_ctx() -> RequestContext { + let req = request_builder() + .method(Method::GET) + .uri("/test") + .header("x-reader-ip", "198.51.100.7") + .header("x-reader-ip-auth", "fictional-shared-secret-0123456789") + .body(Body::empty()) + .expect("should build test request"); + RequestContext::new(req, PathParams::new(HashMap::new())) + } + fn settings_with_response_headers(headers: Vec<(&str, &str)>) -> Settings { // Build from explicit test settings: the settings baked into the // binary contain placeholder secrets that `get_settings()` rejects @@ -257,4 +315,36 @@ mod tests { "should apply operator-configured response headers" ); } + + #[test] + fn sanitize_middleware_strips_configured_trust_headers_before_routing() { + let mut settings = settings_with_response_headers(vec![]); + settings.trusted_client_ip = Some(TrustedClientIpConfig { + ip_header: "x-reader-ip".to_owned(), + auth_header: "x-reader-ip-auth".to_owned(), + shared_secret: Redacted::new("fictional-shared-secret-0123456789".to_owned()), + }); + let middleware = SanitizeRequestMiddleware::new(Arc::new(settings)); + let observed = Arc::new(Mutex::new(None)); + let handler_observed = Arc::clone(&observed); + let handler = Arc::new(move |ctx: RequestContext| { + let handler_observed = Arc::clone(&handler_observed); + async move { + *handler_observed.lock().expect("should lock observation") = Some(( + ctx.request().headers().contains_key("x-reader-ip"), + ctx.request().headers().contains_key("x-reader-ip-auth"), + )); + Ok::(empty_response()) + } + }); + + block_on(middleware.handle(empty_ctx(), Next::new(&[], &*handler))) + .expect("should run middleware"); + + assert_eq!( + *observed.lock().expect("should lock observation"), + Some((false, false)), + "should remove both configured trust headers before the handler" + ); + } } diff --git a/crates/trusted-server-core/src/http_util.rs b/crates/trusted-server-core/src/http_util.rs index 1b0693e56..34656f63d 100644 --- a/crates/trusted-server-core/src/http_util.rs +++ b/crates/trusted-server-core/src/http_util.rs @@ -11,7 +11,7 @@ use crate::cache_policy::{CachePolicy, EdgeCacheHeader}; use crate::constants::INTERNAL_HEADERS; use crate::error::TrustedServerError; use crate::platform::ClientInfo; -use crate::settings::Settings; +use crate::settings::{Settings, TrustedClientIpConfig}; /// Copy `X-*` custom headers from one request to another, skipping TS-internal headers. /// @@ -34,18 +34,36 @@ pub fn copy_custom_headers(from: &Request, to: &mut Request) } } -/// Headers that clients can spoof to hijack URL rewriting. +/// Headers that clients can spoof to hijack URL rewriting or the client address. /// -/// 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. +/// On Fastly Compute these values are client-spoofable at request entry. The +/// Fastly adapter may first consume an authenticated `fastly-client-ip`, but +/// removes it before routing along with every other listed header. Stripping +/// them forces [`RequestInfo::from_request`] to fall back to the trustworthy +/// `Host` header and [`ClientInfo`] TLS detection. pub const SPOOFABLE_FORWARDED_HEADERS: &[&str] = &[ "forwarded", "x-forwarded-host", "x-forwarded-proto", "fastly-ssl", + "fastly-client-ip", ]; +/// Remove the configured client-IP trust headers before routing. +/// +/// Only the Fastly adapter consumes these values, but every adapter removes +/// them so a shared configuration cannot expose an authentication secret to +/// publisher or integration request handling. +pub fn sanitize_trusted_client_ip_headers( + req: &mut Request, + config: Option<&TrustedClientIpConfig>, +) { + if let Some(config) = config { + req.headers_mut().remove(config.ip_header.as_str()); + req.headers_mut().remove(config.auth_header.as_str()); + } +} + /// Strip forwarded headers that clients can spoof. /// /// Call this at the edge entry point (before routing) to prevent @@ -461,6 +479,8 @@ pub fn enforce_max_body_size( mod tests { use super::*; use crate::platform::ClientInfo; + use crate::redacted::Redacted; + use crate::settings::TrustedClientIpConfig; use http::{HeaderName, HeaderValue, Method}; fn build_request(method: Method, uri: &str) -> Request { @@ -660,6 +680,41 @@ mod tests { // Sanitization tests + #[test] + fn sanitize_trusted_client_ip_headers_removes_only_configured_headers() { + let config = TrustedClientIpConfig { + ip_header: "x-reader-ip".to_owned(), + auth_header: "x-reader-ip-auth".to_owned(), + shared_secret: Redacted::new("fictional-shared-secret-0123456789".to_owned()), + }; + let mut req = build_request(Method::GET, "https://example.com/page"); + set_header(&mut req, "x-reader-ip", "198.51.100.7"); + set_header( + &mut req, + "x-reader-ip-auth", + "fictional-shared-secret-0123456789", + ); + set_header(&mut req, "x-unrelated", "preserved"); + + sanitize_trusted_client_ip_headers(&mut req, Some(&config)); + + assert!( + req.headers().get("x-reader-ip").is_none(), + "should remove the configured IP header" + ); + assert!( + req.headers().get("x-reader-ip-auth").is_none(), + "should remove the configured authentication header" + ); + assert_eq!( + req.headers() + .get("x-unrelated") + .expect("should preserve an unrelated header"), + "preserved", + "should not remove unrelated headers" + ); + } + #[test] fn sanitize_removes_all_spoofable_headers() { let mut req = build_request(Method::GET, "https://example.com/page"); diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 1e3444ff0..ae80f0194 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -5,17 +5,20 @@ use glob::{MatchOptions, Pattern}; use regex::Regex; use serde::{Deserialize, Deserializer, Serialize, de::DeserializeOwned}; use serde_json::Value as JsonValue; +use sha2::{Digest as _, Sha256}; use std::collections::{HashMap, HashSet}; use std::ops::{Deref, DerefMut}; use std::str::FromStr; use std::sync::OnceLock; use std::time::Duration; +use subtle::ConstantTimeEq as _; use url::Url; use validator::{Validate, ValidationError}; use crate::auction_config_types::AuctionConfig; use crate::cache_policy::{CachePolicy, CacheVisibility}; use crate::consent_config::ConsentConfig; +use crate::constants::INTERNAL_HEADERS; use crate::creative_opportunities::CreativeOpportunitiesConfig; use crate::error::TrustedServerError; use crate::host_header::validate_host_header_override_value; @@ -2596,6 +2599,108 @@ pub struct TesterCookieConfig { pub enabled: bool, } +/// Authenticated forwarding configuration for a trusted client IP header. +#[derive(Debug, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +#[validate(schema(function = validate_trusted_client_ip))] +pub struct TrustedClientIpConfig { + /// Header containing the client IP address supplied by the trusted edge. + pub ip_header: String, + /// Header containing the shared-secret authentication value. + pub auth_header: String, + /// Shared secret required before accepting the forwarded client IP address. + #[validate(custom(function = validate_redacted_not_empty))] + pub shared_secret: Redacted, +} + +impl TrustedClientIpConfig { + /// Placeholder shared secrets shipped in the example configuration and docs. + pub const SHARED_SECRET_PLACEHOLDERS: &[&str] = &["replace-with-a-random-shared-secret"]; + + /// Minimum accepted `shared_secret` length. + /// + /// Matches `Ec::MIN_PASSPHRASE_LENGTH`. This secret is the only gate on + /// forging the client address that geolocation, EC identity derivation, and + /// bot protection consume, so it is held to the same strength as the EC + /// passphrase. + const MIN_SHARED_SECRET_LENGTH: usize = Ec::MIN_PASSPHRASE_LENGTH; + + /// Returns `true` if `shared_secret` matches a known placeholder value + /// (case-insensitive). + #[must_use] + pub fn is_placeholder_shared_secret(shared_secret: &str) -> bool { + Self::SHARED_SECRET_PLACEHOLDERS + .iter() + .any(|p| p.eq_ignore_ascii_case(shared_secret)) + } + + /// Returns whether `candidate` exactly matches the configured shared secret. + /// + /// # Examples + /// + /// ``` + /// use trusted_server_core::redacted::Redacted; + /// use trusted_server_core::settings::TrustedClientIpConfig; + /// + /// let config = TrustedClientIpConfig { + /// ip_header: "fastly-client-ip".to_owned(), + /// auth_header: "x-trusted-client-auth".to_owned(), + /// shared_secret: Redacted::new("fictional-shared-secret-0123456789".to_owned()), + /// }; + /// + /// assert!(config.authenticates("fictional-shared-secret-0123456789")); + /// assert!(!config.authenticates("fictional-wrong-secret")); + /// ``` + #[must_use] + pub fn authenticates(&self, candidate: &str) -> bool { + let configured_digest = Sha256::digest(self.shared_secret.expose().as_bytes()); + let candidate_digest = Sha256::digest(candidate.as_bytes()); + + configured_digest.ct_eq(&candidate_digest).into() + } +} + +fn validate_trusted_client_ip(config: &TrustedClientIpConfig) -> Result<(), ValidationError> { + let ip_header = http::HeaderName::from_bytes(config.ip_header.as_bytes()) + .map_err(|_| ValidationError::new("invalid_trusted_client_ip_header"))?; + let auth_header = http::HeaderName::from_bytes(config.auth_header.as_bytes()) + .map_err(|_| ValidationError::new("invalid_trusted_client_ip_auth_header"))?; + + if ip_header == auth_header { + return Err(ValidationError::new("identical_trusted_client_ip_headers")); + } + + for header in [&ip_header, &auth_header] { + if INTERNAL_HEADERS.contains(&header.as_str()) { + return Err(ValidationError::new("reserved_trusted_client_ip_header")); + } + } + + if ip_header.as_str() != "fastly-client-ip" && !ip_header.as_str().starts_with("x-") { + return Err(ValidationError::new("unsafe_trusted_client_ip_header")); + } + if !auth_header.as_str().starts_with("x-") { + return Err(ValidationError::new("unsafe_trusted_client_ip_auth_header")); + } + + let shared_secret = config.shared_secret.expose(); + if shared_secret.len() < TrustedClientIpConfig::MIN_SHARED_SECRET_LENGTH { + return Err(ValidationError::new( + "short_trusted_client_ip_shared_secret", + )); + } + if !shared_secret + .bytes() + .all(|byte| matches!(byte, b'!'..=b'~')) + { + return Err(ValidationError::new( + "invalid_trusted_client_ip_shared_secret", + )); + } + + Ok(()) +} + #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] #[serde(deny_unknown_fields)] pub struct Settings { @@ -2603,6 +2708,17 @@ pub struct Settings { pub publisher: Publisher, #[serde(default)] pub tester_cookie: TesterCookieConfig, + /// Optional authenticated trusted client IP forwarding configuration. + /// + /// `None` must stay omitted from serialized config blobs: `Settings` + /// schemas that predate this field reject unknown keys, so emitting + /// `trusted_client_ip: null` would make an unchanged `ts config push` + /// break older instances during rollout or rollback. A configured value + /// remains serialized and requires restoring a compatible blob before + /// rolling back. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[validate(nested)] + pub trusted_client_ip: Option, #[serde(default)] #[validate(nested)] pub ec: Ec, @@ -2815,6 +2931,13 @@ impl Settings { if Publisher::is_placeholder_proxy_secret(self.publisher.proxy_secret.expose()) { insecure_fields.push("publisher.proxy_secret".to_owned()); } + if let Some(trusted_client_ip) = &self.trusted_client_ip + && TrustedClientIpConfig::is_placeholder_shared_secret( + trusted_client_ip.shared_secret.expose(), + ) + { + insecure_fields.push("trusted_client_ip.shared_secret".to_owned()); + } for partner in &self.ec.partners { if EcPartner::is_placeholder_api_token(partner.api_token.expose()) { insecure_fields.push(format!("ec.partners[{}].api_token", partner.source_domain)); @@ -3353,6 +3476,519 @@ mod tests { use crate::redacted::Redacted; use crate::test_support::tests::{crate_test_settings_str, create_test_settings}; + fn trusted_client_ip_toml(ip_header: &str, auth_header: &str, shared_secret: &str) -> String { + format!( + "{}\n[trusted_client_ip]\nip_header = \"{ip_header}\"\nauth_header = \"{auth_header}\"\nshared_secret = \"{shared_secret}\"\n", + crate_test_settings_str() + ) + } + + #[test] + fn trusted_client_ip_is_absent_by_default() { + let settings = Settings::from_toml(&crate_test_settings_str()) + .expect("should parse settings without trusted client IP configuration"); + + assert!( + settings.trusted_client_ip.is_none(), + "should leave trusted client IP configuration disabled by default" + ); + } + + /// Mirrors the `Settings` schema of the revision that predates + /// `trusted_client_ip`: every key that revision knew, and + /// `deny_unknown_fields` so an extra key fails deserialization exactly as an + /// older binary would reject a pushed config blob. + // The fields exist to model the accepted key set, never to be read. + #[allow(dead_code)] + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct BaseRevisionSettings { + #[serde(default)] + publisher: serde::de::IgnoredAny, + #[serde(default)] + tester_cookie: serde::de::IgnoredAny, + #[serde(default)] + ec: serde::de::IgnoredAny, + #[serde(default)] + integrations: serde::de::IgnoredAny, + #[serde(default)] + handlers: serde::de::IgnoredAny, + #[serde(default)] + response_headers: serde::de::IgnoredAny, + #[serde(default)] + request_signing: serde::de::IgnoredAny, + #[serde(default)] + rewrite: serde::de::IgnoredAny, + #[serde(default)] + auction: serde::de::IgnoredAny, + #[serde(default)] + consent: serde::de::IgnoredAny, + #[serde(default)] + cache: serde::de::IgnoredAny, + #[serde(default)] + proxy: serde::de::IgnoredAny, + #[serde(default)] + creative_opportunities: serde::de::IgnoredAny, + #[serde(default)] + image_optimizer: serde::de::IgnoredAny, + #[serde(default)] + tinybird: serde::de::IgnoredAny, + #[serde(default)] + debug: serde::de::IgnoredAny, + } + + #[test] + fn trusted_client_ip_is_omitted_from_serialized_config_when_unset() { + // `ts config push` serializes `Settings` verbatim. Emitting the key — + // even as `null` — makes a `deny_unknown_fields` binary from the base + // revision reject the blob during rollout or rollback. + let settings = Settings::from_toml(&crate_test_settings_str()) + .expect("should parse settings without trusted client IP configuration"); + + let value = serde_json::to_value(&settings).expect("should serialize settings"); + + assert!( + value.get("trusted_client_ip").is_none(), + "unset trusted_client_ip should not be serialized, got {value}" + ); + } + + #[test] + fn serialized_default_config_stays_readable_by_the_base_revision_schema() { + let settings = Settings::from_toml(&crate_test_settings_str()) + .expect("should parse settings without trusted client IP configuration"); + + let value = serde_json::to_value(&settings).expect("should serialize settings"); + + serde_json::from_value::(value) + .expect("base revision schema should accept a config blob with no trusted client IP"); + } + + #[test] + fn trusted_client_ip_parses_and_redacts_shared_secret_in_debug_output() { + let settings = Settings::from_toml(&trusted_client_ip_toml( + "fastly-client-ip", + "x-trusted-client-auth", + "fictional-shared-secret-0123456789", + )) + .expect("should parse valid trusted client IP configuration"); + let config = settings + .trusted_client_ip + .expect("should retain trusted client IP configuration"); + + assert_eq!(config.ip_header, "fastly-client-ip"); + assert_eq!(config.auth_header, "x-trusted-client-auth"); + let debug = format!("{config:?}"); + assert!( + debug.contains("[REDACTED]"), + "should redact trusted client IP shared secret in debug output" + ); + assert!( + !debug.contains("fictional-shared-secret-0123456789"), + "should not expose trusted client IP shared secret in debug output" + ); + } + + #[test] + fn trusted_client_ip_accepts_x_prefixed_ip_header() { + let settings = Settings::from_toml(&trusted_client_ip_toml( + "x-trusted-client-ip", + "x-trusted-client-auth", + "fictional-shared-secret-0123456789", + )) + .expect("should accept an x-prefixed trusted client IP header"); + let config = settings + .trusted_client_ip + .expect("should retain trusted client IP configuration"); + + assert_eq!( + config.ip_header, "x-trusted-client-ip", + "should retain the x-prefixed trusted client IP header" + ); + } + + #[test] + fn trusted_client_ip_authentication_requires_an_exact_match() { + let settings = Settings::from_toml(&trusted_client_ip_toml( + "fastly-client-ip", + "x-trusted-client-auth", + "fictional-shared-secret-0123456789", + )) + .expect("should parse valid trusted client IP configuration"); + let config = settings + .trusted_client_ip + .expect("should retain trusted client IP configuration"); + + assert!( + config.authenticates("fictional-shared-secret-0123456789"), + "should authenticate an exact shared secret match" + ); + assert!( + !config.authenticates("fictional-wrong-secret"), + "should reject a different shared secret" + ); + assert!( + !config.authenticates(" fictional-shared-secret-0123456789"), + "should reject a leading-whitespace shared secret" + ); + assert!( + !config.authenticates("fictional-shared-secret-0123456789 "), + "should reject a trailing-whitespace shared secret" + ); + } + + #[test] + fn trusted_client_ip_rejects_identical_header_names() { + for (ip_header, auth_header) in [ + ("x-trusted-client", "x-trusted-client"), + ("X-Trusted-Client", "x-trusted-client"), + ] { + let error = Settings::from_toml(&trusted_client_ip_toml( + ip_header, + auth_header, + "fictional-shared-secret-0123456789", + )) + .expect_err("should reject identical trusted client IP header names"); + + assert!( + format!("{error:?}").contains("identical_trusted_client_ip_headers"), + "should identify duplicate trusted client IP header names" + ); + } + } + + #[test] + fn trusted_client_ip_rejects_unsafe_header_names() { + for (ip_header, auth_header, expected_code) in [ + ( + "host", + "x-trusted-client-auth", + "unsafe_trusted_client_ip_header", + ), + ( + "fastly-client-ip", + "authorization", + "unsafe_trusted_client_ip_auth_header", + ), + ] { + let error = Settings::from_toml(&trusted_client_ip_toml( + ip_header, + auth_header, + "fictional-shared-secret-0123456789", + )) + .expect_err("should reject unsafe trusted client IP header names"); + let message = format!("{error:?}"); + + assert!( + message.contains(expected_code), + "should identify unsafe trusted client IP header names" + ); + assert!( + !message.contains("fictional-shared-secret-0123456789"), + "should not include the shared secret in validation errors" + ); + } + } + + #[test] + fn trusted_client_ip_rejects_reserved_internal_headers() { + for (ip_header, auth_header) in [ + ("x-ts-tls-protocol", "x-trusted-client-auth"), + ("x-ts-tls-cipher", "x-trusted-client-auth"), + ("fastly-client-ip", "x-ts-tls-protocol"), + ("fastly-client-ip", "x-ts-tls-cipher"), + ("x-forwarded-for", "x-trusted-client-auth"), + ("x-geo-info-available", "x-trusted-client-auth"), + ("fastly-client-ip", "x-ts-ec"), + ] { + let error = Settings::from_toml(&trusted_client_ip_toml( + ip_header, + auth_header, + "fictional-shared-secret-0123456789", + )) + .expect_err("should reject reserved internal headers"); + + assert!( + format!("{error:?}").contains("reserved_trusted_client_ip_header"), + "should identify reserved internal headers" + ); + } + } + + #[test] + fn trusted_client_ip_rejects_empty_secret_malformed_names_and_incomplete_sections() { + let empty_secret = Settings::from_toml(&trusted_client_ip_toml( + "fastly-client-ip", + "x-trusted-client-auth", + "", + )); + assert!( + empty_secret.is_err(), + "should reject an empty trusted client IP shared secret" + ); + + for (ip_header, auth_header, expected_code) in [ + ( + "invalid header", + "x-trusted-client-auth", + "invalid_trusted_client_ip_header", + ), + ( + "fastly-client-ip", + "invalid header", + "invalid_trusted_client_ip_auth_header", + ), + ] { + let error = Settings::from_toml(&trusted_client_ip_toml( + ip_header, + auth_header, + "fictional-shared-secret-0123456789", + )) + .expect_err("should reject malformed trusted client IP header names"); + assert!( + format!("{error:?}").contains(expected_code), + "should identify malformed trusted client IP header names" + ); + } + + for section in [ + "[trusted_client_ip]\nauth_header = \"x-trusted-client-auth\"\nshared_secret = \"fictional-shared-secret-0123456789\"", + "[trusted_client_ip]\nip_header = \"fastly-client-ip\"\nshared_secret = \"fictional-shared-secret-0123456789\"", + "[trusted_client_ip]\nip_header = \"fastly-client-ip\"\nauth_header = \"x-trusted-client-auth\"", + "[trusted_client_ip]\nip_header = \"fastly-client-ip\"\nauth_header = \"x-trusted-client-auth\"\nshared_secret = \"fictional-shared-secret-0123456789\"\nunknown_field = true", + ] { + let result = + Settings::from_toml(&format!("{}\n{section}\n", crate_test_settings_str())); + assert!( + result.is_err(), + "should reject incomplete or unknown trusted client IP configuration" + ); + } + } + + #[test] + fn trusted_client_ip_rejects_control_byte_auth_header_without_exposing_secret() { + let mut settings = serde_json::to_value( + Settings::from_toml(&crate_test_settings_str()) + .expect("should parse base settings for JSON validation"), + ) + .expect("should serialize base settings for JSON validation"); + settings["trusted_client_ip"] = json!({ + "ip_header": "fastly-client-ip", + "auth_header": "x-trusted\u{0000}client-auth", + "shared_secret": "fictional-control-byte-secret-0123", + }); + + let error = Settings::from_json_value(settings) + .expect_err("should reject a control byte in the trusted client IP auth header"); + let message = format!("{error:?}"); + + assert!( + message.contains("invalid_trusted_client_ip_auth_header"), + "should identify the malformed trusted client IP auth header" + ); + assert!( + !message.contains("fictional-control-byte-secret-0123"), + "should not expose the trusted client IP shared secret in validation errors" + ); + } + + #[test] + fn trusted_client_ip_rejects_a_31_byte_shared_secret_without_exposing_it() { + let shared_secret = "1234567890123456789012345678901"; + let error = Settings::from_toml(&trusted_client_ip_toml( + "fastly-client-ip", + "x-trusted-client-auth", + shared_secret, + )) + .expect_err("should reject a shared secret below the minimum length"); + let message = format!("{error:?}"); + + assert!( + message.contains("short_trusted_client_ip_shared_secret"), + "should identify the undersized trusted client IP shared secret" + ); + assert!( + !message.contains(shared_secret), + "should not expose the undersized trusted client IP shared secret" + ); + } + + #[test] + fn trusted_client_ip_accepts_an_exactly_32_byte_ascii_graphic_shared_secret() { + let shared_secret = "0123456789abcdef0123456789ABCDEF"; + let settings = Settings::from_toml(&trusted_client_ip_toml( + "fastly-client-ip", + "x-trusted-client-auth", + shared_secret, + )) + .expect("should accept an exactly 32-byte ASCII graphic shared secret"); + let config = settings + .trusted_client_ip + .expect("should retain trusted client IP configuration"); + + assert_eq!( + config.shared_secret.expose(), + shared_secret, + "should retain the accepted shared secret" + ); + } + + #[test] + fn trusted_client_ip_rejects_a_non_ascii_shared_secret_without_exposing_it() { + let shared_secret = "ascii-graphic-secret-0123456789é"; + let error = Settings::from_toml(&trusted_client_ip_toml( + "fastly-client-ip", + "x-trusted-client-auth", + shared_secret, + )) + .expect_err("should reject a non-ASCII shared secret that exceeds 32 bytes"); + let message = format!("{error:?}"); + + assert!( + message.contains("invalid_trusted_client_ip_shared_secret"), + "should identify the non-header-safe trusted client IP shared secret" + ); + assert!( + !message.contains(shared_secret), + "should not expose the non-ASCII trusted client IP shared secret" + ); + } + + #[test] + fn trusted_client_ip_rejects_a_shared_secret_with_an_embedded_space_without_exposing_it() { + let shared_secret = "valid-shared-secret-with space-012345"; + let error = Settings::from_toml(&trusted_client_ip_toml( + "fastly-client-ip", + "x-trusted-client-auth", + shared_secret, + )) + .expect_err("should reject a shared secret containing an ASCII space"); + let message = format!("{error:?}"); + + assert!( + message.contains("invalid_trusted_client_ip_shared_secret"), + "should identify the non-header-safe trusted client IP shared secret" + ); + assert!( + !message.contains(shared_secret), + "should not expose the shared secret containing an ASCII space" + ); + } + + #[test] + fn trusted_client_ip_rejects_a_shared_secret_with_an_embedded_tab_without_exposing_it() { + let shared_secret = "valid-shared-secret-with\t-tab-012345"; + let mut settings = serde_json::to_value( + Settings::from_toml(&crate_test_settings_str()) + .expect("should parse base settings for JSON validation"), + ) + .expect("should serialize base settings for JSON validation"); + settings["trusted_client_ip"] = json!({ + "ip_header": "fastly-client-ip", + "auth_header": "x-trusted-client-auth", + "shared_secret": shared_secret, + }); + + let error = Settings::from_json_value(settings) + .expect_err("should reject a shared secret containing a horizontal tab"); + let message = format!("{error:?}"); + + assert!( + message.contains("invalid_trusted_client_ip_shared_secret"), + "should identify the non-header-safe trusted client IP shared secret" + ); + assert!( + !message.contains(shared_secret), + "should not expose the shared secret containing a horizontal tab" + ); + } + + #[test] + fn trusted_client_ip_rejects_a_shared_secret_with_del_without_exposing_it() { + let shared_secret = "valid-shared-secret-with\u{007f}-del-012345"; + let mut settings = serde_json::to_value( + Settings::from_toml(&crate_test_settings_str()) + .expect("should parse base settings for JSON validation"), + ) + .expect("should serialize base settings for JSON validation"); + settings["trusted_client_ip"] = json!({ + "ip_header": "fastly-client-ip", + "auth_header": "x-trusted-client-auth", + "shared_secret": shared_secret, + }); + + let error = Settings::from_json_value(settings) + .expect_err("should reject a shared secret containing DEL"); + let message = format!("{error:?}"); + + assert!( + message.contains("invalid_trusted_client_ip_shared_secret"), + "should identify the non-header-safe trusted client IP shared secret" + ); + assert!( + !message.contains(shared_secret), + "should not expose the shared secret containing DEL" + ); + } + + #[test] + fn trusted_client_ip_rejects_a_shared_secret_with_a_control_byte_without_exposing_it() { + let shared_secret = "valid-shared-secret-with\u{0001}-control-012345"; + let mut settings = serde_json::to_value( + Settings::from_toml(&crate_test_settings_str()) + .expect("should parse base settings for JSON validation"), + ) + .expect("should serialize base settings for JSON validation"); + settings["trusted_client_ip"] = json!({ + "ip_header": "fastly-client-ip", + "auth_header": "x-trusted-client-auth", + "shared_secret": shared_secret, + }); + + let error = Settings::from_json_value(settings) + .expect_err("should reject a shared secret containing a control byte"); + let message = format!("{error:?}"); + + assert!( + message.contains("invalid_trusted_client_ip_shared_secret"), + "should identify the non-header-safe trusted client IP shared secret" + ); + assert!( + !message.contains(shared_secret), + "should not expose the shared secret containing a control byte" + ); + } + + #[test] + fn trusted_client_ip_rejects_placeholder_shared_secrets() { + for placeholder in TrustedClientIpConfig::SHARED_SECRET_PLACEHOLDERS { + assert!( + TrustedClientIpConfig::is_placeholder_shared_secret(placeholder), + "should detect placeholder shared secret '{placeholder}'" + ); + assert!( + TrustedClientIpConfig::is_placeholder_shared_secret(&placeholder.to_uppercase()), + "should detect placeholder shared secret case-insensitively" + ); + + let settings = Settings::from_toml(&trusted_client_ip_toml( + "fastly-client-ip", + "x-trusted-client-auth", + placeholder, + )) + .expect("should parse a placeholder trusted client IP shared secret"); + let error = settings + .reject_placeholder_secrets() + .expect_err("should reject a placeholder trusted client IP shared secret"); + + assert!( + format!("{error:?}").contains("trusted_client_ip.shared_secret"), + "should name the placeholder trusted client IP shared secret field" + ); + } + } + #[test] fn auction_debug_comment_options_default_matches_serde_defaults() { let opts = AuctionDebugCommentOptions::default(); diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 1240dce20..35bb7a21f 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -66,17 +66,18 @@ fail and the service will return its startup-error response. ## Key Sections -| Section | Purpose | -| ------------------- | -------------------------------------------- | -| `[publisher]` | Domain, origin, proxy settings | -| `[ec]` | Edge Cookie (EC) ID generation | -| `[tester_cookie]` | Optional tester-cookie endpoint | -| `[proxy]` | Proxy SSRF allowlist and asset routes | -| `[cache]` | Static/rehosted asset cache policy rules | -| `[image_optimizer]` | Reusable Image Optimizer profile sets | -| `[request_signing]` | Ed25519 request signing | -| `[auction]` | Auction orchestration | -| `[integrations.*]` | Partner integrations (Prebid, Next.js, etc.) | +| Section | Purpose | +| --------------------- | -------------------------------------------- | +| `[publisher]` | Domain, origin, proxy settings | +| `[trusted_client_ip]` | Authenticated client-IP forwarding | +| `[ec]` | Edge Cookie (EC) ID generation | +| `[tester_cookie]` | Optional tester-cookie endpoint | +| `[proxy]` | Proxy SSRF allowlist and asset routes | +| `[cache]` | Static/rehosted asset cache policy rules | +| `[image_optimizer]` | Reusable Image Optimizer profile sets | +| `[request_signing]` | Ed25519 request signing | +| `[auction]` | Auction orchestration | +| `[integrations.*]` | Partner integrations (Prebid, Next.js, etc.) | ## Example: Production Setup @@ -353,6 +354,84 @@ a zero-byte cap fails every non-empty publisher response. TRUSTED_SERVER__PUBLISHER__MAX_BUFFERED_BODY_BYTES=16777216 ``` +## Trusted Client IP Configuration + +Use this optional section when a trusted CDN service forwards requests to the +Fastly service running Trusted Server. It lets Trusted Server use the reader's +address instead of the immediate fronting edge node's address. Only the Fastly +adapter honours this section; the Cloudflare, Spin, and Axum adapters validate +it but keep using their own runtime client address. + +### `[trusted_client_ip]` + +| Field | Type | Required | Description | +| --------------- | ------ | -------- | --------------------------------------------------------------------------------- | +| `ip_header` | String | Yes | Header containing exactly one reader IP address | +| `auth_header` | String | Yes | Header containing exactly one shared-secret value | +| `shared_secret` | String | Yes | Secret shared with the trusted front door, 32+ ASCII graphic bytes, no whitespace | + +All three fields are required when the section exists. When the section is +absent, Trusted Server continues to use the immediate peer address, and +`ts config push` omits the section from the published config blob so instances +running an older binary keep accepting the blob. + +Once the section is configured, the pushed blob carries it. Instances running a +binary that predates trusted client-IP support reject that blob, so upgrade +every instance before pushing a config that enables this section, and restore a +config without the section before rolling instances back. + +```toml +[trusted_client_ip] +ip_header = "fastly-client-ip" +auth_header = "x-ts-client-ip-auth" +shared_secret = "replace-with-a-random-shared-secret" +``` + +The front door must overwrite both headers on every request. Trusted Server +accepts the forwarded address only when the request has exactly one +`auth_header` value that matches `shared_secret` byte-for-byte and exactly one +`ip_header` value that parses directly as IPv4 or IPv6. Values are not trimmed +or normalized. Missing, empty, duplicate, non-UTF-8, mismatched, or malformed +values do not reject the request; Trusted Server safely falls back to the +immediate peer address. Both configured headers are removed before routing. + +Header names are validated case-insensitively. `ip_header` must be +`fastly-client-ip` or start with `x-`, while `auth_header` must start with `x-`. +The names must differ. Neither field may use a header name reserved for +Trusted Server's own internal signals (for example `x-forwarded-for`, +`x-geo-info-available`, `x-ts-ec`, `x-ts-tls-protocol`, or `x-ts-tls-cipher`); +the full reserved set is the internal-header list that Trusted Server strips +before forwarding to third parties. These restrictions exclude standard +sensitive headers such as `Host`, `Content-Length`, `Cookie`, and +`Authorization`, as well as every Trusted Server internal header. Choose +dedicated `x-` names that no other application or routing logic uses, because +Trusted Server removes the configured headers before routing. + +Generate `shared_secret` with a cryptographically secure random generator, +encode it as hex or base64url, store the same value only in the front door and +Trusted Server configuration, and never commit it. The value is redacted from +configuration debug output. Configuration requires at least 32 ASCII graphic +bytes (`!` through `~`) with no whitespace, controls, DEL, or non-ASCII bytes, +and startup fails when the value is still the documented placeholder. + +Redaction protects debug output and validation errors; it does not move the +value into a platform secret store. `ts config push` serializes the value in the +Trusted Server application-config blob, so restrict access to that configuration +store. Every adapter removes the configured IP and authentication headers before +routing, although only Fastly uses them for client-IP resolution. + +**Environment Overrides**: + +```bash +TRUSTED_SERVER__TRUSTED_CLIENT_IP__IP_HEADER=fastly-client-ip +TRUSTED_SERVER__TRUSTED_CLIENT_IP__AUTH_HEADER=x-ts-client-ip-auth +TRUSTED_SERVER__TRUSTED_CLIENT_IP__SHARED_SECRET=replace-with-a-random-shared-secret +``` + +Because the typed environment overlay cannot create a missing section, add +`[trusted_client_ip]` and all three fields to the TOML before using these +overrides. + ## Tester Cookie Configuration Settings for the optional tester-cookie endpoints. This feature is disabled by diff --git a/docs/guide/fastly.md b/docs/guide/fastly.md index 20faf1995..6a37a263a 100644 --- a/docs/guide/fastly.md +++ b/docs/guide/fastly.md @@ -72,6 +72,84 @@ When you're ready to use your own domain: - Fastly Compute **only accepts client traffic via TLS** (HTTPS) - Origins and backends can be non-TLS if needed +## CDN-fronted Client IP + +When another CDN or Fastly service fronts Trusted Server, the Fastly Compute +client address identifies the immediate edge node rather than the original +reader. Trusted Server can instead consume an authenticated reader-IP header, +but only after the public front door is configured to overwrite both the IP and +authentication headers on every backend request. Preserving values supplied by +the browser is unsafe because a caller could choose the IP used for geolocation +and other request processing. + +For a dedicated VCL-to-Compute [service chain](https://www.fastly.com/documentation/guides/getting-started/services/service-chaining/), +where every request from the VCL service goes to Trusted Server, overwrite +[`Fastly-Client-IP`](https://www.fastly.com/documentation/reference/http/http-headers/Fastly-Client-IP/) +from the initial [`client.ip`](https://www.fastly.com/documentation/reference/vcl/variables/client-connection/client-ip/) +and set a dedicated authentication header in the fronting service. Read the +secret from a private (write-only) edge dictionary instead of placing it in the +VCL source. For example: + +```vcl +sub vcl_recv { + if (fastly.ff.visits_this_service == 0 && req.restarts == 0) { + unset req.http.Fastly-Client-IP; + unset req.http.X-TS-Client-IP-Auth; + + set req.http.Fastly-Client-IP = client.ip; + set req.http.X-TS-Client-IP-Auth = + table.lookup(ts_private_config, "trusted_client_ip_secret"); + } +} +``` + +In this example, attach an edge dictionary named `ts_private_config` to the +fronting VCL service and store the shared secret under the key +`trusted_client_ip_secret`. If the service also routes to other backends, wrap +the Trusted Server header setup in the same host or path condition that selects +Trusted Server. Strip any client-supplied authentication header on the other +routes, and do not send the dictionary value to unrelated backends. + +The `unset` before each `set` matters. Trusted Server ignores a forwarded +address whenever either trust header carries more than one value, so a client +that sends its own copy of either header could otherwise force the fallback and +keep its real address out of geolocation and bot protection. + +Use a cryptographically random secret of at least 32 ASCII graphic bytes in +production, encoded as hex or base64url with no whitespace. Keep the fronting +copy in a private edge dictionary rather than inlining it in VCL, where it is +readable by anyone with service-configuration access and preserved in every +version diff. Configure the identical header names and secret in Trusted Server: + +```toml +[trusted_client_ip] +ip_header = "fastly-client-ip" +auth_header = "x-ts-client-ip-auth" +shared_secret = "replace-with-a-random-shared-secret" +``` + +The `shared_secret` value above is an intentionally invalid placeholder. Replace +it with the exact value stored in the fronting service's edge dictionary. + +`Fastly-Client-IP` is not protected from modification when it first enters +Fastly, which is why overwriting it and authenticating the handoff are both +required. Trusted Server removes both trust headers before routing. Direct +requests and requests with missing, invalid, or duplicated trust headers remain +available and use the immediate peer address instead. + +The VCL example assumes that the reader connects directly to the fronting +Fastly service. If another CDN is in front, `client.ip` identifies that CDN's +node instead. In that topology, restrict direct access to the Fastly front door, +derive the IP header from the upstream CDN's protected reader-IP value, and +still overwrite both trust headers before the request enters Trusted Server. + +::: warning No-code request routing limitation +Fastly no-code request routing does not provide a point to inject these headers. +If that routing path does not preserve the original reader IP, Trusted Server +cannot recover it with this mechanism. Use a fronting service that can overwrite +both headers before forwarding the request. +::: + ## Create Config and Secret Stores For features like request signing, you'll need to create Fastly stores: diff --git a/docs/superpowers/plans/2026-08-19-trusted-client-ip-header.md b/docs/superpowers/plans/2026-08-19-trusted-client-ip-header.md new file mode 100644 index 000000000..5bb7ffe62 --- /dev/null +++ b/docs/superpowers/plans/2026-08-19-trusted-client-ip-header.md @@ -0,0 +1,719 @@ +# Trusted Client IP Header Implementation Plan + +## Review remediation + +- [x] Add a shared helper that removes the configured IP and authentication + headers from a core request, with a failing unit test proving both names + are removed while unrelated headers remain. +- [x] Invoke that helper from the outer request middleware in the Axum, + Cloudflare, and Spin adapters so a shared multi-adapter configuration + cannot expose either trust header to routing or integrations. +- [x] Add a regression assertion proving the Fastly entry-point `ClientInfo` + address reaches request-scoped services, which are the EC input. +- [x] Remove the partial `X-Forwarded-For` hardening from this PR and its + documentation. Handle trusted XFF reconstruction consistently across all + adapters in a separate change. +- [x] Document that redaction protects logs and debug output but the secret is + serialized into the Trusted Server application-config blob. +- [x] Retain `/.worktrees/` because this checkout has an active unrelated + worktree under that path; removing the ignore would expose its contents. +- [x] Run formatting plus Fastly, Axum, Cloudflare, and Spin tests and + target-matched Clippy checks. + +## Review follow-up: header-safe shared secrets + +PR review found that the request path reads the authentication field with +`HeaderValue::to_str`, while configuration originally accepted any string of at +least 32 UTF-8 bytes. A non-ASCII secret could therefore pass startup validation +but never authenticate a request. Whitespace accepted by `HeaderValue::to_str` +is also unsuitable because an intermediary may normalize it. + +- [x] Add focused settings tests for the 31/32-byte boundary and rejection of + non-ASCII, horizontal-tab, space, DEL, and other control bytes. Assert + rejected values remain redacted, and run the tests first to demonstrate + the current failure. +- [x] Accept only `shared_secret` bytes in the ASCII graphic range + `0x21..=0x7e`, retaining the 32-byte minimum and redacted errors. +- [x] Update the configuration and Fastly guides to specify 32 or more ASCII + graphic bytes and recommend hexadecimal or base64url generation. +- [x] Run focused settings tests, formatting, target-matched tests, Clippy, and + the Fastly release build before updating the PR. + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Resolve the reader IP behind an authenticated fronting CDN while preserving peer-IP fallback and stripping all trust headers before routing. + +**Architecture:** Add an optional validated `TrustedClientIpConfig` to core settings, including fixed-size constant-time shared-secret verification. The Fastly adapter resolves the address exactly once from the original request, removes both static and configured spoofable headers, and shares the selected address with `ClientInfo` and response geo finalization. + +**Tech Stack:** Rust 2024, Serde/TOML, validator, `sha2`, `subtle`, Fastly Compute SDK, Viceroy, Markdown/VitePress documentation. + +--- + +## File Map + +| File | Responsibility | +| ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | +| `crates/trusted-server-core/src/settings.rs` | Define, deserialize, validate, redact, and authenticate the optional trusted-client-IP configuration. | +| `crates/trusted-server-core/src/http_util.rs` | Treat `Fastly-Client-IP` as spoofable unless it was consumed before sanitization. | +| `crates/trusted-server-adapter-fastly/src/platform.rs` | Resolve one authenticated header value or fall back to the SDK peer IP; construct `ClientInfo` with the resolved value. | +| `crates/trusted-server-adapter-fastly/src/compat.rs` | Remove configured trust headers and the static spoofable-header set from the Fastly request. | +| `crates/trusted-server-adapter-fastly/src/main.rs` | Resolve once before sanitization and feed the same IP into request services and geo finalization. | +| `trusted-server.example.toml` | Show the optional configuration with fictional values. | +| `docs/guide/configuration.md` | Document fields, validation, fallback, and environment overrides. | +| `docs/guide/fastly.md` | Document the front-door overwrite requirement and no-code routing limitation. | + +### Task 1: Add the validated trusted-client-IP configuration + +**Files:** + +- Modify: `crates/trusted-server-core/src/settings.rs:1-20` +- Modify: `crates/trusted-server-core/src/settings.rs:1871-1950` +- Test: `crates/trusted-server-core/src/settings.rs:2590-end` + +- [ ] **Step 1: Write parsing and default-behavior tests** + +Add tests beside the existing settings tests. Build TOML from +`crate_test_settings_str()` and append: + +```rust +#[test] +fn trusted_client_ip_is_absent_by_default() { + let settings = Settings::from_toml(&crate_test_settings_str()) + .expect("should parse settings without trusted client IP"); + + assert!( + settings.trusted_client_ip.is_none(), + "should leave trusted client IP disabled by default" + ); +} + +#[test] +fn trusted_client_ip_parses_and_redacts_secret() { + let toml = format!( + "{}\n[trusted_client_ip]\nip_header = \"fastly-client-ip\"\nauth_header = \"x-ts-client-ip-auth\"\nshared_secret = \"unit-test-shared-secret-0123456789\"\n", + crate_test_settings_str() + ); + let settings = Settings::from_toml(&toml) + .expect("should parse trusted client IP settings"); + let config = settings + .trusted_client_ip + .as_ref() + .expect("should contain trusted client IP settings"); + + assert_eq!(config.ip_header, "fastly-client-ip"); + assert_eq!(config.auth_header, "x-ts-client-ip-auth"); + assert_eq!( + format!("{:?}", config.shared_secret), + "[REDACTED]", + "should redact shared secret" + ); +} +``` + +- [ ] **Step 2: Run the new tests and verify RED** + +Run: + +```bash +cargo test --package trusted-server-core --target aarch64-apple-darwin trusted_client_ip -- --nocapture +``` + +Expected: compilation fails because `Settings::trusted_client_ip` and +`TrustedClientIpConfig` do not exist. + +- [ ] **Step 3: Add the minimum serializable settings shape** + +Add the imports needed for fixed-size digest comparison: + +```rust +use sha2::{Digest as _, Sha256}; +use subtle::ConstantTimeEq as _; +``` + +Define the configuration immediately before `Settings`. Do not add the schema +validator attribute until Step 6, so the intermediate RED build remains valid: + +```rust +/// Authenticated client-IP forwarding configuration for a trusted front door. +#[derive(Debug, Clone, Deserialize, Serialize, Validate)] +#[serde(deny_unknown_fields)] +pub struct TrustedClientIpConfig { + /// Request header containing exactly one forwarded IPv4 or IPv6 address. + pub ip_header: String, + /// Request header containing the shared authentication secret. + pub auth_header: String, + /// Shared secret overwritten by the trusted front door on every request. + #[validate(custom(function = validate_redacted_not_empty))] + pub shared_secret: Redacted, +} + +impl TrustedClientIpConfig { + /// Minimum accepted authentication secret length in ASCII graphic bytes. + const MIN_SHARED_SECRET_LENGTH: usize = 32; + + /// Compares a request authentication value with the configured secret. + #[must_use] + pub fn authenticates(&self, candidate: &str) -> bool { + let expected = Sha256::digest(self.shared_secret.expose().as_bytes()); + let actual = Sha256::digest(candidate.as_bytes()); + bool::from(expected.ct_eq(&actual)) + } +} +``` + +Add the optional nested field to `Settings`: + +```rust +/// Optional authenticated client-IP forwarding configuration. +#[serde(default)] +#[validate(nested)] +pub trusted_client_ip: Option, +``` + +- [ ] **Step 4: Add fail-closed configuration tests** + +Use a small helper that appends a `[trusted_client_ip]` block to the standard +test TOML. Add individual tests proving that parsing/validation rejects: + +```rust +#[test] +fn trusted_client_ip_rejects_identical_headers() { /* same name */ } + +#[test] +fn trusted_client_ip_rejects_case_insensitive_identical_headers() { + /* x-client-ip and X-Client-IP */ +} + +#[test] +fn trusted_client_ip_rejects_unsafe_ip_header() { /* ip_header = "host" */ } + +#[test] +fn trusted_client_ip_rejects_unsafe_auth_header() { /* auth_header = "authorization" */ } + +#[test] +fn trusted_client_ip_rejects_fastly_tls_bridge_headers() { + // Exercise x-ts-tls-protocol and x-ts-tls-cipher in both positions. +} + +#[test] +fn trusted_client_ip_rejects_empty_secret() { /* shared_secret = "" */ } + +#[test] +fn trusted_client_ip_rejects_a_31_byte_secret() { /* shared_secret = 31 * "a" */ } + +#[test] +fn trusted_client_ip_accepts_a_32_byte_ascii_graphic_secret() { + /* shared_secret = 32 * "a" */ +} + +#[test] +fn trusted_client_ip_rejects_non_header_safe_secrets_without_exposing_them() { + // Exercise a >=32-byte non-ASCII value, HTAB, space, DEL, and another + // control byte. Assert each validation error omits the rejected value. +} + +#[test] +fn trusted_client_ip_rejects_malformed_header_names() { /* spaces/control bytes */ } + +#[test] +fn trusted_client_ip_rejects_incomplete_section() { /* omit each required field */ } + +#[test] +fn trusted_client_ip_authentication_is_exact() { + let config = trusted_client_ip_test_config(); + assert!(config.authenticates("unit-test-shared-secret-0123456789")); + assert!(!config.authenticates("wrong-secret")); + assert!(!config.authenticates(" unit-test-shared-secret-0123456789")); +} +``` + +Each rejection assertion must check that `Settings::from_toml` returns an error, +not merely that `validate()` fails on a manually constructed value. + +- [ ] **Step 5: Run the validation tests and verify RED** + +Run the same filtered core test command. Expected: parsing tests pass, while the +unsafe/identical-header tests fail because cross-field validation is not yet +implemented. + +- [ ] **Step 6: Implement header-name and shared-secret validation** + +Add `#[validate(schema(function = "validate_trusted_client_ip_config"))]` to +`TrustedClientIpConfig`, then add helpers that parse names through +`http::HeaderName`, compare normalized lowercase names, and enforce the spec: + +```rust +fn validate_trusted_client_ip_config( + config: &TrustedClientIpConfig, +) -> Result<(), ValidationError> { + let ip_header = http::HeaderName::from_bytes(config.ip_header.as_bytes()) + .map_err(|_| ValidationError::new("invalid_trusted_client_ip_header"))?; + let auth_header = http::HeaderName::from_bytes(config.auth_header.as_bytes()) + .map_err(|_| ValidationError::new("invalid_trusted_client_ip_auth_header"))?; + + if ip_header == auth_header { + return Err(ValidationError::new("duplicate_trusted_client_ip_headers")); + } + if ip_header.as_str() != "fastly-client-ip" && !ip_header.as_str().starts_with("x-") { + return Err(ValidationError::new("unsafe_trusted_client_ip_header")); + } + if !auth_header.as_str().starts_with("x-") { + return Err(ValidationError::new("unsafe_trusted_client_ip_auth_header")); + } + for reserved in ["x-ts-tls-protocol", "x-ts-tls-cipher"] { + if ip_header.as_str() == reserved || auth_header.as_str() == reserved { + return Err(ValidationError::new("reserved_trusted_client_ip_header")); + } + } + let shared_secret = config.shared_secret.expose().as_bytes(); + if shared_secret.len() < TrustedClientIpConfig::MIN_SHARED_SECRET_LENGTH { + return Err(ValidationError::new("short_trusted_client_ip_shared_secret")); + } + if !shared_secret.iter().all(|byte| matches!(byte, b'!'..=b'~')) { + return Err(ValidationError::new("invalid_trusted_client_ip_shared_secret")); + } + Ok(()) +} +``` + +Use descriptive validation messages if the validator API allows them without +duplicating logic. Do not include `shared_secret` in errors. Test the 31/32-byte +boundary plus non-ASCII, horizontal tab, space, DEL, and another control byte. + +- [ ] **Step 7: Run the filtered core tests and verify GREEN** + +Run the filtered command from Step 2. Expected: all `trusted_client_ip` tests +pass with no warnings. + +- [ ] **Step 8: Run the complete native core suite** + +Run: + +```bash +cargo test --package trusted-server-core --target aarch64-apple-darwin +``` + +Expected: PASS. + +- [ ] **Step 9: Commit the settings contract** + +```bash +git add crates/trusted-server-core/src/settings.rs +git commit -m "Add trusted client IP configuration" +``` + +### Task 2: Resolve and sanitize the authenticated Fastly client IP + +**Files:** + +- Modify: `crates/trusted-server-core/src/http_util.rs:35-65` +- Modify: `crates/trusted-server-adapter-fastly/src/platform.rs:690-730` +- Modify: `crates/trusted-server-adapter-fastly/src/compat.rs:50-105` +- Modify: `crates/trusted-server-adapter-fastly/src/main.rs:115-165` +- Test: `crates/trusted-server-adapter-fastly/src/platform.rs:730-end` +- Test: `crates/trusted-server-adapter-fastly/src/compat.rs:65-110` + +- [ ] **Step 1: Write resolver tests for the desired behavior** + +In `platform.rs`, add a test helper returning a `TrustedClientIpConfig` with +fictional headers/secrets, plus focused tests for: + +```rust +#[test] +fn resolve_client_ip_uses_peer_without_config() { /* None config */ } + +#[test] +fn resolve_client_ip_accepts_authenticated_ipv4() { /* 198.51.100.7 */ } + +#[test] +fn resolve_client_ip_accepts_authenticated_ipv6() { /* 2001:db8::7 */ } + +#[test] +fn resolve_client_ip_falls_back_for_missing_authentication() { /* no auth */ } + +#[test] +fn resolve_client_ip_falls_back_for_empty_authentication() { /* auth = "" */ } + +#[test] +fn resolve_client_ip_falls_back_for_wrong_authentication() { /* wrong auth */ } + +#[test] +fn resolve_client_ip_falls_back_for_duplicate_authentication() { + // append_header twice; do not use set_header +} + +#[test] +fn resolve_client_ip_falls_back_for_missing_ip_header() { /* valid auth only */ } + +#[test] +fn resolve_client_ip_falls_back_for_invalid_ip_forms() { + // Separate assertions for whitespace, port, IPv6 zone, comma list, empty. +} + +#[test] +fn resolve_client_ip_falls_back_for_duplicate_ip_headers() { + // append_header twice. +} +``` + +Pass a documentation address such as `203.0.113.9` as the explicit peer value +so unit tests do not depend on SDK connection metadata. Build non-UTF-8 cases +with `fastly::http::HeaderValue::from_bytes` and test both auth and IP fields. + +- [ ] **Step 2: Run the Fastly resolver tests and verify RED** + +Run: + +```bash +cargo test-fastly resolve_client_ip -- --nocapture +``` + +Expected: compilation fails because `resolve_client_ip` does not exist. If +Viceroy again fails before executing tests because the macOS native certificate +keychain is unavailable, record that environmental blocker and still require +the compile phase to succeed after implementation. + +- [ ] **Step 3: Implement a single-value reader and resolver** + +Import `TrustedClientIpConfig` and add: + +```rust +fn single_header_str<'a>(req: &'a Request, name: &str) -> Option<&'a str> { + let mut values = req.get_header_all(name); + let value = values.next()?; + if values.next().is_some() { + return None; + } + value.to_str().ok() +} + +/// Selects an authenticated forwarded address or the immediate peer fallback. +#[must_use] +pub(crate) fn resolve_client_ip( + req: &Request, + peer_ip: Option, + config: Option<&TrustedClientIpConfig>, +) -> Option { + let Some(config) = config else { + return peer_ip; + }; + let Some(auth) = single_header_str(req, &config.auth_header) else { + return peer_ip; + }; + if !config.authenticates(auth) { + return peer_ip; + } + single_header_str(req, &config.ip_header) + .and_then(|value| value.parse::().ok()) + .or(peer_ip) +} +``` + +Keep failures non-fatal and do not log header values. If debug logs are added, +log only a fixed reason category. + +- [ ] **Step 4: Pass the resolved value into `ClientInfo`** + +Change the constructor signature and assignment: + +```rust +pub fn client_info_from_request(req: &Request, client_ip: Option) -> ClientInfo { + ClientInfo { + client_ip, + // existing TLS/JA4/server fields unchanged + } +} +``` + +Add a direct unit test proving a supplied address is preserved even though a +synthetic Fastly request has no client connection metadata. This is the +regression test for shared geo/`ClientInfo` wiring: Step 9 derives the geo input +back from this constructed `ClientInfo`, rather than retaining an independent +parallel value. + +In `main.rs`, update the existing call immediately to pass the already-captured +peer value: + +```rust +let client_info = client_info_from_request(&req, client_ip); +``` + +This is a compile-preserving signature migration only; behavior is still the +old peer-IP behavior until Step 9 wires the resolver. + +- [ ] **Step 5: Write the static sanitization test and verify RED** + +Extend `compat.rs` tests using the function's current one-argument signature: + +```rust +#[test] +fn sanitize_fastly_forwarded_headers_strips_fastly_client_ip_without_config() { + // Set Fastly-Client-IP, sanitize, assert absent. +} +``` + +Run: + +```bash +cargo test-fastly sanitize_fastly_forwarded_headers -- --nocapture +``` + +Expected: the new test fails because `Fastly-Client-IP` is still preserved. + +- [ ] **Step 6: Strip the static header and migrate the helper signature** + +Add `"fastly-client-ip"` to `SPOOFABLE_FORWARDED_HEADERS`. Change the Fastly +compatibility helper to accept an intentionally unused +`Option<&TrustedClientIpConfig>` while preserving its current static loop: + +Import `trusted_server_core::settings::TrustedClientIpConfig` in `compat.rs`. + +```rust +pub(crate) fn sanitize_fastly_forwarded_headers( + req: &mut fastly::Request, + _config: Option<&TrustedClientIpConfig>, +) { + // Existing static loop unchanged. +} +``` + +Update existing compat test calls and the entry-point call to pass `None`. Run +the Step 5 command again. Expected: PASS (or the documented Viceroy environment +failure after successful compilation). This leaves the code green before the +next behavior test. + +- [ ] **Step 7: Write configured sanitization tests and verify RED** + +Now that the two-argument API compiles, add: + +```rust +#[test] +fn sanitize_fastly_forwarded_headers_strips_configured_trust_headers() { + // Set custom IP and auth fields, sanitize with Some(config), assert absent. +} +``` + +Also cover a configuration whose `ip_header` is `fastly-client-ip` to prove +double removal is harmless. Run the focused sanitization command. Expected: the +custom-header test fails because the config argument is not yet consumed. + +- [ ] **Step 8: Implement configured sanitization and verify GREEN** + +Remove the configured IP/auth headers first, then loop over the static list. +Never read or log their values. Run the focused sanitization command again. +Expected: PASS (or the documented post-compilation Viceroy environment failure). + +- [ ] **Step 9: Wire one resolved address through the entry point** + +In `main.rs`, after `settings_snapshot` is created and before sanitization: + +Add `resolve_client_ip` to the existing `crate::platform` import. + +```rust +let trusted_client_ip = settings_snapshot + .as_deref() + .and_then(|settings| settings.trusted_client_ip.as_ref()); +let resolved_client_ip = resolve_client_ip( + &req, + req.get_client_ip_addr(), + trusted_client_ip, +); +compat::sanitize_fastly_forwarded_headers(&mut req, trusted_client_ip); +``` + +Remove the later direct `req.get_client_ip_addr()` capture. Pass +`resolved_client_ip` to `client_info_from_request`, then derive the geo/finalize +input from that object: + +```rust +let client_info = client_info_from_request(&req, resolved_client_ip); +let client_ip = client_info.client_ip; +``` + +Leave both calls to `apply_entry_point_finalize_headers(..., client_ip)` +unchanged. This creates one stored source of truth: the address inserted into +request services and the address passed to response geo are both read from the +same `ClientInfo` construction. The `client_info_from_request` preservation test +from Step 4 proves that selected addresses cross this boundary unchanged. + +The startup-error path has no settings snapshot, so it passes `None`, uses the +peer address, and still strips `Fastly-Client-IP` through the static list. + +- [ ] **Step 10: Run focused tests and verify GREEN** + +Run: + +```bash +cargo test-fastly resolve_client_ip -- --nocapture +cargo test-fastly sanitize_fastly_forwarded_headers -- --nocapture +``` + +Expected: PASS when Viceroy can run. On the known keychain failure, require both +commands to compile the Fastly Wasm test binary successfully before the same +external Viceroy startup error. + +- [ ] **Step 11: Verify Fastly compilation** + +Run: + +```bash +cargo check-fastly +``` + +Expected: PASS with no warnings. + +- [ ] **Step 12: Commit the Fastly behavior** + +```bash +git add \ + crates/trusted-server-core/src/http_util.rs \ + crates/trusted-server-adapter-fastly/src/platform.rs \ + crates/trusted-server-adapter-fastly/src/compat.rs \ + crates/trusted-server-adapter-fastly/src/main.rs +git commit -m "Resolve authenticated forwarded client IP" +``` + +### Task 3: Document secure front-door configuration + +**Files:** + +- Modify: `trusted-server.example.toml:12-20` +- Modify: `docs/guide/configuration.md:50-90` +- Modify: `docs/guide/fastly.md:50-135` + +- [ ] **Step 1: Add the commented example configuration** + +Add after `[publisher]` in `trusted-server.example.toml`: + +```toml +# Optional: trust a fronting CDN's reader IP only when it also supplies the +# matching shared secret. The front door must overwrite both headers. +# [trusted_client_ip] +# ip_header = "fastly-client-ip" +# auth_header = "x-ts-client-ip-auth" +# shared_secret = "replace-with-a-random-shared-secret" +``` + +- [ ] **Step 2: Add the configuration reference** + +Add `[trusted_client_ip]` to the key-sections table and document: + +- all three fields and their required status when the section exists; +- absence preserving peer-IP behavior; +- exact-one-value parsing and fail-to-peer behavior; +- safe header-name restrictions; +- `TRUSTED_SERVER__TRUSTED_CLIENT_IP__*` override names; +- redaction and random-secret requirements. + +Use only fictional `example.com` domains and documentation IP ranges. + +- [ ] **Step 3: Add Fastly front-door instructions** + +Add a "CDN-fronted client IP" section explaining: + +1. The public front door must overwrite the configured IP and auth headers on + every backend request; preserving browser-supplied values is unsafe. +2. For VCL/Fastly chaining, overwrite `Fastly-Client-IP` from the initial + `client.ip` and set the authentication header to the shared secret. +3. Configure the identical header names and secret in Trusted Server. +4. Direct or unauthenticated requests fall back to the immediate peer. +5. Fastly no-code request routing has no header-injection point; if it does not + preserve the reader IP, this mechanism cannot recover it. + +Link to Fastly's official `client.ip`, `Fastly-Client-IP`, and service-chaining +documentation. Do not include a deployable real secret. + +- [ ] **Step 4: Format and inspect documentation** + +Run: + +```bash +(cd docs && npm run format) +git diff --check +git diff -- trusted-server.example.toml docs/guide/configuration.md docs/guide/fastly.md +``` + +Expected: formatting succeeds; only the intended example and guide sections +change; no whitespace errors. + +- [ ] **Step 5: Commit documentation** + +```bash +git add trusted-server.example.toml docs/guide/configuration.md docs/guide/fastly.md +git commit -m "Document trusted client IP forwarding" +``` + +### Task 4: Run final verification and review + +**Files:** + +- Verify: all files changed by Tasks 1-3 + +- [ ] **Step 1: Run formatting checks** + +```bash +cargo fmt --all -- --check +(cd docs && npm run format) +``` + +Expected: PASS with no further diffs after formatting. + +- [ ] **Step 2: Run target-matched tests** + +```bash +cargo test --package trusted-server-core --target aarch64-apple-darwin +cargo test-fastly +``` + +Expected: all core and Fastly tests pass. If Viceroy remains blocked by the +native certificate keychain, preserve the complete error output and separately +confirm `cargo check-fastly` succeeds; do not report `cargo test-fastly` as +passing. + +- [ ] **Step 3: Run target-matched compilation and linting** + +```bash +cargo check-fastly +cargo clippy-fastly +``` + +Expected: PASS with warnings denied by the repository alias. + +- [ ] **Step 4: Run unaffected-adapter regression tests required by CI** + +Because `Settings` and the shared spoofable-header list are in core, run: + +```bash +cargo test-axum +cargo test-cloudflare +cargo test-spin +cargo clippy-axum +cargo clippy-cloudflare +cargo clippy-cloudflare-wasm +cargo clippy-spin-native +cargo clippy-spin-wasm +``` + +Expected: PASS. + +- [ ] **Step 5: Review the final diff for security invariants** + +Confirm from the diff that: + +- no code trusts a forwarded IP without successful authentication; +- duplicate or non-UTF-8 fields cannot be accepted through a first-value API; +- no log or error formats `shared_secret` or request header values; +- both configured trust fields are removed before conversion/routing; +- `ClientInfo` and response geo receive the same resolved address; +- configuration absence preserves peer-IP behavior. + +- [ ] **Step 6: Commit any verification-only corrections** + +If verification required code changes, repeat the narrow failing test first, +then commit only the correction with an imperative sentence-case message. If no +changes were needed, do not create an empty commit. + +- [ ] **Step 7: Request final code review** + +Use `superpowers:requesting-code-review` against the branch diff from `main` and +address any correctness or security findings before handoff. diff --git a/docs/superpowers/specs/2026-08-19-trusted-client-ip-header-design.md b/docs/superpowers/specs/2026-08-19-trusted-client-ip-header-design.md new file mode 100644 index 000000000..125be4a89 --- /dev/null +++ b/docs/superpowers/specs/2026-08-19-trusted-client-ip-header-design.md @@ -0,0 +1,223 @@ +# Trusted Client IP Header Design + +## Summary + +Trusted Server's Fastly adapter currently treats +`fastly::Request::get_client_ip_addr()` as the reader address. On a direct +request that is correct, but on a request chained through another Fastly +service it is the fronting edge node. Geo lookup, EC generation, cluster +classification, consent jurisdiction, auction device IPs, and downstream +integration forwarding then all consume the edge-node address. + +Fastly preserves the original address in `Fastly-Client-IP`, but the header is +caller-controlled at the public edge unless the fronting service overwrites it. +Trusted Server must therefore not trust that header based on presence alone. + +This change adds an optional authenticated client-IP header. Existing +deployments remain unchanged until an operator explicitly configures both the +forwarded-IP and authentication headers with a shared secret. + +Related issues: #1040 and #1041. + +## Goals + +- Allow a CDN-fronted Fastly deployment to supply the reader's IP address. +- Establish an explicit trust boundary before using a forwarded address. +- Use one resolved address consistently for geo and all `ClientInfo` consumers. +- Preserve current peer-IP behavior when the feature is absent or authentication + fails. +- Remove trust headers before routing so they cannot leak downstream or be + interpreted by unrelated code. + +## Non-goals + +- Automatically infer whether a request came from another Fastly service. +- Trust `Fastly-Client-IP`, `Fastly-FF`, or `X-Forwarded-For` by presence. +- Change client-IP resolution in Cloudflare, Spin, or Axum adapters. Those + adapters still remove configured trust headers so a shared configuration + cannot expose the authentication value downstream. +- Add rotating or time-limited request signatures in this change. +- Make Fastly no-code request routing preserve a value it does not expose. + +## Configuration Contract + +Add an optional top-level section: + +```toml +[trusted_client_ip] +ip_header = "fastly-client-ip" +auth_header = "x-ts-client-ip-auth" +shared_secret = "replace-with-a-random-shared-secret" +``` + +All three fields are required when the section is present. `shared_secret` uses +the existing `Redacted` type so debug representations do not disclose +it. Configuration validation rejects invalid header names, identical header +names, secrets containing bytes outside the ASCII graphic range +`0x21..=0x7e`, and secrets shorter than 32 ASCII bytes. This is intentionally +stricter than the request reader's `HeaderValue::to_str` contract, which also +accepts horizontal tab: excluding all whitespace prevents an intermediary from +normalizing a configured secret into a different request value. The shared +`reject_placeholder_secrets` startup gate also +rejects the placeholder secret published in the example configuration and +guides, so a copied config cannot ship a publicly known secret. + +To ensure request entry can remove the fields without deleting a required HTTP +field, `ip_header` must either be `fastly-client-ip` or begin with `x-`, and +`auth_header` must begin with `x-`. Comparison is case-insensitive after parsing +through `http::HeaderName`. The two Fastly-injected TLS bridge fields +(`x-ts-tls-protocol` and `x-ts-tls-cipher`) are forbidden for either setting +because the entry point owns and re-injects them after sanitization. These rules +exclude framing, routing, representation, cookie, and authorization fields such +as `host`, `content-length`, `accept`, `cookie`, and `authorization`. A fronting +CDN whose native client-IP field does not meet this contract must copy it into a +dedicated `x-` field before forwarding. + +The section is absent by default. Because the runtime configuration uses strict +unknown-key validation, the example and configuration reference must document +the exact field names. + +## Request Processing + +The Fastly entry point resolves the client address after application state has +loaded but before spoofable headers are sanitized: + +1. Capture `req.get_client_ip_addr()` as the fallback peer address. +2. If `trusted_client_ip` is absent, select the peer address. +3. If configured, require exactly one authentication-header field value. It + must be representable by `HeaderValue::to_str` and match the configured + ASCII-graphic secret byte-for-byte, without trimming or other normalization. + Compare fixed-size SHA-256 digests using a constant-time comparison. A + missing, duplicated, non-ASCII, empty, or mismatched authentication value + fails authentication. +4. Only after authentication succeeds, require exactly one IP-header field + value and parse it directly as `std::net::IpAddr`. Do not trim or normalize + the value. This accepts canonical or otherwise Rust-supported IPv4 and IPv6 + spellings but rejects whitespace, ports, IPv6 zone identifiers, + comma-separated lists, empty values, non-UTF-8 bytes, and duplicate fields. +5. Select the parsed forwarded address on success. Missing headers, a wrong + secret, a malformed header value, or a non-IP value all select the peer + address without rejecting the request. +6. Remove the configured IP and authentication headers, then run the existing + forwarded-header sanitizer. Steps 1-6 sit behind a single + `resolve_and_sanitize_client_ip` call so resolution cannot be reordered + after the sanitization that removes the headers it reads. +7. Pass the selected address into `client_info_from_request` and use the same + value for entry-point geo response finalization. + +`Fastly-Client-IP` is added to the static spoofable-header list. This ensures it +is stripped even when the feature is not configured. A configured header is +read before sanitization and removed explicitly, so configuring +`fastly-client-ip` remains valid. + +The core request sanitizer removes the two configured trust headers on every +adapter. Only Fastly consumes them, but applying the removal invariant across +adapters prevents a shared multi-platform configuration from forwarding an +authentication value to routing or integrations. Cross-adapter treatment of +client-supplied `X-Forwarded-For` is separate from this feature. + +Authentication failures do not log supplied secrets or IP values. A debug-level +message may record only the reason category (missing authentication, mismatch, +or invalid IP) and that the peer fallback was used. + +## Component Boundaries + +### Core settings + +`trusted-server-core/src/settings.rs` owns the serializable +`TrustedClientIpConfig`, validation, redaction, and constant-time secret +verification. Keeping the security rule with the configuration type prevents +adapter call sites from comparing variable-length secrets directly. + +### Fastly client-IP resolution + +`trusted-server-adapter-fastly/src/platform.rs` owns a small resolver that reads +Fastly request headers and returns either the authenticated forwarded IP or the +SDK peer IP. `client_info_from_request` accepts the already-resolved address so +it cannot accidentally re-read the immediate peer. + +### Fastly entry point and sanitization + +`trusted-server-adapter-fastly/src/main.rs` invokes the resolver once and shares +its result with request services and response geo finalization. +`trusted-server-adapter-fastly/src/compat.rs` removes the configured dynamic +headers and continues applying the static spoofable-header list. + +The Axum, Cloudflare, and Spin outer request middleware invokes the shared core +sanitizer before routing. Those adapters do not use this configuration to +resolve their client address. + +No core EC, consent, auction, or integration logic changes: those consumers +already use `RuntimeServices::client_info().client_ip` correctly. + +## Security Model + +The fronting CDN must overwrite both configured headers on every request sent to +Trusted Server. It must never preserve caller-provided values. The shared secret +must be generated randomly, stored in both the fronting service and Trusted +Server configuration, and excluded from responses and origin requests. + +`Redacted` prevents the secret from appearing in debug output and +validation errors; it does not move the value into a platform secret store. The +secret is serialized in the Trusted Server application-config blob, so access +to that configuration store must be restricted. + +This design protects against callers that can reach the Trusted Server hostname +and inject an arbitrary IP header, provided they do not know the shared secret. +It does not provide replay protection: any party that learns the static secret +can authenticate arbitrary IP values. A timestamped HMAC would address replay +and secret reuse but is outside #1041's requested scope. + +Direct requests remain supported. Without valid trust headers they use the +direct peer address, which is the reader address on a one-hop request. + +## Testing + +Tests follow red-green-refactor and cover: + +- absent configuration uses the peer IP; +- valid authentication plus an IPv4 header selects the forwarded IP; +- valid authentication plus an IPv6 header selects the forwarded IP; +- missing, empty, incorrect, non-UTF-8, or duplicate authentication falls back + to the peer IP; +- whitespace-padded, port-bearing, zone-qualified, comma-separated, non-UTF-8, + empty, or duplicate IP input falls back to the peer IP; +- configured headers are removed after resolution; +- configured headers are removed by every adapter before routing; +- `Fastly-Client-IP` is stripped when configuration is absent; +- settings parse, validation, secret redaction, and default behavior; +- a 31-byte secret is rejected and a 32-byte ASCII-graphic secret is accepted; +- non-ASCII, horizontal-tab, space, DEL, and other control-character shared + secrets fail configuration validation without appearing in the error; +- `client_info_from_request` and entry-point geo finalization receive the same + selected address. + +Targeted Fastly and core tests run after each change. Final verification uses +the repository's Fastly/core test alias, formatting, and target-matched Clippy. +The existing local Viceroy certificate-keychain failure may require running the +Fastly integration suite in an environment with native certificates available; +native unit tests and Wasm compilation still provide local evidence. + +## Documentation + +- Add a commented `[trusted_client_ip]` example to + `trusted-server.example.toml` using only `example.com`-safe material. +- Add the new section to `docs/guide/configuration.md`. +- Add Fastly front-door setup guidance to `docs/guide/fastly.md`, emphasizing + that both headers must be overwritten and that enabling the reader without a + correctly configured front door creates a spoofing vulnerability. +- Document the no-code request-routing limitation from #1041. + +## Acceptance Criteria + +- Configuration absent: runtime behavior remains peer-IP based and + `Fastly-Client-IP` is stripped. +- Valid configured authentication: geo and every `ClientInfo` consumer use the + forwarded reader IP. +- Missing, wrong, or malformed authentication: the request succeeds using the + peer IP. +- Invalid forwarded IP: the request succeeds using the peer IP. +- Trust headers never reach routing or downstream origins. +- Existing direct Fastly deployments require no configuration migration. +- Configuration accepts only shared secrets of 32 or more ASCII graphic bytes + (`0x21..=0x7e`), excluding whitespace and non-ASCII values. diff --git a/trusted-server.example.toml b/trusted-server.example.toml index 5bbdfb857..3705254c1 100644 --- a/trusted-server.example.toml +++ b/trusted-server.example.toml @@ -11,6 +11,13 @@ origin_url = "https://origin.example.com" # origin_host_header_override = "www.example.com" proxy_secret = "change-me-proxy-secret" +# Optional: trust a fronting CDN's reader IP only when it also supplies the +# matching shared secret. The front door must overwrite both headers. +# [trusted_client_ip] +# ip_header = "fastly-client-ip" +# auth_header = "x-ts-client-ip-auth" +# shared_secret = "replace-with-a-random-shared-secret" + [ec] passphrase = "trusted-server-placeholder-secret" ec_store = "ec_identity_store"