Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 114 additions & 1 deletion crates/trusted-server-adapter-fastly/src/main.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Comment thread
prk-Jr marked this conversation as resolved.
.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)
Comment thread
prk-Jr marked this conversation as resolved.
}

async fn route_request(
settings: &Settings,
orchestrator: &AuctionOrchestrator,
Expand Down Expand Up @@ -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!(
Comment thread
prk-Jr marked this conversation as resolved.
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"
Comment thread
prk-Jr marked this conversation as resolved.
);
assert!(
body.contains("ch-platform: not sent"),
"should include sec-ch-ua-platform fallback"
);
}
}
14 changes: 14 additions & 0 deletions crates/trusted-server-core/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -423,6 +435,8 @@ pub struct Settings {
pub consent: ConsentConfig,
#[serde(default)]
pub proxy: Proxy,
#[serde(default)]
pub debug: DebugConfig,
}

#[allow(unused)]
Expand Down
18 changes: 18 additions & 0 deletions trusted-server.toml
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,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.
Expand Down
Loading