Skip to content
Open
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")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrenchCache-Control is silently overridable by operator [response_headers]

The handler sets Cache-Control: no-store, private, but the response then flows through finalize_response, whose final loop unconditionally calls response.set_header(key, value) for every operator-configured [response_headers] entry (crates/trusted-server-adapter-fastly/src/main.rs:349-351). If an operator configures Cache-Control = "public, max-age=..." (a common edge default), our no-store, private is silently replaced and a sensitive fingerprint response can land in shared caches.

The route-level test does not catch this because create_test_settings() does not configure [response_headers]. The finalize_response doc says "operators can intentionally override any managed header" — that contract is acceptable for cosmetic headers, not for cache-control on a debug endpoint that exposes TLS metadata.

Fix: Handle this route the same way /health is handled — before route_request/finalize_response (top of main(), after init_logger):

if req.get_method() == Method::GET && req.get_path() == "/_ts/debug/ja4" {
    build_ja4_debug_response(&req).send_to_client();
    return;
}

That also avoids paying the cost of building the auction orchestrator and integration registry for a simple debug probe, and pairs naturally with the staging-only gate suggested in the other 🔧.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — handler moved to main() after get_settings() but before build_orchestrator(), same pattern as /health. Calls send_to_client() directly and returns, bypassing finalize_response entirely. Both the enabled and disabled (404) cases are handled there.

.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 @@ -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.
Expand Down
Loading