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
98 changes: 97 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 @@ -109,6 +109,33 @@ fn main() {
}
}

fn build_ja4_debug_response(req: &Request) -> Response {
let ja4 = req.get_tls_ja4().unwrap_or("unavailable");
let h2 = req.get_client_h2_fingerprint().unwrap_or("unavailable");
let cipher = req.get_tls_cipher_openssl_name().unwrap_or("unavailable");
let tls_version = req.get_tls_protocol().unwrap_or("unavailable");
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.

📝 note — Some of these fields are already on the platform abstraction

tls_protocol and tls_cipher are exposed by the platform abstraction via runtime_services.client_info() (see route_tests.rs:175-176). JA4 and H2 fingerprint are Fastly-specific and not on the abstraction yet, so direct Fastly calls are unavoidable for those. Calling req.get_tls_protocol() / req.get_tls_cipher_openssl_name() directly here is acceptable because this file lives in the adapter-fastly crate, but using the abstraction for fields that have one would be more consistent with how the rest of the adapter reads TLS metadata.

No action required if you'd rather keep one consistent source for all five values in this single handler.

let ua = req.get_header_str("user-agent").unwrap_or("none");
let ch_mobile = req.get_header_str("sec-ch-ua-mobile").unwrap_or("not sent");
let ch_platform = req
.get_header_str("sec-ch-ua-platform")
.unwrap_or("not sent");
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.

nitpick — Extract magic strings to consts

Fallback values ("unavailable", "not sent", "none") and label strings ("ja4: ", etc.) are duplicated across this function and both tests. A handful of consts near build_ja4_debug_response would prevent drift if the format changes.


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 🔧.

.with_content_type(fastly::mime::TEXT_PLAIN_UTF_8)
.with_body(body)
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.

🤔 thinking — Defensive Vary header

Output varies by User-Agent, sec-ch-ua-mobile, sec-ch-ua-platform, and TLS metadata. With no-store set this is moot for shared caches — but if the cache-control gets overridden (see the 🔧 on line 134), Vary: User-Agent, Sec-CH-UA-Mobile, Sec-CH-UA-Platform would be a useful defense-in-depth backstop. Lower priority once the override path is fixed.

}

async fn route_request(
settings: &Settings,
orchestrator: &AuctionOrchestrator,
Expand Down Expand Up @@ -186,6 +213,15 @@ async fn route_request(
}
}

// JA4/TLS debug endpoint — only active when debug.ja4_endpoint_enabled = true.
(Method::GET, "/_ts/debug/ja4") => {
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.

🔧 wrench — Public-by-default fingerprint reflection oracle

enforce_basic_auth only protects paths matched by an explicit [[handlers]] regex (see crates/trusted-server-core/src/auth.rs:32). The default config does not include /_ts/debug, so this route is reachable unauthenticated by anyone — including arbitrary same-origin scripts on the publisher page. It then reflects Fastly-observed JA4, H2 fingerprint, cipher, TLS version, and Client Hints back as same-origin readable text. Browser JS cannot normally read JA4/cipher/TLS-version directly, so this endpoint hands a fingerprinting third-party script exactly the values that being privileged-to-Fastly was protecting.

This is also at odds with trusted-server's privacy-preserving positioning.

This was raised in the prior review round and remains unaddressed.

Fix (any of):

  1. Gate behind a new [debug] ja4_endpoint = false flag in trusted-server.toml (default off). Return 404 when off so the path does not even disclose its existence.
  2. Restrict to staging only — short-circuit when std::env::var(ENV_FASTLY_IS_STAGING).as_deref() != Ok("1").
  3. Move the path under an existing admin handler (e.g. /_ts/admin/debug/ja4 covered by an ^/_ts/admin basic-auth handler) and document the required [[handlers]] entry.

if settings.debug.ja4_endpoint_enabled {
Ok(build_ja4_debug_response(&req))
} else {
Ok(Response::from_status(fastly::http::StatusCode::NOT_FOUND))
}
}

