Add JA4/TLS fingerprint debug endpoint at /_ts/debug/ja4#646
Add JA4/TLS fingerprint debug endpoint at /_ts/debug/ja4#646
Conversation
ChristianPavilonis
left a comment
There was a problem hiding this comment.
Summary
Thanks for adding the temporary JA4/TLS debug endpoint and route coverage. The implementation is straightforward and CI is green, but before this ships I think the endpoint should be explicitly gated so it cannot become a public same-origin fingerprint reflection API by default.
Additional finding folded into body
Document the debug endpoint and config gate
If this endpoint is retained behind a new trusted-server.toml debug flag, please also document the flag and endpoint behavior in the API/config docs. The docs should make clear that this is temporary/debug-only, disabled by default, what fields are returned (ja4, h2_fp, cipher, tls_version, user-agent, ch-mobile, ch-platform), the fallback values, and that the response uses Cache-Control: no-store, private.
aram356
left a comment
There was a problem hiding this comment.
Summary
Adds a GET /_ts/debug/ja4 endpoint to the Fastly adapter for inspecting JA4/H2/TLS fingerprint metadata and Client Hints. CI is green and the route-level test correctly exercises dispatch. Two blocking concerns remain before this can ship: the endpoint is reachable unauthenticated by default (carried over from the prior review round), and its Cache-Control: no-store, private header is silently overridable by operator [response_headers] because the response flows through finalize_response.
Blocking
🔧 wrench
- Public-by-default fingerprint reflection oracle:
/_ts/debug/ja4is not protected by basic-auth in the default config and reflects values JS cannot otherwise read directly (main.rs:217). Gate behind a debug flag, restrict to staging, or move under an admin path. Cache-Controloverridable by operator[response_headers]: handler-setno-store, privateis replaced byfinalize_responseif operators setCache-Controlin[response_headers](main.rs:134 → main.rs:349-351). Handle the route alongside/healthbeforeroute_requestruns.
Non-blocking
♻️ refactor
- Unit test duplicates the route-level test: identical assertions; route-level test is strictly stronger (main.rs:359-411 vs route_tests.rs:253-317).
🤔 thinking
- Defensive
Varyheader:User-Agent, Client-Hints, and TLS metadata vary the response — useful backstop if the override onCache-Controlis fixed (main.rs:133-136).
🌱 seedling
- Track removal: PR description and code call this "temporary" but neither has a removal trigger or follow-up issue. Add a
// TODO: remove after JA4 evaluation completes — see #645and consider a scheduled cleanup PR.
📝 note
- Bypasses platform abstraction for fields that have one:
tls_protocolandtls_cipherare available viaruntime_services.client_info()(main.rs:115-116). JA4/H2 are Fastly-specific so direct calls are unavoidable for those; mixing in the abstraction for fields that have one would be more consistent.
⛏ nitpick
- Use
header::CACHE_CONTROLin test assertions instead of"cache-control"(main.rs:376, route_tests.rs:282). - Extract magic strings to
consts:"unavailable","not sent","none"and label strings are duplicated across production and both tests (main.rs:113-130).
CI Status
- fmt: PASS
- clippy: PASS
- rust tests: PASS
- js tests: PASS
- wasm32-wasip1 release build: PASS
| } | ||
|
|
||
| // JA4/TLS debug endpoint — only active when debug.ja4_endpoint_enabled = true. | ||
| (Method::GET, "/_ts/debug/ja4") => { |
There was a problem hiding this comment.
🔧 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):
- Gate behind a new
[debug] ja4_endpoint = falseflag intrusted-server.toml(default off). Return 404 when off so the path does not even disclose its existence. - Restrict to staging only — short-circuit when
std::env::var(ENV_FASTLY_IS_STAGING).as_deref() != Ok("1"). - Move the path under an existing admin handler (e.g.
/_ts/admin/debug/ja4covered by an^/_ts/adminbasic-auth handler) and document the required[[handlers]]entry.
| ); | ||
|
|
||
| Response::from_status(fastly::http::StatusCode::OK) | ||
| .with_header(header::CACHE_CONTROL, "no-store, private") |
There was a problem hiding this comment.
🔧 wrench — Cache-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 🔧.
| ); | ||
| assert!( | ||
| body.contains("ch-mobile: not sent"), | ||
| "should include sec-ch-ua-mobile fallback" |
There was a problem hiding this comment.
♻️ 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.
| fastly::http::StatusCode::OK, | ||
| "should return 200 OK" | ||
| ); | ||
| assert_eq!( |
There was a problem hiding this comment.
⛏ 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.
| 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"); |
There was a problem hiding this comment.
⛏ 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.
| Response::from_status(fastly::http::StatusCode::OK) | ||
| .with_header(header::CACHE_CONTROL, "no-store, private") | ||
| .with_content_type(fastly::mime::TEXT_PLAIN_UTF_8) | ||
| .with_body(body) |
There was a problem hiding this comment.
🤔 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.
| 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"); |
There was a problem hiding this comment.
📝 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.
Summary
GET /_ts/debug/ja4endpoint to the Fastly adapter to surface JA4 TLS fingerprint, H2 fingerprint, cipher suite, TLS version, user-agent, and selected Client Hints for browser fingerprint evaluation/_ts/debug/ja4router pathChanges
crates/trusted-server-adapter-fastly/src/main.rsbuild_ja4_debug_response, registeredGET /_ts/debug/ja4, and added unit coverage for fallback response fieldscrates/trusted-server-adapter-fastly/src/route_tests.rs/_ts/debug/ja4dispatch, content type, and fallback body valuesCloses
Closes #645
Test plan
cargo fmt --all -- --checkcargo test --workspacecargo clippy --workspace --all-targets --all-features -- -D warningscargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1cargo test,vitest,integration tests,browser integration tests,format-docs,format-typescript,cargo fmt, and CodeQLfastly compute serveChecklist
unwrap()in production code - useexpect("should ...")logmacros (notprintln!)