diff --git a/Cargo.lock b/Cargo.lock index 4991d92bd..dfed6c2a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -731,6 +731,7 @@ dependencies = [ "futures", "futures-util", "http", + "ipnet", "log", "redb", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index cde6c0c69..37d8fe7e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,6 +62,7 @@ handlebars = "6" http = "1" http-body = "1" http-body-util = "0.1" +ipnet = "2" log = "0.4" log-fastly = "0.13" matchit = "0.9" diff --git a/crates/edgezero-adapter-axum/Cargo.toml b/crates/edgezero-adapter-axum/Cargo.toml index 9979e39a5..4c67cbdf7 100644 --- a/crates/edgezero-adapter-axum/Cargo.toml +++ b/crates/edgezero-adapter-axum/Cargo.toml @@ -17,6 +17,7 @@ axum = [ "dep:tokio", "dep:tower", "dep:futures-util", + "dep:ipnet", "dep:reqwest", "dep:redb", ] @@ -41,6 +42,7 @@ ctor = { workspace = true, optional = true } futures = { workspace = true } futures-util = { workspace = true, optional = true } http = { workspace = true } +ipnet = { workspace = true, optional = true } log = { workspace = true } redb = { workspace = true, optional = true } reqwest = { workspace = true, optional = true } diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index b27253299..7efb23566 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -31,6 +31,7 @@ use simple_logger::SimpleLogger; use std::collections::BTreeMap; use crate::config_store::AxumConfigStore; +use crate::ja4::TrustedJa4Header; use crate::key_value_store::PersistentKvStore; use crate::secret_store::EnvSecretStore; use crate::service::EdgeZeroAxumService; @@ -77,6 +78,7 @@ pub struct AxumDevServer { config: AxumDevServerConfig, router: RouterService, stores: Stores, + trusted_ja4_header: Option, } impl AxumDevServer { @@ -87,6 +89,7 @@ impl AxumDevServer { config: AxumDevServerConfig::default(), router, stores: Stores::default(), + trusted_ja4_header: None, } } @@ -107,6 +110,7 @@ impl AxumDevServer { router, config, stores, + trusted_ja4_header, } = self; // Allow binding to already-open listener if caller created one to surface errors early. @@ -119,7 +123,14 @@ impl AxumDevServer { let listener = TokioTcpListener::from_std(std_listener) .context("failed to adopt std listener into tokio")?; - serve_with_stores(router, listener, config.enable_ctrl_c, stores).await + serve_with_stores( + router, + listener, + config.enable_ctrl_c, + stores, + trusted_ja4_header, + ) + .await } #[cfg(test)] @@ -128,8 +139,16 @@ impl AxumDevServer { router, config, stores, + trusted_ja4_header, } = self; - serve_with_stores(router, listener, config.enable_ctrl_c, stores).await + serve_with_stores( + router, + listener, + config.enable_ctrl_c, + stores, + trusted_ja4_header, + ) + .await } #[must_use] @@ -139,6 +158,7 @@ impl AxumDevServer { config, router, stores: Stores::default(), + trusted_ja4_header: None, } } @@ -193,6 +213,14 @@ impl AxumDevServer { self.stores.secret_registry = Some(registry); self } + + /// Read the client's JA4 from a trusted TLS-terminating proxy's header. + #[must_use] + #[inline] + pub fn with_trusted_ja4_header(mut self, config: TrustedJa4Header) -> Self { + self.trusted_ja4_header = Some(config); + self + } } fn kv_init_requirement(stores: StoresMetadata) -> KvInitRequirement { @@ -279,6 +307,7 @@ async fn serve_with_stores( listener: TokioTcpListener, enable_ctrl_c: bool, stores: Stores, + trusted_ja4_header: Option, ) -> anyhow::Result<()> { let service = { let mut service = EdgeZeroAxumService::new(router); @@ -300,6 +329,9 @@ async fn serve_with_stores( if let Some(handle) = stores.secrets { service = service.with_secret_handle(handle); } + if let Some(config) = trusted_ja4_header { + service = service.with_trusted_ja4_header(config); + } service }; let axum_router = @@ -357,6 +389,7 @@ where log::warn!("{warning}"); } let addr = resolution.addr; + let trusted_ja4_header = TrustedJa4Header::from_env(&env)?; let app = A::build_app(); let router = app.router().clone(); @@ -386,7 +419,7 @@ where secret_registry, ..Stores::default() }; - serve_with_stores(router, listener, true, request_stores).await + serve_with_stores(router, listener, true, request_stores, trusted_ja4_header).await }) } @@ -985,6 +1018,57 @@ mod integration_tests { server.handle.abort(); } + #[tokio::test(flavor = "multi_thread")] + async fn server_reads_ja4_only_from_trusted_proxy_peers() { + use edgezero_core::ja4::{CapturedJa4, Ja4Source, Ja4Unavailable}; + + async fn ja4_state(ctx: RequestContext) -> Result<&'static str, EdgeError> { + Ok(match ctx.ja4() { + Some(CapturedJa4::Captured { + fingerprint, + source: Ja4Source::TrustedProxyHeader, + }) if fingerprint.as_str() == "t13d1516h2_8daaf6152771_e5627efa2ab1" => "captured", + Some(CapturedJa4::Unavailable(Ja4Unavailable::NotExposed)) => "not_exposed", + _ => "unexpected", + }) + } + + // The test client connects from loopback, so only the first policy trusts it. + for (trusted_proxies, expected) in + [("127.0.0.0/8", "captured"), ("192.0.2.0/24", "not_exposed")] + { + let listener = TokioTcpListener::bind("127.0.0.1:0") + .await + .expect("bind test server"); + let addr = listener.local_addr().expect("local addr"); + let config = AxumDevServerConfig { + addr, + enable_ctrl_c: false, + }; + let trusted = TrustedJa4Header::parse("x-ja4", trusted_proxies) + .expect("should parse trusted proxy config"); + let server = AxumDevServer::with_config( + RouterService::builder().get("/ja4", ja4_state).build(), + config, + ) + .with_trusted_ja4_header(trusted); + let handle = tokio::spawn(async move { + let _result = server.run_with_listener(listener).await; + }); + + let client = reqwest::Client::new(); + let url = format!("http://{addr}/ja4"); + let response = send_with_retry(&client, |http_client| { + http_client + .get(url.as_str()) + .header("x-ja4", "t13d1516h2_8daaf6152771_e5627efa2ab1") + }) + .await; + assert_eq!(response.text().await.expect("body"), expected); + handle.abort(); + } + } + #[tokio::test(flavor = "multi_thread")] async fn server_returns_404_for_unknown_routes() { let router = RouterService::builder().build(); diff --git a/crates/edgezero-adapter-axum/src/ja4.rs b/crates/edgezero-adapter-axum/src/ja4.rs new file mode 100644 index 000000000..4701dc73e --- /dev/null +++ b/crates/edgezero-adapter-axum/src/ja4.rs @@ -0,0 +1,232 @@ +//! JA4 from a trusted TLS-terminating proxy. +//! +//! The Axum adapter serves plain HTTP and never sees a TLS `ClientHello`, so on +//! its own it reports JA4 as `NotExposed`. A TLS-terminating proxy in front of +//! it (`FoxIO`'s nginx module, `HAProxy`'s ja4 module, the Apache Traffic Server +//! plugin, `CloudFront`, …) can compute JA4 and forward it in a header. The +//! adapter records that header only when the direct TCP peer is inside an +//! operator-configured CIDR allowlist; from any other peer JA4 stays +//! `NotExposed`, so clients cannot forge it. + +use std::net::{IpAddr, SocketAddr}; + +use anyhow::{Context as _, bail}; +use edgezero_core::env_config::EnvConfig; +use edgezero_core::http::{HeaderMap, HeaderName}; +use edgezero_core::ja4::{CapturedJa4, Ja4Unavailable}; +use ipnet::IpNet; + +/// `EDGEZERO__ADAPTER__JA4_HEADER`: the header the trusted proxy writes JA4 to. +const HEADER_KEY: [&str; 2] = ["adapter", "ja4_header"]; +/// `EDGEZERO__ADAPTER__JA4_TRUSTED_PROXY_CIDRS`: comma-separated proxy CIDRs or IPs. +const TRUSTED_PROXIES_KEY: [&str; 2] = ["adapter", "ja4_trusted_proxy_cidrs"]; + +/// Operator configuration for reading JA4 from a trusted proxy header. +#[derive(Clone, Debug)] +pub struct TrustedJa4Header { + header: HeaderName, + trusted_proxies: Vec, +} + +impl TrustedJa4Header { + /// Record the client's JA4 for this request. + /// + /// A peer inside the allowlist yields the header's capture (`Captured`, + /// `Missing` or `Malformed`). An unknown or untrusted peer yields + /// `NotExposed`, and the header is ignored. + pub(crate) fn capture(&self, peer: Option, headers: &HeaderMap) -> CapturedJa4 { + if peer.is_some_and(|address| self.trusts(address.ip())) { + CapturedJa4::from_trusted_proxy_header(headers.get_all(&self.header)) + } else { + CapturedJa4::Unavailable(Ja4Unavailable::NotExposed) + } + } + + /// Read the configuration from `EDGEZERO__ADAPTER__JA4_HEADER` and + /// `EDGEZERO__ADAPTER__JA4_TRUSTED_PROXY_CIDRS`. + /// + /// Returns `Ok(None)` when neither variable is set. + /// + /// # Errors + /// Fails closed when only one variable is set, the header name is invalid, + /// or the proxy list is empty, invalid or contains a `/0` network. + #[inline] + pub fn from_env(env: &EnvConfig) -> anyhow::Result> { + match (env.get(&HEADER_KEY), env.get(&TRUSTED_PROXIES_KEY)) { + (None, None) => Ok(None), + (Some(header), Some(proxies)) => Self::parse(header, proxies).map(Some), + _ => bail!( + "EDGEZERO__ADAPTER__JA4_HEADER and EDGEZERO__ADAPTER__JA4_TRUSTED_PROXY_CIDRS \ + must be set together" + ), + } + } + + /// Trust `header` only from peers inside `trusted_proxies`. + #[inline] + #[must_use] + pub const fn new(header: HeaderName, trusted_proxies: Vec) -> Self { + Self { + header, + trusted_proxies, + } + } + + /// Parse a header name and a comma-separated list of CIDRs or bare IPs. + /// + /// # Errors + /// Fails when the header name is invalid, the list is empty, an entry is + /// not a CIDR or IP, or an entry is a `/0` network that would trust every + /// peer. + #[inline] + pub fn parse(header_name: &str, proxy_list: &str) -> anyhow::Result { + let header = HeaderName::from_bytes(header_name.trim().as_bytes()) + .context("invalid JA4 proxy header name")?; + let trusted_proxies = proxy_list + .split(',') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(parse_proxy) + .collect::>>()?; + if trusted_proxies.is_empty() { + bail!("JA4 trusted proxy list is empty"); + } + Ok(Self::new(header, trusted_proxies)) + } + + /// Whether `peer` is a configured proxy. IPv4-mapped IPv6 peers from + /// dual-stack listeners match their IPv4 networks. + fn trusts(&self, peer: IpAddr) -> bool { + let canonical = peer.to_canonical(); + self.trusted_proxies + .iter() + .any(|network| network.contains(&canonical)) + } +} + +fn parse_proxy(entry: &str) -> anyhow::Result { + let network = entry + .parse::() + .or_else(|_error| entry.parse::().map(IpNet::from)) + .with_context(|| format!("invalid JA4 trusted proxy `{entry}`"))?; + if network.prefix_len() == 0 { + bail!("JA4 trusted proxy `{entry}` would trust every peer"); + } + Ok(network) +} + +#[cfg(test)] +mod tests { + use super::TrustedJa4Header; + use edgezero_core::env_config::EnvConfig; + use edgezero_core::http::{HeaderMap, HeaderValue}; + use edgezero_core::ja4::{CapturedJa4, Ja4Source, Ja4Unavailable}; + + const FINGERPRINT: &str = "t13d1516h2_8daaf6152771_e5627efa2ab1"; + + fn headers(values: &[&'static str]) -> HeaderMap { + let mut headers = HeaderMap::new(); + for value in values { + headers.append("x-ja4", HeaderValue::from_static(value)); + } + headers + } + + fn config() -> TrustedJa4Header { + TrustedJa4Header::parse("X-JA4", "10.0.0.0/8, 192.0.2.7, 2001:db8::/32") + .expect("should parse config") + } + + #[test] + fn ja4_proxy_trusted_peer_header_is_captured() { + for peer in [ + "10.1.2.3:443", + "192.0.2.7:1", + "[::ffff:10.0.0.1]:1", + "[2001:db8::1]:1", + ] { + let captured = + config().capture(Some(peer.parse().expect("peer")), &headers(&[FINGERPRINT])); + assert!( + matches!( + captured, + CapturedJa4::Captured { + source: Ja4Source::TrustedProxyHeader, + .. + } + ), + "should capture from trusted peer {peer}" + ); + } + } + + #[test] + fn ja4_proxy_untrusted_or_unknown_peer_is_not_exposed() { + let not_exposed = CapturedJa4::Unavailable(Ja4Unavailable::NotExposed); + for address in [ + Some("192.0.2.8:1"), + Some("11.0.0.1:1"), + Some("[2001:db9::1]:1"), + None, + ] { + let peer = address.map(|text| text.parse().expect("peer")); + assert_eq!( + config().capture(peer, &headers(&[FINGERPRINT])), + not_exposed + ); + } + } + + #[test] + fn ja4_proxy_trusted_peer_reports_missing_and_malformed() { + let peer = Some("10.0.0.1:1".parse().expect("peer")); + assert_eq!( + config().capture(peer, &headers(&[])), + CapturedJa4::Unavailable(Ja4Unavailable::Missing) + ); + for values in [&["not-a-ja4"][..], &[FINGERPRINT, FINGERPRINT]] { + assert_eq!( + config().capture(peer, &headers(values)), + CapturedJa4::Unavailable(Ja4Unavailable::Malformed) + ); + } + } + + #[test] + fn ja4_proxy_config_rejects_unsafe_or_invalid_input() { + for (header, proxies) in [ + ("x-ja4", ""), + ("x-ja4", " , "), + ("x-ja4", "not-an-ip"), + ("x-ja4", "10.0.0.0/33"), + ("x-ja4", "0.0.0.0/0"), + ("x-ja4", "::/0"), + ("bad header", "10.0.0.1"), + ] { + assert!( + TrustedJa4Header::parse(header, proxies).is_err(), + "should reject {header:?} / {proxies:?}" + ); + } + } + + #[test] + fn ja4_proxy_env_requires_both_settings() { + let header = ("EDGEZERO__ADAPTER__JA4_HEADER", "x-ja4"); + let proxies = ("EDGEZERO__ADAPTER__JA4_TRUSTED_PROXY_CIDRS", "10.0.0.0/8"); + assert!( + TrustedJa4Header::from_env(&EnvConfig::from_vars(Vec::<(&str, &str)>::new())) + .expect("should accept no config") + .is_none() + ); + assert!( + TrustedJa4Header::from_env(&EnvConfig::from_vars([header, proxies])) + .expect("should accept full config") + .is_some() + ); + for partial in [[header], [proxies]] { + let _error = TrustedJa4Header::from_env(&EnvConfig::from_vars(partial)) + .expect_err("should require both settings"); + } + } +} diff --git a/crates/edgezero-adapter-axum/src/lib.rs b/crates/edgezero-adapter-axum/src/lib.rs index f6bca2ce8..f31c0ad0c 100644 --- a/crates/edgezero-adapter-axum/src/lib.rs +++ b/crates/edgezero-adapter-axum/src/lib.rs @@ -7,6 +7,8 @@ pub mod context; #[cfg(feature = "axum")] pub mod dev_server; #[cfg(feature = "axum")] +pub mod ja4; +#[cfg(feature = "axum")] pub mod key_value_store; #[cfg(feature = "axum")] pub mod proxy; diff --git a/crates/edgezero-adapter-axum/src/request.rs b/crates/edgezero-adapter-axum/src/request.rs index b8bdb1c81..5b72318a6 100644 --- a/crates/edgezero-adapter-axum/src/request.rs +++ b/crates/edgezero-adapter-axum/src/request.rs @@ -1,4 +1,5 @@ use std::net::SocketAddr; +use std::sync::Arc; use axum::body::{Body as AxumBody, to_bytes}; use axum::extract::connect_info::ConnectInfo; @@ -11,9 +12,11 @@ use edgezero_core::ingress::{ CapturedTarget, HeaderFidelity, InboundOrigin, OriginSource, Preservation, RequestIngress, TargetSource, }; +use edgezero_core::ja4::{CapturedJa4, Ja4Unavailable}; use edgezero_core::proxy::ProxyHandle; use crate::context::AxumRequestContext; +use crate::ja4::TrustedJa4Header; use crate::proxy::AxumProxyClient; /// Marks the plain HTTP listener owned by this adapter. @@ -29,7 +32,15 @@ pub(crate) struct HttpTransport; pub async fn into_core_request(request: Request) -> Result { let (mut parts, axum_body) = request.into_parts(); let transport = parts.extensions.remove::(); - let ingress = capture_request_ingress(&parts.uri, &parts.headers, transport); + let peer = parts + .extensions + .get::>() + .map(|ConnectInfo(addr)| *addr); + let ja4 = parts.extensions.remove::>().map_or( + CapturedJa4::Unavailable(Ja4Unavailable::NotExposed), + |trusted| trusted.capture(peer, &parts.headers), + ); + let ingress = capture_request_ingress(&parts.uri, &parts.headers, transport, ja4); let body = match parts.headers.get(CONTENT_TYPE) { Some(value) if is_json_content_type(value) => { @@ -47,11 +58,7 @@ pub async fn into_core_request(request: Request) -> Result>() - .map(|ConnectInfo(addr)| *addr) - { + if let Some(remote_addr) = peer { core_request .extensions_mut() .remove::>(); @@ -76,6 +83,7 @@ fn capture_request_ingress( uri: &Uri, headers: &HeaderMap, transport: Option, + ja4: CapturedJa4, ) -> RequestIngress { let source = if uri.scheme().is_some() { TargetSource::RuntimeUrl @@ -84,7 +92,7 @@ fn capture_request_ingress( }; let target = CapturedTarget::capture(&uri.to_string(), source, Preservation::Unknown); let common = HeaderFidelity::RUNTIME_HEADER_MAP; - RequestIngress::new(target, inbound_origin(uri, headers, transport), common) + RequestIngress::new(target, inbound_origin(uri, headers, transport), common).with_ja4(ja4) } fn inbound_origin( @@ -268,6 +276,31 @@ mod ingress_tests { CapturedTarget, MAX_TARGET_BYTES, OriginSource, Preservation, RequestIngress, TargetUnavailable, }; + use edgezero_core::ja4::{CapturedJa4, Ja4Unavailable}; + + #[tokio::test] + async fn ingress_ja4_is_not_exposed_even_with_ja4_headers() { + let request = request_builder() + .uri("/") + .header("ja4", "t13d1516h2_8daaf6152771_e5627efa2ab1") + .header( + "cloudfront-viewer-ja4-fingerprint", + "t13d1516h2_8daaf6152771_e5627efa2ab1", + ) + .body(AxumBody::empty()) + .expect("should build request"); + let core = into_core_request(request) + .await + .expect("should convert request"); + let ingress = core + .extensions() + .get::() + .expect("should insert metadata"); + assert_eq!( + ingress.ja4(), + Some(&CapturedJa4::Unavailable(Ja4Unavailable::NotExposed)) + ); + } #[tokio::test] async fn ingress_retains_runtime_parts_without_trusting_headers() { diff --git a/crates/edgezero-adapter-axum/src/service.rs b/crates/edgezero-adapter-axum/src/service.rs index ecaf9b1c1..1d6be7a4f 100644 --- a/crates/edgezero-adapter-axum/src/service.rs +++ b/crates/edgezero-adapter-axum/src/service.rs @@ -1,6 +1,7 @@ use std::convert::Infallible; use std::future::Future; use std::pin::Pin; +use std::sync::Arc; use std::task::{Context, Poll}; use axum::body::Body as AxumBody; @@ -16,6 +17,7 @@ use edgezero_core::store_registry::{ use tokio::{runtime::Handle, task}; use tower::Service; +use crate::ja4::TrustedJa4Header; use crate::request::into_core_request; use crate::response::into_axum_response; @@ -29,6 +31,7 @@ pub struct EdgeZeroAxumService { router: RouterService, secret_handle: Option, secret_registry: Option, + trusted_ja4_header: Option>, } impl EdgeZeroAxumService { @@ -43,6 +46,7 @@ impl EdgeZeroAxumService { router, secret_handle: None, secret_registry: None, + trusted_ja4_header: None, } } @@ -121,6 +125,16 @@ impl EdgeZeroAxumService { self.secret_registry = Some(registry); self } + + /// Read the client's JA4 from a trusted TLS-terminating proxy's header. + /// + /// Without this, `ctx.ja4()` reports `NotExposed` on Axum. + #[must_use] + #[inline] + pub fn with_trusted_ja4_header(mut self, config: TrustedJa4Header) -> Self { + self.trusted_ja4_header = Some(Arc::new(config)); + self + } } impl Service> for EdgeZeroAxumService { @@ -129,7 +143,10 @@ impl Service> for EdgeZeroAxumService { type Response = Response; #[inline] - fn call(&mut self, req: Request) -> Self::Future { + fn call(&mut self, mut req: Request) -> Self::Future { + if let Some(config) = &self.trusted_ja4_header { + req.extensions_mut().insert(Arc::clone(config)); + } let router = self.router.clone(); // Hard-cutoff: legacy bare `KvHandle` / // `ConfigStoreHandle` / `SecretHandle` entries are NO diff --git a/crates/edgezero-adapter-cloudflare/src/request.rs b/crates/edgezero-adapter-cloudflare/src/request.rs index cf5588086..22d381f8e 100644 --- a/crates/edgezero-adapter-cloudflare/src/request.rs +++ b/crates/edgezero-adapter-cloudflare/src/request.rs @@ -12,6 +12,7 @@ use edgezero_core::ingress::{ CapturedTarget, HeaderFidelity, InboundOrigin, OriginSource, Preservation, RequestIngress, TargetSource, }; +use edgezero_core::ja4::CapturedJa4; use edgezero_core::key_value_store::KvHandle; use edgezero_core::proxy::ProxyHandle; use edgezero_core::secret_store::SecretHandle; @@ -276,7 +277,12 @@ pub async fn into_core_request( InboundOrigin::parse(scheme, authority.as_str(), OriginSource::RuntimeUri).ok() }); let fidelity = HeaderFidelity::RUNTIME_HEADER_MAP; - let ingress = RequestIngress::new(target, origin, fidelity); + let ja4 = req + .cf() + .and_then(worker::Cf::bot_management) + .and_then(|bot| bot.ja4()); + let ingress = RequestIngress::new(target, origin, fidelity) + .with_ja4(CapturedJa4::from_runtime_tls(ja4.as_deref())); let mut builder = request_builder().method(method).uri(uri); let headers = req.headers(); diff --git a/crates/edgezero-adapter-cloudflare/tests/contract.rs b/crates/edgezero-adapter-cloudflare/tests/contract.rs index 4e9e6409e..1ec7553c2 100644 --- a/crates/edgezero-adapter-cloudflare/tests/contract.rs +++ b/crates/edgezero-adapter-cloudflare/tests/contract.rs @@ -34,6 +34,7 @@ mod tests { CapturedTarget, MAX_TARGET_BYTES, OriginSource, Preservation, RequestIngress, TargetUnavailable, }; + use edgezero_core::ja4::{CapturedJa4, Ja4Unavailable}; use edgezero_core::router::{PreDispatchHook, RouterService}; use futures::stream; use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; @@ -184,6 +185,26 @@ mod tests { } } + #[wasm_bindgen_test] + async fn ingress_ja4_is_missing_without_cf_despite_header() { + let raw = web_sys::Request::new_with_str("https://example.com/").expect("should construct"); + raw.headers() + .set("ja4", "t13d1516h2_8daaf6152771_e5627efa2ab1") + .expect("should set header"); + let (env, ctx) = test_env_ctx(); + let converted = into_core_request(CfRequest::from(raw), env, ctx) + .await + .expect("should convert Web Request"); + assert_eq!( + converted + .extensions() + .get::() + .expect("should insert metadata") + .ja4(), + Some(&CapturedJa4::Unavailable(Ja4Unavailable::Missing)) + ); + } + #[wasm_bindgen_test] async fn ingress_non_http_runtime_url_has_no_origin() { let raw = web_sys::Request::new_with_str("ftp://example.com/reserved") diff --git a/crates/edgezero-adapter-fastly/src/request.rs b/crates/edgezero-adapter-fastly/src/request.rs index e1f5962a3..d3611baf3 100644 --- a/crates/edgezero-adapter-fastly/src/request.rs +++ b/crates/edgezero-adapter-fastly/src/request.rs @@ -13,6 +13,7 @@ use edgezero_core::http::{Extensions, HeaderValue, Request, Uri, request_builder use edgezero_core::ingress::{ CapturedTarget, HeaderFidelity, InboundOrigin, OriginSource, RequestIngress, TargetUnavailable, }; +use edgezero_core::ja4::CapturedJa4; use edgezero_core::key_value_store::KvHandle; use edgezero_core::proxy::ProxyHandle; use edgezero_core::secret_store::SecretHandle; @@ -608,6 +609,7 @@ pub fn capture_request_ingress(req: &FastlyRequest) -> RequestIngress { None }; RequestIngress::new(target, origin, HeaderFidelity::default()) + .with_ja4(CapturedJa4::from_runtime_tls(req.get_tls_ja4())) } fn origin_from_runtime_uri<'header>( @@ -1101,6 +1103,17 @@ mod ingress_tests { assert!(ingress.origin().is_none()); } + #[test] + fn ingress_ja4_is_missing_for_non_client_request_despite_header() { + use edgezero_core::ja4::{CapturedJa4, Ja4Unavailable}; + let mut native = FastlyRequest::get("https://example.com/"); + native.set_header("ja4", "t13d1516h2_8daaf6152771_e5627efa2ab1"); + assert_eq!( + capture_request_ingress(&native).ja4(), + Some(&CapturedJa4::Unavailable(Ja4Unavailable::Missing)) + ); + } + #[test] fn ingress_snapshot_and_header_bytes_survive_conversion() { let mut native = FastlyRequest::new("EXAMPLE-METHOD", "https://example.com/later"); diff --git a/crates/edgezero-adapter-spin/src/ingress.rs b/crates/edgezero-adapter-spin/src/ingress.rs index afc8f521c..41f8f3537 100644 --- a/crates/edgezero-adapter-spin/src/ingress.rs +++ b/crates/edgezero-adapter-spin/src/ingress.rs @@ -3,6 +3,7 @@ use edgezero_core::ingress::{ CapturedTarget, HeaderFidelity, InboundOrigin, OriginSource, Preservation, RequestIngress, TargetSource, }; +use edgezero_core::ja4::{CapturedJa4, Ja4Unavailable}; pub(crate) fn capture_request_ingress(uri: &Uri) -> RequestIngress { let origin = uri @@ -19,6 +20,7 @@ pub(crate) fn capture_request_ingress(uri: &Uri) -> RequestIngress { let target = CapturedTarget::capture(&uri.to_string(), source, Preservation::Unknown); let headers = HeaderFidelity::RUNTIME_HEADER_MAP; RequestIngress::new(target, origin, headers) + .with_ja4(CapturedJa4::Unavailable(Ja4Unavailable::NotExposed)) } #[cfg(test)] @@ -26,6 +28,16 @@ mod ingress_tests { use super::capture_request_ingress; use edgezero_core::http::Uri; use edgezero_core::ingress::{CapturedTarget, OriginSource, Preservation}; + use edgezero_core::ja4::{CapturedJa4, Ja4Unavailable}; + + #[test] + fn ingress_reports_ja4_not_exposed() { + let uri: Uri = "https://example.com/".parse().expect("should parse URI"); + assert_eq!( + capture_request_ingress(&uri).ja4(), + Some(&CapturedJa4::Unavailable(Ja4Unavailable::NotExposed)) + ); + } #[test] fn ingress_captures_runtime_uri_and_origin_only() { diff --git a/crates/edgezero-core/src/context.rs b/crates/edgezero-core/src/context.rs index 4bde2deaf..27e60f5bf 100644 --- a/crates/edgezero-core/src/context.rs +++ b/crates/edgezero-core/src/context.rs @@ -1,6 +1,8 @@ use crate::body::Body; use crate::error::EdgeError; use crate::http::Request; +use crate::ingress::RequestIngress; +use crate::ja4::CapturedJa4; use crate::params::PathParams; use crate::proxy::ProxyHandle; use crate::store_registry::{ @@ -102,6 +104,16 @@ impl RequestContext { self.request } + /// The adapter's JA4 capture for this request, read from [`RequestIngress`]. + /// `None` if the request has no ingress metadata or the adapter made no claim. + #[inline] + pub fn ja4(&self) -> Option<&CapturedJa4> { + self.request + .extensions() + .get::() + .and_then(RequestIngress::ja4) + } + /// # Errors /// Returns [`EdgeError::bad_request`] if the body is not valid JSON for `T`. #[inline] @@ -756,4 +768,22 @@ mod tests { "unknown id must yield None" ); } + + #[test] + fn ja4_reads_adapter_ingress_claim() { + use crate::ingress::{CapturedTarget, HeaderFidelity, TargetUnavailable}; + use crate::ja4::{CapturedJa4, Ja4Unavailable}; + let mut context = ctx("/", Body::empty(), PathParams::default()); + assert!(context.ja4().is_none()); + let unavailable = CapturedJa4::Unavailable(Ja4Unavailable::NotExposed); + context.request_mut().extensions_mut().insert( + RequestIngress::new( + CapturedTarget::Unavailable(TargetUnavailable::NotExposed), + None, + HeaderFidelity::default(), + ) + .with_ja4(unavailable.clone()), + ); + assert_eq!(context.ja4(), Some(&unavailable)); + } } diff --git a/crates/edgezero-core/src/ingress.rs b/crates/edgezero-core/src/ingress.rs index 1fc72b86b..bf360e5e6 100644 --- a/crates/edgezero-core/src/ingress.rs +++ b/crates/edgezero-core/src/ingress.rs @@ -13,6 +13,7 @@ use http::uri::Authority; use crate::error::EdgeError; use crate::http::HeaderName; +use crate::ja4::CapturedJa4; /// Maximum complete target metadata, measured in UTF-8 bytes. pub const MAX_TARGET_BYTES: usize = 16 * 1024; @@ -308,6 +309,7 @@ pub enum Preservation { #[derive(Clone)] pub struct RequestIngress { common_header_fidelity: HeaderFidelity, + ja4: Option, origin: Option, overrides: Vec<(HeaderName, HeaderFidelity)>, target: CapturedTarget, @@ -324,6 +326,13 @@ impl RequestIngress { .unwrap_or(self.common_header_fidelity) } + /// Borrow the adapter's JA4 capture. `None` means the adapter made no claim. + #[inline] + #[must_use] + pub const fn ja4(&self) -> Option<&CapturedJa4> { + self.ja4.as_ref() + } + /// Construct metadata without header-specific overrides. /// /// Sources and preservation guarantees are caller assertions, not validated @@ -337,6 +346,7 @@ impl RequestIngress { ) -> Self { Self { common_header_fidelity, + ja4: None, origin, overrides: Vec::new(), target, @@ -378,6 +388,14 @@ impl RequestIngress { self.overrides = overrides; Ok(self) } + + /// Record the adapter's JA4 capture. + #[inline] + #[must_use] + pub fn with_ja4(mut self, ja4: CapturedJa4) -> Self { + self.ja4 = Some(ja4); + self + } } impl fmt::Debug for CapturedTarget { @@ -414,6 +432,7 @@ impl fmt::Debug for RequestIngress { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("RequestIngress") .field("common_header_fidelity", &self.common_header_fidelity) + .field("ja4", &self.ja4) .finish_non_exhaustive() } } @@ -450,6 +469,8 @@ mod tests { CapturedTarget, HeaderFidelity, InboundOrigin, OriginSource, Preservation, RequestIngress, TargetSource, TargetUnavailable, }; + use crate::ja4::CapturedJa4; + use crate::ja4::tests::FOXIO_EXAMPLE; #[test] fn ingress_fidelity_defaults_are_unknown() { @@ -606,6 +627,22 @@ mod tests { ); } + #[test] + fn ingress_ja4_defaults_to_no_claim_and_round_trips() { + let ingress = RequestIngress::new( + CapturedTarget::Unavailable(TargetUnavailable::NotExposed), + None, + HeaderFidelity::default(), + ); + assert!(ingress.ja4().is_none()); + let captured = CapturedJa4::from_runtime_tls(Some(FOXIO_EXAMPLE)); + let with_ja4 = ingress.with_ja4(captured.clone()); + assert_eq!(with_ja4.ja4(), Some(&captured)); + let output = format!("{with_ja4:?}"); + assert!(output.contains("RuntimeTls")); + assert!(!output.contains(FOXIO_EXAMPLE)); + } + #[test] fn ingress_target_counts_utf8_bytes() { let value = "\u{e9}".repeat(8193); diff --git a/crates/edgezero-core/src/ja4.rs b/crates/edgezero-core/src/ja4.rs new file mode 100644 index 000000000..b466fc0c5 --- /dev/null +++ b/crates/edgezero-core/src/ja4.rs @@ -0,0 +1,294 @@ +//! Client JA4 TLS fingerprints captured by adapters. +//! +//! JA4 is a TLS client fingerprinting method by `FoxIO`, licensed under +//! BSD-3-Clause. JA4+ is a trademark of `FoxIO`. Other JA4+ methods are not +//! provided. Fingerprints are only ever taken from trusted runtime APIs, never +//! from request headers. + +use std::fmt; + +use crate::error::EdgeError; +use crate::http::HeaderValue; + +/// Byte offset of the `_` that ends `JA4_a`. +const SUMMARY_END: usize = 10; +/// Byte offset of the `_` that ends `JA4_b`. +const CIPHER_HASH_END: usize = 23; +/// Length in bytes of a JA4 fingerprint, which ends with `JA4_c`. +const JA4_LEN: usize = 36; + +/// A JA4 TLS client fingerprint, validated as `a_b_c`. +/// +/// `Debug` is redacted and there is no `Display` or `Serialize`; read the value +/// explicitly with [`Ja4Fingerprint::as_str`] or a section accessor. +#[derive(Clone, PartialEq, Eq)] +pub struct Ja4Fingerprint(Box); + +impl Ja4Fingerprint { + /// The full 36-character fingerprint. + #[inline] + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// `FoxIO` `JA4_b`: truncated SHA-256 of the sorted cipher suites. + #[inline] + #[must_use] + pub fn cipher_hash(&self) -> &str { + self.0 + .get(SUMMARY_END + 1..CIPHER_HASH_END) + .unwrap_or_default() + } + + /// `FoxIO` `JA4_c`: truncated SHA-256 of the sorted extensions and signature algorithms. + #[inline] + #[must_use] + pub fn extension_hash(&self) -> &str { + self.0.get(CIPHER_HASH_END + 1..JA4_LEN).unwrap_or_default() + } + + /// Validate `value` as a JA4 fingerprint without normalising it. + /// + /// # Errors + /// Returns a bad-request error for any value that is not a JA4 + /// fingerprint. The error never contains the input. + #[inline] + pub fn parse(value: &str) -> Result { + let valid = value.len() == JA4_LEN + && value + .bytes() + .enumerate() + .all(|(position, byte)| match position { + // Protocol: TLS, QUIC or DTLS. + 0 => matches!(byte, b't' | b'q' | b'd'), + // TLS version code; tolerant of future codes. + 1 | 2 => matches!(byte, b'a'..=b'z' | b'0'..=b'9'), + // SNI: domain or IP/none. + 3 => matches!(byte, b'd' | b'i'), + // Cipher and extension counts. + 4..=7 => byte.is_ascii_digit(), + // First and last ALPN characters. + 8 | 9 => byte.is_ascii_alphanumeric(), + SUMMARY_END | CIPHER_HASH_END => byte == b'_', + // `JA4_b` and `JA4_c`: lowercase hex. + _ => matches!(byte, b'0'..=b'9' | b'a'..=b'f'), + }); + if valid { + Ok(Self(Box::from(value))) + } else { + Err(EdgeError::bad_request("invalid JA4 fingerprint")) + } + } + + /// `FoxIO` `JA4_a`: protocol, TLS version, SNI, cipher and extension counts, ALPN. + #[inline] + #[must_use] + pub fn summary(&self) -> &str { + self.0.get(..SUMMARY_END).unwrap_or_default() + } +} + +impl fmt::Debug for Ja4Fingerprint { + #[inline] + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Ja4Fingerprint([redacted])") + } +} + +/// What an adapter captured for this request. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CapturedJa4 { + /// A validated fingerprint with its provenance. + Captured { + /// The validated fingerprint. + fingerprint: Ja4Fingerprint, + /// Where the fingerprint came from. + source: Ja4Source, + }, + /// No fingerprint is available, with the reason. + Unavailable(Ja4Unavailable), +} + +impl CapturedJa4 { + /// Map a runtime TLS accessor's result. Invalid values are discarded. + #[inline] + #[must_use] + pub fn from_runtime_tls(value: Option<&str>) -> Self { + value.map_or(Self::Unavailable(Ja4Unavailable::Missing), |present| { + Self::validated(present, Ja4Source::RuntimeTls) + }) + } + + /// Map the values of a JA4 header from a proxy peer the adapter has + /// already verified as trusted. + /// + /// No field is `Missing`. Exactly one field must be a valid fingerprint; + /// a repeated field, non-UTF-8 bytes or an invalid value is `Malformed` + /// and discarded. Callers must not pass headers from unverified peers. + #[inline] + #[must_use] + pub fn from_trusted_proxy_header<'value, I>(values: I) -> Self + where + I: IntoIterator, + { + let mut fields = values.into_iter(); + match (fields.next(), fields.next()) { + (None, _) => Self::Unavailable(Ja4Unavailable::Missing), + (Some(field), None) => field + .to_str() + .map_or(Self::Unavailable(Ja4Unavailable::Malformed), |value| { + Self::validated(value, Ja4Source::TrustedProxyHeader) + }), + (Some(_), Some(_)) => Self::Unavailable(Ja4Unavailable::Malformed), + } + } + + /// Capture a present provider value, discarding it when invalid. + fn validated(value: &str, source: Ja4Source) -> Self { + Ja4Fingerprint::parse(value).map_or( + Self::Unavailable(Ja4Unavailable::Malformed), + |fingerprint| Self::Captured { + fingerprint, + source, + }, + ) + } +} + +/// Where a captured fingerprint came from. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Ja4Source { + /// Computed by the runtime that terminated the client's TLS connection. + RuntimeTls, + /// Computed by a TLS-terminating proxy and read from an operator-configured + /// header, only when the direct peer is a configured trusted proxy. + TrustedProxyHeader, +} + +/// Why no fingerprint was captured. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Ja4Unavailable { + /// The provider returned a value that failed validation; it was discarded. + Malformed, + /// The provider exposes JA4 but supplied none for this request. + Missing, + /// No trusted JA4 provider is available for this request. + NotExposed, +} + +#[cfg(test)] +pub(crate) mod tests { + use crate::http::HeaderValue; + use crate::ja4::{CapturedJa4, Ja4Fingerprint, Ja4Source, Ja4Unavailable}; + + /// The example from the `FoxIO` JA4 technical details. + pub(crate) const FOXIO_EXAMPLE: &str = "t13d1516h2_8daaf6152771_e5627efa2ab1"; + + #[test] + fn ja4_accepts_valid_fingerprints() { + for value in [ + FOXIO_EXAMPLE, + "q13d0310h3_55b375c5d22e_cd85d2d88918", + "d12i0000h2_000000000000_000000000000", + "t13i1516H2_8daaf6152771_e5627efa2ab1", + "t1ad151600_8daaf6152771_e5627efa2ab1", + ] { + let parsed = Ja4Fingerprint::parse(value).expect("should accept JA4"); + assert_eq!(parsed.as_str(), value); + } + } + + #[test] + fn ja4_section_accessors() { + let parsed = Ja4Fingerprint::parse(FOXIO_EXAMPLE).expect("should accept JA4"); + assert_eq!(parsed.summary(), "t13d1516h2"); + assert_eq!(parsed.cipher_hash(), "8daaf6152771"); + assert_eq!(parsed.extension_hash(), "e5627efa2ab1"); + } + + #[test] + fn ja4_rejects_invalid_values() { + let non_ascii = format!("t13d1516h2_8daaf6152771_e5627efa2a{}", '\u{e9}'); + for value in [ + "", + "t13d1516h2_8daaf6152771_e5627efa2ab", + "t13d1516h2_8daaf6152771_e5627efa2ab11", + " t13d1516h2_8daaf6152771_e5627efa2ab", + "t13d1516h2_8daaf6152771_e5627efa2ab ", + "t13d1516h2_8DAAF6152771_e5627efa2ab1", + "t13d1516h2_8daaf6152771_E5627EFA2AB1", + "t13d1516h2-8daaf6152771_e5627efa2ab1", + "t13d1516h2_8daaf6152771-e5627efa2ab1", + "t13d1516h28daaf6152771_e5627efa2ab12", + "x13d1516h2_8daaf6152771_e5627efa2ab1", + "t13x1516h2_8daaf6152771_e5627efa2ab1", + "t13d15a6h2_8daaf6152771_e5627efa2ab1", + "t13d1516h2_8daaf615277g_e5627efa2ab1", + non_ascii.as_str(), + "e7d705a3286e19ea42f587b344ee6865", + "t13d1516h2_002f,0035,009c_0005,000a", + ] { + assert!( + Ja4Fingerprint::parse(value).is_err(), + "should reject {value:?}" + ); + } + } + + #[test] + fn ja4_runtime_mapping() { + assert_eq!( + CapturedJa4::from_runtime_tls(None), + CapturedJa4::Unavailable(Ja4Unavailable::Missing) + ); + assert_eq!( + CapturedJa4::from_runtime_tls(Some(FOXIO_EXAMPLE)), + CapturedJa4::Captured { + fingerprint: Ja4Fingerprint::parse(FOXIO_EXAMPLE).expect("should accept JA4"), + source: Ja4Source::RuntimeTls, + } + ); + assert_eq!( + CapturedJa4::from_runtime_tls(Some("not-a-ja4")), + CapturedJa4::Unavailable(Ja4Unavailable::Malformed) + ); + } + + #[test] + fn ja4_trusted_proxy_header_mapping() { + let valid = HeaderValue::from_static(FOXIO_EXAMPLE); + assert_eq!( + CapturedJa4::from_trusted_proxy_header([]), + CapturedJa4::Unavailable(Ja4Unavailable::Missing) + ); + assert_eq!( + CapturedJa4::from_trusted_proxy_header([&valid]), + CapturedJa4::Captured { + fingerprint: Ja4Fingerprint::parse(FOXIO_EXAMPLE).expect("should accept JA4"), + source: Ja4Source::TrustedProxyHeader, + } + ); + let invalid = HeaderValue::from_static("not-a-ja4"); + let non_utf8 = HeaderValue::from_bytes(b"t13d1516h2_8daaf6152771_e5627efa2a\xff\xff") + .expect("should build opaque value"); + for fields in [vec![&valid, &valid], vec![&invalid], vec![&non_utf8]] { + assert_eq!( + CapturedJa4::from_trusted_proxy_header(fields), + CapturedJa4::Unavailable(Ja4Unavailable::Malformed) + ); + } + } + + #[test] + fn ja4_never_prints_fingerprint_or_input() { + let captured = CapturedJa4::from_runtime_tls(Some(FOXIO_EXAMPLE)); + let output = format!("{captured:?}"); + assert!(!output.contains("8daaf6152771")); + assert!(output.contains("redacted")); + let error = Ja4Fingerprint::parse("secret-input").expect_err("should reject"); + assert!(!format!("{error:?} {error}").contains("secret-input")); + } +} diff --git a/crates/edgezero-core/src/lib.rs b/crates/edgezero-core/src/lib.rs index c0f1e89e8..27d4aa916 100644 --- a/crates/edgezero-core/src/lib.rs +++ b/crates/edgezero-core/src/lib.rs @@ -29,6 +29,7 @@ pub mod handler; pub mod http; pub mod ingress; pub mod introspection; +pub mod ja4; pub mod key_value_store; pub mod manifest; pub mod middleware; diff --git a/docs/guide/adapters/axum.md b/docs/guide/adapters/axum.md index fff6da6d2..73666e947 100644 --- a/docs/guide/adapters/axum.md +++ b/docs/guide/adapters/axum.md @@ -304,6 +304,51 @@ Origin validation checks HTTP(S) URI authority syntax, including registered names and trailing-dot hosts. It does not establish DNS resolution or ownership. Consumers that require DNS-label syntax must apply that additional policy. +## JA4 from a trusted proxy + +The Axum adapter serves plain HTTP and never sees a TLS handshake, so by +default `ctx.ja4()` reports `Unavailable(NotExposed)` and JA4 headers such as +`cloudfront-viewer-ja4-fingerprint` are ignored. See +[Client TLS fingerprint](./overview#client-tls-fingerprint-ja4). + +When a TLS-terminating proxy in front of Axum computes JA4 (FoxIO's +[ja4-nginx-module](https://github.com/FoxIO-LLC/ja4-nginx-module), HAProxy's +ja4 module, the Apache Traffic Server JA4 plugin, CloudFront, …), Axum can read +it from a header, but only when the direct TCP peer is one of the proxies you +name: + +```sh +EDGEZERO__ADAPTER__JA4_HEADER=x-ja4 +EDGEZERO__ADAPTER__JA4_TRUSTED_PROXY_CIDRS=10.0.0.0/8,192.0.2.7 +``` + +Both variables must be set together. Entries are CIDRs or bare IPs; startup +fails on an invalid entry, an empty list or a `/0` network. IPv4-mapped IPv6 +peers match their IPv4 networks. + +| Request | `ctx.ja4()` | +| ------------------------------------------------ | ------------------------------------------------ | +| From a trusted proxy, one valid header | `Captured { source: TrustedProxyHeader, .. }` | +| From a trusted proxy, no header | `Unavailable(Missing)` | +| From a trusted proxy, invalid or repeated header | `Unavailable(Malformed)` | +| From any other peer | `Unavailable(NotExposed)`; the header is ignored | + +::: warning The proxy must overwrite the header +The peer check proves which hop sent the request, not who wrote the header. +Configure the proxy to set the header on every request and drop any copy the +client sent, including when it has no JA4. With FoxIO's nginx module, +`proxy_set_header` does both, because it replaces the client's header and +omits it when the value is empty: + +```nginx +proxy_set_header X-JA4 $http_ssl_ja4; +``` + +::: + +In code, build the setting with `TrustedJa4Header::parse(header, cidrs)` and +pass it to `AxumDevServer::with_trusted_ja4_header`. + ## Next Steps - Deploy to [Fastly Compute](/guide/adapters/fastly) for production diff --git a/docs/guide/adapters/cloudflare.md b/docs/guide/adapters/cloudflare.md index c31cf3678..c905ac4ba 100644 --- a/docs/guide/adapters/cloudflare.md +++ b/docs/guide/adapters/cloudflare.md @@ -163,6 +163,11 @@ async fn handler(ctx: RequestContext) -> Result { } ``` +`ctx.ja4()` returns the client's JA4 fingerprint only on zones with Enterprise +Bot Management. Otherwise, and in local development and tests (no `cf` +object), it reports `Unavailable(Missing)`. See +[Client TLS fingerprint](./overview#client-tls-fingerprint-ja4). + ## Environment Variables Define non-secret variables in `wrangler.toml`; the adapter reads the diff --git a/docs/guide/adapters/fastly.md b/docs/guide/adapters/fastly.md index 582575bb0..fb912105c 100644 --- a/docs/guide/adapters/fastly.md +++ b/docs/guide/adapters/fastly.md @@ -130,27 +130,28 @@ Fastly logging endpoint, level, and stdout behavior now come from `EDGEZERO__LOGGING__*` values cannot vary those settings between production and staging for the same release. -### Capturing raw-request signals (JA4, H2 fingerprint) +### Capturing raw-request signals (H2 fingerprint and others) `run_app` converts the `fastly::Request` into a neutral core request before dispatch. The client IP is carried across automatically — read it via -`FastlyRequestContext` (see [Context Access](#context-access) below). Other -Fastly-only signals that are readable only on the raw request -(`get_tls_ja4()`, `get_client_h2_fingerprint()`) aren't reachable from handlers -by default. Use `run_app_with_request_extensions`, which +`FastlyRequestContext` (see [Context Access](#context-access) below). The JA4 +TLS fingerprint is also captured automatically: read it with `ctx.ja4()` (see +[Client TLS fingerprint](./overview#client-tls-fingerprint-ja4)). Other +Fastly-only signals that are readable only on the raw request (such as +`get_client_h2_fingerprint()`) aren't reachable from handlers by default. Use `run_app_with_request_extensions`, which runs an app closure against a scratch `Extensions` **before** conversion and merges the values into the core request — so a `State`/extractor or middleware can read them: ```rust #[derive(Clone)] -struct Ja4(String); +struct H2Fingerprint(String); #[fastly::main] fn main(req: fastly::Request) -> Result { edgezero_adapter_fastly::run_app_with_request_extensions::(req, |raw, ext| { - if let Some(ja4) = raw.get_tls_ja4() { - ext.insert(Ja4(ja4.to_owned())); + if let Some(fingerprint) = raw.get_client_h2_fingerprint() { + ext.insert(H2Fingerprint(fingerprint.to_owned())); } }) } @@ -159,7 +160,7 @@ fn main(req: fastly::Request) -> Result { `run_app` is exactly `run_app_with_request_extensions::(req, |_, _| {})`. The closure runs once per request; insert whatever typed values your handlers need, then read them in a handler via a custom extractor or -`ctx.request().extensions().get::()`. +`ctx.request().extensions().get::()`. ### Owning your own logging diff --git a/docs/guide/adapters/overview.md b/docs/guide/adapters/overview.md index 69e25e172..54a13b03c 100644 --- a/docs/guide/adapters/overview.md +++ b/docs/guide/adapters/overview.md @@ -225,6 +225,53 @@ adapter conversion. Fastly, Cloudflare and Spin still buffer bodies; Axum still buffers JSON and streams other bodies. Existing store/capability bootstrap and outer finalizers retain their behavior. +## Client TLS fingerprint (JA4) + +Handlers and middleware read the client's JA4 fingerprint through one +provider-neutral accessor, `RequestContext::ja4()`. Adapters record it on +`RequestIngress`; it is never derived from request headers. + +```rust +use edgezero_core::ja4::{CapturedJa4, Ja4Unavailable}; + +match ctx.ja4() { + Some(CapturedJa4::Captured { fingerprint, source }) => { + // fingerprint.as_str(), .summary(), .cipher_hash(), .extension_hash() + } + Some(CapturedJa4::Unavailable(Ja4Unavailable::NotExposed)) => { /* platform can't supply JA4 */ } + Some(CapturedJa4::Unavailable(_)) => { /* none for this request, or the provider value was rejected */ } + None => { /* the adapter made no claim (custom adapter, hand-built request) */ } +} +``` + +Pre-dispatch hooks receive the `Request` and read the same value with +`request.extensions().get::().and_then(RequestIngress::ja4)`. + +| Adapter | HTTPS client request | Plain HTTP, local development, tests | Provider value fails validation | +| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | ------------------------------- | +| Fastly | `Captured { RuntimeTls }` | `Unavailable(Missing)` | `Unavailable(Malformed)` | +| Cloudflare | `Captured { RuntimeTls }` with Enterprise Bot Management; otherwise `Unavailable(Missing)` | `Unavailable(Missing)` | `Unavailable(Malformed)` | +| Spin | `Unavailable(NotExposed)` | `Unavailable(NotExposed)` | n/a | +| Axum | `Unavailable(NotExposed)`; with a [trusted proxy](./axum#ja4-from-a-trusted-proxy), `Captured { TrustedProxyHeader }` | `Unavailable(NotExposed)` | `Unavailable(Malformed)` | +| Custom adapter or hand-built request | `None` | `None` | n/a | + +`Ja4Fingerprint::parse` accepts only the 36-character `a_b_c` shape, with +lowercase hex in the hash sections. A provider value that fails validation is +dropped and reported as `Malformed`. `Ja4Fingerprint`'s `Debug` output is +redacted, and it has no `Display` or `Serialize`; a fingerprint is a +quasi-identifier, so do not log it casually. + +Trust model: `Captured` values come only from the provider SDK accessor, or +on Axum from a header sent by a proxy peer the operator explicitly trusts. +The ingress snapshot is inserted after Fastly raw-request hook values are +merged, so a hook cannot replace it. Application middleware and pre-dispatch +hooks can still mutate request extensions; values are provider-computed and +may differ between providers for the same client. + +JA4 is a TLS client fingerprinting method by FoxIO, licensed under +BSD-3-Clause. JA4+ is a trademark of FoxIO. Other JA4+ methods are not +provided. + ## Opt-in application retention Use retention when measurements show that constructing the app is expensive. diff --git a/docs/guide/adapters/spin.md b/docs/guide/adapters/spin.md index 2b3f22dd7..e19a28bd2 100644 --- a/docs/guide/adapters/spin.md +++ b/docs/guide/adapters/spin.md @@ -385,6 +385,10 @@ also imports Spin-specific host interfaces, so plain Wasmtime cannot execute that artifact. The real Spin HTTP fixture supplies runtime conversion evidence. See [the capability matrix](./overview#inbound-request-fidelity). +Spin components never see the TLS handshake, so `ctx.ja4()` reports +`Unavailable(NotExposed)`. See +[Client TLS fingerprint](./overview#client-tls-fingerprint-ja4). + ## Next Steps - [Migration guide](/guide/manifest-store-migration) — moving from the diff --git a/docs/superpowers/plans/2026-11-13-ja4-request-interface.md b/docs/superpowers/plans/2026-11-13-ja4-request-interface.md new file mode 100644 index 000000000..fead72f7c --- /dev/null +++ b/docs/superpowers/plans/2026-11-13-ja4-request-interface.md @@ -0,0 +1,100 @@ +# Standard JA4 request interface — implementation plan + +**Spec:** `docs/superpowers/specs/2026-11-12-ja4-request-interface-design.md` +**Issue:** #283 +**Branch:** `spec/283-ja4-request-interface` + +**Goal:** expose one provider-neutral, adapter-populated JA4 value +(`ctx.ja4()`) backed by `RequestIngress`, populated by Fastly and Cloudflare +from their runtime TLS APIs and reported as `NotExposed` by Spin and Axum. + +**Architecture:** a new `edgezero_core::ja4` module holds the validated +fingerprint type and capture enums. `RequestIngress` gains an optional +`CapturedJa4` (absence = adapter made no claim). Each adapter appends one +`.with_ja4(...)` to the ingress it already builds. No adapter reads a header. + +**Tech:** Rust 2024, no new dependencies, workspace `restriction` clippy lints. + +## Tasks + +### Task 1 — Core `ja4` module (spec §5.1–5.3, §5.6) + +Files: create `crates/edgezero-core/src/ja4.rs`; modify `crates/edgezero-core/src/lib.rs`. + +- [x] Write colocated tests first: FoxIO example + `q`/`d`/`i`/zero sections/uppercase ALPN accepted; section accessors; rejection list (empty, 35/37 bytes, whitespace, uppercase hex, separators, unknown protocol/SNI, non-digit counts, non-ASCII, JA3 hash, `ja4_r`); `from_runtime_tls` for `None`/valid/invalid; redacted `Debug`; error text never contains input. +- [x] Implement `Ja4Fingerprint` (`parse`, `as_str`, `summary`, `cipher_hash`, `extension_hash`, redacted `Debug`), `CapturedJa4` (+ `from_runtime_tls`), `Ja4Source`, `Ja4Unavailable`. +- [x] `pub mod ja4;` in `lib.rs`. +- [x] `cargo test -p edgezero-core ja4`. + +### Task 2 — `RequestIngress` and `RequestContext` (spec §5.4–5.5) + +Files: `crates/edgezero-core/src/ingress.rs`, `crates/edgezero-core/src/context.rs`. + +- [x] Add `ja4: Option` field, `with_ja4`, `ja4()`; `new` sets `None`; `Debug` prints `ja4` (redacted through `Ja4Fingerprint`). +- [x] Add `RequestContext::ja4()`. +- [x] Tests: default `None`, round-trip, `Debug` redaction; context `None` without ingress and stored value with it. +- [x] `cargo test -p edgezero-core`. + +### Task 3 — Adapters (spec §6) + +- [x] Fastly `capture_request_ingress`: `.with_ja4(CapturedJa4::from_runtime_tls(req.get_tls_ja4()))`. Test (Viceroy): non-client request → `Unavailable(Missing)` even with a `ja4` header. +- [x] Cloudflare `into_core_request`: read `req.cf()?.bot_management()?.ja4()` before body read. Browser contract test: no `cf` → `Unavailable(Missing)` with a `ja4` header present. +- [x] Spin `capture_request_ingress`: `.with_ja4(Unavailable(NotExposed))`. Native colocated test. +- [x] Axum `capture_request_ingress`: `.with_ja4(Unavailable(NotExposed))`. Test with `ja4` and `cloudfront-viewer-ja4-fingerprint` headers. + +### Task 4 — Documentation (spec §11) + +- [x] `docs/guide/adapters/overview.md`: "Client TLS fingerprint (JA4)" section with example, capability matrix, trust model, FoxIO attribution. +- [x] `fastly.md`: point raw-request section at `ctx.ja4()`. +- [x] `cloudflare.md`: Enterprise Bot Management note. +- [x] `spin.md`, `axum.md`: one-line `NotExposed` note. + +### Task 6 — Axum JA4 from a trusted proxy (spec §5.8) + +- [x] Core: `Ja4Source::TrustedProxyHeader`, `CapturedJa4::from_trusted_proxy_header` (none → `Missing`, one valid → `Captured`, invalid / non-UTF-8 / repeated → `Malformed`); `from_runtime_tls` shares the validation helper. +- [x] Axum `ja4.rs`: `TrustedJa4Header` (header + CIDR allowlist via `ipnet`), `parse` / `new` / `from_env`, fail-closed config, canonicalised peer check. +- [x] Wiring: `EdgeZeroAxumService::with_trusted_ja4_header` inserts it as a request extension; `into_core_request` reads it with the `ConnectInfo` peer; `AxumDevServer::with_trusted_ja4_header`; `run_app` reads the env vars. +- [x] Tests: core mapping; peer matrix (IPv4, bare IP, mapped IPv6, IPv6, untrusted, unknown); `Missing` / `Malformed`; config rejection; env pairing; real-TCP dev-server test (trusted vs untrusted loopback). +- [x] Docs: `axum.md` "JA4 from a trusted proxy" with nginx example and overwrite warning; overview matrix and trust model. + +### Task 5 — Verification + +- [x] `cargo fmt --all -- --check` +- [x] `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- [x] `cargo test --workspace --all-targets` +- [x] `cargo check --workspace --all-targets --features "fastly cloudflare spin"` +- [x] `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` +- [x] Docs Prettier. +- [ ] Manual (record in PR): Fastly HTTPS → `Captured`, HTTP → `Missing`; Cloudflare with/without Bot Management. + +## Verification log (2026-11-13) + +| Check | Result | +| ----------------------------------------------------------------------- | -------------------- | +| `cargo fmt --all -- --check` | pass | +| `cargo clippy --workspace --all-targets --all-features -- -D warnings` | pass (rust 1.98.1) | +| Cloudflare / Fastly clippy on their wasm targets | pass | +| `cargo test --workspace --all-targets` | 1559 passed | +| `cargo check --workspace --all-targets --features "fastly cloudflare spin"` | pass | +| `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` | pass | +| Fastly lib tests, Viceroy (`wasm32-wasip1`) | 98 passed | +| Cloudflare contract tests, `wasm-bindgen-test-runner` + Chromium | 14 passed | +| Spin contract tests, wasmtime 48.0.1 | 12 passed | +| Docs Prettier | pass | + +| Runner unit tests (`justrust test`) | 20 passed | +| Real-runtime fixture `scripts/test-request-fidelity.sh all` (Spin 4.1.0, Viceroy 0.21.0, workerd) | pass; observations unchanged except new `ja4_state` | + +**Spin through `into_core_request` (spec §10).** `spin_sdk::http::Request` +wraps a WASI-HTTP incoming-request resource that only a Spin host can create, +so the plain-wasmtime contract suite cannot construct one. Instead, the +request-fidelity fixture reports a fixed `ja4_state` label from its +pre-dispatch hook, and its `spoof` case sends `ja4` and +`cloudfront-viewer-ja4-fingerprint` headers. Against the real runtimes, every +intercepted case reports `missing` on Fastly (Viceroy, plain HTTP) and +Cloudflare (workerd, no `cf`) and `not_exposed` on Spin, spoofed headers +included. The runner asserts this for each platform. + +**Still manual.** Live Fastly (HTTPS → `captured`, HTTP → `missing`) and +Cloudflare (Bot Management → `captured`) checks need provider credentials and a +deployment; record results in the PR. diff --git a/docs/superpowers/specs/2026-11-12-ja4-request-interface-design.md b/docs/superpowers/specs/2026-11-12-ja4-request-interface-design.md new file mode 100644 index 000000000..de0a007f7 --- /dev/null +++ b/docs/superpowers/specs/2026-11-12-ja4-request-interface-design.md @@ -0,0 +1,447 @@ +# Standard JA4 request interface + +**Status:** Implemented in [#469](https://github.com/IABTechLab/edgezero/pull/469), including the Axum trusted-proxy path (§5.8). +**Date:** 2026-11-12. +**Issue:** [#283 Expose JA4 through a standard request interface](https://github.com/IABTechLab/edgezero/issues/283). +**Baseline:** EdgeZero `origin/main` `e11ea308`, which includes the request ingress metadata from [#403](https://github.com/IABTechLab/edgezero/pull/403). +**Related:** [#306](https://github.com/IABTechLab/edgezero/pull/306) (Fastly raw-request extension hook), [#409](https://github.com/IABTechLab/edgezero/pull/409) (Axum trusted proxy metadata, open), [#439](https://github.com/IABTechLab/edgezero/pull/439) (pluggable Geo providers and protected CloudFront ingress, open). + +## 1. Problem and outcome + +The only way for an EdgeZero application to read the client's JA4 TLS fingerprint today is the Fastly-only raw-request hook, `run_app_with_request_extensions`. The documented example (`docs/guide/adapters/fastly.md`, "Capturing raw-request signals") has each application define its own `Ja4(String)` type and call `fastly::Request::get_tls_ja4()` directly. Cloudflare, Spin and Axum have no equivalent. An application also cannot tell "this platform never supplies JA4" apart from "this request has no JA4". + +**Outcome.** Handlers, middleware and pre-dispatch hooks read one adapter-populated value: + +```rust +use edgezero_core::ja4::{CapturedJa4, Ja4Unavailable}; + +match ctx.ja4() { + Some(CapturedJa4::Captured { fingerprint, source }) => { + // fingerprint.as_str(), .summary(), .cipher_hash(), .extension_hash() + } + Some(CapturedJa4::Unavailable(Ja4Unavailable::NotExposed)) => { /* platform can't supply JA4 */ } + Some(CapturedJa4::Unavailable(_)) => { /* none for this request, or the provider value was rejected */ } + None => { /* the adapter made no claim (custom adapter, hand-built request) */ } +} +``` + +The value is either a validated fingerprint with its provenance, or an explicit reason it is unavailable. It is never derived from request headers. + +## 2. Requirements + +From the issue's "Done when": + +| ID | Requirement | +| --- | -------------------------------------------------------------------------------------------- | +| R1 | Add and document one standard, provider-neutral JA4 interface. | +| R2 | Populate it on adapters whose trusted provider APIs expose JA4. | +| R3 | Return explicit absence or unsupported behaviour on other adapters. | +| R4 | Cover present, absent and unsupported paths with core and adapter tests. | +| R5 | Preserve provenance and absence semantics; never infer a fingerprint from client-supplied headers. | + +Additional constraints from `CLAUDE.md` and the workspace configuration: + +- **WASM-compatible core.** No new dependencies and no Tokio in core or adapter crates. +- **Strict lints.** The workspace denies the whole clippy `restriction` group. That rules out `string_slice`, `indexing_slicing`, `unwrap_used`, `expect_used` and `arithmetic_side_effects`, and requires `#[inline]` on public items. +- **Small changes.** Each change touches as little existing code as possible. + +### Non-goals + +- Other JA4+ methods (JA4S, JA4H, JA4L, JA4X, JA4SSH, JA4T, JA4D, …). See §3.2. +- JA3, and Fastly's proprietary HTTP/2 and original-header fingerprints. These remain reachable through the existing Fastly hook. +- Lookups against FoxIO's JA4DB or any other fingerprint database. +- The unhashed and original-order variants (`ja4_r`, `ja4_o`, `ja4_ro`). No provider exposes them. +- Computing JA4 inside EdgeZero (§13.3). +- A trusted-proxy path for Spin (§13.2). Axum's is in scope (§5.8). +- A dedicated `FromRequest` extractor. `RequestContext::ja4()` is enough, and an extractor can be added later without breaking anything. + +## 3. Background + +### 3.1 JA4 format + +JA4 is defined by FoxIO in [JA4 technical details](https://github.com/FoxIO-LLC/ja4/blob/main/technical_details/JA4.md). It is a 36-character string in three `_`-separated sections, for example `t13d1516h2_8daaf6152771_e5627efa2ab1`: + +| Section | FoxIO name | Content | +| ------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `a` | `JA4_a` | 10 readable characters: protocol (`t` TLS, `q` QUIC, `d` DTLS), TLS version (`13`, `12`, …, `s3`, `d1`, `00`), SNI (`d` domain, `i` none), cipher count, extension count, ALPN first and last character | +| `b` | `JA4_b` | first 12 lowercase hex characters of the SHA-256 of the sorted cipher list, or `000000000000` if empty | +| `c` | `JA4_c` | first 12 lowercase hex characters of the SHA-256 of the sorted extensions (excluding SNI and ALPN) and signature algorithms, or `000000000000` if empty | + +FoxIO recommends matching on individual sections, for example `a` + `c`, which stays stable when a client only reorders or changes ciphers. The API therefore exposes each section. + +### 3.2 FoxIO licensing and project status + +| Topic | Finding | Consequence | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| Licence | JA4 (TLS client) is BSD-3-Clause. All other JA4+ methods are under the FoxIO License 1.1, which forbids monetization without an OEM licence ([License FAQ](https://github.com/FoxIO-LLC/ja4/blob/main/License%20FAQ.md)). | EdgeZero ships JA4 only. This change passes provider strings through and vendors no FoxIO code, so it needs only attribution. | +| Trademark | "JA4+" is a FoxIO trademark. | Docs credit FoxIO and describe compatibility only. | +| Releases | [v1.0.0](https://github.com/FoxIO-LLC/ja4/releases/tag/v1.0.0) (2026-08-14) replaced the Zeek scripts with a C++ plugin. [v1.0.1](https://github.com/FoxIO-LLC/ja4/releases/tag/v1.0.1) (2026-08-31) fixed JA4/JA4H edge cases, including ignoring GREASE values in signature algorithms. No change to the JA4 format was announced. | Providers compute JA4 with their own implementations. The API records the source and does not claim that two providers produce identical values for the same client. | +| Rust library | FoxIO's Rust code is a tshark-based command-line tool, not a library crate. The third-party `ja4` crate (0.3.0, published 2026-11-06) lists no repository. | No dependency is needed or added. | +| JA4DB | FoxIO runs a hosted fingerprint database. No published licence for bulk data was found. | Out of scope; applications may integrate it themselves. | + +### 3.3 Platform capabilities + +Verified against the SDK versions pinned in `Cargo.lock` on the baseline. + +| Platform | JA4 source | Availability | +| ---------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| Fastly (`fastly` 0.13.1) | `fastly::Request::get_tls_ja4() -> Option<&str>` | Client requests over TLS. `None` for plain HTTP (including Viceroy's local server) and non-client requests. | +| Cloudflare (`worker` 0.8.5) | `Request::cf()?.bot_management()?.ja4() -> Option` | Only on zones with Enterprise Bot Management. `None` otherwise, and in local development and tests, which have no `cf` object. | +| Spin (`spin-sdk` 7.0.0) | none | WASI-HTTP gives the component method, URI, headers and body only. The runtime's `spin-*` headers carry no TLS data. | +| Axum (native) | none natively; an operator-configured header from a trusted proxy (§5.8) | The adapter listens on plain TCP and never sees a TLS handshake. #409's design explicitly excludes an incoming TLS implementation. A TLS-terminating proxy in front of it can compute JA4. | + +## 4. Approaches considered + +| Approach | Assessment | Decision | +| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | +| **A. Field on `RequestIngress`, types in a new `edgezero_core::ja4` module** | `RequestIngress` is already adapter-owned metadata whose absence means "capability unknown". Every adapter already builds it from the native request, and the Fastly adapter inserts it after merging the application's hook values, so application hooks cannot replace it. | **Chosen** | +| B. Field on each adapter's context (`FastlyRequestContext`, …) | Not provider-neutral. `FastlyRequestContext` is inserted inside `convert_request`, *before* the hook values are merged, so a hook could silently replace it. | Rejected | +| C. Separate core request extension | Workable, but each adapter would need to repeat the insert-after-the-hook ordering that `RequestIngress` already guarantees. | Rejected | +| D. Compute JA4 inside EdgeZero from raw ClientHello bytes | More capable (adds the unhashed variants), but a larger change that needs FoxIO conformance vectors. Only Fastly exposes the raw bytes. | Follow-up (§13) | + +The types live in their own module rather than in `ingress.rs`, because fingerprint parsing is a self-contained unit with its own tests, and `ingress.rs` stays focused on request-fidelity facts. + +## 5. Design + +### 5.1 Module `edgezero_core::ja4` + +New file `crates/edgezero-core/src/ja4.rs`, declared as `pub mod ja4;` in `crates/edgezero-core/src/lib.rs`. All public items carry `#[inline]` and `#[must_use]` where applicable, as the workspace lints require. + +```rust +//! Client JA4 TLS fingerprints captured by adapters. +//! +//! JA4 is a TLS client fingerprinting method by FoxIO, licensed under +//! BSD-3-Clause. JA4+ is a trademark of FoxIO. + +/// A JA4 TLS client fingerprint, validated as `a_b_c` (§5.2). +#[derive(Clone, PartialEq, Eq)] +pub struct Ja4Fingerprint(Box); + +impl Ja4Fingerprint { + /// Validates `value` against §5.2 without normalising it. + /// + /// # Errors + /// Returns `EdgeError::bad_request("invalid JA4 fingerprint")` for any + /// value that does not match. The error never contains the input. + pub fn parse(value: &str) -> Result; + + /// The full 36-character fingerprint. + pub fn as_str(&self) -> &str; + + /// FoxIO `JA4_a`: protocol, TLS version, SNI, cipher and extension counts, ALPN. + pub fn summary(&self) -> &str; + + /// FoxIO `JA4_b`: truncated SHA-256 of the sorted cipher suites. + pub fn cipher_hash(&self) -> &str; + + /// FoxIO `JA4_c`: truncated SHA-256 of the sorted extensions and signature algorithms. + pub fn extension_hash(&self) -> &str; +} + +/// What an adapter captured for this request. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CapturedJa4 { + Captured { + fingerprint: Ja4Fingerprint, + source: Ja4Source, + }, + Unavailable(Ja4Unavailable), +} + +impl CapturedJa4 { + /// Maps a runtime TLS accessor's result (§5.3). + pub fn from_runtime_tls(value: Option<&str>) -> Self; +} + +/// Where a captured fingerprint came from. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Ja4Source { + /// Computed by the runtime that terminated the client's TLS connection. + RuntimeTls, + /// Computed by a TLS-terminating proxy and read from an operator-configured + /// header, only when the direct peer is a configured trusted proxy (§5.8). + TrustedProxyHeader, +} + +/// Why no fingerprint was captured. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Ja4Unavailable { + /// The runtime offers no JA4 API. + NotExposed, + /// The runtime exposes JA4 but returned none for this request. + Missing, + /// The runtime returned a value that failed validation; it was discarded. + Malformed, +} +``` + +`Ja4Source` and `Ja4Unavailable` are `#[non_exhaustive]` so later work can add variants (§5.8 adds `TrustedProxyHeader`; §13.3 would add `Computed`). `CapturedJa4` is exhaustive: "captured or unavailable" is the whole space. + +### 5.2 Validation grammar + +`Ja4Fingerprint::parse` accepts a value only if all of the following hold. Any non-ASCII byte fails one of the per-position checks, so no separate UTF-8 check is needed. + +| Byte positions | Rule | Meaning | +| -------------- | ------------------------------------------ | -------------------------------- | +| whole value | exactly 36 bytes | checked first; bounds the work | +| `0` | `t`, `q` or `d` | protocol | +| `1..3` | ASCII lowercase letter or digit | TLS version code | +| `3` | `d` or `i` | SNI present or absent | +| `4..8` | ASCII digits | cipher and extension counts | +| `8..10` | ASCII letter (either case) or digit | ALPN first and last character | +| `10`, `23` | `_` | section separators | +| `11..23` | lowercase hex (`0-9`, `a-f`) | `JA4_b` | +| `24..36` | lowercase hex (`0-9`, `a-f`) | `JA4_c` | + +The rules are strict about shape but tolerant of the values inside section `a`. A future TLS version code is still accepted, while anything that is not a JA4 is rejected, including JA3 hashes, `ja4_r` strings and surrounding whitespace. Uppercase hex in `b` or `c` is rejected, not lowercased, because FoxIO defines those sections as lowercase. + +**Implementation note.** The workspace lints forbid string slicing and indexing. Validation iterates over `value.bytes().enumerate()`, and the section accessors use `str::get` (for example `self.0.get(0..10).unwrap_or_default()`). After successful validation, `get` always returns `Some`. + +### 5.3 Runtime mapping + +`CapturedJa4::from_runtime_tls` is the single mapping used by every runtime-backed adapter: + +| Runtime result | `CapturedJa4` | +| -------------------------------------- | --------------------------------------------------------------- | +| `None` | `Unavailable(Missing)` | +| `Some(v)` and `Ja4Fingerprint::parse(v)` succeeds | `Captured { fingerprint, source: Ja4Source::RuntimeTls }` | +| `Some(v)` and parsing fails | `Unavailable(Malformed)`; `v` is dropped and never logged | + +`Malformed` is a separate variant so that a change in a provider's output format shows up as a distinct, observable state instead of reading as "no JA4". + +### 5.4 `RequestIngress` integration + +In `crates/edgezero-core/src/ingress.rs`: + +```rust +pub struct RequestIngress { + // existing fields unchanged + ja4: Option, +} + +impl RequestIngress { + // `new` is unchanged and sets `ja4: None`. + + /// Records the adapter's JA4 capture. + pub fn with_ja4(self, ja4: CapturedJa4) -> Self; + + /// The adapter's JA4 capture. `None` means the adapter made no claim. + pub const fn ja4(&self) -> Option<&CapturedJa4>; +} +``` + +- **`None` means unknown.** There is deliberately no `Unknown` variant. An adapter that never calls `with_ja4`, such as a custom adapter or a hand-built test request, leaves the field unset. This matches the module's existing rule that missing metadata means unknown capability. +- **`Debug`.** `RequestIngress`'s `Debug` gains a `ja4` field. It shows the variant and source but never the fingerprint (§5.6). + +### 5.5 Access from handlers, middleware and hooks + +In `crates/edgezero-core/src/context.rs`: + +```rust +impl RequestContext { + /// The adapter's JA4 capture for this request, read from `RequestIngress`. + /// `None` if the request has no ingress metadata or the adapter made no claim. + pub fn ja4(&self) -> Option<&CapturedJa4> { + self.request() + .extensions() + .get::() + .and_then(RequestIngress::ja4) + } +} +``` + +- **Handlers and middleware** use `ctx.ja4()`. +- **Pre-dispatch hooks** (`PreDispatchHook::handle(&self, request: &mut Request)`, from #403) receive the `Request` rather than a `RequestContext`. They read the same value with `request.extensions().get::().and_then(RequestIngress::ja4)`. + +### 5.6 Privacy + +A JA4 fingerprint helps identify a client, and the `ingress` module already keeps identifying data out of `Debug` output. + +- **`Ja4Fingerprint`** implements `Debug` as `Ja4Fingerprint([redacted])` and does not implement `Display` or `serde::Serialize`. Callers read the value explicitly with `as_str()` or a section accessor. +- **`CapturedJa4`** derives `Debug`, so it prints `Captured { fingerprint: Ja4Fingerprint([redacted]), source: RuntimeTls }`. +- **`parse` errors** never include the input. + +### 5.7 Trust model + +- **Provider values only.** A `Captured` value with source `RuntimeTls` comes only from the provider SDK accessor, never from a request header. R5 is met by construction: no adapter reads a header for JA4. +- **Ordering in the Fastly adapter.** The ingress snapshot, and therefore JA4, is inserted after the application's raw-request hook values are merged (`dispatch_with_handles` and `into_core_request_with_registries_and_ingress` in `crates/edgezero-adapter-fastly/src/request.rs`). A hook that inserts its own `RequestIngress` is overwritten. +- **Out of scope for the guarantee.** It does not protect against the application's own code. A caller of `dispatch_with_registries_and_ingress` supplies its own snapshot, and middleware or a pre-dispatch hook can mutate request extensions. This matches the existing `RequestIngress` contract: "Sources and preservation guarantees are caller assertions." + +### 5.8 Axum: JA4 from a trusted proxy header + +Axum normally runs behind a TLS-terminating proxy. Several proxies can compute JA4 and forward it in a header: FoxIO's [ja4-nginx-module](https://github.com/FoxIO-LLC/ja4-nginx-module), HAProxy's ja4 module, the Apache Traffic Server JA4 plugin and CloudFront (`CloudFront-Viewer-JA4-Fingerprint`). The Axum adapter accepts that header only from a proxy the operator names. + +**Core.** `Ja4Source::TrustedProxyHeader` and: + +```rust +impl CapturedJa4 { + /// Header values from a peer the adapter already verified as trusted. + pub fn from_trusted_proxy_header<'v, I: IntoIterator>(values: I) -> Self; +} +``` + +| Header fields | `CapturedJa4` | +| -------------------------------------- | ------------------------------------------------------ | +| none | `Unavailable(Missing)` | +| exactly one, valid (§5.2) | `Captured { fingerprint, source: TrustedProxyHeader }` | +| exactly one, invalid or non-UTF-8 | `Unavailable(Malformed)`; value dropped | +| more than one | `Unavailable(Malformed)`; a proxy writes one field, so repetition means something else also wrote it | + +**Axum adapter** (`crates/edgezero-adapter-axum/src/ja4.rs`). `TrustedJa4Header { header, trusted_proxies: Vec }`: + +- **Peer check.** The direct TCP peer comes from Axum's `ConnectInfo`. IPv4-mapped IPv6 peers are canonicalised first, so a dual-stack listener matches IPv4 networks. A peer inside the allowlist gets `from_trusted_proxy_header(headers.get_all(header))`. An untrusted peer, or a request with no known peer, gets `NotExposed` and the header is ignored. +- **Configuration.** `EDGEZERO__ADAPTER__JA4_HEADER` (header name) and `EDGEZERO__ADAPTER__JA4_TRUSTED_PROXY_CIDRS` (comma-separated CIDRs or bare IPs), read by `run_app`. Programmatic users call `TrustedJa4Header::parse` or `new` and pass it to `AxumDevServer::with_trusted_ja4_header` or `EdgeZeroAxumService::with_trusted_ja4_header`. +- **Fail closed.** Startup fails if only one variable is set, the header name is invalid, the list is empty or has an invalid entry, or an entry is a `/0` network, which would trust every client. +- **Default.** With no configuration, Axum reports `NotExposed` exactly as before. +- **Wiring.** `EdgeZeroAxumService` inserts the configuration as a request extension. `into_core_request` removes it, reads the peer, and records the result on `RequestIngress`, the same way it handles `HttpTransport`. Calling `into_core_request` on a request without the extension gives `NotExposed`. +- **Dependency.** `ipnet` 2, already in the lockfile through `reqwest` and also used by #409. Native-only; no WASM impact. + +**Relationship to #409.** #409's `TrustedProxyPolicy` (`EDGEZERO__ADAPTER__TRUSTED_PROXY_CIDRS`) governs forwarding headers such as `Forwarded`. This setting is separate on purpose: trusting a proxy for its JA4 header is a narrower claim than trusting its forwarding headers, and it keeps this change independent of #409's merge. Once #409 lands, `TrustedJa4Header` can take its peer check from `CheckedProxyMetadata` without changing the public `ctx.ja4()` contract. + +**Operator requirement.** The trusted proxy must overwrite the header on every request and remove any client-sent copy, including when it has no JA4 (for example, plain HTTP). A proxy that only adds the header, or passes a client's copy through, lets a client forge JA4 even though the peer check passes. The docs show this for nginx. + +**What the guarantee covers.** It establishes which hop set the header. It does not make the proxy's JA4 implementation match Fastly's or Cloudflare's; `Ja4Source` records the difference. + +## 6. Adapter requirements + +| Adapter | Location on baseline | Change | +| ---------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Fastly | `capture_request_ingress`, `crates/edgezero-adapter-fastly/src/request.rs:601` | Append `.with_ja4(CapturedJa4::from_runtime_tls(req.get_tls_ja4()))` to the returned `RequestIngress`. Every Fastly entry point builds ingress through this function or receives a caller snapshot. | +| Cloudflare | `into_core_request`, `crates/edgezero-adapter-cloudflare/src/request.rs:279` (before `req.bytes()` at `:289`) | `let ja4 = req.cf().and_then(\|cf\| cf.bot_management()).and_then(\|bot\| bot.ja4());` then `RequestIngress::new(…).with_ja4(CapturedJa4::from_runtime_tls(ja4.as_deref()))`. | +| Spin | `capture_request_ingress`, `crates/edgezero-adapter-spin/src/ingress.rs:7` | `.with_ja4(CapturedJa4::Unavailable(Ja4Unavailable::NotExposed))`. | +| Axum | `into_core_request` / `capture_request_ingress`, `crates/edgezero-adapter-axum/src/request.rs` | `NotExposed` by default; with a `TrustedJa4Header`, the trusted-proxy capture from §5.8. | + +- `run_app_with_request_extensions` and `FastlyRequestContext` are unchanged. The hook remains the way to reach other Fastly-only signals such as `get_client_h2_fingerprint()`. +- No adapter reads a request header for JA4. + +## 7. Capability matrix + +| Adapter | HTTPS client request | Plain HTTP, local development, tests | Provider value fails validation | +| ---------- | ---------------------------------------------------- | ------------------------------------ | ------------------------------- | +| Fastly | `Captured { RuntimeTls }` | `Unavailable(Missing)` | `Unavailable(Malformed)` | +| Cloudflare | `Captured { RuntimeTls }` with Enterprise Bot Management; otherwise `Unavailable(Missing)` | `Unavailable(Missing)` | `Unavailable(Malformed)` | +| Spin | `Unavailable(NotExposed)` | `Unavailable(NotExposed)` | n/a | +| Axum, no proxy configured | `Unavailable(NotExposed)` | `Unavailable(NotExposed)` | n/a | +| Axum, trusted proxy configured | From a trusted peer: `Captured { TrustedProxyHeader }`, or `Missing` without the header. From any other peer: `Unavailable(NotExposed)` | same | `Unavailable(Malformed)` | +| Custom adapter or hand-built request | `None` | `None` | n/a | + +Fastly's `q` (QUIC/HTTP/3) fingerprints are accepted as-is. Whether Fastly reports JA4 for HTTP/3 connections is unverified (§14). + +## 8. Why Spin, and Axum by default, report `NotExposed` + +- **Spin.** The component receives requests through WASI-HTTP: method, URI, headers and body. The runtime adds `spin-*` headers (`spin-client-addr`, `spin-full-url`, …; see `crates/edgezero-adapter-spin/src/request.rs:58`), none of which carry TLS data. On hosted Spin and SpinKube, TLS ends at the platform ingress. Even when `spin up` terminates TLS itself, the ClientHello is never passed to the component. +- **Axum.** The adapter serves plain HTTP behind a TLS-terminating proxy, which is the deployment model #409 is built on. It never sees a ClientHello. +- **Why not read a header.** A header such as `cloudfront-viewer-ja4-fingerprint` reaches these adapters, but without a check on which hop set it, any client could forge it. That breaks R5. §5.8 adds that check for Axum; Spin has no trust configuration yet (§13.2). + +## 9. Edge cases + +| Case | Behaviour | +| ----------------------------------------------------------------- | ------------------------------------------------------------------------- | +| Fastly non-client request (e.g. built with `FastlyRequest::new`) | SDK returns `None` → `Unavailable(Missing)`. | +| Cloudflare request without `cf` (wrangler dev, workerd, browser tests) | `cf()` is `None` → `Unavailable(Missing)`. | +| Cloudflare zone without Bot Management | `bot_management()` is `None` → `Unavailable(Missing)`. | +| Provider value with uppercase hex, whitespace or the wrong length | `Unavailable(Malformed)`; the value is dropped. | +| Client sends a `ja4` or `cloudfront-viewer-ja4-fingerprint` header | Ignored by every adapter; the header stays in the `HeaderMap` as an ordinary header. | +| Fastly raw-request hook inserts its own `RequestIngress` | Overwritten by the adapter's snapshot. | +| App code mutates extensions in middleware or a pre-dispatch hook | Not prevented (§5.7). | + +## 10. Testing and acceptance + +| Layer | Runner | Cases | +| ----------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Core `ja4.rs` | native `cargo test -p edgezero-core` | • Accepts FoxIO's example `t13d1516h2_8daaf6152771_e5627efa2ab1`, a `q` value, a `d` value, `i` SNI, `000000000000` sections and uppercase ALPN characters.
• Section accessors return `t13d1516h2`, `8daaf6152771` and `e5627efa2ab1`.
• Rejects: empty, 35 and 37 bytes, leading or trailing whitespace, uppercase hex in `b` or `c`, wrong or missing separators, an unknown protocol or SNI letter, non-digit counts, a non-ASCII value of the right byte length, a 32-character JA3 hash and a `ja4_r` string.
• `from_runtime_tls` for `None`, valid and invalid input.
• `Debug` output of `Ja4Fingerprint`, `CapturedJa4` and `RequestIngress` never contains the fingerprint, and `parse` errors never contain the input. | +| Core `ingress.rs`, `context.rs` | native | • `RequestIngress::new(…).ja4()` is `None`; `with_ja4` round-trips.
• `RequestContext::ja4()` is `None` without `RequestIngress` and returns the stored value when present. | +| Fastly | Viceroy (`wasm32-wasip1`) | • `capture_request_ingress(&FastlyRequest::new(…))` gives `Some(Unavailable(Missing))`.
• Through `dispatch_with_registries`, a raw-request hook that inserts a `RequestIngress` with a fake captured JA4 does not change what the handler sees (modelled on the existing hook-override test at `crates/edgezero-adapter-fastly/src/request.rs:1153`).
• A client `ja4` header does not produce `Captured`. | +| Cloudflare | `wasm-bindgen-test` in the browser | `into_core_request` on a `web_sys::Request` (no `cf`) gives `Some(Unavailable(Missing))`, including when the request carries a `ja4` header. | +| Spin | native colocated test in `ingress.rs`, plus wasmtime contract test | `capture_request_ingress` gives `Some(Unavailable(NotExposed))`. It takes only the URI, so headers cannot influence it; the wasmtime contract test confirms the same through `into_core_request` with a `ja4` header present. | +| Axum | native colocated tests in `request.rs`, `ja4.rs`, `dev_server.rs` | • `into_core_request` gives `Some(Unavailable(NotExposed))` without configuration, including when the request carries `ja4` and `cloudfront-viewer-ja4-fingerprint` headers.
• Trusted IPv4, bare-IP, IPv4-mapped IPv6 and IPv6 peers capture; untrusted and unknown peers give `NotExposed`; a trusted peer without the header gives `Missing`, and with an invalid or repeated header gives `Malformed`.
• Configuration rejects empty, invalid and `/0` lists and invalid header names; the environment requires both variables.
• Over real TCP through the dev server, a loopback client is captured under `127.0.0.0/8` and `NotExposed` under `192.0.2.0/24`. | + +**Manual verification.** This can't run in CI because it needs live provider accounts. Record the results in the pull request. + +- A Fastly service over HTTPS returns `Captured`, and over HTTP returns `Missing`. This also confirms Fastly emits lowercase hex. +- A Cloudflare zone with Bot Management returns `Captured`; one without returns `Missing`. + +**Acceptance against requirements:** + +| Requirement | Met by | +| ----------- | ---------------------------------------------------------------------- | +| R1 | §5 API and §11 documentation | +| R2 | Fastly and Cloudflare (§6) | +| R3 | Spin and Axum `NotExposed`; `Missing` and `Malformed` on Fastly and Cloudflare | +| R4 | §10 test matrix | +| R5 | §5.7 and the header-ignored tests in §10 | + +All five CI gates in `CLAUDE.md` must pass, plus the docs Prettier and ESLint checks. + +## 11. Documentation + +| File | Change | +| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `docs/guide/adapters/overview.md` | New section "Client TLS fingerprint (JA4)" after "Inbound request fidelity": the `ctx.ja4()` example, the capability matrix from §7, the trust model from §5.7 and the FoxIO attribution ("JA4 is a TLS client fingerprinting method by FoxIO, licensed under BSD-3-Clause. JA4+ is a trademark of FoxIO. Other JA4+ methods are not provided."). | +| `docs/guide/adapters/fastly.md` | In "Capturing raw-request signals (JA4, H2 fingerprint)", replace the `Ja4(String)` example with a pointer to `ctx.ja4()`, and rename the section to cover the HTTP/2 fingerprint and other raw-request signals. | +| `docs/guide/adapters/cloudflare.md` | Note that JA4 needs Enterprise Bot Management and is `Missing` otherwise. | +| `docs/guide/adapters/spin.md` | One line: JA4 is `NotExposed`, with a link to the overview section. | +| `docs/guide/adapters/axum.md` | The trusted-proxy configuration from §5.8, with an nginx (FoxIO module) example and the trust warning. | + +## 12. Compatibility + +- **Additive only.** Existing `RequestIngress::new` callers compile unchanged. +- **Existing apps keep working.** Apps that insert their own `Ja4(String)` through the Fastly hook are unaffected and can migrate when convenient. +- **No dependencies or feature flags.** The workspace version is `0.1.0`, and adding variants later to the `#[non_exhaustive]` enums is non-breaking. +- **Merge order with #439.** #439 also edits `ingress.rs`, `context.rs` and adapter `request.rs` files. Whichever merges second rebases; the changes here are small and additive. + +## 13. Follow-ups + +Each needs its own spec or plan, and none blocks this change. + +### 13.1 Axum: JA4 from a trusted proxy + +Implemented in §5.8. Remaining: reuse #409's peer check once it merges, and decide whether "header present but peer not trusted" deserves its own `Ja4Unavailable` variant (today it is `NotExposed`). + +### 13.2 Spin: trusted proxy or upstream runtime support + +- **Option 1.** Apply the same trusted-proxy contract as §13.1. This first needs a Spin trust configuration, which does not exist today. +- **Option 2.** Propose to the Spin project that the runtime add a JA4 header when it terminates TLS, the same way it adds `spin-client-addr`. + +### 13.3 EdgeZero-computed JA4 + +- **What.** A small in-tree ClientHello parser that uses the workspace `sha2` dependency, fed by Fastly's `get_tls_client_hello()`. It would add `Ja4Source::Computed` and possibly the unhashed variants. +- **Testing.** Against FoxIO's published sample captures, cross-checked against `get_tls_ja4()`. +- **Prerequisite.** Confirm whether the SDK returns the full TLS record or only the handshake message. + +## 14. Risks and open questions + +| Item | Mitigation | +| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| A provider emits a format the strict grammar rejects (for example uppercase hex) | Surfaces as `Malformed` instead of silent loss; manual verification (§10) checks real output before release. | +| Providers' JA4 implementations differ in edge cases (FoxIO v1.0.1 fixed several) | `Ja4Source` records provenance; docs say values are provider-computed and may differ across providers. | +| Whether Fastly reports JA4 for HTTP/3 (QUIC) connections | Unverified. Either result is handled; confirm during manual verification and document it. | +| Cloudflare `cf` access throwing instead of returning `None` | `worker` exposes both accessors as `Option`. The browser test covers the missing-`cf` path. | + +## 15. Decision log + +| Decision | Reason | +| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | +| Store on `RequestIngress` | Existing adapter-owned provenance record; already inserted after hook values are merged (§4). | +| Separate `ja4` module | Fingerprint parsing is a self-contained, independently testable unit. | +| `Option<&CapturedJa4>` instead of an `Unknown` variant | Matches "absence means unknown capability". | +| Separate `Malformed` variant | Makes provider format changes observable; follows #439's `GeoStatus::Error(InvalidAssertion)` precedent. | +| Strict shape, tolerant section `a` | Rejects non-JA4 input without rejecting future TLS version codes. | +| Redacted `Debug`, no `Display` or `Serialize` | A fingerprint is a quasi-identifier; follows the `ingress` module's existing redaction. | +| Names follow `ingress.rs` | `CapturedTarget` → `CapturedJa4`; `TargetUnavailable::NotExposed` → `Ja4Unavailable::NotExposed`; `OriginSource::RuntimeUri` → `Ja4Source::RuntimeTls`. | +| JA4 only; no JA4+ | FoxIO License 1.1 restricts commercial use of the other methods. | +| Axum trusts a proxy header only by CIDR allowlist | The adapter cannot see TLS; only the peer address proves which hop wrote the header (R5). | +| Separate `JA4_TRUSTED_PROXY_CIDRS` setting | Narrower than #409's forwarding-header trust and independent of its merge; can converge later. | +| Repeated JA4 header field is `Malformed` | A proxy writes one field; a second means another hop or the client also wrote it. | +| Reject `/0` networks | Trusting every peer would let any client forge JA4. | +| No extractor | YAGNI; `ctx.ja4()` meets R1, and an extractor can be added later. | + +## 16. References + +- FoxIO: [JA4 repository](https://github.com/FoxIO-LLC/ja4), [JA4 technical details](https://github.com/FoxIO-LLC/ja4/blob/main/technical_details/JA4.md), [License FAQ](https://github.com/FoxIO-LLC/ja4/blob/main/License%20FAQ.md), [releases](https://github.com/FoxIO-LLC/ja4/releases), [ja4-nginx-module](https://github.com/FoxIO-LLC/ja4-nginx-module). +- Fastly: [`tls.client.ja4` VCL variable](https://www.fastly.com/documentation/reference/vcl/variables/client-connection/tls-client-ja4/); Rust SDK `fastly::Request::get_tls_ja4`. +- Cloudflare: [JA4 signals](https://blog.cloudflare.com/ja4-signals/); `worker::Cf::bot_management`. +- AWS: [CloudFront JA4 fingerprinting](https://aws.amazon.com/about-aws/whats-new/2024/10/amazon-cloudfront-ja4-fingerprinting), [CloudFront request headers](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/adding-cloudfront-headers.html). +- Apache Traffic Server: [JA4 Fingerprint Plugin](https://docs.trafficserver.apache.org/en/latest/admin-guide/plugins/ja4_fingerprint.en.html). diff --git a/examples/app-demo/Cargo.lock b/examples/app-demo/Cargo.lock index 3af3f34f7..3e8c64ca3 100644 --- a/examples/app-demo/Cargo.lock +++ b/examples/app-demo/Cargo.lock @@ -780,6 +780,7 @@ dependencies = [ "futures", "futures-util", "http", + "ipnet", "log", "redb", "reqwest", diff --git a/tests/fixtures/request-fidelity-runner/src/main.rs b/tests/fixtures/request-fidelity-runner/src/main.rs index fbb65bba2..6c237081e 100644 --- a/tests/fixtures/request-fidelity-runner/src/main.rs +++ b/tests/fixtures/request-fidelity-runner/src/main.rs @@ -906,6 +906,11 @@ fn cases() -> Vec { (b"Forwarded", b"proto=https;host=spoof.example.com"), (b"spin-full-url", b"https://spoof.example.com/reserved"), (b"spin-client-addr", b"192.0.2.1:1"), + (b"ja4", b"t13d1516h2_8daaf6152771_e5627efa2ab1"), + ( + b"cloudfront-viewer-ja4-fingerprint", + b"t13d1516h2_8daaf6152771_e5627efa2ab1", + ), ] } "length-identical" | "length-conflict" => { @@ -981,6 +986,20 @@ fn verify_observation(platform: &str, case: &str, status: u16, verdict: &Value) case, "order capability and origin provenance must remain explicit", )?; + // Local runtimes serve plain HTTP: Fastly and Cloudflare expose JA4 + // but have none to report; Spin never exposes it. Spoofed JA4 headers + // in the `spoof` case must not change this. + let expected_ja4_state = if platform == "spin" { + "not_exposed" + } else { + "missing" + }; + require( + verdict["ja4_state"] == expected_ja4_state, + platform, + case, + "JA4 must come only from the runtime TLS API", + )?; require( verdict["target_available"] == (platform != "fastly"), platform, @@ -1661,7 +1680,7 @@ mod tests { #[test] fn observation_verification_rejects_utf8_corruption_even_during_collection() { - let mut verdict = json!({"hook": true, "ordinary": false, "global_order_unavailable": true, "spoofed_origin_ignored": true, "origin_available": true, "origin_matches_expected": true, "target_available": true, "target_matches_runtime": true, "cookie_utf8": false, "cookie_latin1": true}); + let mut verdict = json!({"hook": true, "ordinary": false, "global_order_unavailable": true, "spoofed_origin_ignored": true, "origin_available": true, "origin_matches_expected": true, "target_available": true, "target_matches_runtime": true, "ja4_state": "missing", "cookie_utf8": false, "cookie_latin1": true}); let error = verify_observation("cloudflare", "utf8", 200, &verdict) .expect_err("should reject the former C3 A9 to E9 defect"); assert!( diff --git a/tests/fixtures/request-fidelity/README.md b/tests/fixtures/request-fidelity/README.md index d09ddc3da..23112468e 100644 --- a/tests/fixtures/request-fidelity/README.md +++ b/tests/fixtures/request-fidelity/README.md @@ -72,6 +72,11 @@ Known gates: - Spin 4.1.0 rejects non-UTF-8 header values before component invocation. It consumes chunked framing and removes Transfer-Encoding before the hook in `chunked` and `te-cl` cases. +- JA4: the hook reports only a fixed `ja4_state` label, never the value. The + local runtimes serve plain HTTP, so Fastly and Cloudflare report `missing` + and Spin reports `not_exposed`; the `spoof` case's `ja4` and + `cloudfront-viewer-ja4-fingerprint` headers must not change this. Captured + fingerprints need a live TLS deployment. - Global original field order is unavailable on Axum, Cloudflare and Spin. Fastly reports Unknown: original-name/count APIs provide partial evidence, without associating original fields with converted values. Local evidence diff --git a/tests/fixtures/request-fidelity/observations.json b/tests/fixtures/request-fidelity/observations.json index 4fcfb02d0..6ec57a259 100644 --- a/tests/fixtures/request-fidelity/observations.json +++ b/tests/fixtures/request-fidelity/observations.json @@ -23,6 +23,7 @@ "extension_method": false, "global_order_unavailable": true, "hook": true, + "ja4_state": "missing", "native_client_preserved": null, "ordinary": false, "origin_available": true, @@ -56,6 +57,7 @@ "extension_method": false, "global_order_unavailable": true, "hook": true, + "ja4_state": "missing", "native_client_preserved": null, "ordinary": false, "origin_available": true, @@ -89,6 +91,7 @@ "extension_method": false, "global_order_unavailable": true, "hook": true, + "ja4_state": "missing", "native_client_preserved": null, "ordinary": false, "origin_available": true, @@ -128,6 +131,7 @@ "extension_method": false, "global_order_unavailable": true, "hook": true, + "ja4_state": "missing", "native_client_preserved": null, "ordinary": false, "origin_available": true, @@ -161,6 +165,7 @@ "extension_method": false, "global_order_unavailable": true, "hook": true, + "ja4_state": "missing", "native_client_preserved": null, "ordinary": false, "origin_available": true, @@ -198,6 +203,7 @@ "extension_method": false, "global_order_unavailable": true, "hook": true, + "ja4_state": "missing", "native_client_preserved": null, "ordinary": false, "origin_available": true, @@ -239,6 +245,7 @@ "extension_method": false, "global_order_unavailable": true, "hook": true, + "ja4_state": "missing", "native_client_preserved": null, "ordinary": false, "origin_available": true, @@ -288,6 +295,7 @@ "extension_method": false, "global_order_unavailable": true, "hook": true, + "ja4_state": "missing", "native_client_preserved": null, "ordinary": false, "origin_available": true, @@ -321,6 +329,7 @@ "extension_method": false, "global_order_unavailable": true, "hook": true, + "ja4_state": "missing", "native_client_preserved": null, "ordinary": false, "origin_available": true, @@ -354,6 +363,7 @@ "extension_method": false, "global_order_unavailable": true, "hook": true, + "ja4_state": "missing", "native_client_preserved": null, "ordinary": false, "origin_available": true, @@ -387,6 +397,7 @@ "extension_method": false, "global_order_unavailable": true, "hook": true, + "ja4_state": "missing", "native_client_preserved": null, "ordinary": false, "origin_available": true, @@ -420,6 +431,7 @@ "extension_method": false, "global_order_unavailable": true, "hook": true, + "ja4_state": "missing", "native_client_preserved": null, "ordinary": false, "origin_available": true, @@ -455,6 +467,7 @@ "extension_method": false, "global_order_unavailable": false, "hook": true, + "ja4_state": "missing", "native_client_preserved": true, "ordinary": false, "origin_available": true, @@ -488,6 +501,7 @@ "extension_method": false, "global_order_unavailable": false, "hook": true, + "ja4_state": "missing", "native_client_preserved": true, "ordinary": false, "origin_available": true, @@ -521,6 +535,7 @@ "extension_method": false, "global_order_unavailable": false, "hook": true, + "ja4_state": "missing", "native_client_preserved": true, "ordinary": false, "origin_available": true, @@ -554,6 +569,7 @@ "extension_method": false, "global_order_unavailable": false, "hook": true, + "ja4_state": "missing", "native_client_preserved": true, "ordinary": false, "origin_available": true, @@ -593,6 +609,7 @@ "extension_method": false, "global_order_unavailable": false, "hook": true, + "ja4_state": "missing", "native_client_preserved": true, "ordinary": false, "origin_available": true, @@ -626,6 +643,7 @@ "extension_method": false, "global_order_unavailable": false, "hook": true, + "ja4_state": "missing", "native_client_preserved": true, "ordinary": false, "origin_available": true, @@ -659,6 +677,7 @@ "extension_method": true, "global_order_unavailable": false, "hook": true, + "ja4_state": "missing", "native_client_preserved": true, "ordinary": false, "origin_available": true, @@ -692,6 +711,7 @@ "extension_method": false, "global_order_unavailable": false, "hook": true, + "ja4_state": "missing", "native_client_preserved": true, "ordinary": false, "origin_available": true, @@ -729,6 +749,7 @@ "extension_method": false, "global_order_unavailable": false, "hook": true, + "ja4_state": "missing", "native_client_preserved": true, "ordinary": false, "origin_available": true, @@ -776,6 +797,7 @@ "extension_method": false, "global_order_unavailable": false, "hook": true, + "ja4_state": "missing", "native_client_preserved": true, "ordinary": false, "origin_available": true, @@ -809,6 +831,7 @@ "extension_method": false, "global_order_unavailable": false, "hook": true, + "ja4_state": "missing", "native_client_preserved": true, "ordinary": false, "origin_available": true, @@ -842,6 +865,7 @@ "extension_method": false, "global_order_unavailable": false, "hook": true, + "ja4_state": "missing", "native_client_preserved": true, "ordinary": false, "origin_available": true, @@ -875,6 +899,7 @@ "extension_method": false, "global_order_unavailable": false, "hook": true, + "ja4_state": "missing", "native_client_preserved": true, "ordinary": false, "origin_available": true, @@ -908,6 +933,7 @@ "extension_method": false, "global_order_unavailable": false, "hook": true, + "ja4_state": "missing", "native_client_preserved": true, "ordinary": false, "origin_available": true, @@ -941,6 +967,7 @@ "extension_method": false, "global_order_unavailable": false, "hook": true, + "ja4_state": "missing", "native_client_preserved": true, "ordinary": false, "origin_available": true, @@ -974,6 +1001,7 @@ "extension_method": false, "global_order_unavailable": false, "hook": true, + "ja4_state": "missing", "native_client_preserved": true, "ordinary": false, "origin_available": true, @@ -1009,6 +1037,7 @@ "extension_method": false, "global_order_unavailable": true, "hook": true, + "ja4_state": "not_exposed", "native_client_preserved": null, "ordinary": false, "origin_available": true, @@ -1042,6 +1071,7 @@ "extension_method": false, "global_order_unavailable": true, "hook": true, + "ja4_state": "not_exposed", "native_client_preserved": null, "ordinary": false, "origin_available": true, @@ -1079,6 +1109,7 @@ "extension_method": false, "global_order_unavailable": true, "hook": true, + "ja4_state": "not_exposed", "native_client_preserved": null, "ordinary": false, "origin_available": true, @@ -1118,6 +1149,7 @@ "extension_method": false, "global_order_unavailable": true, "hook": true, + "ja4_state": "not_exposed", "native_client_preserved": null, "ordinary": false, "origin_available": true, @@ -1151,6 +1183,7 @@ "extension_method": false, "global_order_unavailable": true, "hook": true, + "ja4_state": "not_exposed", "native_client_preserved": null, "ordinary": false, "origin_available": true, @@ -1184,6 +1217,7 @@ "extension_method": true, "global_order_unavailable": true, "hook": true, + "ja4_state": "not_exposed", "native_client_preserved": null, "ordinary": false, "origin_available": true, @@ -1225,6 +1259,7 @@ "extension_method": false, "global_order_unavailable": true, "hook": true, + "ja4_state": "not_exposed", "native_client_preserved": null, "ordinary": false, "origin_available": true, @@ -1258,6 +1293,7 @@ "extension_method": false, "global_order_unavailable": true, "hook": true, + "ja4_state": "not_exposed", "native_client_preserved": null, "ordinary": false, "origin_available": true, @@ -1291,6 +1327,7 @@ "extension_method": false, "global_order_unavailable": true, "hook": true, + "ja4_state": "not_exposed", "native_client_preserved": null, "ordinary": false, "origin_available": true, @@ -1324,6 +1361,7 @@ "extension_method": false, "global_order_unavailable": true, "hook": true, + "ja4_state": "not_exposed", "native_client_preserved": null, "ordinary": false, "origin_available": true, @@ -1357,6 +1395,7 @@ "extension_method": false, "global_order_unavailable": true, "hook": true, + "ja4_state": "not_exposed", "native_client_preserved": null, "ordinary": false, "origin_available": true, @@ -1390,6 +1429,7 @@ "extension_method": false, "global_order_unavailable": true, "hook": true, + "ja4_state": "not_exposed", "native_client_preserved": null, "ordinary": false, "origin_available": true, @@ -1423,6 +1463,7 @@ "extension_method": false, "global_order_unavailable": true, "hook": true, + "ja4_state": "not_exposed", "native_client_preserved": null, "ordinary": false, "origin_available": true, diff --git a/tests/fixtures/request-fidelity/src/lib.rs b/tests/fixtures/request-fidelity/src/lib.rs index 7286026fa..9938bcd0c 100644 --- a/tests/fixtures/request-fidelity/src/lib.rs +++ b/tests/fixtures/request-fidelity/src/lib.rs @@ -13,6 +13,7 @@ use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; use edgezero_core::http::{Request, Response, response_builder}; use edgezero_core::ingress::{CapturedTarget, Preservation, RequestIngress}; +use edgezero_core::ja4::{CapturedJa4, Ja4Unavailable}; use edgezero_core::middleware::{Middleware, Next}; use edgezero_core::router::{PreDispatchHook, RouterService}; use serde_json::json; @@ -101,6 +102,15 @@ impl PreDispatchHook for Probe { let same_name_order = cookies == [b"a=1".as_slice(), b"b=2".as_slice()]; let cookie_name = "cookie".parse().map_err(EdgeError::internal)?; let fidelity = ingress.header_fidelity(&cookie_name); + // A fixed state label only; the fingerprint itself is never echoed. + let ja4_state = match ingress.ja4() { + None => "none", + Some(CapturedJa4::Captured { .. }) => "captured", + Some(CapturedJa4::Unavailable(Ja4Unavailable::Missing)) => "missing", + Some(CapturedJa4::Unavailable(Ja4Unavailable::Malformed)) => "malformed", + Some(CapturedJa4::Unavailable(Ja4Unavailable::NotExposed)) => "not_exposed", + Some(CapturedJa4::Unavailable(_)) => "other", + }; #[cfg(feature = "fastly")] let native_client_preserved = request @@ -142,6 +152,7 @@ impl PreDispatchHook for Probe { "origin_matches_expected": ingress.origin().is_some_and(|origin| origin.scheme() == "http" && origin.authority() == "example.com"), "spoofed_origin_ignored": ingress.origin().is_none_or(|origin| !origin.authority().contains("spoof")), "global_order_unavailable": fidelity.global_field_order() == Preservation::Unavailable, + "ja4_state": ja4_state, }); Ok(Some( response_builder() diff --git a/tests/fixtures/reusable-app/Cargo.lock b/tests/fixtures/reusable-app/Cargo.lock index 2c122ed8e..215afccfb 100644 --- a/tests/fixtures/reusable-app/Cargo.lock +++ b/tests/fixtures/reusable-app/Cargo.lock @@ -572,6 +572,7 @@ dependencies = [ "futures", "futures-util", "http", + "ipnet", "log", "redb", "reqwest",