mirror of
https://github.com/telemt/telemt.git
synced 2026-09-07 02:56:27 +03:00
Carrier negotiation + WebSocket lifecycle
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
@@ -114,6 +114,7 @@ pub(super) enum CollectBodyError {
|
||||
pub(super) async fn collect_body(
|
||||
request: Request<RequestBody>,
|
||||
runtime: &WebProcessRuntime,
|
||||
body_timeout: Duration,
|
||||
limit: usize,
|
||||
allow_empty: bool,
|
||||
) -> Result<CollectedBody, CollectBodyError> {
|
||||
@@ -133,8 +134,6 @@ pub(super) async fn collect_body(
|
||||
let Some((reader_budget, body_budget)) = runtime.try_body_budget(limit) else {
|
||||
return Err(CollectBodyError::Limit);
|
||||
};
|
||||
let body_timeout =
|
||||
Duration::from_secs(runtime.active_generation().config().web.timeouts.body_secs);
|
||||
let body = match tokio::time::timeout(body_timeout, Limited::new(body, limit).collect()).await {
|
||||
Ok(Ok(body)) => body.to_bytes(),
|
||||
_ => {
|
||||
|
||||
+10
-1
@@ -1,4 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use hyper::header::{self, HeaderName, HeaderValue};
|
||||
use hyper::{Request, StatusCode};
|
||||
@@ -46,7 +47,15 @@ pub(super) async fn handle_down(
|
||||
request,
|
||||
body,
|
||||
_body_budget,
|
||||
} = match collect_body(request, &runtime, 1, true).await {
|
||||
} = match collect_body(
|
||||
request,
|
||||
&runtime,
|
||||
Duration::from_secs(session.timeouts().body_secs),
|
||||
1,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(CollectBodyError::Limit) => return service_unavailable(),
|
||||
Err(CollectBodyError::Invalid(request)) => {
|
||||
|
||||
@@ -178,7 +178,7 @@ async fn metadata_free_native_client_uses_fallback_when_candidates_are_enabled()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_native_capabilities_participate_in_automatic_selection() {
|
||||
async fn explicit_native_capabilities_are_limited_to_https() {
|
||||
let capability = [56; 32];
|
||||
let generation = test_runtime_generation(
|
||||
1,
|
||||
@@ -202,12 +202,9 @@ async fn explicit_native_capabilities_participate_in_automatic_selection() {
|
||||
.await;
|
||||
let (headers, _) = split_response(&response);
|
||||
assert!(headers.starts_with(b"HTTP/1.1 200"));
|
||||
assert_eq!(
|
||||
response_header(headers, "x-carrier-mode"),
|
||||
"websocket-lanes"
|
||||
);
|
||||
assert_eq!(response_header(headers, "x-carrier-mode"), "https");
|
||||
assert_eq!(response_header(headers, "x-carrier-attempt"), "1");
|
||||
assert_eq!(response_header(headers, "x-carrier-candidate-count"), "2");
|
||||
assert_eq!(response_header(headers, "x-carrier-candidate-count"), "1");
|
||||
|
||||
runtime.shutdown().await;
|
||||
generation.stop_sessions().await;
|
||||
|
||||
+12
-285
@@ -140,6 +140,11 @@ pub(super) fn carrier_request<B>(request: &Request<B>, host: &str) -> Option<Car
|
||||
}
|
||||
(Some(capabilities), Some(attempt)) => {
|
||||
let capabilities = parse_capabilities(capabilities)?;
|
||||
let capabilities = if native_ios {
|
||||
capabilities.intersection(CarrierCapabilities::ios())?
|
||||
} else {
|
||||
capabilities
|
||||
};
|
||||
if (attempt == 1) != failure.is_none() {
|
||||
return None;
|
||||
}
|
||||
@@ -156,15 +161,14 @@ pub(super) fn carrier_request<B>(request: &Request<B>, host: &str) -> Option<Car
|
||||
))
|
||||
}
|
||||
(None, Some(attempt)) if strict_browser_hint(request, host) => {
|
||||
if native_ios {
|
||||
return None;
|
||||
}
|
||||
if (attempt == 1) != failure.is_none() {
|
||||
return None;
|
||||
}
|
||||
Some(CarrierRequest::automatic(
|
||||
if native_ios {
|
||||
CarrierClientClass::Ios
|
||||
} else {
|
||||
CarrierClientClass::BrowserHint
|
||||
},
|
||||
CarrierClientClass::BrowserHint,
|
||||
CarrierCapabilities::all(),
|
||||
attempt,
|
||||
failure,
|
||||
@@ -259,284 +263,7 @@ fn single_header<B>(request: &Request<B>, name: impl header::AsHeaderName) -> Op
|
||||
values.next().is_none().then_some(value)
|
||||
}
|
||||
|
||||
// Canonical request parsing and client-classification tests.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ipnetwork::IpNetwork;
|
||||
|
||||
use crate::config::{WebCarrier, WebClientIpSource};
|
||||
|
||||
#[test]
|
||||
fn canonical_bridge_query_rejects_aliases() {
|
||||
let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([7u8; 32]);
|
||||
assert!(bridge_candidate(Some(&format!("bridge={token}"))).1);
|
||||
assert!(!bridge_candidate(Some(&format!("x=1&bridge={token}"))).1);
|
||||
assert!(!bridge_candidate(Some(&format!("bridge={token}="))).1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_is_canonical_and_forwarded_identity_is_single_parseable_ip() {
|
||||
let request = Request::builder()
|
||||
.header(header::HOST, "proxy.example.com:443")
|
||||
.header("x-forwarded-for", "192.0.2.10")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert_eq!(canonical_request_host(&request), Some("proxy.example.com"));
|
||||
let trusted: [IpNetwork; 1] = ["127.0.0.1/32".parse().unwrap()];
|
||||
assert_eq!(
|
||||
client_ip(
|
||||
&request,
|
||||
"127.0.0.1:40000".parse().unwrap(),
|
||||
WebClientIpSource::XForwardedFor,
|
||||
&trusted,
|
||||
),
|
||||
Some("192.0.2.10".parse().unwrap())
|
||||
);
|
||||
|
||||
let expanded_ipv6 = Request::builder()
|
||||
.header("x-forwarded-for", "2001:0db8:0:0:0:0:0:10")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
client_ip(
|
||||
&expanded_ipv6,
|
||||
"127.0.0.1:40000".parse().unwrap(),
|
||||
WebClientIpSource::XForwardedFor,
|
||||
&trusted,
|
||||
),
|
||||
Some("2001:db8::10".parse().unwrap())
|
||||
);
|
||||
|
||||
let without_forwarded_address = Request::builder().body(()).unwrap();
|
||||
assert_eq!(
|
||||
client_ip(
|
||||
&without_forwarded_address,
|
||||
"127.0.0.1:40000".parse().unwrap(),
|
||||
WebClientIpSource::XForwardedFor,
|
||||
&trusted,
|
||||
),
|
||||
Some("127.0.0.1".parse().unwrap())
|
||||
);
|
||||
|
||||
let empty_forwarded_address = Request::builder()
|
||||
.header("x-forwarded-for", "")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
client_ip(
|
||||
&empty_forwarded_address,
|
||||
"127.0.0.1:40000".parse().unwrap(),
|
||||
WebClientIpSource::XForwardedFor,
|
||||
&trusted,
|
||||
),
|
||||
Some("127.0.0.1".parse().unwrap())
|
||||
);
|
||||
|
||||
let uppercase = Request::builder()
|
||||
.header(header::HOST, "Proxy.Example.com")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(canonical_request_host(&uppercase).is_none());
|
||||
let appended = Request::builder()
|
||||
.header("x-forwarded-for", "192.0.2.10, 198.51.100.4")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(
|
||||
client_ip(
|
||||
&appended,
|
||||
"127.0.0.1:40000".parse().unwrap(),
|
||||
WebClientIpSource::XForwardedFor,
|
||||
&trusted,
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearer_and_sequence_headers_reject_noncanonical_aliases() {
|
||||
let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([1u8; 32]);
|
||||
let request = Request::builder()
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header("x-up-seq", "17")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
bearer_token_hash(&request),
|
||||
Some(Sha256::digest([1u8; 32]).into())
|
||||
);
|
||||
assert_eq!(canonical_u64_header(&request, "x-up-seq"), Some(17));
|
||||
|
||||
let leading_zero = Request::builder()
|
||||
.header("x-up-seq", "017")
|
||||
.body(())
|
||||
.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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn carrier_metadata_is_canonical_and_legacy_safe() {
|
||||
let automatic = Request::builder()
|
||||
.header(
|
||||
"x-carrier-capabilities",
|
||||
"https,https-lanes,websocket,websocket-lanes",
|
||||
)
|
||||
.header("x-carrier-attempt", "2")
|
||||
.header("x-carrier-failure", "timeout")
|
||||
.header(header::USER_AGENT, "Example Browser")
|
||||
.body(())
|
||||
.unwrap();
|
||||
let parsed = carrier_request(&automatic, "proxy.example.com").unwrap();
|
||||
assert!(parsed.is_automatic());
|
||||
assert_eq!(parsed.attempt(), Some(2));
|
||||
assert_eq!(parsed.failure(), Some(CarrierFailure::Timeout));
|
||||
|
||||
let missing_failure = Request::builder()
|
||||
.header(
|
||||
"x-carrier-capabilities",
|
||||
"https,https-lanes,websocket,websocket-lanes",
|
||||
)
|
||||
.header("x-carrier-attempt", "2")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(carrier_request(&missing_failure, "proxy.example.com").is_none());
|
||||
|
||||
let legacy = Request::builder()
|
||||
.header(header::USER_AGENT, "Native")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(
|
||||
!carrier_request(&legacy, "proxy.example.com")
|
||||
.unwrap()
|
||||
.is_automatic()
|
||||
);
|
||||
|
||||
let reordered = Request::builder()
|
||||
.header("x-carrier-capabilities", "websocket,https")
|
||||
.header("x-carrier-attempt", "1")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(carrier_request(&reordered, "proxy.example.com").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_ios_user_agent_classifies_without_overriding_capabilities() {
|
||||
let metadata_free = Request::builder()
|
||||
.header(
|
||||
header::USER_AGENT,
|
||||
"Telemt/1 CFNetwork/1498.700.2 Darwin/23.6.0",
|
||||
)
|
||||
.body(())
|
||||
.unwrap();
|
||||
let parsed = carrier_request(&metadata_free, "proxy.example.com").unwrap();
|
||||
assert_eq!(parsed.class(), CarrierClientClass::Ios);
|
||||
assert!(!parsed.is_automatic());
|
||||
assert!(!parsed.uses_capabilities());
|
||||
|
||||
let automatic = Request::builder()
|
||||
.header("x-carrier-capabilities", "https,https-lanes")
|
||||
.header("x-carrier-attempt", "1")
|
||||
.header(
|
||||
header::USER_AGENT,
|
||||
"Telemt/1 CFNetwork/1498.700.2 Darwin/23.6.0",
|
||||
)
|
||||
.body(())
|
||||
.unwrap();
|
||||
let parsed = carrier_request(&automatic, "proxy.example.com").unwrap();
|
||||
assert_eq!(parsed.class(), CarrierClientClass::Ios);
|
||||
assert!(parsed.is_automatic());
|
||||
assert!(parsed.supports(WebCarrier::Https));
|
||||
assert!(parsed.supports(WebCarrier::HttpsLanes));
|
||||
assert!(!parsed.supports(WebCarrier::Websocket));
|
||||
assert!(!parsed.supports(WebCarrier::WebsocketLanes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapped_private_addresses_are_not_learning_evidence() {
|
||||
for address in ["::ffff:127.0.0.1", "::ffff:10.0.0.1"] {
|
||||
let effective_ip = address.parse().unwrap();
|
||||
let request = Request::builder()
|
||||
.header("x-forwarded-for", address)
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(!carrier_ip_learning_eligible(&request, effective_ip));
|
||||
}
|
||||
let effective_ip = "::ffff:8.8.8.8".parse().unwrap();
|
||||
let request = Request::builder()
|
||||
.header("x-forwarded-for", "::ffff:8.8.8.8")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(carrier_ip_learning_eligible(&request, effective_ip));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_browser_metadata_recovers_a_stripped_capability_marker() {
|
||||
let request = Request::builder()
|
||||
.header("x-carrier-attempt", "1")
|
||||
.header(header::ORIGIN, "https://proxy.example.com")
|
||||
.header("sec-fetch-site", "same-origin")
|
||||
.header("sec-fetch-mode", "cors")
|
||||
.header("sec-fetch-dest", "empty")
|
||||
.body(())
|
||||
.unwrap();
|
||||
let parsed = carrier_request(&request, "proxy.example.com").unwrap();
|
||||
assert_eq!(parsed.class(), CarrierClientClass::BrowserHint);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_agent_learning_key_is_case_and_whitespace_normalized() {
|
||||
let first = Request::builder()
|
||||
.header(header::USER_AGENT, " Example\t Browser ")
|
||||
.body(())
|
||||
.unwrap();
|
||||
let second = Request::builder()
|
||||
.header(header::USER_AGENT, "example browser")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
normalized_user_agent_hash(&first),
|
||||
normalized_user_agent_hash(&second)
|
||||
);
|
||||
}
|
||||
}
|
||||
#[path = "request/tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
use super::*;
|
||||
use ipnetwork::IpNetwork;
|
||||
|
||||
use crate::config::{WebCarrier, WebClientIpSource};
|
||||
|
||||
#[test]
|
||||
fn canonical_bridge_query_rejects_aliases() {
|
||||
let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([7u8; 32]);
|
||||
assert!(bridge_candidate(Some(&format!("bridge={token}"))).1);
|
||||
assert!(!bridge_candidate(Some(&format!("x=1&bridge={token}"))).1);
|
||||
assert!(!bridge_candidate(Some(&format!("bridge={token}="))).1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_is_canonical_and_forwarded_identity_is_single_parseable_ip() {
|
||||
let request = Request::builder()
|
||||
.header(header::HOST, "proxy.example.com:443")
|
||||
.header("x-forwarded-for", "192.0.2.10")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert_eq!(canonical_request_host(&request), Some("proxy.example.com"));
|
||||
let trusted: [IpNetwork; 1] = ["127.0.0.1/32".parse().unwrap()];
|
||||
assert_eq!(
|
||||
client_ip(
|
||||
&request,
|
||||
"127.0.0.1:40000".parse().unwrap(),
|
||||
WebClientIpSource::XForwardedFor,
|
||||
&trusted,
|
||||
),
|
||||
Some("192.0.2.10".parse().unwrap())
|
||||
);
|
||||
|
||||
let expanded_ipv6 = Request::builder()
|
||||
.header("x-forwarded-for", "2001:0db8:0:0:0:0:0:10")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
client_ip(
|
||||
&expanded_ipv6,
|
||||
"127.0.0.1:40000".parse().unwrap(),
|
||||
WebClientIpSource::XForwardedFor,
|
||||
&trusted,
|
||||
),
|
||||
Some("2001:db8::10".parse().unwrap())
|
||||
);
|
||||
|
||||
let without_forwarded_address = Request::builder().body(()).unwrap();
|
||||
assert_eq!(
|
||||
client_ip(
|
||||
&without_forwarded_address,
|
||||
"127.0.0.1:40000".parse().unwrap(),
|
||||
WebClientIpSource::XForwardedFor,
|
||||
&trusted,
|
||||
),
|
||||
Some("127.0.0.1".parse().unwrap())
|
||||
);
|
||||
|
||||
let empty_forwarded_address = Request::builder()
|
||||
.header("x-forwarded-for", "")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
client_ip(
|
||||
&empty_forwarded_address,
|
||||
"127.0.0.1:40000".parse().unwrap(),
|
||||
WebClientIpSource::XForwardedFor,
|
||||
&trusted,
|
||||
),
|
||||
Some("127.0.0.1".parse().unwrap())
|
||||
);
|
||||
|
||||
let uppercase = Request::builder()
|
||||
.header(header::HOST, "Proxy.Example.com")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(canonical_request_host(&uppercase).is_none());
|
||||
let appended = Request::builder()
|
||||
.header("x-forwarded-for", "192.0.2.10, 198.51.100.4")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(
|
||||
client_ip(
|
||||
&appended,
|
||||
"127.0.0.1:40000".parse().unwrap(),
|
||||
WebClientIpSource::XForwardedFor,
|
||||
&trusted,
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearer_and_sequence_headers_reject_noncanonical_aliases() {
|
||||
let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([1u8; 32]);
|
||||
let request = Request::builder()
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header("x-up-seq", "17")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
bearer_token_hash(&request),
|
||||
Some(Sha256::digest([1u8; 32]).into())
|
||||
);
|
||||
assert_eq!(canonical_u64_header(&request, "x-up-seq"), Some(17));
|
||||
|
||||
let leading_zero = Request::builder()
|
||||
.header("x-up-seq", "017")
|
||||
.body(())
|
||||
.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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn carrier_metadata_is_canonical_and_legacy_safe() {
|
||||
let automatic = Request::builder()
|
||||
.header(
|
||||
"x-carrier-capabilities",
|
||||
"https,https-lanes,websocket,websocket-lanes",
|
||||
)
|
||||
.header("x-carrier-attempt", "2")
|
||||
.header("x-carrier-failure", "timeout")
|
||||
.header(header::USER_AGENT, "Example Browser")
|
||||
.body(())
|
||||
.unwrap();
|
||||
let parsed = carrier_request(&automatic, "proxy.example.com").unwrap();
|
||||
assert!(parsed.is_automatic());
|
||||
assert_eq!(parsed.attempt(), Some(2));
|
||||
assert_eq!(parsed.failure(), Some(CarrierFailure::Timeout));
|
||||
|
||||
let missing_failure = Request::builder()
|
||||
.header(
|
||||
"x-carrier-capabilities",
|
||||
"https,https-lanes,websocket,websocket-lanes",
|
||||
)
|
||||
.header("x-carrier-attempt", "2")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(carrier_request(&missing_failure, "proxy.example.com").is_none());
|
||||
|
||||
let legacy = Request::builder()
|
||||
.header(header::USER_AGENT, "Native")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(
|
||||
!carrier_request(&legacy, "proxy.example.com")
|
||||
.unwrap()
|
||||
.is_automatic()
|
||||
);
|
||||
|
||||
let reordered = Request::builder()
|
||||
.header("x-carrier-capabilities", "websocket,https")
|
||||
.header("x-carrier-attempt", "1")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(carrier_request(&reordered, "proxy.example.com").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_ios_user_agent_enforces_the_https_capability_ceiling() {
|
||||
let metadata_free = Request::builder()
|
||||
.header(
|
||||
header::USER_AGENT,
|
||||
"Telemt/1 CFNetwork/1498.700.2 Darwin/23.6.0",
|
||||
)
|
||||
.body(())
|
||||
.unwrap();
|
||||
let parsed = carrier_request(&metadata_free, "proxy.example.com").unwrap();
|
||||
assert_eq!(parsed.class(), CarrierClientClass::Ios);
|
||||
assert!(!parsed.is_automatic());
|
||||
assert!(!parsed.uses_capabilities());
|
||||
|
||||
let automatic = Request::builder()
|
||||
.header("x-carrier-capabilities", "https,https-lanes")
|
||||
.header("x-carrier-attempt", "1")
|
||||
.header(
|
||||
header::USER_AGENT,
|
||||
"Telemt/1 CFNetwork/1498.700.2 Darwin/23.6.0",
|
||||
)
|
||||
.body(())
|
||||
.unwrap();
|
||||
let parsed = carrier_request(&automatic, "proxy.example.com").unwrap();
|
||||
assert_eq!(parsed.class(), CarrierClientClass::Ios);
|
||||
assert!(parsed.is_automatic());
|
||||
assert!(parsed.supports(WebCarrier::Https));
|
||||
assert!(!parsed.supports(WebCarrier::HttpsLanes));
|
||||
assert!(!parsed.supports(WebCarrier::Websocket));
|
||||
assert!(!parsed.supports(WebCarrier::WebsocketLanes));
|
||||
|
||||
let incompatible = Request::builder()
|
||||
.header("x-carrier-capabilities", "websocket")
|
||||
.header("x-carrier-attempt", "1")
|
||||
.header(
|
||||
header::USER_AGENT,
|
||||
"Telemt/1 CFNetwork/1498.700.2 Darwin/23.6.0",
|
||||
)
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(carrier_request(&incompatible, "proxy.example.com").is_none());
|
||||
|
||||
let stripped = Request::builder()
|
||||
.header("x-carrier-attempt", "1")
|
||||
.header(header::ORIGIN, "https://proxy.example.com")
|
||||
.header("sec-fetch-site", "same-origin")
|
||||
.header("sec-fetch-mode", "cors")
|
||||
.header("sec-fetch-dest", "empty")
|
||||
.header(
|
||||
header::USER_AGENT,
|
||||
"Telemt/1 CFNetwork/1498.700.2 Darwin/23.6.0",
|
||||
)
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(carrier_request(&stripped, "proxy.example.com").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapped_private_addresses_are_not_learning_evidence() {
|
||||
for address in ["::ffff:127.0.0.1", "::ffff:10.0.0.1"] {
|
||||
let effective_ip = address.parse().unwrap();
|
||||
let request = Request::builder()
|
||||
.header("x-forwarded-for", address)
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(!carrier_ip_learning_eligible(&request, effective_ip));
|
||||
}
|
||||
let effective_ip = "::ffff:8.8.8.8".parse().unwrap();
|
||||
let request = Request::builder()
|
||||
.header("x-forwarded-for", "::ffff:8.8.8.8")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert!(carrier_ip_learning_eligible(&request, effective_ip));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_browser_metadata_recovers_a_stripped_capability_marker() {
|
||||
let request = Request::builder()
|
||||
.header("x-carrier-attempt", "1")
|
||||
.header(header::ORIGIN, "https://proxy.example.com")
|
||||
.header("sec-fetch-site", "same-origin")
|
||||
.header("sec-fetch-mode", "cors")
|
||||
.header("sec-fetch-dest", "empty")
|
||||
.body(())
|
||||
.unwrap();
|
||||
let parsed = carrier_request(&request, "proxy.example.com").unwrap();
|
||||
assert_eq!(parsed.class(), CarrierClientClass::BrowserHint);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_agent_learning_key_is_case_and_whitespace_normalized() {
|
||||
let first = Request::builder()
|
||||
.header(header::USER_AGENT, " Example\t Browser ")
|
||||
.body(())
|
||||
.unwrap();
|
||||
let second = Request::builder()
|
||||
.header(header::USER_AGENT, "example browser")
|
||||
.body(())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
normalized_user_agent_hash(&first),
|
||||
normalized_user_agent_hash(&second)
|
||||
);
|
||||
}
|
||||
+13
-4
@@ -1,5 +1,6 @@
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use hyper::header::{self, HeaderName, HeaderValue};
|
||||
use hyper::{Method, Request, StatusCode};
|
||||
@@ -33,17 +34,22 @@ pub(super) async fn handle_session(
|
||||
if request.headers().contains_key(header::CONTENT_TYPE) {
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
}
|
||||
let session = runtime.get_session(token_hash, &vhost.host).ok();
|
||||
if let Some(trace) = request_trace(&request)
|
||||
&& let Ok(session) = runtime.get_session(token_hash, &vhost.host)
|
||||
&& let Some(session) = &session
|
||||
{
|
||||
trace.set_route(TraceRoute::Session);
|
||||
trace.bind_identity(session.trace_identity());
|
||||
}
|
||||
let body_timeout = session.as_ref().map_or_else(
|
||||
|| Duration::from_secs(runtime.active_generation().config().web.timeouts.body_secs),
|
||||
|session| Duration::from_secs(session.timeouts().body_secs),
|
||||
);
|
||||
let CollectedBody {
|
||||
request,
|
||||
body,
|
||||
_body_budget,
|
||||
} = match collect_body(request, &runtime, 1, true).await {
|
||||
} = match collect_body(request, &runtime, body_timeout, 1, true).await {
|
||||
Ok(result) => result,
|
||||
Err(CollectBodyError::Limit) => return service_unavailable(),
|
||||
Err(CollectBodyError::Invalid(request)) => {
|
||||
@@ -62,7 +68,7 @@ pub(super) async fn handle_session(
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
};
|
||||
let ip_learning_eligible = carrier_ip_learning_eligible(&request, client_ip);
|
||||
let Some((trace_session_id, profile)) =
|
||||
let Some((trace_session_id, profile, frozen_body_timeout)) =
|
||||
runtime.bootstrap_trace_identity(token_hash, &vhost.host)
|
||||
else {
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
@@ -71,11 +77,14 @@ pub(super) async fn handle_session(
|
||||
trace.set_route(TraceRoute::Session);
|
||||
trace.bind_profile(&profile, trace_session_id);
|
||||
}
|
||||
let body_timeout = frozen_body_timeout.unwrap_or_else(|| {
|
||||
Duration::from_secs(runtime.active_generation().config().web.timeouts.body_secs)
|
||||
});
|
||||
let CollectedBody {
|
||||
request,
|
||||
body,
|
||||
_body_budget,
|
||||
} = match collect_body(request, &runtime, CREATE_BODY_LIMIT, false).await {
|
||||
} = match collect_body(request, &runtime, body_timeout, CREATE_BODY_LIMIT, false).await {
|
||||
Ok(result) => result,
|
||||
Err(CollectBodyError::Limit) => return service_unavailable(),
|
||||
Err(CollectBodyError::Invalid(request)) => {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
use super::*;
|
||||
|
||||
async fn request_with_body_delay(
|
||||
listener: &TcpListener,
|
||||
runtime: &Arc<WebProcessRuntime>,
|
||||
head: Vec<u8>,
|
||||
body: &[u8],
|
||||
delay: std::time::Duration,
|
||||
) -> Vec<u8> {
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (accepted, client) = tokio::join!(listener.accept(), TcpStream::connect(addr));
|
||||
let (server, peer) = accepted.unwrap();
|
||||
let mut client = client.unwrap();
|
||||
let permit = runtime.try_http_connection().unwrap();
|
||||
let task = tokio::spawn(serve_connection(
|
||||
server,
|
||||
peer,
|
||||
WebClientIpSource::XForwardedFor,
|
||||
Arc::from(["127.0.0.1/32".parse().unwrap()]),
|
||||
Arc::clone(runtime),
|
||||
CancellationToken::new(),
|
||||
permit,
|
||||
));
|
||||
client.write_all(&head).await.unwrap();
|
||||
tokio::time::sleep(delay).await;
|
||||
let _ = client.write_all(body).await;
|
||||
let mut response = Vec::new();
|
||||
client.read_to_end(&mut response).await.unwrap();
|
||||
task.await.unwrap();
|
||||
response
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn live_session_body_and_closed_token_timeouts_survive_reload() {
|
||||
let capability = [21u8; 32];
|
||||
let mut initial_config = runtime_config(capability, WebCarrier::Https);
|
||||
initial_config.web.timeouts.body_secs = 3;
|
||||
initial_config.web.timeouts.bootstrap_lifetime_secs = 5;
|
||||
let generation = test_runtime_generation(1, initial_config);
|
||||
let active_runtime = Arc::new(ArcSwap::from(Arc::clone(&generation)));
|
||||
let runtime = WebProcessRuntime::start(Arc::clone(&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\nConnection: close\r\n\r\n"
|
||||
)
|
||||
.into_bytes();
|
||||
let root_response = request(&listener, &runtime, root).await;
|
||||
let (_, root_body) = split_response(&root_response);
|
||||
let bootstrap = std::str::from_utf8(root_body)
|
||||
.unwrap()
|
||||
.split_once("bootstrap=\"")
|
||||
.and_then(|(_, suffix)| suffix.split_once('"'))
|
||||
.map(|(token, _)| token.to_string())
|
||||
.unwrap();
|
||||
let hello = frame::encode(FrameType::Hello, 0, &[1]);
|
||||
let create_head = 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\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
hello.len()
|
||||
)
|
||||
.into_bytes();
|
||||
let mut create = create_head.clone();
|
||||
create.extend_from_slice(&hello);
|
||||
let create_response = request(&listener, &runtime, create).await;
|
||||
let (create_headers, _) = split_response(&create_response);
|
||||
assert!(create_headers.starts_with(b"HTTP/1.1 200"));
|
||||
let session = response_header(create_headers, "x-session-token").to_string();
|
||||
|
||||
let mut replacement_config = runtime_config(capability, WebCarrier::Https);
|
||||
replacement_config.web.timeouts.body_secs = 1;
|
||||
replacement_config.web.timeouts.bootstrap_lifetime_secs = 1;
|
||||
let replacement = test_runtime_generation(2, replacement_config);
|
||||
active_runtime.store(Arc::clone(&replacement));
|
||||
|
||||
let retry_response = request_with_body_delay(
|
||||
&listener,
|
||||
&runtime,
|
||||
create_head,
|
||||
&hello,
|
||||
std::time::Duration::from_millis(1200),
|
||||
)
|
||||
.await;
|
||||
let (retry_headers, _) = split_response(&retry_response);
|
||||
assert!(retry_headers.starts_with(b"HTTP/1.1 200"));
|
||||
assert_eq!(response_header(retry_headers, "x-session-token"), session);
|
||||
|
||||
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\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
)
|
||||
.into_bytes();
|
||||
let close_response = request(&listener, &runtime, close.clone()).await;
|
||||
assert!(close_response.starts_with(b"HTTP/1.1 204"));
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
|
||||
let close_retry_response = request(&listener, &runtime, close).await;
|
||||
assert!(close_retry_response.starts_with(b"HTTP/1.1 204"));
|
||||
|
||||
runtime.shutdown().await;
|
||||
generation.stop_sessions().await;
|
||||
generation.stop_background_tasks().await;
|
||||
replacement.stop_sessions().await;
|
||||
replacement.stop_background_tasks().await;
|
||||
}
|
||||
@@ -21,6 +21,9 @@ use crate::web::manager::WebProcessRuntime;
|
||||
mod legacy_tests;
|
||||
#[path = "negotiation_tests.rs"]
|
||||
mod negotiation_tests;
|
||||
// Reload-stability tests for session-owned timeout policy.
|
||||
#[path = "session_policy_tests.rs"]
|
||||
mod session_policy_tests;
|
||||
|
||||
const TEST_CARRIER_DEADLINES_SECS: [u64; 4] = [3, 5, 8, 12];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user