From 4fe3eae1f0911495c84032708104acc73d9621d9 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:39:38 +0300 Subject: [PATCH] Fix Windows WEB carrier empty-cookie compatibility Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> --- src/web/http.rs | 4 +- src/web/http/request.rs | 52 +++++++++++++++++++++ src/web/http/tests.rs | 100 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 2 deletions(-) diff --git a/src/web/http.rs b/src/web/http.rs index 47efad9..768621f 100644 --- a/src/web/http.rs +++ b/src/web/http.rs @@ -39,7 +39,7 @@ use body::{CollectBodyError, CollectedBody, collect_body}; use decoy::serve_decoy; use request::{ bearer_token_hash, binary_content_type, bridge_candidate, canonical_request_host, - canonical_u64_header, client_ip, match_profile, + canonical_u64_header, client_ip, compatible_cookie_header, match_profile, }; type BoxError = Box; @@ -231,7 +231,7 @@ async fn handle_api( runtime: Arc, vhost: Arc, ) -> HttpResponse { - if request.uri().query().is_some() || request.headers().contains_key(header::COOKIE) { + if request.uri().query().is_some() || !compatible_cookie_header(&request) { return serve_decoy(request, vhost, true, &runtime).await; } let Some(client_ip) = client_ip(&request, peer, client_ip_source, trusted_proxy_cidrs) else { diff --git a/src/web/http/request.rs b/src/web/http/request.rs index 669f21c..0695067 100644 --- a/src/web/http/request.rs +++ b/src/web/http/request.rs @@ -141,6 +141,16 @@ pub(super) fn binary_content_type(request: &Request) -> bool { && value.is_some_and(|value| value.eq_ignore_ascii_case("application/octet-stream")) } +/// Accepts no Cookie or the one empty value emitted by restricted Windows WebView2. +pub(super) fn compatible_cookie_header(request: &Request) -> bool { + let values = request.headers().get_all(header::COOKIE); + let mut values = values.iter(); + match values.next() { + None => true, + Some(value) => value.as_bytes().is_empty() && values.next().is_none(), + } +} + /// Parses one canonical unsigned decimal carrier sequence header. pub(super) fn canonical_u64_header(request: &Request, name: &'static str) -> Option { let values = request.headers().get_all(name); @@ -267,4 +277,46 @@ mod tests { .unwrap(); assert!(canonical_u64_header(&leading_zero, "x-up-seq").is_none()); } + + #[test] + fn cookie_header_accepts_only_absent_or_one_empty_value() { + let absent = Request::new(()); + assert!(compatible_cookie_header(&absent)); + + let empty = Request::builder() + .header(header::COOKIE, "") + .body(()) + .unwrap(); + assert!(compatible_cookie_header(&empty)); + + let nonempty = Request::builder() + .header(header::COOKIE, "state=unexpected") + .body(()) + .unwrap(); + assert!(!compatible_cookie_header(&nonempty)); + + let whitespace = Request::builder() + .header(header::COOKIE, " ") + .body(()) + .unwrap(); + assert!(!compatible_cookie_header(&whitespace)); + + let mut duplicate_empty = Request::new(()); + duplicate_empty + .headers_mut() + .append(header::COOKIE, "".parse().unwrap()); + duplicate_empty + .headers_mut() + .append(header::COOKIE, "".parse().unwrap()); + assert!(!compatible_cookie_header(&duplicate_empty)); + + let mut duplicate_mixed = Request::new(()); + duplicate_mixed + .headers_mut() + .append(header::COOKIE, "".parse().unwrap()); + duplicate_mixed + .headers_mut() + .append(header::COOKIE, "state=unexpected".parse().unwrap()); + assert!(!compatible_cookie_header(&duplicate_mixed)); + } } diff --git a/src/web/http/tests.rs b/src/web/http/tests.rs index ca874fc..afcb17d 100644 --- a/src/web/http/tests.rs +++ b/src/web/http/tests.rs @@ -403,3 +403,103 @@ async fn https_lanes_is_advertised_and_requires_canonical_lane_headers() { generation.stop_sessions().await; generation.stop_background_tasks().await; } + +#[tokio::test] +async fn windows_restricted_webview_empty_cookie_preserves_the_carrier_flow() { + for (index, carrier) in [WebCarrier::Https, WebCarrier::HttpsLanes] + .into_iter() + .enumerate() + { + let capability = [12 + index as u8; 32]; + let mut config = runtime_config(capability, carrier); + config.web.timeouts.long_poll_secs = 0; + let generation = test_runtime_generation(1, config); + let active_runtime = Arc::new(ArcSwap::from(Arc::clone(&generation))); + let runtime = WebProcessRuntime::start(active_runtime); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(capability); + let root = format!( + "GET /?bridge={encoded} HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nCookie:\r\nConnection: close\r\n\r\n" + ) + .into_bytes(); + let root_response = request(&listener, &runtime, root).await; + let (root_headers, root_body) = split_response(&root_response); + assert!(root_headers.starts_with(b"HTTP/1.1 200")); + let root_body = std::str::from_utf8(root_body).unwrap(); + let bootstrap = root_body + .split_once("bootstrap='") + .and_then(|(_, suffix)| suffix.split_once('\'')) + .map(|(token, _)| token) + .unwrap(); + + let hello = frame::encode(FrameType::Hello, 0, &[1]); + let create = |cookie: &str| { + let mut request = format!( + "POST /api/v1/session HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {bootstrap}\r\nContent-Type: application/octet-stream\r\n{cookie}Content-Length: {}\r\nConnection: close\r\n\r\n", + hello.len() + ) + .into_bytes(); + request.extend_from_slice(&hello); + request + }; + let nonempty_cookie = request( + &listener, + &runtime, + create("Cookie: state=unexpected\r\n"), + ) + .await; + assert!(!nonempty_cookie.starts_with(b"HTTP/1.1 200")); + let duplicate_cookie = request( + &listener, + &runtime, + create("Cookie:\r\nCookie: state=unexpected\r\n"), + ) + .await; + assert!(!duplicate_cookie.starts_with(b"HTTP/1.1 200")); + + let create_response = request(&listener, &runtime, create("Cookie:\r\n")).await; + let (create_headers, create_body) = split_response(&create_response); + assert!(create_headers.starts_with(b"HTTP/1.1 200")); + assert_eq!( + response_header(create_headers, "x-carrier-mode"), + carrier.as_str() + ); + assert_eq!(create_body, frame::encode(FrameType::Welcome, 0, &[])); + let session = response_header(create_headers, "x-session-token").to_string(); + let lane = (carrier == WebCarrier::HttpsLanes) + .then_some("X-Lane-ID: 0\r\n") + .unwrap_or_default(); + + let pong = frame::encode(FrameType::Pong, 0, &[]); + let mut uplink = format!( + "POST /api/v1/up HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {session}\r\nContent-Type: application/octet-stream\r\nCookie:\r\nX-Up-Seq: 1\r\n{lane}Content-Length: {}\r\nConnection: close\r\n\r\n", + pong.len() + ) + .into_bytes(); + uplink.extend_from_slice(&pong); + let uplink_response = request(&listener, &runtime, uplink).await; + let (uplink_headers, _) = split_response(&uplink_response); + assert!(uplink_headers.starts_with(b"HTTP/1.1 204")); + assert_eq!(response_header(uplink_headers, "x-up-ack"), "1"); + + let downlink = format!( + "POST /api/v1/down HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {session}\r\nCookie:\r\nX-Down-Cursor: 0\r\n{lane}Content-Length: 0\r\nConnection: close\r\n\r\n" + ) + .into_bytes(); + let downlink_response = request(&listener, &runtime, downlink).await; + let (downlink_headers, _) = split_response(&downlink_response); + assert!(downlink_headers.starts_with(b"HTTP/1.1 204")); + assert_eq!(response_header(downlink_headers, "x-down-cursor"), "0"); + + let close = format!( + "DELETE /api/v1/session HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {session}\r\nCookie:\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ) + .into_bytes(); + let close_response = request(&listener, &runtime, close).await; + assert!(close_response.starts_with(b"HTTP/1.1 204")); + + runtime.shutdown().await; + generation.stop_sessions().await; + generation.stop_background_tasks().await; + } +}