Merge pull request #901 from telemt/flow-web-empty-cookie

Fix Windows WEB carrier empty-cookie compatibility
This commit is contained in:
Alexey
2026-08-23 14:56:04 +03:00
committed by GitHub
5 changed files with 152 additions and 4 deletions
Generated
+1 -1
View File
@@ -2900,7 +2900,7 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
[[package]]
name = "telemt"
version = "3.5.1"
version = "3.5.2"
dependencies = [
"aes",
"anyhow",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "telemt"
version = "3.5.1"
version = "3.5.2"
edition = "2024"
[features]
+2 -2
View File
@@ -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<dyn Error + Send + Sync>;
@@ -231,7 +231,7 @@ async fn handle_api(
runtime: Arc<WebProcessRuntime>,
vhost: Arc<WebRuntimeVhost>,
) -> 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 {
+52
View File
@@ -141,6 +141,16 @@ pub(super) fn binary_content_type<B>(request: &Request<B>) -> 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<B>(request: &Request<B>) -> 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<B>(request: &Request<B>, name: &'static str) -> Option<u64> {
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));
}
}
+96
View File
@@ -403,3 +403,99 @@ 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;
}
}