WEB: Base Path: security + reload coverage fixes&tests

This commit is contained in:
Alexey
2026-09-26 14:25:24 +03:00
parent 5feded2919
commit 38eabc50e3
25 changed files with 1294 additions and 56 deletions
+3
View File
@@ -447,6 +447,9 @@ fn deep_merge(base: &mut Toml, patch: &Toml) {
}
}
#[cfg(test)]
#[path = "config_edit/base_path_tests.rs"]
mod base_path_tests;
#[cfg(test)]
#[path = "config_edit/tests.rs"]
mod tests;
+88
View File
@@ -0,0 +1,88 @@
use super::*;
fn web_config() -> &'static str {
r#"
[access.users]
alice = "000102030405060708090a0b0c0d0e0f"
[[server.listeners]]
ip = "127.0.0.1"
port = 18080
transport = "web"
proxy_protocol = false
web_client_ip_source = "x_forwarded_for"
web_trusted_proxy_cidrs = ["127.0.0.1/32"]
[web]
enabled = true
[[web.vhosts]]
host = "proxy.example.com"
public_addr = "203.0.113.10:443"
[web.vhosts.decoy]
mode = "http_upstream"
upstream = "http://127.0.0.1:18081"
[[web.vhosts.profiles]]
user = "alice"
secret_mode = "plain"
"#
}
fn vhosts_patch(base_path: &str) -> Json {
serde_json::json!({
"web": {
"vhosts": [{
"host": "proxy.example.com",
"base_path": base_path,
"public_addr": "203.0.113.10:443",
"decoy": {
"mode": "http_upstream",
"upstream": "http://127.0.0.1:18081"
},
"profiles": [{
"user": "alice",
"secret_mode": "plain"
}]
}]
}
})
}
#[tokio::test]
async fn config_api_applies_valid_base_path_and_preserves_source_on_invalid_patch() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.toml");
std::fs::write(&path, web_config()).unwrap();
let active = ProxyConfig::load(&path).unwrap();
let mut response = apply_patch_to_path(&path, &vhosts_patch("MixedCase/path"), None)
.await
.unwrap();
let desired = ProxyConfig::load(&path).unwrap();
let resolved = reconcile_runtime_effect(&mut response, &active, &desired).unwrap();
assert!(!response.restart_required);
assert!(response.runtime_reload_required);
assert!(!response.process_restart_required);
assert!(response.deferred_process_fields.is_empty());
assert!(resolved.runtime_changed);
assert_eq!(desired.web.vhosts[0].base_path, "MixedCase/path");
assert_eq!(
resolved.effective.web.runtime.as_ref().unwrap().vhosts["proxy.example.com"].base,
"/MixedCase/path/"
);
let (managed, _revision) = read_managed_config(&path).await.unwrap();
let vhosts = managed["web"]["vhosts"].as_array().unwrap();
assert_eq!(vhosts[0]["base_path"].as_str(), Some("MixedCase/path"));
assert!(managed["web"].get("runtime").is_none());
assert!(!managed.as_table().unwrap().contains_key("access"));
let before_invalid = std::fs::read(&path).unwrap();
let error = apply_patch_to_path(&path, &vhosts_patch("/invalid"), None)
.await
.unwrap_err();
assert_eq!(error.status, hyper::StatusCode::BAD_REQUEST);
assert_eq!(std::fs::read(&path).unwrap(), before_invalid);
}
+3
View File
@@ -62,5 +62,8 @@ use reporting::log_changes;
#[cfg(test)]
use watcher::{ReloadState, reload_config};
#[cfg(test)]
#[path = "hot_reload/base_path_tests.rs"]
mod base_path_tests;
#[cfg(test)]
mod tests;
+81
View File
@@ -0,0 +1,81 @@
use base64::Engine as _;
use super::*;
fn write_base_path_config(path: &Path, base_path: &str) {
let base_path = if base_path.is_empty() {
String::new()
} else {
format!("base_path = \"{base_path}\"\n")
};
let config = format!(
r#"
[access.users]
alice = "000102030405060708090a0b0c0d0e0f"
[[server.listeners]]
ip = "127.0.0.1"
port = 18080
transport = "web"
proxy_protocol = false
web_client_ip_source = "x_forwarded_for"
web_trusted_proxy_cidrs = ["127.0.0.1/32"]
[web]
enabled = true
[[web.vhosts]]
host = "proxy.example.com"
{base_path}public_addr = "203.0.113.10:443"
[web.vhosts.decoy]
mode = "http_upstream"
upstream = "http://127.0.0.1:18081"
[[web.vhosts.profiles]]
user = "alice"
secret_mode = "plain"
"#,
);
std::fs::write(path, config).unwrap();
}
#[test]
fn reload_rejects_invalid_base_then_publishes_route_identity_together() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.toml");
write_base_path_config(&path, "");
let initial = Arc::new(ProxyConfig::load(&path).unwrap());
let initial_hash = ProxyConfig::load_with_metadata(&path)
.unwrap()
.rendered_hash;
let initial_capability = initial.web.runtime.as_ref().unwrap().capabilities[0];
let (config_tx, _config_rx) = watch::channel(Arc::clone(&initial));
let (log_tx, _log_rx) = watch::channel(initial.general.log_level.clone());
let mut reload_state = ReloadState::new(Some(initial_hash));
write_base_path_config(&path, "/invalid");
reload_config(&path, &config_tx, &log_tx, None, None, &mut reload_state);
let unchanged = config_tx.borrow().clone();
assert!(Arc::ptr_eq(&unchanged, &initial));
assert_eq!(unchanged.web.vhosts[0].base_path, "");
assert_eq!(
unchanged.web.runtime.as_ref().unwrap().capabilities[0],
initial_capability
);
write_base_path_config(&path, "dobry-cola-super-app");
reload_config(&path, &config_tx, &log_tx, None, None, &mut reload_state);
let applied = config_tx.borrow().clone();
let runtime = applied.web.runtime.as_ref().unwrap();
let vhost = &runtime.vhosts["proxy.example.com"];
assert_eq!(applied.web.vhosts[0].base_path, "dobry-cola-super-app");
assert_eq!(vhost.base, "/dobry-cola-super-app/");
assert_eq!(vhost.capabilities[0], vhost.profiles[0].capability);
assert_eq!(runtime.capabilities.as_ref(), vhost.capabilities.as_ref());
assert!(!runtime.capabilities.contains(&initial_capability));
assert_eq!(
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(vhost.capabilities[0]),
"hHz99Xs93EN1j91G9gpNepXwGNNt5YdAFkEVk_LlqdQ"
);
}
+17
View File
@@ -38,6 +38,23 @@ fn capability_matches_reference_vectors() {
}
}
#[test]
fn capability_binds_the_exact_host_and_base_path_identity() {
let secret = hex::decode("000102030405060708090a0b0c0d0e0f").unwrap();
let root = derive_web_capability(&secret, b"proxy.example.com", b"").unwrap();
let mixed = derive_web_capability(&secret, b"proxy.example.com", b"MixedCase/path").unwrap();
let lower = derive_web_capability(&secret, b"proxy.example.com", b"mixedcase/path").unwrap();
let other_path =
derive_web_capability(&secret, b"proxy.example.com", b"MixedCase/other").unwrap();
let other_host =
derive_web_capability(&secret, b"other.example.com", b"MixedCase/path").unwrap();
let identities = [root, mixed, lower, other_path, other_host]
.into_iter()
.collect::<std::collections::HashSet<_>>();
assert_eq!(identities.len(), 5);
}
#[cfg(unix)]
#[test]
fn static_snapshot_remains_anchored_after_root_path_replacement() {
+1 -7
View File
@@ -365,13 +365,7 @@ const WEB_TIMEOUTS_CONFIG_KEYS: &[&str] = &[
"decoy_header_secs",
];
const WEB_VHOST_CONFIG_KEYS: &[&str] = &[
"host",
"base_path",
"public_addr",
"decoy",
"profiles",
];
const WEB_VHOST_CONFIG_KEYS: &[&str] = &["host", "base_path", "public_addr", "decoy", "profiles"];
const WEB_DECOY_CONFIG_KEYS: &[&str] = &["mode", "upstream", "directory", "index"];
const WEB_PROFILE_CONFIG_KEYS: &[&str] = &[
"user",
+3 -1
View File
@@ -85,7 +85,9 @@ fn validate_web_base_path(value: &str, field: &str) -> Result<()> {
&& !value.ends_with('/')
&& value.split('/').all(|segment| {
let mut bytes = segment.bytes();
bytes.next().is_some_and(|byte| byte.is_ascii_alphanumeric())
bytes
.next()
.is_some_and(|byte| byte.is_ascii_alphanumeric())
&& bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
});
if value.is_empty() || valid {
@@ -1,5 +1,8 @@
use super::*;
#[path = "web_tests/base_path_tests.rs"]
mod base_path_tests;
const WEB_CONFIG: &str = r#"
[access.users]
alice = "000102030405060708090a0b0c0d0e0f"
@@ -64,7 +67,7 @@ fn web_config_builds_canonical_runtime_snapshot() {
#[test]
fn web_base_path_is_canonical_and_precomputed() {
let maximum = "a".repeat(128);
for base_path in ["Dobry-Cola/super_app", maximum.as_str()] {
for base_path in ["a", "a/b/c9_x-y", "Dobry-Cola/super_app", maximum.as_str()] {
let configured = WEB_CONFIG.replace(
"host = \"Proxy.Example.COM\"",
&format!("host = \"Proxy.Example.COM\"\nbase_path = \"{base_path}\""),
@@ -99,6 +102,11 @@ fn web_base_path_rejects_noncanonical_forms() {
"relay//nested",
"-relay",
"_relay",
"a/-lead",
"a/_lead",
"dot.ted",
"..",
"/",
"relay/.hidden",
"relay/%2fhidden",
"relay path",
@@ -0,0 +1,33 @@
use super::*;
#[test]
fn web_runtime_collects_every_vhost_capability() {
let configured = format!(
"{WEB_CONFIG}\n{}",
r#"
[[web.vhosts]]
host = "Other.Example.COM"
base_path = "other/path"
public_addr = "203.0.113.11:443"
[web.vhosts.decoy]
mode = "http_upstream"
upstream = "http://127.0.0.1:18082"
[[web.vhosts.profiles]]
user = "alice"
secret_mode = "dd"
"#
);
let config = load_config_from_temp_toml(&configured);
let runtime = config.web.runtime.as_ref().unwrap();
let first = &runtime.vhosts["proxy.example.com"];
let second = &runtime.vhosts["other.example.com"];
assert_eq!(runtime.capabilities.len(), 2);
assert_eq!(first.capabilities[0], first.profiles[0].capability);
assert_eq!(second.capabilities[0], second.profiles[0].capability);
assert_ne!(first.capabilities[0], second.capabilities[0]);
assert!(runtime.capabilities.contains(&first.capabilities[0]));
assert!(runtime.capabilities.contains(&second.capabilities[0]));
}
+31 -9
View File
@@ -93,12 +93,9 @@ pub(crate) fn print_web_proxy_links(config: &ProxyConfig) {
"User: {} ({:?})",
profile.user, profile.secret_mode
));
if let Some(link) = format_web_proxy_link(
&profile.host,
&vhost.base_path,
secret,
profile.secret_mode,
) {
if let Some(link) =
format_web_proxy_link(&profile.host, &vhost.base_path, secret, profile.secret_mode)
{
print_maestro_line(format!("WEB: {link}"));
}
}
@@ -129,9 +126,7 @@ fn format_web_proxy_link(
let server = url::form_urlencoded::byte_serialize(format!("{host}/{base_path}").as_bytes())
.collect::<String>();
let marked = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(marked);
Some(format!(
"tg://webproxy?server={server}&secret={marked}"
))
Some(format!("tg://webproxy?server={server}&secret={marked}"))
}
/// Durably replaces one Beobachten snapshot without following Unix symlinks.
@@ -451,4 +446,31 @@ mod tests {
Some("tg://webproxy?server=proxy.example.com%2Fdobry-cola%2Fsuper_app&secret=cN0AAQIDBAUGBwgJCgsMDQ4P".to_string())
);
}
#[test]
fn path_web_proxy_link_round_trips_through_the_tdesktop_grammar() {
for (mode, expected_secret) in [
(WebSecretMode::Plain, hex::decode(SECRET).unwrap()),
(
WebSecretMode::Dd,
[vec![0xdd], hex::decode(SECRET).unwrap()].concat(),
),
] {
let link = format_web_proxy_link("proxy.example.com", "MixedCase/a_b-9", SECRET, mode)
.unwrap();
let parsed = url::Url::parse(&link).unwrap();
let query = parsed
.query_pairs()
.collect::<std::collections::BTreeMap<_, _>>();
assert_eq!(
query["server"].as_ref(),
"proxy.example.com/MixedCase/a_b-9"
);
let marked = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(query["secret"].as_bytes())
.unwrap();
assert_eq!(marked.first(), Some(&0x70));
assert_eq!(&marked[1..], expected_secret.as_slice());
}
}
}
+22
View File
@@ -295,6 +295,28 @@ fn web_decoy_fasttrack_mode_is_deferred_without_runtime_publication() {
assert!(!resolved.runtime_changed);
}
#[test]
fn base_path_change_is_runtime_owned_and_rebuilds_route_identity() {
let old = web_config_with_fasttrack("off");
let old_runtime = old.web.runtime.as_ref().unwrap();
let old_capability = old_runtime.vhosts["proxy.example.com"].capabilities[0];
let mut desired = old.clone();
desired.web.vhosts[0].base_path = "MixedCase/path".to_string();
let resolved = resolve_reload_config(&old, &desired).unwrap();
assert!(resolved.deferred_process_fields.is_empty());
assert!(resolved.runtime_changed);
assert_eq!(resolved.effective.web.vhosts[0].base_path, "MixedCase/path");
let runtime = resolved.effective.web.runtime.as_ref().unwrap();
let vhost = &runtime.vhosts["proxy.example.com"];
assert_eq!(vhost.base, "/MixedCase/path/");
assert_ne!(vhost.capabilities[0], old_capability);
assert_eq!(vhost.capabilities[0], vhost.profiles[0].capability);
assert_eq!(runtime.capabilities.as_ref(), vhost.capabilities.as_ref());
assert!(!runtime.capabilities.contains(&old_capability));
}
#[test]
fn enabling_learning_is_deferred_when_retained_capacity_is_too_small() {
let mut old = ProxyConfig::default();
+13 -7
View File
@@ -98,16 +98,22 @@ fn rendered_page_resolves_carriers_against_the_exact_base_path() {
&SecureRandom::new(),
);
assert!(page.body.contains(
"relayBase=relayOrigin+'/Dobry-Cola/super_app'"
));
assert!(
page.body
.contains("relayBase=relayOrigin+'/Dobry-Cola/super_app'")
);
assert!(page.body.contains("fetch(settings.base()+path"));
assert!(page.body.contains(
"relayBase.replace(/^https:/,'wss:')+'/api/v1/ws'"
));
assert!(
page.body
.contains("relayBase.replace(/^https:/,'wss:')+'/api/v1/ws'")
);
assert!(page.body.contains("fetch(relayBase+'/api/v1/diagnostic'"));
assert!(page.body.contains("url:()=>relayOrigin+recoveryPath"));
assert!(!page.body.contains("/Dobry-Cola/super_app/Dobry-Cola/super_app"));
assert!(
!page
.body
.contains("/Dobry-Cola/super_app/Dobry-Cola/super_app")
);
assert!(!page.body.contains("__BASE_PREFIX__"));
}
+20 -19
View File
@@ -1,5 +1,12 @@
use super::*;
#[path = "base_path_tests/credentials.rs"]
mod credentials;
#[path = "base_path_tests/reload.rs"]
mod reload;
#[path = "base_path_tests/routing.rs"]
mod routing;
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"
@@ -65,9 +72,11 @@ async fn base_path_routes_only_the_exact_prefixed_contract() {
.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'"));
assert!(
std::str::from_utf8(body)
.unwrap()
.contains("relayBase=relayOrigin+'/dobry-cola'")
);
for path in [
format!("/?bridge={encoded}"),
@@ -310,8 +319,7 @@ async fn process_token_provenance_survives_registry_expiry() {
#[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/");
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)));
@@ -331,11 +339,7 @@ async fn generation_swap_switches_base_path_and_capability_together() {
let replacement_capability = [27u8; 32];
let replacement = test_runtime_generation(
2,
runtime_config_with_base(
replacement_capability,
WebCarrier::Https,
"/new-path/",
),
runtime_config_with_base(replacement_capability, WebCarrier::Https, "/new-path/"),
);
active.store(Arc::clone(&replacement));
let stale = request(
@@ -359,9 +363,11 @@ async fn generation_swap_switches_base_path_and_capability_together() {
.await;
let (headers, body) = split_response(&current);
assert!(headers.starts_with(b"HTTP/1.1 200"));
assert!(std::str::from_utf8(body)
.unwrap()
.contains("relayBase=relayOrigin+'/new-path'"));
assert!(
std::str::from_utf8(body)
.unwrap()
.contains("relayBase=relayOrigin+'/new-path'")
);
runtime.shutdown().await;
generation.stop_sessions().await;
@@ -414,12 +420,7 @@ async fn prefixed_decoy_request_keeps_its_original_path_and_query() {
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;
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();
+162
View File
@@ -0,0 +1,162 @@
use super::*;
fn assert_private_not_found(response: &[u8]) {
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");
}
fn percent_encode(value: &str) -> String {
value
.bytes()
.enumerate()
.map(|(index, byte)| {
if index % 2 == 0 {
format!("%{byte:02x}")
} else {
format!("%{byte:02X}")
}
})
.collect()
}
async fn issue_session_credentials(
listener: &TcpListener,
runtime: &Arc<WebProcessRuntime>,
capability: [u8; 32],
) -> (String, String) {
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 hello = frame::encode(FrameType::Hello, 0, &[1]);
let mut create = format!(
"POST /relay/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"));
(
bootstrap,
response_header(headers, "x-session-token").to_string(),
)
}
#[tokio::test]
async fn every_authentic_credential_placement_stays_out_of_the_upstream() {
let site = TcpListener::bind("127.0.0.1:0").await.unwrap();
let site_addr = site.local_addr().unwrap();
let capability = [91u8; 32];
let mut config = runtime_config_with_base(capability, WebCarrier::Https, "/relay/");
config.web.limits.max_bootstraps_per_ip = 2;
let runtime_config = config.web.runtime.as_ref().unwrap();
let profile = runtime_config.profiles[0].clone();
let mut vhosts = BTreeMap::new();
vhosts.insert(
"proxy.example.com".to_string(),
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![profile.clone()],
capabilities: vec![capability].into_boxed_slice(),
}),
);
vhosts.insert(
"other.example.com".to_string(),
Arc::new(WebRuntimeVhost {
host: "other.example.com".to_string(),
base: "/other/".to_string(),
decoy_fasttrack_mode: WebDecoyFastTrackMode::Off,
decoy: WebRuntimeDecoy::HttpUpstream {
addr: site_addr,
authority: "decoy.internal".to_string(),
},
decoy_header_secs: 1,
profiles: Vec::new(),
capabilities: Vec::new().into_boxed_slice(),
}),
);
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 encoded_capability = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(capability);
let (bootstrap, session) = issue_session_credentials(&listener, &runtime, capability).await;
for secret in [&encoded_capability, &bootstrap, &session] {
for raw in [
format!(
"GET /wrong/{secret} 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\nConnection: close\r\n\r\n",
percent_encode(secret)
),
format!(
"GET /?bridge={secret}&extra=1 HTTP/1.1\r\nHost: proxy.example.com\r\nConnection: close\r\n\r\n"
),
format!(
"POST /wrong HTTP/1.1\r\nHost: proxy.example.com\r\nAuthorization: Bearer {secret}\r\nCookie: opaque={secret}\r\nReferer: https://example.invalid/{secret}\r\nX-Unexpected: random,{secret}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
),
format!(
"GET /api/v1/ws HTTP/1.1\r\nHost: proxy.example.com\r\nSec-WebSocket-Protocol: chat, tproxy-v1.{secret}\r\nConnection: close\r\n\r\n"
),
format!(
"GET /other/wrong HTTP/1.1\r\nHost: other.example.com\r\nX-Unexpected: {secret}\r\nConnection: close\r\n\r\n"
),
] {
assert_private_not_found(&request(&listener, &runtime, raw.into_bytes()).await);
}
}
assert!(
tokio::time::timeout(std::time::Duration::from_millis(100), site.accept())
.await
.is_err()
);
let site_task = tokio::spawn(async move {
let (mut stream, _) = site.accept().await.unwrap();
let mut received = [0; 1024];
let _ = stream.read(&mut received).await.unwrap();
stream
.write_all(
b"HTTP/1.1 404 Not Found\r\nContent-Length: 4\r\nConnection: close\r\n\r\nsite",
)
.await
.unwrap();
});
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"site");
site_task.await.unwrap();
runtime.shutdown().await;
generation.stop_sessions().await;
generation.stop_background_tasks().await;
}
+283
View File
@@ -0,0 +1,283 @@
use super::*;
async fn issue_bootstrap(
listener: &TcpListener,
runtime: &Arc<WebProcessRuntime>,
base: &str,
capability: [u8; 32],
) -> String {
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(capability);
let response = request(
listener,
runtime,
bridge_request(&format!("{base}?bridge={encoded}")),
)
.await;
let (headers, body) = split_response(&response);
assert!(headers.starts_with(b"HTTP/1.1 200"));
bridge_token(body)
}
async fn create_session(
listener: &TcpListener,
runtime: &Arc<WebProcessRuntime>,
base: &str,
bootstrap: &str,
) -> String {
let hello = frame::encode(FrameType::Hello, 0, &[1]);
let mut request_bytes = format!(
"POST {base}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();
request_bytes.extend_from_slice(&hello);
let response = request(listener, runtime, request_bytes).await;
let (headers, _) = split_response(&response);
assert!(headers.starts_with(b"HTTP/1.1 200"));
response_header(headers, "x-session-token").to_string()
}
fn session_request(base: &str, session: &str) -> Vec<u8> {
format!(
"POST {base}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()
}
fn assert_private_not_found(response: &[u8]) {
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");
}
fn assert_decoy(response: &[u8]) {
assert_eq!(
split_response(response).1,
b"<!doctype html><title>decoy</title>"
);
}
#[tokio::test]
async fn generation_swap_preserves_process_tokens_and_replaces_route_identity() {
let old_capability = [101u8; 32];
let mut old_config = runtime_config_with_base(old_capability, WebCarrier::Https, "/old/");
old_config.web.limits.max_bootstraps_per_ip = 8;
old_config.web.timeouts.long_poll_secs = 1;
let old_generation = test_runtime_generation(1, old_config);
let active = Arc::new(ArcSwap::from(Arc::clone(&old_generation)));
let runtime = WebProcessRuntime::start(Arc::clone(&active));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let unused_bootstrap = issue_bootstrap(&listener, &runtime, "/old/", old_capability).await;
let used_bootstrap = issue_bootstrap(&listener, &runtime, "/old/", old_capability).await;
let session = create_session(&listener, &runtime, "/old/", &used_bootstrap).await;
let new_capability = [102u8; 32];
let mut new_config = runtime_config_with_base(new_capability, WebCarrier::Https, "/new/");
new_config.web.limits.max_bootstraps_per_ip = 8;
new_config.web.timeouts.long_poll_secs = 1;
let new_generation = test_runtime_generation(2, new_config);
active.store(Arc::clone(&new_generation));
let old_encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(old_capability);
let new_encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(new_capability);
assert_decoy(
&request(
&listener,
&runtime,
bridge_request(&format!("/old/?bridge={old_encoded}")),
)
.await,
);
assert_private_not_found(
&request(
&listener,
&runtime,
bridge_request(&format!("/old/?bridge={new_encoded}")),
)
.await,
);
assert_decoy(
&request(
&listener,
&runtime,
bridge_request(&format!("/new/?bridge={old_encoded}")),
)
.await,
);
let old_bootstrap_path = format!(
"GET /old/wrong HTTP/1.1\r\nHost: proxy.example.com\r\nAuthorization: Bearer {unused_bootstrap}\r\nConnection: close\r\n\r\n"
)
.into_bytes();
assert_private_not_found(&request(&listener, &runtime, old_bootstrap_path).await);
assert_private_not_found(
&request(&listener, &runtime, session_request("/old/", &session)).await,
);
assert!(
request(&listener, &runtime, session_request("/new/", &session),)
.await
.starts_with(b"HTTP/1.1 204")
);
let hello = frame::encode(FrameType::Hello, 0, &[1]);
let mut stale_bootstrap_request = format!(
"POST /new/api/v1/session HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {unused_bootstrap}\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
hello.len()
)
.into_bytes();
stale_bootstrap_request.extend_from_slice(&hello);
assert_private_not_found(&request(&listener, &runtime, stale_bootstrap_request).await);
let current = request(
&listener,
&runtime,
bridge_request(&format!("/new/?bridge={new_encoded}")),
)
.await;
let (headers, body) = split_response(&current);
assert!(headers.starts_with(b"HTTP/1.1 200"));
let new_bootstrap = bridge_token(body);
create_session(&listener, &runtime, "/new/", &new_bootstrap).await;
runtime.shutdown().await;
old_generation.stop_sessions().await;
old_generation.stop_background_tasks().await;
new_generation.stop_sessions().await;
new_generation.stop_background_tasks().await;
}
#[tokio::test]
async fn routed_uplink_finishes_under_its_acquisition_generation() {
let capability = [103u8; 32];
let mut old_config = runtime_config_with_base(capability, WebCarrier::Https, "/old/");
old_config.web.timeouts.long_poll_secs = 1;
let old_generation = test_runtime_generation(1, old_config);
let active = Arc::new(ArcSwap::from(Arc::clone(&old_generation)));
let runtime = WebProcessRuntime::start(Arc::clone(&active));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let bootstrap = issue_bootstrap(&listener, &runtime, "/old/", capability).await;
let session = create_session(&listener, &runtime, "/old/", &bootstrap).await;
let address = listener.local_addr().unwrap();
let (accepted, client) = tokio::join!(listener.accept(), TcpStream::connect(address));
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,
));
let pong = frame::encode(FrameType::Pong, 0, &[]);
let head = format!(
"POST /old/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()
);
client.write_all(head.as_bytes()).await.unwrap();
tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {
let body_readers = runtime
.capacity_snapshot()
.resources
.into_iter()
.find(|resource| resource.resource == "body_readers")
.unwrap();
if body_readers.used == 1 {
break;
}
tokio::task::yield_now().await;
}
})
.await
.expect("uplink never entered body collection");
let mut new_config = runtime_config_with_base([104u8; 32], WebCarrier::Https, "/new/");
new_config.web.timeouts.long_poll_secs = 1;
let new_generation = test_runtime_generation(2, new_config);
active.store(Arc::clone(&new_generation));
client.write_all(&pong).await.unwrap();
let mut response = Vec::new();
client.read_to_end(&mut response).await.unwrap();
task.await.unwrap();
assert!(response.starts_with(b"HTTP/1.1 204"));
assert_private_not_found(
&request(&listener, &runtime, session_request("/old/", &session)).await,
);
assert!(
request(&listener, &runtime, session_request("/new/", &session),)
.await
.starts_with(b"HTTP/1.1 204")
);
runtime.shutdown().await;
old_generation.stop_sessions().await;
old_generation.stop_background_tasks().await;
new_generation.stop_sessions().await;
new_generation.stop_background_tasks().await;
}
#[tokio::test]
async fn generation_transition_burst_never_authenticates_a_torn_route_identity() {
let old_capability = [105u8; 32];
let new_capability = [106u8; 32];
let old_generation = test_runtime_generation(
1,
runtime_config_with_base(old_capability, WebCarrier::Https, "/old/"),
);
let new_generation = test_runtime_generation(
2,
runtime_config_with_base(new_capability, WebCarrier::Https, "/new/"),
);
let active = Arc::new(ArcSwap::from(Arc::clone(&old_generation)));
let runtime = WebProcessRuntime::start(Arc::clone(&active));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let barrier = Arc::new(tokio::sync::Barrier::new(2));
let swap_barrier = Arc::clone(&barrier);
let swap_active = Arc::clone(&active);
let swap_old = Arc::clone(&old_generation);
let swap_new = Arc::clone(&new_generation);
let swaps = tokio::spawn(async move {
swap_barrier.wait().await;
for index in 0..10_000 {
if index % 2 == 0 {
swap_active.store(Arc::clone(&swap_new));
} else {
swap_active.store(Arc::clone(&swap_old));
}
if index % 8 == 0 {
tokio::task::yield_now().await;
}
}
});
barrier.wait().await;
let old_encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(old_capability);
let new_encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(new_capability);
for index in 0..256 {
let target = if index % 2 == 0 {
format!("/old/?bridge={new_encoded}")
} else {
format!("/new/?bridge={old_encoded}")
};
let response = request(&listener, &runtime, bridge_request(&target)).await;
assert!(!response.starts_with(b"HTTP/1.1 200"));
assert!(
!std::str::from_utf8(split_response(&response).1)
.unwrap()
.contains("bootstrap=\"")
);
}
swaps.await.unwrap();
runtime.shutdown().await;
old_generation.stop_sessions().await;
old_generation.stop_background_tasks().await;
new_generation.stop_sessions().await;
new_generation.stop_background_tasks().await;
}
+291
View File
@@ -0,0 +1,291 @@
use super::*;
const RECOVERY_TYPE: &str = "application/vnd.telemt.web-recovery+json";
async fn issue_bootstrap(
listener: &TcpListener,
runtime: &Arc<WebProcessRuntime>,
path: &str,
capability: [u8; 32],
) -> String {
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(capability);
let response = request(
listener,
runtime,
bridge_request(&format!("{path}?bridge={encoded}")),
)
.await;
let (headers, body) = split_response(&response);
assert!(headers.starts_with(b"HTTP/1.1 200"));
bridge_token(body)
}
async fn create_session(
listener: &TcpListener,
runtime: &Arc<WebProcessRuntime>,
path: &str,
bootstrap: &str,
) -> String {
let hello = frame::encode(FrameType::Hello, 0, &[1]);
let mut request_bytes = format!(
"POST {path} 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();
request_bytes.extend_from_slice(&hello);
let response = request(listener, runtime, request_bytes).await;
let (headers, _) = split_response(&response);
assert!(headers.starts_with(b"HTTP/1.1 200"));
response_header(headers, "x-session-token").to_string()
}
fn assert_private_not_found(response: &[u8]) {
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");
}
fn down_request(path: &str, session: &str) -> Vec<u8> {
format!(
"POST {path} 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()
}
#[tokio::test]
async fn session_token_distinguishes_the_exact_route_from_every_path_alias() {
let capability = [81u8; 32];
let mut config = runtime_config_with_base(capability, WebCarrier::Https, "/relay/");
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 bootstrap = issue_bootstrap(&listener, &runtime, "/relay/", capability).await;
let session = create_session(&listener, &runtime, "/relay/api/v1/session", &bootstrap).await;
let exact = request(
&listener,
&runtime,
down_request("/relay/api/v1/down", &session),
)
.await;
assert!(exact.starts_with(b"HTTP/1.1 204"));
for alias in [
"/api/v1/down",
"/Relay/api/v1/down",
"/relayx/api/v1/down",
"/relay//api/v1/down",
"/relay%2Fapi/v1/down",
"/relay/api%2Fv1/down",
"/relay/api/v1/down/",
"/relay/api/v1/down?q=1",
] {
let response = request(&listener, &runtime, down_request(alias, &session)).await;
assert_private_not_found(&response);
}
runtime.shutdown().await;
generation.stop_sessions().await;
generation.stop_background_tasks().await;
}
#[tokio::test]
async fn inactive_capabilities_remain_decoy_and_the_active_path_is_case_sensitive() {
let active_capability = [82u8; 32];
let inactive_capability = [83u8; 32];
let generation = test_runtime_generation(
1,
runtime_config_with_base(
active_capability,
WebCarrier::Https,
"/Dobry-Cola/super_app/",
),
);
let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(Arc::clone(&generation))));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let active = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(active_capability);
let inactive = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(inactive_capability);
let exact = request(
&listener,
&runtime,
bridge_request(&format!("/Dobry-Cola/super_app/?bridge={active}")),
)
.await;
assert!(exact.starts_with(b"HTTP/1.1 200"));
for path in [
format!("/dobry-cola/super_app/?bridge={active}"),
format!("/Dobry-Cola/super_app?bridge={active}"),
format!("/Dobry-Cola/super_app/nested?bridge={active}"),
] {
assert_private_not_found(&request(&listener, &runtime, bridge_request(&path)).await);
}
let decoy = request(
&listener,
&runtime,
bridge_request(&format!("/Dobry-Cola/super_app/?bridge={inactive}")),
)
.await;
assert_eq!(
split_response(&decoy).1,
b"<!doctype html><title>decoy</title>"
);
runtime.shutdown().await;
generation.stop_sessions().await;
generation.stop_background_tasks().await;
}
#[tokio::test]
async fn decoy_forwarding_preserves_every_reference_request_target() {
let targets = [
"/relay/",
"/relay/whatever?q=1",
"/relay/api/v1/session",
"/relay",
"/api/v1/ws",
"/relay//api/v1/up",
"/relay%2Fapi/v1/up",
"/Relay/api/v1/down",
];
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 request_targets = Vec::new();
for _ in 0..targets.len() {
let (mut stream, _) = site.accept().await.unwrap();
let mut received = Vec::new();
loop {
let mut chunk = [0; 1024];
let read = stream.read(&mut chunk).await.unwrap();
if read == 0 {
break;
}
received.extend_from_slice(&chunk[..read]);
if received.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
let line = std::str::from_utf8(&received)
.unwrap()
.split("\r\n")
.next()
.unwrap()
.to_string();
request_targets.push(line);
stream
.write_all(
b"HTTP/1.1 404 Not Found\r\nContent-Length: 4\r\nConnection: close\r\n\r\nsite",
)
.await
.unwrap();
}
request_targets
});
let capability = [84u8; 32];
let mut config = runtime_config_with_base(capability, WebCarrier::Https, "/relay/");
let runtime_config = config.web.runtime.as_ref().unwrap();
let mut vhosts = runtime_config.vhosts.clone();
let previous = &runtime_config.vhosts["proxy.example.com"];
vhosts.insert(
"proxy.example.com".to_string(),
Arc::new(WebRuntimeVhost {
host: previous.host.clone(),
base: previous.base.clone(),
decoy_fasttrack_mode: previous.decoy_fasttrack_mode,
decoy: WebRuntimeDecoy::HttpUpstream {
addr: site_addr,
authority: "decoy.internal".to_string(),
},
decoy_header_secs: 1,
profiles: previous.profiles.clone(),
capabilities: previous.capabilities.clone(),
}),
);
config.web.runtime = Some(Arc::new(WebRuntimeConfig {
vhosts,
profiles: runtime_config.profiles.clone(),
capabilities: runtime_config.capabilities.clone(),
}));
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();
for target in targets {
let response = request(&listener, &runtime, bridge_request(target)).await;
assert_eq!(split_response(&response).1, b"site");
}
let forwarded = site_task.await.unwrap();
let expected = targets
.into_iter()
.map(|target| format!("GET {target} HTTP/1.1"))
.collect::<Vec<_>>();
assert_eq!(forwarded, expected);
runtime.shutdown().await;
generation.stop_sessions().await;
generation.stop_background_tasks().await;
}
#[tokio::test]
async fn diagnostic_and_recovery_use_only_the_exact_prefixed_root() {
let capability = [85u8; 32];
let mut config = runtime_config_with_base(capability, WebCarrier::Https, "/relay/");
config.web.debug.enabled = true;
config.web.debug.sideband = true;
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 bootstrap = issue_bootstrap(&listener, &runtime, "/relay/", capability).await;
let body = br#"{"v":1,"event":"runtime_started"}"#;
let mut diagnostic = format!(
"POST /relay/api/v1/diagnostic HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {bootstrap}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
)
.into_bytes();
diagnostic.extend_from_slice(body);
assert!(
request(&listener, &runtime, diagnostic)
.await
.starts_with(b"HTTP/1.1 204")
);
let root_diagnostic = format!(
"POST /api/v1/diagnostic HTTP/1.1\r\nHost: proxy.example.com\r\nAuthorization: Bearer {bootstrap}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
)
.into_bytes();
assert_private_not_found(&request(&listener, &runtime, root_diagnostic).await);
let session = create_session(&listener, &runtime, "/relay/api/v1/session", &bootstrap).await;
let recovery_request = |path: &str| {
format!(
"GET {path}?bridge={encoded} HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAccept: {RECOVERY_TYPE}\r\nAuthorization: Bearer {session}\r\nConnection: close\r\n\r\n"
)
.into_bytes()
};
assert_private_not_found(&request(&listener, &runtime, recovery_request("/")).await);
let recovered = request(&listener, &runtime, recovery_request("/relay/")).await;
let (headers, body) = split_response(&recovered);
assert!(headers.starts_with(b"HTTP/1.1 200"));
assert_eq!(response_header(headers, "content-type"), RECOVERY_TYPE);
let document: serde_json::Value = serde_json::from_slice(body).unwrap();
let recovery_bootstrap = document["bootstrap"].as_str().unwrap();
create_session(
&listener,
&runtime,
"/relay/api/v1/session",
recovery_bootstrap,
)
.await;
runtime.shutdown().await;
generation.stop_sessions().await;
generation.stop_background_tasks().await;
}
+4 -1
View File
@@ -211,7 +211,10 @@ async fn malformed_or_over_capacity_recovery_is_indistinguishable_from_decoy() {
.await;
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!(
response_header(invalid_headers, "cache-control"),
"no-store"
);
assert_eq!(invalid_body, b"<!doctype html><title>decoy</title>");
let malformed_accept = request(
+7 -3
View File
@@ -18,9 +18,9 @@ pub(super) fn mark_internal_credential<B>(
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)
});
|| 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)
@@ -84,3 +84,7 @@ fn hex_value(value: u8) -> Option<u8> {
_ => None,
}
}
#[cfg(test)]
#[path = "secrets/tests.rs"]
mod tests;
+76
View File
@@ -0,0 +1,76 @@
use std::sync::Arc;
use arc_swap::ArcSwap;
use base64::Engine as _;
use super::*;
use crate::config::WebCarrier;
use crate::maestro::generation::test_runtime_generation;
use crate::web::http::tests::runtime_config_with_base;
fn percent_encode_byte(value: u8, lowercase: bool) -> String {
if lowercase {
format!("%{value:02x}")
} else {
format!("%{value:02X}")
}
}
#[tokio::test]
async fn scanner_recognizes_exact_credentials_across_encoding_boundaries() {
let capability = [71u8; 32];
let generation = test_runtime_generation(
1,
runtime_config_with_base(capability, WebCarrier::Https, "/relay/"),
);
let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(Arc::clone(&generation))));
let generation_config = generation.config();
let config = generation_config.web.runtime.as_ref().unwrap();
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(capability);
assert!(contains_secret(encoded.as_bytes(), config, &runtime));
assert!(contains_secret(
format!("prefix{encoded}suffix").as_bytes(),
config,
&runtime,
));
for index in 0..encoded.len() {
let mut escaped = String::with_capacity(encoded.len() + 2);
escaped.push_str(&encoded[..index]);
escaped.push_str(&percent_encode_byte(
encoded.as_bytes()[index],
index % 2 == 0,
));
escaped.push_str(&encoded[index + 1..]);
assert!(
contains_secret(escaped.as_bytes(), config, &runtime),
"percent-encoded byte {index} was not recognized"
);
}
let profile = config.profiles[0].clone();
let bootstrap = runtime
.issue_bootstrap(profile, "192.0.2.10".parse().unwrap())
.unwrap()
.token;
let escaped_bootstrap = bootstrap
.bytes()
.enumerate()
.map(|(index, byte)| percent_encode_byte(byte, index % 2 == 0))
.collect::<String>();
assert!(contains_secret(bootstrap.as_bytes(), config, &runtime));
assert!(contains_secret(
escaped_bootstrap.as_bytes(),
config,
&runtime,
));
let split = format!("{}%zz{}", &encoded[..20], &encoded[20..]);
assert!(!contains_secret(split.as_bytes(), config, &runtime));
let inactive = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([72u8; 32]);
assert!(!contains_secret(inactive.as_bytes(), config, &runtime));
runtime.shutdown().await;
generation.stop_sessions().await;
generation.stop_background_tasks().await;
}
+3
View File
@@ -21,6 +21,9 @@ use crate::web::manager::{
CarrierCapabilities, CarrierClientClass, CarrierFailure, CarrierRequest, WebProcessRuntime,
};
#[path = "tests/base_path.rs"]
mod base_path;
fn request(protocol: &str) -> Request<()> {
Request::builder()
.method(Method::GET)
+107
View File
@@ -0,0 +1,107 @@
use super::*;
async fn rejected_upgrade(
listener: &TcpListener,
runtime: &Arc<WebProcessRuntime>,
path: &str,
protocol: &str,
) -> Vec<u8> {
let address = listener.local_addr().unwrap();
let (accepted, client) = tokio::join!(listener.accept(), TcpStream::connect(address));
let (server, peer) = accepted.unwrap();
let mut client = client.unwrap();
let permit = runtime.try_http_connection().unwrap();
let task = tokio::spawn(super::super::super::serve_connection(
server,
peer,
WebClientIpSource::XForwardedFor,
Arc::from(["127.0.0.1/32".parse().unwrap()]),
Arc::clone(runtime),
CancellationToken::new(),
permit,
));
let request = format!(
"GET {path} HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nConnection: close, Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Protocol: {protocol}\r\n\r\n"
);
client.write_all(request.as_bytes()).await.unwrap();
let mut response = Vec::new();
client.read_to_end(&mut response).await.unwrap();
task.await.unwrap();
response
}
fn assert_private_not_found(response: &[u8]) {
let (headers, body) = crate::web::http::tests::split_response(response);
assert!(headers.starts_with(b"HTTP/1.1 404"));
assert_eq!(
crate::web::http::tests::response_header(headers, "cache-control"),
"no-store"
);
assert_eq!(body, b"not found\n");
}
#[tokio::test]
async fn authentic_websocket_protocol_rejects_every_base_path_alias_locally() {
let config = runtime_config_with_base([32; 32], WebCarrier::Websocket, "/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 (session, _) = create_session(&runtime);
let protocol = format!("tproxy-v1.{session}");
for path in [
"/api/v1/ws",
"/Relay/api/v1/ws",
"/relayx/api/v1/ws",
"/relay//api/v1/ws",
"/relay%2Fapi/v1/ws",
"/relay/api%2Fv1/ws",
"/relay/api/v1/ws/",
] {
assert_private_not_found(&rejected_upgrade(&listener, &runtime, path, &protocol).await);
}
runtime.shutdown().await;
generation.stop_sessions().await;
generation.stop_background_tasks().await;
}
#[tokio::test]
async fn upgraded_socket_survives_base_path_generation_swap() {
let old_generation = test_runtime_generation(
1,
runtime_config_with_base([33; 32], WebCarrier::Websocket, "/old/"),
);
let active = Arc::new(ArcSwap::from(Arc::clone(&old_generation)));
let runtime = WebProcessRuntime::start(Arc::clone(&active));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let (session, _) = create_session(&runtime);
let protocol = format!("tproxy-v1.{session}");
let mut socket = upgrade_at(&listener, &runtime, "/old/api/v1/ws", &protocol).await;
let new_generation = test_runtime_generation(
2,
runtime_config_with_base([34; 32], WebCarrier::Websocket, "/new/"),
);
active.store(Arc::clone(&new_generation));
socket
.send(Message::Ping(Bytes::from_static(b"after-reload")))
.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"after-reload")));
assert_private_not_found(
&rejected_upgrade(&listener, &runtime, "/old/api/v1/ws", &protocol).await,
);
let _ = socket.close(None).await;
runtime.shutdown().await;
old_generation.stop_sessions().await;
old_generation.stop_background_tasks().await;
new_generation.stop_sessions().await;
new_generation.stop_background_tasks().await;
}
+5 -2
View File
@@ -108,8 +108,7 @@ impl TokenAuthenticator {
let bootstrap = self.tag(TokenKind::Bootstrap, nonce);
let session = self.tag(TokenKind::Session, nonce);
bool::from(
bootstrap[..TOKEN_NONCE_BYTES].ct_eq(tag)
| session[..TOKEN_NONCE_BYTES].ct_eq(tag),
bootstrap[..TOKEN_NONCE_BYTES].ct_eq(tag) | session[..TOKEN_NONCE_BYTES].ct_eq(tag),
)
}
@@ -513,3 +512,7 @@ impl WebProcessRuntime {
self.telemetry.record_limit_hit();
}
}
#[cfg(test)]
#[path = "manager/token_authenticator_tests.rs"]
mod token_authenticator_tests;
+1 -3
View File
@@ -9,9 +9,7 @@ use super::state::{
Bootstrap, CarrierChainPhase, allow_rate, evict_oldest_unused_bootstrap, matching_profile,
new_unique_token, profile_key, remove_expired_locked,
};
use super::{
BootstrapResult, ManagerError, TOKEN_BYTES, TokenHash, TokenKind, WebProcessRuntime,
};
use super::{BootstrapResult, ManagerError, TOKEN_BYTES, TokenHash, TokenKind, WebProcessRuntime};
use crate::config::WebRuntimeProfile;
use crate::maestro::generation::RuntimeGeneration;
use crate::web::session::{SessionCloseReason, WebSession};
+1 -3
View File
@@ -7,9 +7,7 @@ use base64::Engine as _;
use sha2::{Digest, Sha256};
use zeroize::Zeroizing;
use super::{
CarrierRequest, ProfileKey, TokenAuthenticator, TokenHash, TokenKind,
};
use super::{CarrierRequest, ProfileKey, TokenAuthenticator, TokenHash, TokenKind};
use crate::config::{WebCarrier, WebRuntimeConfig, WebRuntimeProfile, WebTimeoutsConfig};
use crate::maestro::generation::RuntimeGeneration;
use crate::proxy::user_admission::UserSessionRegistration;
@@ -0,0 +1,30 @@
use super::*;
fn authenticator(key: u8) -> TokenAuthenticator {
TokenAuthenticator {
key: Zeroizing::new([key; 32]),
}
}
#[test]
fn process_tokens_are_domain_separated_and_fail_closed_after_mutation() {
let issuer = authenticator(0x11);
let other_process = authenticator(0x22);
let nonce = [0x33; TOKEN_NONCE_BYTES];
let bootstrap = issuer.issue(TokenKind::Bootstrap, nonce);
let session = issuer.issue(TokenKind::Session, nonce);
assert_ne!(bootstrap, session);
assert!(issuer.authentic(&bootstrap));
assert!(issuer.authentic(&session));
assert!(!other_process.authentic(&bootstrap));
assert!(!other_process.authentic(&session));
for token in [bootstrap, session] {
for index in 0..TOKEN_BYTES {
let mut mutated = token;
mutated[index] ^= 1;
assert!(!issuer.authentic(&mutated), "mutation at byte {index}");
}
}
}