diff --git a/src/api/config_edit.rs b/src/api/config_edit.rs index b86ec5c..b1e8719 100644 --- a/src/api/config_edit.rs +++ b/src/api/config_edit.rs @@ -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; diff --git a/src/api/config_edit/base_path_tests.rs b/src/api/config_edit/base_path_tests.rs new file mode 100644 index 0000000..5a2bbfb --- /dev/null +++ b/src/api/config_edit/base_path_tests.rs @@ -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); +} diff --git a/src/config/hot_reload.rs b/src/config/hot_reload.rs index 1a5f511..b01ac54 100644 --- a/src/config/hot_reload.rs +++ b/src/config/hot_reload.rs @@ -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; diff --git a/src/config/hot_reload/base_path_tests.rs b/src/config/hot_reload/base_path_tests.rs new file mode 100644 index 0000000..8d06772 --- /dev/null +++ b/src/config/hot_reload/base_path_tests.rs @@ -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" + ); +} diff --git a/src/config/load/runtime_web/tests.rs b/src/config/load/runtime_web/tests.rs index 6dadeb2..5d3f5b2 100644 --- a/src/config/load/runtime_web/tests.rs +++ b/src/config/load/runtime_web/tests.rs @@ -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::>(); + assert_eq!(identities.len(), 5); +} + #[cfg(unix)] #[test] fn static_snapshot_remains_anchored_after_root_path_replacement() { diff --git a/src/config/load/strict_keys.rs b/src/config/load/strict_keys.rs index 78fe1ba..3040518 100644 --- a/src/config/load/strict_keys.rs +++ b/src/config/load/strict_keys.rs @@ -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", diff --git a/src/config/load/validate_web/vhosts.rs b/src/config/load/validate_web/vhosts.rs index 74a17a4..571d4fa 100644 --- a/src/config/load/validate_web/vhosts.rs +++ b/src/config/load/validate_web/vhosts.rs @@ -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 { diff --git a/src/config/tests/load_basic_tests/web_tests.rs b/src/config/tests/load_basic_tests/web_tests.rs index 5246d38..b0a071d 100644 --- a/src/config/tests/load_basic_tests/web_tests.rs +++ b/src/config/tests/load_basic_tests/web_tests.rs @@ -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", diff --git a/src/config/tests/load_basic_tests/web_tests/base_path_tests.rs b/src/config/tests/load_basic_tests/web_tests/base_path_tests.rs new file mode 100644 index 0000000..37417cc --- /dev/null +++ b/src/config/tests/load_basic_tests/web_tests/base_path_tests.rs @@ -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])); +} diff --git a/src/maestro/helpers/runtime.rs b/src/maestro/helpers/runtime.rs index 78d4dc0..fcd19d1 100644 --- a/src/maestro/helpers/runtime.rs +++ b/src/maestro/helpers/runtime.rs @@ -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::(); 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::>(); + 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()); + } + } } diff --git a/src/maestro/runtime_build_tests.rs b/src/maestro/runtime_build_tests.rs index 87b0c2a..1554ddc 100644 --- a/src/maestro/runtime_build_tests.rs +++ b/src/maestro/runtime_build_tests.rs @@ -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(); diff --git a/src/web/bridge/tests.rs b/src/web/bridge/tests.rs index 93be9f2..7775d0d 100644 --- a/src/web/bridge/tests.rs +++ b/src/web/bridge/tests.rs @@ -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__")); } diff --git a/src/web/http/base_path_tests.rs b/src/web/http/base_path_tests.rs index d6d66bb..3337fa8 100644 --- a/src/web/http/base_path_tests.rs +++ b/src/web/http/base_path_tests.rs @@ -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 { 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(¤t); 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(); diff --git a/src/web/http/base_path_tests/credentials.rs b/src/web/http/base_path_tests/credentials.rs new file mode 100644 index 0000000..45be870 --- /dev/null +++ b/src/web/http/base_path_tests/credentials.rs @@ -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, + 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; +} diff --git a/src/web/http/base_path_tests/reload.rs b/src/web/http/base_path_tests/reload.rs new file mode 100644 index 0000000..cb35999 --- /dev/null +++ b/src/web/http/base_path_tests/reload.rs @@ -0,0 +1,283 @@ +use super::*; + +async fn issue_bootstrap( + listener: &TcpListener, + runtime: &Arc, + 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, + 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 { + 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"decoy" + ); +} + +#[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(¤t); + 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; +} diff --git a/src/web/http/base_path_tests/routing.rs b/src/web/http/base_path_tests/routing.rs new file mode 100644 index 0000000..a0253b9 --- /dev/null +++ b/src/web/http/base_path_tests/routing.rs @@ -0,0 +1,291 @@ +use super::*; + +const RECOVERY_TYPE: &str = "application/vnd.telemt.web-recovery+json"; + +async fn issue_bootstrap( + listener: &TcpListener, + runtime: &Arc, + 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, + 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 { + 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"decoy" + ); + + 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::>(); + 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; +} diff --git a/src/web/http/recovery_tests.rs b/src/web/http/recovery_tests.rs index f6448ff..8d8d6b7 100644 --- a/src/web/http/recovery_tests.rs +++ b/src/web/http/recovery_tests.rs @@ -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"decoy"); let malformed_accept = request( diff --git a/src/web/http/secrets.rs b/src/web/http/secrets.rs index 2628c1d..62e8671 100644 --- a/src/web/http/secrets.rs +++ b/src/web/http/secrets.rs @@ -18,9 +18,9 @@ pub(super) fn mark_internal_credential( 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 { _ => None, } } + +#[cfg(test)] +#[path = "secrets/tests.rs"] +mod tests; diff --git a/src/web/http/secrets/tests.rs b/src/web/http/secrets/tests.rs new file mode 100644 index 0000000..9a3d36c --- /dev/null +++ b/src/web/http/secrets/tests.rs @@ -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::(); + 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; +} diff --git a/src/web/http/websocket/tests.rs b/src/web/http/websocket/tests.rs index c780c52..2f712b2 100644 --- a/src/web/http/websocket/tests.rs +++ b/src/web/http/websocket/tests.rs @@ -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) diff --git a/src/web/http/websocket/tests/base_path.rs b/src/web/http/websocket/tests/base_path.rs new file mode 100644 index 0000000..a486abb --- /dev/null +++ b/src/web/http/websocket/tests/base_path.rs @@ -0,0 +1,107 @@ +use super::*; + +async fn rejected_upgrade( + listener: &TcpListener, + runtime: &Arc, + path: &str, + protocol: &str, +) -> Vec { + 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; +} diff --git a/src/web/manager.rs b/src/web/manager.rs index 4f2ceb0..f0f0816 100644 --- a/src/web/manager.rs +++ b/src/web/manager.rs @@ -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; diff --git a/src/web/manager/credentials.rs b/src/web/manager/credentials.rs index 18f94e2..dca907a 100644 --- a/src/web/manager/credentials.rs +++ b/src/web/manager/credentials.rs @@ -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}; diff --git a/src/web/manager/state.rs b/src/web/manager/state.rs index 1efb7a7..439c0e3 100644 --- a/src/web/manager/state.rs +++ b/src/web/manager/state.rs @@ -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; diff --git a/src/web/manager/token_authenticator_tests.rs b/src/web/manager/token_authenticator_tests.rs new file mode 100644 index 0000000..43a0e14 --- /dev/null +++ b/src/web/manager/token_authenticator_tests.rs @@ -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}"); + } + } +}