mirror of
https://github.com/telemt/telemt.git
synced 2026-10-29 12:07:15 +03:00
WEB: Base Path added
This commit is contained in:
@@ -0,0 +1,431 @@
|
||||
use super::*;
|
||||
|
||||
fn bridge_request(path: &str) -> Vec<u8> {
|
||||
format!(
|
||||
"GET {path} 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()
|
||||
}
|
||||
|
||||
fn bridge_token(body: &[u8]) -> String {
|
||||
std::str::from_utf8(body)
|
||||
.unwrap()
|
||||
.split_once("bootstrap=\"")
|
||||
.and_then(|(_, suffix)| suffix.split_once('"'))
|
||||
.map(|(token, _)| token.to_string())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn request_without_body(
|
||||
listener: &TcpListener,
|
||||
runtime: &Arc<WebProcessRuntime>,
|
||||
request_head: &[u8],
|
||||
) -> 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(request_head).await.unwrap();
|
||||
let mut response = Vec::new();
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(2),
|
||||
client.read_to_end(&mut response),
|
||||
)
|
||||
.await
|
||||
.expect("private rejection waited for the request body")
|
||||
.unwrap();
|
||||
task.await.unwrap();
|
||||
response
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn base_path_routes_only_the_exact_prefixed_contract() {
|
||||
let capability = [21u8; 32];
|
||||
let config = runtime_config_with_base(capability, WebCarrier::Https, "/dobry-cola/");
|
||||
let generation = test_runtime_generation(1, config);
|
||||
let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(Arc::clone(&generation))));
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(capability);
|
||||
|
||||
let response = request(
|
||||
&listener,
|
||||
&runtime,
|
||||
bridge_request(&format!("/dobry-cola/?bridge={encoded}")),
|
||||
)
|
||||
.await;
|
||||
let (headers, body) = split_response(&response);
|
||||
assert!(headers.starts_with(b"HTTP/1.1 200"));
|
||||
assert!(std::str::from_utf8(body)
|
||||
.unwrap()
|
||||
.contains("relayBase=relayOrigin+'/dobry-cola'"));
|
||||
|
||||
for path in [
|
||||
format!("/?bridge={encoded}"),
|
||||
format!("/dobry-cola?bridge={encoded}"),
|
||||
format!("/dobry-cola/nested/?bridge={encoded}"),
|
||||
] {
|
||||
let response = request(&listener, &runtime, bridge_request(&path)).await;
|
||||
let (headers, body) = split_response(&response);
|
||||
assert!(headers.starts_with(b"HTTP/1.1 404"));
|
||||
assert_eq!(response_header(headers, "cache-control"), "no-store");
|
||||
assert_eq!(body, b"not found\n");
|
||||
}
|
||||
|
||||
for path in [
|
||||
"/",
|
||||
"/api/v1/session",
|
||||
"/dobry-cola/unknown?q=1",
|
||||
"/dobry-cola//api/v1/up",
|
||||
"/dobry-cola%2Fapi/v1/up",
|
||||
] {
|
||||
let response = request(&listener, &runtime, bridge_request(path)).await;
|
||||
let (_, body) = split_response(&response);
|
||||
assert_eq!(body, b"<!doctype html><title>decoy</title>");
|
||||
}
|
||||
|
||||
runtime.shutdown().await;
|
||||
generation.stop_sessions().await;
|
||||
generation.stop_background_tasks().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prefixed_https_carrier_creates_uses_and_closes_a_session() {
|
||||
let capability = [22u8; 32];
|
||||
let mut config = runtime_config_with_base(capability, WebCarrier::Https, "/relay/nested/");
|
||||
config.web.timeouts.long_poll_secs = 1;
|
||||
let generation = test_runtime_generation(1, config);
|
||||
let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(Arc::clone(&generation))));
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(capability);
|
||||
|
||||
let bridge = request(
|
||||
&listener,
|
||||
&runtime,
|
||||
bridge_request(&format!("/relay/nested/?bridge={encoded}")),
|
||||
)
|
||||
.await;
|
||||
let bootstrap = bridge_token(split_response(&bridge).1);
|
||||
let hello = frame::encode(FrameType::Hello, 0, &[1]);
|
||||
let mut create = format!(
|
||||
"POST /relay/nested/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();
|
||||
create.extend_from_slice(&hello);
|
||||
let created = request(&listener, &runtime, create).await;
|
||||
let (headers, _) = split_response(&created);
|
||||
assert!(headers.starts_with(b"HTTP/1.1 200"));
|
||||
let session = response_header(headers, "x-session-token").to_string();
|
||||
|
||||
let misplaced = format!(
|
||||
"GET /wrong HTTP/1.1\r\nHost: proxy.example.com\r\nAuthorization: Bearer {session}\r\nConnection: close\r\n\r\n"
|
||||
)
|
||||
.into_bytes();
|
||||
let misplaced = request(&listener, &runtime, misplaced).await;
|
||||
let (headers, body) = split_response(&misplaced);
|
||||
assert!(headers.starts_with(b"HTTP/1.1 404"));
|
||||
assert_eq!(response_header(headers, "cache-control"), "no-store");
|
||||
assert_eq!(body, b"not found\n");
|
||||
|
||||
let pong = frame::encode(FrameType::Pong, 0, &[]);
|
||||
let mut uplink = format!(
|
||||
"POST /relay/nested/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\nX-Up-Seq: 1\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
pong.len()
|
||||
)
|
||||
.into_bytes();
|
||||
uplink.extend_from_slice(&pong);
|
||||
assert!(
|
||||
request(&listener, &runtime, uplink)
|
||||
.await
|
||||
.starts_with(b"HTTP/1.1 204")
|
||||
);
|
||||
|
||||
let downlink = format!(
|
||||
"POST /relay/nested/api/v1/down HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {session}\r\nX-Down-Cursor: 0\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
)
|
||||
.into_bytes();
|
||||
assert!(
|
||||
request(&listener, &runtime, downlink)
|
||||
.await
|
||||
.starts_with(b"HTTP/1.1 204")
|
||||
);
|
||||
|
||||
let close = format!(
|
||||
"DELETE /relay/nested/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();
|
||||
assert!(
|
||||
request(&listener, &runtime, close)
|
||||
.await
|
||||
.starts_with(b"HTTP/1.1 204")
|
||||
);
|
||||
|
||||
runtime.shutdown().await;
|
||||
generation.stop_sessions().await;
|
||||
generation.stop_background_tasks().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authentic_credentials_never_reach_the_decoy() {
|
||||
let capability = [23u8; 32];
|
||||
let config = runtime_config_with_base(capability, WebCarrier::Https, "/relay/");
|
||||
let generation = test_runtime_generation(1, config);
|
||||
let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(Arc::clone(&generation))));
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(capability);
|
||||
let bridge = request(
|
||||
&listener,
|
||||
&runtime,
|
||||
bridge_request(&format!("/relay/?bridge={encoded}")),
|
||||
)
|
||||
.await;
|
||||
let bootstrap = bridge_token(split_response(&bridge).1);
|
||||
let escaped = format!("%{:02X}{}", bootstrap.as_bytes()[0], &bootstrap[1..]);
|
||||
|
||||
for raw in [
|
||||
format!(
|
||||
"GET /wrong/{bootstrap} HTTP/1.1\r\nHost: proxy.example.com\r\nConnection: close\r\n\r\n"
|
||||
),
|
||||
format!(
|
||||
"GET /wrong/{escaped} HTTP/1.1\r\nHost: proxy.example.com\r\nConnection: close\r\n\r\n"
|
||||
),
|
||||
format!(
|
||||
"GET /wrong HTTP/1.1\r\nHost: proxy.example.com\r\nCookie: opaque={bootstrap}\r\nConnection: close\r\n\r\n"
|
||||
),
|
||||
format!(
|
||||
"GET /wrong HTTP/1.1\r\nHost: proxy.example.com\r\nReferer: https://example.invalid/{encoded}\r\nConnection: close\r\n\r\n"
|
||||
),
|
||||
] {
|
||||
let response = request(&listener, &runtime, raw.into_bytes()).await;
|
||||
let (headers, body) = split_response(&response);
|
||||
assert!(headers.starts_with(b"HTTP/1.1 404"));
|
||||
assert_eq!(response_header(headers, "cache-control"), "no-store");
|
||||
assert_eq!(body, b"not found\n");
|
||||
}
|
||||
|
||||
let mut forged = bootstrap.into_bytes();
|
||||
forged[0] = if forged[0] == b'A' { b'B' } else { b'A' };
|
||||
let forged = String::from_utf8(forged).unwrap();
|
||||
let response = request(
|
||||
&listener,
|
||||
&runtime,
|
||||
bridge_request(&format!("/wrong/{forged}")),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
split_response(&response).1,
|
||||
b"<!doctype html><title>decoy</title>"
|
||||
);
|
||||
|
||||
runtime.shutdown().await;
|
||||
generation.stop_sessions().await;
|
||||
generation.stop_background_tasks().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn misplaced_process_token_is_rejected_without_reading_the_body() {
|
||||
let capability = [24u8; 32];
|
||||
let config = runtime_config_with_base(capability, WebCarrier::Https, "/relay/");
|
||||
let generation = test_runtime_generation(1, config);
|
||||
let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(Arc::clone(&generation))));
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(capability);
|
||||
let bridge = request(
|
||||
&listener,
|
||||
&runtime,
|
||||
bridge_request(&format!("/relay/?bridge={encoded}")),
|
||||
)
|
||||
.await;
|
||||
let bootstrap = bridge_token(split_response(&bridge).1);
|
||||
let head = format!(
|
||||
"POST /wrong HTTP/1.1\r\nHost: proxy.example.com\r\nAuthorization: Bearer {bootstrap}\r\nContent-Length: 1048576\r\nConnection: close\r\n\r\n"
|
||||
);
|
||||
|
||||
let response = request_without_body(&listener, &runtime, head.as_bytes()).await;
|
||||
let (headers, body) = split_response(&response);
|
||||
assert!(headers.starts_with(b"HTTP/1.1 404"));
|
||||
assert_eq!(response_header(headers, "cache-control"), "no-store");
|
||||
assert_eq!(body, b"not found\n");
|
||||
|
||||
runtime.shutdown().await;
|
||||
generation.stop_sessions().await;
|
||||
generation.stop_background_tasks().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn process_token_provenance_survives_registry_expiry() {
|
||||
let capability = [25u8; 32];
|
||||
let mut config = runtime_config_with_base(capability, WebCarrier::Https, "/relay/");
|
||||
config.web.timeouts.bootstrap_lifetime_secs = 1;
|
||||
let generation = test_runtime_generation(1, config);
|
||||
let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(Arc::clone(&generation))));
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(capability);
|
||||
let bridge = request(
|
||||
&listener,
|
||||
&runtime,
|
||||
bridge_request(&format!("/relay/?bridge={encoded}")),
|
||||
)
|
||||
.await;
|
||||
let bootstrap = bridge_token(split_response(&bridge).1);
|
||||
|
||||
tokio::time::timeout(std::time::Duration::from_secs(4), async {
|
||||
loop {
|
||||
let status = serde_json::to_value(runtime.try_status()).unwrap();
|
||||
if status["manager"]["bootstraps"] == 0 {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("bootstrap registry entry did not expire");
|
||||
|
||||
let response = request(
|
||||
&listener,
|
||||
&runtime,
|
||||
bridge_request(&format!("/wrong/{bootstrap}")),
|
||||
)
|
||||
.await;
|
||||
let (headers, body) = split_response(&response);
|
||||
assert!(headers.starts_with(b"HTTP/1.1 404"));
|
||||
assert_eq!(response_header(headers, "cache-control"), "no-store");
|
||||
assert_eq!(body, b"not found\n");
|
||||
|
||||
runtime.shutdown().await;
|
||||
generation.stop_sessions().await;
|
||||
generation.stop_background_tasks().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generation_swap_switches_base_path_and_capability_together() {
|
||||
let initial_capability = [26u8; 32];
|
||||
let mut initial =
|
||||
runtime_config_with_base(initial_capability, WebCarrier::Https, "/old-path/");
|
||||
initial.web.limits.max_bootstraps_per_ip = 2;
|
||||
let generation = test_runtime_generation(1, initial);
|
||||
let active = Arc::new(ArcSwap::from(Arc::clone(&generation)));
|
||||
let runtime = WebProcessRuntime::start(Arc::clone(&active));
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let initial_encoded =
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(initial_capability);
|
||||
|
||||
let old_bridge = request(
|
||||
&listener,
|
||||
&runtime,
|
||||
bridge_request(&format!("/old-path/?bridge={initial_encoded}")),
|
||||
)
|
||||
.await;
|
||||
assert!(old_bridge.starts_with(b"HTTP/1.1 200"));
|
||||
|
||||
let replacement_capability = [27u8; 32];
|
||||
let replacement = test_runtime_generation(
|
||||
2,
|
||||
runtime_config_with_base(
|
||||
replacement_capability,
|
||||
WebCarrier::Https,
|
||||
"/new-path/",
|
||||
),
|
||||
);
|
||||
active.store(Arc::clone(&replacement));
|
||||
let stale = request(
|
||||
&listener,
|
||||
&runtime,
|
||||
bridge_request(&format!("/old-path/?bridge={initial_encoded}")),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
split_response(&stale).1,
|
||||
b"<!doctype html><title>decoy</title>"
|
||||
);
|
||||
|
||||
let replacement_encoded =
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(replacement_capability);
|
||||
let current = request(
|
||||
&listener,
|
||||
&runtime,
|
||||
bridge_request(&format!("/new-path/?bridge={replacement_encoded}")),
|
||||
)
|
||||
.await;
|
||||
let (headers, body) = split_response(¤t);
|
||||
assert!(headers.starts_with(b"HTTP/1.1 200"));
|
||||
assert!(std::str::from_utf8(body)
|
||||
.unwrap()
|
||||
.contains("relayBase=relayOrigin+'/new-path'"));
|
||||
|
||||
runtime.shutdown().await;
|
||||
generation.stop_sessions().await;
|
||||
generation.stop_background_tasks().await;
|
||||
replacement.stop_sessions().await;
|
||||
replacement.stop_background_tasks().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prefixed_decoy_request_keeps_its_original_path_and_query() {
|
||||
let site = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let site_addr = site.local_addr().unwrap();
|
||||
let site_task = tokio::spawn(async move {
|
||||
let (mut stream, _) = site.accept().await.unwrap();
|
||||
let mut request = vec![0; 4096];
|
||||
let read = stream.read(&mut request).await.unwrap();
|
||||
request.truncate(read);
|
||||
stream
|
||||
.write_all(
|
||||
b"HTTP/1.1 404 Not Found\r\nContent-Length: 4\r\nConnection: close\r\n\r\nsite",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
request
|
||||
});
|
||||
|
||||
let capability = [28u8; 32];
|
||||
let mut config = runtime_config_with_base(capability, WebCarrier::Https, "/relay/");
|
||||
let profile = Arc::clone(&config.web.runtime.as_ref().unwrap().profiles[0]);
|
||||
let vhost = Arc::new(WebRuntimeVhost {
|
||||
host: "proxy.example.com".to_string(),
|
||||
base: "/relay/".to_string(),
|
||||
decoy_fasttrack_mode: WebDecoyFastTrackMode::Off,
|
||||
decoy: WebRuntimeDecoy::HttpUpstream {
|
||||
addr: site_addr,
|
||||
authority: "decoy.internal".to_string(),
|
||||
},
|
||||
decoy_header_secs: 1,
|
||||
profiles: vec![Arc::clone(&profile)],
|
||||
capabilities: vec![capability].into_boxed_slice(),
|
||||
});
|
||||
let mut vhosts = BTreeMap::new();
|
||||
vhosts.insert("proxy.example.com".to_string(), vhost);
|
||||
config.web.runtime = Some(Arc::new(WebRuntimeConfig {
|
||||
vhosts,
|
||||
profiles: vec![profile],
|
||||
capabilities: vec![capability].into_boxed_slice(),
|
||||
}));
|
||||
let generation = test_runtime_generation(1, config);
|
||||
let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(Arc::clone(&generation))));
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
|
||||
let response = request(
|
||||
&listener,
|
||||
&runtime,
|
||||
bridge_request("/relay/ordinary?q=1"),
|
||||
)
|
||||
.await;
|
||||
assert!(response.starts_with(b"HTTP/1.1 404"));
|
||||
assert_eq!(split_response(&response).1, b"site");
|
||||
let forwarded = site_task.await.unwrap();
|
||||
assert!(forwarded.starts_with(b"GET /relay/ordinary?q=1 HTTP/1.1\r\n"));
|
||||
|
||||
runtime.shutdown().await;
|
||||
generation.stop_sessions().await;
|
||||
generation.stop_background_tasks().await;
|
||||
}
|
||||
@@ -32,28 +32,35 @@ pub(super) fn bridge_candidate(query: Option<&str>) -> BridgeCandidate {
|
||||
let Some(value) = query.and_then(|query| query.strip_prefix("bridge=")) else {
|
||||
return BridgeCandidate::NonCanonical;
|
||||
};
|
||||
canonical_credential(value.as_bytes())
|
||||
.map(BridgeCandidate::Canonical)
|
||||
.unwrap_or(BridgeCandidate::NonCanonical)
|
||||
}
|
||||
|
||||
/// Decodes one exact canonical 32-byte base64url credential.
|
||||
pub(super) fn canonical_credential(value: &[u8]) -> Option<[u8; 32]> {
|
||||
if value.len() != 43 {
|
||||
return BridgeCandidate::NonCanonical;
|
||||
return None;
|
||||
}
|
||||
let mut decoded = [0u8; 32];
|
||||
let Ok(decoded_len) =
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.decode_slice(value, &mut decoded)
|
||||
else {
|
||||
return BridgeCandidate::NonCanonical;
|
||||
return None;
|
||||
};
|
||||
let mut canonical = [0u8; 43];
|
||||
let Ok(encoded_len) =
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode_slice(decoded, &mut canonical)
|
||||
else {
|
||||
return BridgeCandidate::NonCanonical;
|
||||
return None;
|
||||
};
|
||||
if decoded_len != decoded.len()
|
||||
|| encoded_len != canonical.len()
|
||||
|| !bool::from(canonical.ct_eq(value.as_bytes()))
|
||||
|| !bool::from(canonical.ct_eq(value))
|
||||
{
|
||||
return BridgeCandidate::NonCanonical;
|
||||
return None;
|
||||
}
|
||||
BridgeCandidate::Canonical(decoded)
|
||||
Some(decoded)
|
||||
}
|
||||
|
||||
/// Internal result of one complete capability-table scan.
|
||||
|
||||
@@ -29,6 +29,9 @@ where
|
||||
B: hyper::body::Body<Data = Bytes> + Send + 'static,
|
||||
B::Error: Error + Send + Sync + 'static,
|
||||
{
|
||||
if super::secrets::has_internal_credential(&request) {
|
||||
return super::response::private_not_found();
|
||||
}
|
||||
super::set_trace_route(&request, crate::web::trace::TraceRoute::Decoy);
|
||||
if sanitize_transport {
|
||||
sanitize_transport_request(&mut request);
|
||||
|
||||
@@ -153,12 +153,9 @@ async fn pause_preserves_decoy_retry_and_exact_session_replay() {
|
||||
.into_bytes();
|
||||
let decoy = request(&listener, &runtime, decoy).await;
|
||||
let (decoy_headers, decoy_body) = split_response(&decoy);
|
||||
assert!(decoy_headers.starts_with(b"HTTP/1.1 200"));
|
||||
assert!(
|
||||
!decoy_body
|
||||
.windows(11)
|
||||
.any(|window| window == b"bootstrap=\"")
|
||||
);
|
||||
assert!(decoy_headers.starts_with(b"HTTP/1.1 404"));
|
||||
assert_eq!(response_header(decoy_headers, "cache-control"), "no-store");
|
||||
assert_eq!(decoy_body, b"not found\n");
|
||||
|
||||
runtime.resume_operator().await.unwrap();
|
||||
let created = request(&listener, &runtime, create_request(&bootstrap, &hello)).await;
|
||||
|
||||
@@ -195,12 +195,12 @@ async fn malformed_or_over_capacity_recovery_is_indistinguishable_from_decoy() {
|
||||
|
||||
let malformed = recover(&listener, &runtime, &encoded, "Bearer malformed").await;
|
||||
let (malformed_headers, malformed_body) = split_response(&malformed);
|
||||
assert!(malformed_headers.starts_with(b"HTTP/1.1 200"));
|
||||
assert!(malformed_headers.starts_with(b"HTTP/1.1 404"));
|
||||
assert_eq!(
|
||||
response_header(malformed_headers, "cache-control"),
|
||||
"no-store"
|
||||
);
|
||||
assert_eq!(malformed_body, b"<!doctype html><title>decoy</title>");
|
||||
assert_eq!(malformed_body, b"not found\n");
|
||||
|
||||
let invalid_capability = recover(
|
||||
&listener,
|
||||
@@ -209,7 +209,10 @@ async fn malformed_or_over_capacity_recovery_is_indistinguishable_from_decoy() {
|
||||
&format!("Bearer {}", "U".repeat(43)),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(invalid_capability, malformed);
|
||||
let (invalid_headers, invalid_body) = split_response(&invalid_capability);
|
||||
assert!(invalid_headers.starts_with(b"HTTP/1.1 200"));
|
||||
assert_eq!(response_header(invalid_headers, "cache-control"), "no-store");
|
||||
assert_eq!(invalid_body, b"<!doctype html><title>decoy</title>");
|
||||
|
||||
let malformed_accept = request(
|
||||
&listener,
|
||||
@@ -231,12 +234,12 @@ async fn malformed_or_over_capacity_recovery_is_indistinguishable_from_decoy() {
|
||||
)
|
||||
.await;
|
||||
let (capacity_headers, capacity_body) = split_response(&over_capacity);
|
||||
assert!(capacity_headers.starts_with(b"HTTP/1.1 200"));
|
||||
assert!(capacity_headers.starts_with(b"HTTP/1.1 404"));
|
||||
assert_eq!(
|
||||
response_header(capacity_headers, "cache-control"),
|
||||
"no-store"
|
||||
);
|
||||
assert_eq!(capacity_body, b"<!doctype html><title>decoy</title>");
|
||||
assert_eq!(capacity_body, b"not found\n");
|
||||
|
||||
runtime.shutdown().await;
|
||||
generation.stop_sessions().await;
|
||||
|
||||
@@ -62,6 +62,15 @@ pub(super) fn generic_not_found() -> HttpResponse {
|
||||
full_response(StatusCode::NOT_FOUND, Bytes::from_static(b"not found\n"))
|
||||
}
|
||||
|
||||
/// Builds a non-cacheable local rejection for misplaced internal credentials.
|
||||
pub(super) fn private_not_found() -> HttpResponse {
|
||||
let mut response = generic_not_found();
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
response
|
||||
}
|
||||
|
||||
/// Builds one in-memory response with an exact content length.
|
||||
pub(super) fn full_response(status: StatusCode, body: Bytes) -> HttpResponse {
|
||||
let length = body.len();
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
use super::*;
|
||||
|
||||
/// Builds a static-decoy runtime with one explicit WEB endpoint base.
|
||||
pub(in crate::web::http) fn runtime_config_with_base(
|
||||
capability: [u8; 32],
|
||||
carrier: WebCarrier,
|
||||
base: &str,
|
||||
) -> ProxyConfig {
|
||||
runtime_config_with_carriers_and_deadlines(
|
||||
capability,
|
||||
carrier,
|
||||
false,
|
||||
true,
|
||||
Arc::from([carrier]),
|
||||
TEST_CARRIER_DEADLINES_SECS,
|
||||
base,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
use hyper::Request;
|
||||
|
||||
use super::capability::{canonical_credential, scan_capabilities};
|
||||
use crate::config::WebRuntimeConfig;
|
||||
use crate::web::manager::WebProcessRuntime;
|
||||
|
||||
/// Request extension proving that metadata contains an authentic internal credential.
|
||||
#[derive(Clone, Copy)]
|
||||
struct InternalCredential;
|
||||
|
||||
/// Marks requests whose metadata contains a capability or process token.
|
||||
pub(super) fn mark_internal_credential<B>(
|
||||
request: &mut Request<B>,
|
||||
config: &WebRuntimeConfig,
|
||||
runtime: &WebProcessRuntime,
|
||||
) {
|
||||
let uri = request.uri();
|
||||
let uri_contains = uri
|
||||
.authority()
|
||||
.is_some_and(|authority| contains_secret(authority.as_str().as_bytes(), config, runtime))
|
||||
|| uri.path_and_query().is_some_and(|path| {
|
||||
contains_secret(path.as_str().as_bytes(), config, runtime)
|
||||
});
|
||||
let headers_contain = request.headers().iter().any(|(name, value)| {
|
||||
contains_secret(name.as_str().as_bytes(), config, runtime)
|
||||
|| contains_secret(value.as_bytes(), config, runtime)
|
||||
});
|
||||
if uri_contains || headers_contain {
|
||||
request.extensions_mut().insert(InternalCredential);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether request metadata was authenticated before routing.
|
||||
pub(super) fn has_internal_credential<B>(request: &Request<B>) -> bool {
|
||||
request.extensions().get::<InternalCredential>().is_some()
|
||||
}
|
||||
|
||||
fn contains_secret(text: &[u8], config: &WebRuntimeConfig, runtime: &WebProcessRuntime) -> bool {
|
||||
let mut window = [0; 43];
|
||||
let mut run_len = 0usize;
|
||||
let mut offset = 0usize;
|
||||
while offset < text.len() {
|
||||
let (byte, consumed) = if text[offset] == b'%' && offset + 2 < text.len() {
|
||||
match (hex_value(text[offset + 1]), hex_value(text[offset + 2])) {
|
||||
(Some(high), Some(low)) => ((high << 4) | low, 3),
|
||||
_ => (text[offset], 1),
|
||||
}
|
||||
} else {
|
||||
(text[offset], 1)
|
||||
};
|
||||
offset += consumed;
|
||||
if base64url_byte(byte) {
|
||||
if run_len < window.len() {
|
||||
window[run_len] = byte;
|
||||
run_len += 1;
|
||||
} else {
|
||||
window.copy_within(1.., 0);
|
||||
window[42] = byte;
|
||||
}
|
||||
if run_len >= window.len()
|
||||
&& canonical_credential(&window).is_some_and(|candidate| {
|
||||
bool::from(scan_capabilities(&config.capabilities, &candidate).matched)
|
||||
|| runtime.authentic_token(&candidate)
|
||||
})
|
||||
{
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
run_len = 0;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn base64url_byte(value: u8) -> bool {
|
||||
value.is_ascii_alphanumeric() || matches!(value, b'-' | b'_')
|
||||
}
|
||||
|
||||
fn hex_value(value: u8) -> Option<u8> {
|
||||
match value {
|
||||
b'0'..=b'9' => Some(value - b'0'),
|
||||
b'a'..=b'f' => Some(value - b'a' + 10),
|
||||
b'A'..=b'F' => Some(value - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
+15
-2
@@ -49,11 +49,18 @@ mod recovery_tests;
|
||||
// Decoy fast-track routing and telemetry remain isolated from carrier protocol scenarios.
|
||||
#[path = "decoy_fasttrack_tests.rs"]
|
||||
mod decoy_fasttrack_tests;
|
||||
// Base-path routing and credential containment share reference-contract coverage.
|
||||
#[path = "base_path_tests.rs"]
|
||||
mod base_path_tests;
|
||||
// Raw response parsing helpers are shared by the HTTP integration test modules.
|
||||
#[path = "response_test_support.rs"]
|
||||
mod response_test_support;
|
||||
// Alternate runtime fixtures remain separate from the main integration scenarios.
|
||||
#[path = "runtime_test_support.rs"]
|
||||
mod runtime_test_support;
|
||||
|
||||
pub(super) use response_test_support::{response_header, split_response};
|
||||
pub(super) use runtime_test_support::runtime_config_with_base;
|
||||
|
||||
const TEST_CARRIER_DEADLINES_SECS: [u64; 4] = [3, 5, 8, 12];
|
||||
|
||||
@@ -86,6 +93,7 @@ fn runtime_config_with_carriers(
|
||||
carrier_learning,
|
||||
carriers,
|
||||
TEST_CARRIER_DEADLINES_SECS,
|
||||
"/",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -104,6 +112,7 @@ pub(super) fn negotiation_runtime_config_with_deadlines(
|
||||
carrier_learning,
|
||||
carriers,
|
||||
carrier_negotiation_deadlines_secs,
|
||||
"/",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -114,6 +123,7 @@ fn runtime_config_with_carriers_and_deadlines(
|
||||
carrier_learning: bool,
|
||||
carriers: Arc<[WebCarrier]>,
|
||||
carrier_negotiation_deadlines_secs: [u64; 4],
|
||||
base: &str,
|
||||
) -> ProxyConfig {
|
||||
let profile = Arc::new(WebRuntimeProfile {
|
||||
host: "proxy.example.com".to_string(),
|
||||
@@ -147,6 +157,7 @@ fn runtime_config_with_carriers_and_deadlines(
|
||||
});
|
||||
let vhost = Arc::new(WebRuntimeVhost {
|
||||
host: "proxy.example.com".to_string(),
|
||||
base: base.to_string(),
|
||||
decoy_fasttrack_mode: WebDecoyFastTrackMode::Off,
|
||||
decoy: WebRuntimeDecoy::StaticDirectory(Arc::clone(&site)),
|
||||
decoy_header_secs: 1,
|
||||
@@ -159,6 +170,7 @@ fn runtime_config_with_carriers_and_deadlines(
|
||||
"other.example.com".to_string(),
|
||||
Arc::new(WebRuntimeVhost {
|
||||
host: "other.example.com".to_string(),
|
||||
base: "/".to_string(),
|
||||
decoy_fasttrack_mode: WebDecoyFastTrackMode::Off,
|
||||
decoy: WebRuntimeDecoy::StaticDirectory(site),
|
||||
decoy_header_secs: 1,
|
||||
@@ -181,6 +193,7 @@ fn runtime_config_with_carriers_and_deadlines(
|
||||
config.web.runtime = Some(Arc::new(WebRuntimeConfig {
|
||||
vhosts,
|
||||
profiles: vec![profile],
|
||||
capabilities: vec![capability].into_boxed_slice(),
|
||||
}));
|
||||
config
|
||||
}
|
||||
@@ -328,12 +341,12 @@ async fn rejected_bridge_bootstrap_falls_back_to_uncacheable_static_index() {
|
||||
|
||||
let fallback_response = request(&listener, &runtime, bridge_request()).await;
|
||||
let (fallback_headers, fallback_body) = split_response(&fallback_response);
|
||||
assert!(fallback_headers.starts_with(b"HTTP/1.1 200"));
|
||||
assert!(fallback_headers.starts_with(b"HTTP/1.1 404"));
|
||||
assert_eq!(
|
||||
response_header(fallback_headers, "cache-control"),
|
||||
"no-store"
|
||||
);
|
||||
assert_eq!(fallback_body, b"<!doctype html><title>decoy</title>");
|
||||
assert_eq!(fallback_body, b"not found\n");
|
||||
|
||||
runtime.shutdown().await;
|
||||
generation.stop_sessions().await;
|
||||
|
||||
@@ -14,7 +14,9 @@ use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::maestro::generation::{RuntimeGeneration, test_runtime_generation};
|
||||
use crate::web::frame::{self, FrameType};
|
||||
use crate::web::http::tests::{negotiation_runtime_config, runtime_config};
|
||||
use crate::web::http::tests::{
|
||||
negotiation_runtime_config, runtime_config, runtime_config_with_base,
|
||||
};
|
||||
use crate::web::manager::{
|
||||
CarrierCapabilities, CarrierClientClass, CarrierFailure, CarrierRequest, WebProcessRuntime,
|
||||
};
|
||||
@@ -210,6 +212,15 @@ async fn upgrade(
|
||||
listener: &TcpListener,
|
||||
runtime: &Arc<WebProcessRuntime>,
|
||||
protocol: &str,
|
||||
) -> WebSocketStream<TcpStream> {
|
||||
upgrade_at(listener, runtime, "/api/v1/ws", protocol).await
|
||||
}
|
||||
|
||||
async fn upgrade_at(
|
||||
listener: &TcpListener,
|
||||
runtime: &Arc<WebProcessRuntime>,
|
||||
path: &str,
|
||||
protocol: &str,
|
||||
) -> WebSocketStream<TcpStream> {
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (accepted, client) = tokio::join!(listener.accept(), TcpStream::connect(addr));
|
||||
@@ -226,7 +237,7 @@ async fn upgrade(
|
||||
permit,
|
||||
));
|
||||
let request = format!(
|
||||
"GET /api/v1/ws HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Protocol: {protocol}\r\nCookie: browser-state=allowed\r\n\r\n"
|
||||
"GET {path} HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Protocol: {protocol}\r\nCookie: browser-state=allowed\r\n\r\n"
|
||||
);
|
||||
client.write_all(request.as_bytes()).await.unwrap();
|
||||
let mut response = Vec::new();
|
||||
@@ -244,6 +255,38 @@ async fn upgrade(
|
||||
WebSocketStream::from_raw_socket(client, Role::Client, None).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prefixed_websocket_route_upgrades_at_the_exact_base() {
|
||||
let live = live_runtime_from_config(
|
||||
runtime_config_with_base([31; 32], WebCarrier::Websocket, "/relay/nested/"),
|
||||
1,
|
||||
);
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let (session, _) = create_session(&live.runtime);
|
||||
let protocol = format!("tproxy-v1.{session}");
|
||||
let mut socket = upgrade_at(
|
||||
&listener,
|
||||
&live.runtime,
|
||||
"/relay/nested/api/v1/ws",
|
||||
&protocol,
|
||||
)
|
||||
.await;
|
||||
|
||||
socket
|
||||
.send(Message::Ping(Bytes::from_static(b"prefixed")))
|
||||
.await
|
||||
.unwrap();
|
||||
let response = tokio::time::timeout(Duration::from_secs(2), socket.next())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(response, Message::Pong(Bytes::from_static(b"prefixed")));
|
||||
|
||||
let _ = socket.close(None).await;
|
||||
live.shutdown().await;
|
||||
}
|
||||
|
||||
fn masked_message(opcode: u8, payload: &[u8], mask: [u8; 4]) -> Vec<u8> {
|
||||
masked_frame(true, opcode, payload, mask)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user