// tsjs endpoints
(Method::GET, "/first-party/proxy") => {
handle_first_party_proxy(settings, runtime_services, req).await
Expand Down Expand Up @@ -320,3 +356,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!(
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.

nitpick — Use header::CACHE_CONTROL constant

Production code uses header::CACHE_CONTROL (line 134); the test uses the literal "cache-control". Use the constant here too for rename safety.

Same applies to route_tests.rs:282.

response.get_content_type(),
Some(mime::TEXT_PLAIN_UTF_8),
"should return plain text content"
);
assert_eq!(
response.get_header_str("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"
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.

♻️ refactor — Unit test duplicates the route-level test

This test and ja4_debug_route_returns_plain_text_fallback_response in route_tests.rs:253-317 assert identical things — status 200, text/plain; charset=utf-8, Cache-Control: no-store, private, and the seven fallback substrings. The route-level test additionally exercises dispatch, which is the load-bearing signal. This unit test is strictly subsumed.

Suggestion: delete this test, or repurpose it to assert formatting output for populated TLS values — but that is currently not testable because Request::get(...) does not stub get_tls_ja4(). Deletion is the simpler call.

);
assert!(
body.contains("ch-platform: not sent"),
"should include sec-ch-ua-platform fallback"
);
}
}
132 changes: 131 additions & 1 deletion crates/trusted-server-adapter-fastly/src/route_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use std::sync::Arc;
use edgezero_core::key_value_store::NoopKvStore;
use error_stack::Report;
use fastly::http::StatusCode;
use fastly::Request;
use fastly::{mime, Request};
use trusted_server_core::auction::build_orchestrator;
use trusted_server_core::integrations::IntegrationRegistry;
use trusted_server_core::platform::{
Expand Down Expand Up @@ -249,3 +249,133 @@ fn configured_missing_consent_store_only_breaks_consent_routes() {
"should scope consent store failures to the consent-dependent routes"
);
}

#[test]
fn ja4_debug_route_returns_404_when_disabled() {
let settings = create_test_settings();
let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator");
let integration_registry =
IntegrationRegistry::new(&settings).expect("should create integration registry");

let req = Request::get("https://test.com/_ts/debug/ja4");
let runtime_services = test_runtime_services(&req);
let response = futures::executor::block_on(route_request(
&settings,
&orchestrator,
&integration_registry,
&runtime_services,
req,
))
.expect("should route ja4 debug request");

assert_eq!(
response.get_status(),
StatusCode::NOT_FOUND,
"should return 404 when debug.ja4_endpoint_enabled is false"
);
}

fn create_debug_enabled_settings() -> Settings {
let base = r#"
[[handlers]]
path = "^/admin"
username = "admin"
password = "admin-pass"

[publisher]
domain = "test-publisher.com"
cookie_domain = ".test-publisher.com"
origin_url = "https://origin.test-publisher.com"
proxy_secret = "unit-test-proxy-secret"

[edge_cookie]
secret_key = "test-secret-key"

[request_signing]
enabled = false
config_store_id = "test-config-store-id"
secret_store_id = "test-secret-store-id"

[consent]
consent_store = "missing-consent-store"

[integrations.prebid]
enabled = true
server_url = "https://test-prebid.com/openrtb2/auction"

[auction]
enabled = true
providers = ["prebid"]
timeout_ms = 2000

[debug]
ja4_endpoint_enabled = true
"#;
Settings::from_toml(base).expect("should parse debug-enabled test settings")
}

#[test]
fn ja4_debug_route_returns_plain_text_fallback_response() {
let settings = create_debug_enabled_settings();
let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator");
let integration_registry =
IntegrationRegistry::new(&settings).expect("should create integration registry");

let req = Request::get("https://test.com/_ts/debug/ja4");
let runtime_services = test_runtime_services(&req);
let mut response = futures::executor::block_on(route_request(
&settings,
&orchestrator,
&integration_registry,
&runtime_services,
req,
))
.expect("should route ja4 debug request");

assert_eq!(
response.get_status(),
StatusCode::OK,
"should return 200 OK for the ja4 debug route"
);
assert_eq!(
response.get_content_type(),
Some(mime::TEXT_PLAIN_UTF_8),
"should return plain text content for the ja4 debug route"
);
assert_eq!(
response.get_header_str("cache-control"),
Some("no-store, private"),
"should disable caching for the ja4 debug route"
);

let body = response.take_body_str();

assert!(
body.contains("ja4: unavailable"),
"should include the JA4 fallback when Fastly omits the fingerprint"
);
assert!(
body.contains("h2_fp: unavailable"),
"should include the H2 fingerprint fallback when Fastly omits it"
);
assert!(
body.contains("cipher: unavailable"),
"should include the cipher fallback when Fastly omits it"
);
assert!(
body.contains("tls_version: unavailable"),
"should include the TLS version fallback when Fastly omits it"
);
assert!(
body.contains("user-agent: none"),
"should include the user-agent fallback when the header is absent"
);
assert!(
body.contains("ch-mobile: not sent"),
"should include the mobile client hints fallback when the header is absent"
);
assert!(
body.contains("ch-platform: not sent"),
"should include the platform client hints fallback when the header is absent"
);
}
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
17 changes: 17 additions & 0 deletions trusted-server.toml
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,23 @@ 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
# All fields fall back to "unavailable" or "not sent" when Fastly does not provide them.
# 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