diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 52c869d7..a0b17006 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -1,5 +1,5 @@ use error_stack::Report; -use fastly::http::Method; +use fastly::http::{header, Method}; use fastly::{Request, Response}; use trusted_server_core::auction::endpoints::handle_auction; @@ -70,6 +70,17 @@ fn main() { }; log::debug!("Settings {settings:?}"); + // Short-circuit the ja4 debug probe before finalize_response so that + // Cache-Control: no-store, private cannot be replaced by operator [response_headers]. + if req.get_method() == Method::GET && req.get_path() == "/_ts/debug/ja4" { + if settings.debug.ja4_endpoint_enabled { + build_ja4_debug_response(&req).send_to_client(); + } else { + Response::from_status(fastly::http::StatusCode::NOT_FOUND).send_to_client(); + } + return; + } + // Build the auction orchestrator once at startup let orchestrator = match build_orchestrator(&settings) { Ok(o) => o, @@ -109,6 +120,48 @@ fn main() { } } +const FALLBACK_UNAVAILABLE: &str = "unavailable"; +const FALLBACK_NOT_SENT: &str = "not sent"; +const FALLBACK_NONE: &str = "none"; + +// TODO: remove after JA4 evaluation completes — see #645 +fn build_ja4_debug_response(req: &Request) -> Response { + let ja4 = req.get_tls_ja4().unwrap_or(FALLBACK_UNAVAILABLE); + let h2 = req + .get_client_h2_fingerprint() + .unwrap_or(FALLBACK_UNAVAILABLE); + let cipher = req + .get_tls_cipher_openssl_name() + .unwrap_or(FALLBACK_UNAVAILABLE); + let tls_version = req.get_tls_protocol().unwrap_or(FALLBACK_UNAVAILABLE); + let ua = req.get_header_str("user-agent").unwrap_or(FALLBACK_NONE); + let ch_mobile = req + .get_header_str("sec-ch-ua-mobile") + .unwrap_or(FALLBACK_NOT_SENT); + let ch_platform = req + .get_header_str("sec-ch-ua-platform") + .unwrap_or(FALLBACK_NOT_SENT); + + let body = format!( + "ja4: {ja4}\n\ + h2_fp: {h2}\n\ + cipher: {cipher}\n\ + tls_version: {tls_version}\n\ + user-agent: {ua}\n\ + ch-mobile: {ch_mobile}\n\ + ch-platform: {ch_platform}\n" + ); + + Response::from_status(fastly::http::StatusCode::OK) + .with_header(header::CACHE_CONTROL, "no-store, private") + .with_header( + header::VARY, + "User-Agent, Sec-CH-UA-Mobile, Sec-CH-UA-Platform", + ) + .with_content_type(fastly::mime::TEXT_PLAIN_UTF_8) + .with_body(body) +} + async fn route_request( settings: &Settings, orchestrator: &AuctionOrchestrator, @@ -320,3 +373,63 @@ fn finalize_response(settings: &Settings, geo_info: Option<&GeoInfo>, response: response.set_header(key, value); } } + +#[cfg(test)] +mod tests { + use super::*; + use fastly::mime; + + #[test] + fn ja4_debug_response_uses_plain_text_and_fallback_values() { + let req = Request::get("https://example.com/_ts/debug/ja4"); + + let mut response = build_ja4_debug_response(&req); + + assert_eq!( + response.get_status(), + fastly::http::StatusCode::OK, + "should return 200 OK" + ); + assert_eq!( + response.get_content_type(), + Some(mime::TEXT_PLAIN_UTF_8), + "should return plain text content" + ); + assert_eq!( + response.get_header_str(header::CACHE_CONTROL), + Some("no-store, private"), + "should disable caching for the debug response" + ); + + let body = response.take_body_str(); + + assert!( + body.contains("ja4: unavailable"), + "should include JA4 fallback" + ); + assert!( + body.contains("h2_fp: unavailable"), + "should include H2 fingerprint fallback" + ); + assert!( + body.contains("cipher: unavailable"), + "should include cipher fallback" + ); + assert!( + body.contains("tls_version: unavailable"), + "should include TLS version fallback" + ); + assert!( + body.contains("user-agent: none"), + "should include user-agent fallback" + ); + assert!( + body.contains("ch-mobile: not sent"), + "should include sec-ch-ua-mobile fallback" + ); + assert!( + body.contains("ch-platform: not sent"), + "should include sec-ch-ua-platform fallback" + ); + } +} diff --git a/crates/trusted-server-core/src/settings.rs b/crates/trusted-server-core/src/settings.rs index 78549262..f4564bad 100644 --- a/crates/trusted-server-core/src/settings.rs +++ b/crates/trusted-server-core/src/settings.rs @@ -399,6 +399,18 @@ impl Proxy { } } +/// Debug-only features. All flags default to `false` (off in production). +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +pub struct DebugConfig { + /// Expose the JA4/TLS fingerprint debug endpoint at `GET /_ts/debug/ja4`. + /// + /// When `false` (the default), the endpoint returns 404. Enable only for + /// intentional Fastly/browser TLS investigation — the endpoint reflects + /// Fastly-observed TLS details that browser JS cannot normally read. + #[serde(default)] + pub ja4_endpoint_enabled: bool, +} + #[derive(Debug, Default, Clone, Deserialize, Serialize, Validate)] pub struct Settings { #[validate(nested)] @@ -423,6 +435,8 @@ pub struct Settings { pub consent: ConsentConfig, #[serde(default)] pub proxy: Proxy, + #[serde(default)] + pub debug: DebugConfig, } #[allow(unused)] diff --git a/trusted-server.toml b/trusted-server.toml index d9189aaa..6bc7bb0f 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -184,6 +184,24 @@ enabled = false endpoint = "https://origin-mocktioneer.cdintel.com/adserver/mediate" timeout_ms = 1000 +# Debug configuration (all flags default to false — do not enable in production) +# [debug] +# Enable the JA4/TLS fingerprint debug endpoint at GET /_ts/debug/ja4. +# Returns a plain-text response with the following fields (Fastly-observed values): +# ja4 — JA4 TLS client fingerprint +# h2_fp — HTTP/2 client fingerprint +# cipher — TLS cipher suite (OpenSSL name) +# tls_version — TLS protocol version +# user-agent — User-Agent request header +# ch-mobile — Sec-CH-UA-Mobile client hint +# ch-platform — Sec-CH-UA-Platform client hint +# Fastly TLS/fingerprint fields fall back to "unavailable"; client hints fall back +# to "not sent"; user-agent falls back to "none" when absent. +# Response always carries Cache-Control: no-store, private. +# IMPORTANT: This endpoint reflects TLS details that browser JS cannot normally read. +# Disable after investigation is complete. +# ja4_endpoint_enabled = false + # Map auction-request context keys to mediation URL query parameters. # Each key is a context key from the JS client; the value becomes the # query parameter name. Arrays are joined with commas.