-
Notifications
You must be signed in to change notification settings - Fork 8
Add JA4/TLS fingerprint debug endpoint at /_ts/debug/ja4 #646
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
f23c4c4
8af61fc
0a32bb8
f0b3bd2
5fc6a4a
bf74be9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
|
|
@@ -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"); | ||
| 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"); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ⛏ nitpick — Extract magic strings to Fallback values ( |
||
|
|
||
| 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") | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔧 wrench — The handler sets The route-level test does not catch this because Fix: Handle this route the same way 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) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤔 thinking — Defensive Output varies by |
||
| } | ||
|
|
||
| async fn route_request( | ||
| settings: &Settings, | ||
| orchestrator: &AuctionOrchestrator, | ||
|
|
@@ -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") => { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔧 wrench — Public-by-default fingerprint reflection oracle
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):
|
||
| 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 | ||
|
|
@@ -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!( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ⛏ nitpick — Use Production code uses Same applies to |
||
| 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" | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ♻️ refactor — Unit test duplicates the route-level test This test and Suggestion: delete this test, or repurpose it to assert formatting output for populated TLS values — but that is currently not testable because |
||
| ); | ||
| assert!( | ||
| body.contains("ch-platform: not sent"), | ||
| "should include sec-ch-ua-platform fallback" | ||
| ); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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_protocolandtls_cipherare exposed by the platform abstraction viaruntime_services.client_info()(seeroute_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. Callingreq.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.