Skip to content

Add JA4/TLS fingerprint debug endpoint at /_ts/debug/ja4#646

Open
prk-Jr wants to merge 6 commits intomainfrom
feat/ja4-debug-endpoint
Open

Add JA4/TLS fingerprint debug endpoint at /_ts/debug/ja4#646
prk-Jr wants to merge 6 commits intomainfrom
feat/ja4-debug-endpoint

Conversation

@prk-Jr
Copy link
Copy Markdown
Collaborator

@prk-Jr prk-Jr commented Apr 20, 2026

Summary

  • Adds a temporary GET /_ts/debug/ja4 endpoint 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
  • Keeps the endpoint response readable when Fastly omits individual TLS or Client Hint values by using explicit fallback text
  • Adds route-level regression coverage so the adapter test suite exercises both the response builder and the /_ts/debug/ja4 router path

Changes

File Change
crates/trusted-server-adapter-fastly/src/main.rs Added build_ja4_debug_response, registered GET /_ts/debug/ja4, and added unit coverage for fallback response fields
crates/trusted-server-adapter-fastly/src/route_tests.rs Added a route-level test covering /_ts/debug/ja4 dispatch, content type, and fallback body values

Closes

Closes #645

Test plan

  • cargo fmt --all -- --check
  • cargo test --workspace
  • cargo clippy --workspace --all-targets --all-features -- -D warnings
  • cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
  • GitHub Actions checks green for cargo test, vitest, integration tests, browser integration tests, format-docs, format-typescript, cargo fmt, and CodeQL
  • Manual testing via fastly compute serve

Checklist

  • Changes follow CLAUDE.md conventions
  • No unwrap() in production code - use expect("should ...")
  • Uses log macros (not println!)
  • New code has tests
  • No secrets or credentials committed

@prk-Jr prk-Jr self-assigned this Apr 20, 2026
@aram356 aram356 marked this pull request as draft April 20, 2026 15:41
@prk-Jr prk-Jr changed the title Add temporary JA4/TLS debug endpoint Add JA4/TLS fingerprint debug endpoint at /_ts/debug/ja4 Apr 29, 2026
@prk-Jr prk-Jr marked this pull request as ready for review April 29, 2026 13:40
@prk-Jr prk-Jr requested review from ChristianPavilonis, aram356 and jevansnyc and removed request for ChristianPavilonis April 29, 2026 13:58
Copy link
Copy Markdown
Collaborator

@ChristianPavilonis ChristianPavilonis left a comment

Choose a reason for hiding this comment

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

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.

Comment thread crates/trusted-server-adapter-fastly/src/main.rs Outdated
Copy link
Copy Markdown
Collaborator

@aram356 aram356 left a comment

Choose a reason for hiding this comment

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

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/ja4 is 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-Control overridable by operator [response_headers]: handler-set no-store, private is replaced by finalize_response if operators set Cache-Control in [response_headers] (main.rs:134 → main.rs:349-351). Handle the route alongside /health before route_request runs.

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 Vary header: User-Agent, Client-Hints, and TLS metadata vary the response — useful backstop if the override on Cache-Control is 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 #645 and consider a scheduled cleanup PR.

📝 note

  • Bypasses platform abstraction for fields that have one: tls_protocol and tls_cipher are available via runtime_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_CONTROL in 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") => {
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.

);

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

);
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.

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.

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.

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

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add JA4/TLS fingerprint debug endpoint

3 participants