From 7f4e637b994afccc7b9d0042caf908ca3f4ceba2 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:01:52 +0300 Subject: [PATCH] Decoy Contract sanitized Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> --- src/metrics/web/lifecycle.rs | 2 +- src/web/http.rs | 14 +++++++++++--- src/web/http/recovery.rs | 18 +++++++++++++++--- src/web/http/recovery_tests.rs | 20 ++++++++++++++++++++ src/web/session/status.rs | 2 +- src/web/telemetry/lifecycle.rs | 2 +- 6 files changed, 49 insertions(+), 9 deletions(-) diff --git a/src/metrics/web/lifecycle.rs b/src/metrics/web/lifecycle.rs index 152c458..cff1753 100644 --- a/src/metrics/web/lifecycle.rs +++ b/src/metrics/web/lifecycle.rs @@ -29,7 +29,7 @@ pub(super) fn render( let _ = writeln!( out, - "# HELP telemt_web_session_lifecycle_observations_total Authenticated activity after bounded lifecycle gaps" + "# HELP telemt_web_session_lifecycle_observations_total Authenticated WEB session lifecycle observations" ); let _ = writeln!( out, diff --git a/src/web/http.rs b/src/web/http.rs index 5c291a6..ccbe5a1 100644 --- a/src/web/http.rs +++ b/src/web/http.rs @@ -151,7 +151,7 @@ pub(crate) async fn serve_connection( } async fn handle_request( - request: Request, + mut request: Request, peer: SocketAddr, client_ip_source: WebClientIpSource, trusted_proxy_cidrs: &[IpNetwork], @@ -209,7 +209,11 @@ async fn handle_request( ) .await; } - serve_decoy(request, vhost, false, &runtime).await + let sanitize_recovery = recovery::has_media_type(&request); + if sanitize_recovery { + strip_query(&mut request); + } + serve_decoy(request, vhost, sanitize_recovery, &runtime).await } async fn handle_root( @@ -228,8 +232,12 @@ async fn handle_root( } let (candidate, canonical) = bridge_candidate(request.uri().query()); let profile = match_profile(&vhost, &candidate); + let recovery_requested = matches!(representation, recovery::RootRepresentation::Recovery(_)); let Some(profile) = profile.filter(|_| canonical && request.method() == Method::GET) else { - return serve_decoy(request, vhost, false, &runtime).await; + if recovery_requested { + strip_query(&mut request); + } + return serve_decoy(request, vhost, recovery_requested, &runtime).await; }; let Some(client_ip) = client_ip(&request, peer, client_ip_source, trusted_proxy_cidrs) else { strip_query(&mut request); diff --git a/src/web/http/recovery.rs b/src/web/http/recovery.rs index f1594f1..1d0f00a 100644 --- a/src/web/http/recovery.rs +++ b/src/web/http/recovery.rs @@ -30,9 +30,7 @@ pub(super) fn classify(request: &Request) -> RootRepresentation { let first = values.next(); let exact = first.is_some_and(|value| value.as_bytes() == MEDIA_TYPE.as_bytes()) && values.next().is_none(); - let recovery_present = accepts - .iter() - .any(|value| value.as_bytes() == MEDIA_TYPE.as_bytes()); + let recovery_present = has_media_type(request); let authorization_present = request.headers().contains_key(header::AUTHORIZATION); if !exact { return if authorization_present || recovery_present { @@ -52,6 +50,20 @@ pub(super) fn classify(request: &Request) -> RootRepresentation { .unwrap_or(RootRepresentation::Invalid) } +/// Detects a recovery media token even when its Accept syntax is noncanonical. +pub(super) fn has_media_type(request: &Request) -> bool { + request.headers().get_all(header::ACCEPT).iter().any(|value| { + value.to_str().ok().is_some_and(|value| { + value.split(',').any(|entry| { + entry + .split(';') + .next() + .is_some_and(|media| media.trim().eq_ignore_ascii_case(MEDIA_TYPE)) + }) + }) + }) +} + /// Builds the bounded no-store recovery representation. pub(super) fn response( bootstrap: &BootstrapResult, diff --git a/src/web/http/recovery_tests.rs b/src/web/http/recovery_tests.rs index d8f74cb..7027d36 100644 --- a/src/web/http/recovery_tests.rs +++ b/src/web/http/recovery_tests.rs @@ -170,6 +170,26 @@ async fn malformed_or_over_capacity_recovery_is_indistinguishable_from_decoy() { assert_eq!(response_header(malformed_headers, "cache-control"), "no-store"); assert_eq!(malformed_body, b"decoy"); + let invalid_capability = recover( + &listener, + &runtime, + &base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([99u8; 32]), + &format!("Bearer {}", "U".repeat(43)), + ) + .await; + assert_eq!(invalid_capability, malformed); + + let malformed_accept = request( + &listener, + &runtime, + format!( + "GET /?bridge={encoded} HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.40\r\nAccept: {RECOVERY_TYPE}, */*\r\nConnection: close\r\n\r\n" + ) + .into_bytes(), + ) + .await; + assert_eq!(malformed_accept, malformed); + let _held = bridge_bootstrap(&listener, &runtime, &encoded).await; let over_capacity = recover( &listener, diff --git a/src/web/session/status.rs b/src/web/session/status.rs index 7e1a8f7..325c9a3 100644 --- a/src/web/session/status.rs +++ b/src/web/session/status.rs @@ -52,7 +52,7 @@ pub(crate) struct WebSessionStatus { pub(crate) control_items: usize, /// Monotonic age since session creation. pub(crate) age_ms: u64, - /// Monotonic age since the latest carrier activity. + /// Monotonic age since the latest peer or carrier progress. pub(crate) idle_ms: u64, /// Monotonic age since the latest validated peer operation. pub(crate) peer_idle_ms: u64, diff --git a/src/web/telemetry/lifecycle.rs b/src/web/telemetry/lifecycle.rs index 16fee77..331e0be 100644 --- a/src/web/telemetry/lifecycle.rs +++ b/src/web/telemetry/lifecycle.rs @@ -12,7 +12,7 @@ pub(super) const SESSION_CLOSE_SLOTS: usize = pub(super) const SESSION_OBSERVATION_SLOTS: usize = WebCarrier::ALL.len() * WebSessionLifecycleObservation::ALL.len(); -/// Stable observation emitted after an authenticated session lifecycle gap. +/// Stable authenticated session lifecycle observation. #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[repr(usize)] pub(crate) enum WebSessionLifecycleObservation {