diff --git a/src/config/load/runtime_web.rs b/src/config/load/runtime_web.rs index 0268e77..90698cb 100644 --- a/src/config/load/runtime_web.rs +++ b/src/config/load/runtime_web.rs @@ -30,7 +30,8 @@ use crate::util::secure_fs::open_dir_nofollow; #[cfg(not(unix))] mod static_site_fallback; -const WEB_CAPABILITY_CONTEXT: &[u8] = b"tdesktop-web-proxy-bridge-v1\n"; +const WEB_CAPABILITY_CONTEXT_V1: &[u8] = b"tdesktop-web-proxy-bridge-v1\n"; +const WEB_CAPABILITY_CONTEXT_V2: &[u8] = b"tdesktop-web-proxy-bridge-v2\n"; const WEB_DEBUG_FINGERPRINT_CONTEXT: &[u8] = b"telemt-web-debug-key-fingerprint-v1\0"; const MAX_WEB_STATIC_DEPTH: usize = 64; @@ -41,6 +42,7 @@ pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> { })?; let mut runtime_vhosts = BTreeMap::new(); let mut runtime_profiles = Vec::new(); + let mut runtime_capabilities = Vec::new(); let mut static_files = 0usize; let mut static_bytes = 0usize; @@ -67,8 +69,11 @@ pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> { })?; let (client_secret, client_secret_len) = client_secret(auth_entry.secret, profile.secret_mode); - let capability = - derive_web_capability(&client_secret[..client_secret_len], vhost.host.as_bytes())?; + let capability = derive_web_capability( + &client_secret[..client_secret_len], + vhost.host.as_bytes(), + vhost.base_path.as_bytes(), + )?; let key_fingerprint = debug_key_fingerprint(&client_secret[..client_secret_len]); if !capabilities.insert(capability) { return Err(ProxyError::Config(format!( @@ -104,6 +109,7 @@ pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> { .unwrap_or(config.web.limits.max_streams_per_session), }); capability_table.push(capability); + runtime_capabilities.push(capability); profiles.push(Arc::clone(&runtime_profile)); runtime_profiles.push(runtime_profile); } @@ -111,6 +117,11 @@ pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> { vhost.host.clone(), Arc::new(WebRuntimeVhost { host: vhost.host.clone(), + base: if vhost.base_path.is_empty() { + "/".to_string() + } else { + format!("/{}/", vhost.base_path) + }, decoy_fasttrack_mode: config.web.decoy_fasttrack_mode, decoy, decoy_header_secs: config.web.timeouts.decoy_header_secs, @@ -123,6 +134,7 @@ pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> { config.web.runtime = Some(Arc::new(WebRuntimeConfig { vhosts: runtime_vhosts, profiles: runtime_profiles, + capabilities: runtime_capabilities.into_boxed_slice(), })); Ok(()) } @@ -134,12 +146,23 @@ fn debug_key_fingerprint(secret: &[u8]) -> String { hex::encode(&digest.finalize()[..8]) } -/// Derives the Telegram Desktop WEB capability for one exact secret and host. -pub(crate) fn derive_web_capability(secret: &[u8], host: &[u8]) -> Result<[u8; 32]> { +/// Derives the Telegram Desktop WEB capability for one exact secret, host, and base path. +pub(crate) fn derive_web_capability( + secret: &[u8], + host: &[u8], + base_path: &[u8], +) -> Result<[u8; 32]> { let mut mac = Hmac::::new_from_slice(secret) .map_err(|_| ProxyError::Config("WEB capability secret must not be empty".to_string()))?; - mac.update(WEB_CAPABILITY_CONTEXT); - mac.update(host); + if base_path.is_empty() { + mac.update(WEB_CAPABILITY_CONTEXT_V1); + mac.update(host); + } else { + mac.update(WEB_CAPABILITY_CONTEXT_V2); + mac.update(host); + mac.update(b"\n"); + mac.update(base_path); + } Ok(mac.finalize().into_bytes().into()) } @@ -482,62 +505,7 @@ fn static_content_type(path: &Path) -> &'static str { } } +// Runtime WEB construction tests remain separate from the production loader. #[cfg(test)] -mod tests { - use base64::Engine as _; - - use super::*; - - #[test] - fn capability_matches_reference_vectors() { - let secret = hex::decode("000102030405060708090a0b0c0d0e0f").unwrap(); - let plain = derive_web_capability(&secret, b"proxy.example.com").unwrap(); - assert_eq!( - base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(plain), - "MHLEY5PmW1GWqJkSrlmJpvJUiLhBH_QKy6yKg8a0JPk" - ); - let mut dd_secret = vec![0xdd]; - dd_secret.extend_from_slice(&secret); - let dd = derive_web_capability(&dd_secret, b"proxy.example.com").unwrap(); - assert_eq!( - base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(dd), - "IpJrt3e7sKtzPyoXy6w-Zj6GGEvsvclN66JzQEfPYLA" - ); - } - - #[cfg(unix)] - #[test] - fn static_snapshot_remains_anchored_after_root_path_replacement() { - use std::os::unix::fs::symlink; - - let temp = tempfile::tempdir().unwrap(); - let root = temp.path().join("site"); - let detached = temp.path().join("detached"); - let replacement = temp.path().join("replacement"); - fs::create_dir(&root).unwrap(); - fs::write(root.join("index.html"), b"original").unwrap(); - fs::create_dir(&replacement).unwrap(); - fs::write(replacement.join("index.html"), b"replacement").unwrap(); - - let directory = open_static_root(&root).unwrap(); - fs::rename(&root, &detached).unwrap(); - symlink(&replacement, &root).unwrap(); - - let mut assets = BTreeMap::new(); - let mut total_files = 0; - let mut total_bytes = 0; - load_static_directory( - directory, - Path::new(""), - &root, - &mut assets, - &mut total_files, - &mut total_bytes, - &WebLimitsConfig::default(), - 0, - ) - .unwrap(); - - assert_eq!(assets["/index.html"].body.as_ref(), b"original"); - } -} +#[path = "runtime_web/tests.rs"] +mod tests; diff --git a/src/config/load/runtime_web/tests.rs b/src/config/load/runtime_web/tests.rs new file mode 100644 index 0000000..6dadeb2 --- /dev/null +++ b/src/config/load/runtime_web/tests.rs @@ -0,0 +1,75 @@ +use base64::Engine as _; + +use super::*; + +#[test] +fn capability_matches_reference_vectors() { + let secret = hex::decode("000102030405060708090a0b0c0d0e0f").unwrap(); + let mut dd_secret = vec![0xdd]; + dd_secret.extend_from_slice(&secret); + for (client_secret, base_path, expected) in [ + ( + secret.as_slice(), + b"".as_slice(), + "MHLEY5PmW1GWqJkSrlmJpvJUiLhBH_QKy6yKg8a0JPk", + ), + ( + dd_secret.as_slice(), + b"".as_slice(), + "IpJrt3e7sKtzPyoXy6w-Zj6GGEvsvclN66JzQEfPYLA", + ), + ( + secret.as_slice(), + b"dobry-cola-super-app".as_slice(), + "hHz99Xs93EN1j91G9gpNepXwGNNt5YdAFkEVk_LlqdQ", + ), + ( + dd_secret.as_slice(), + b"dobry-cola-super-app".as_slice(), + "TGUkZaevsavLbHvlNWipnRoYxgzZ51ioWvbxgGT3wHo", + ), + ] { + let capability = + derive_web_capability(client_secret, b"proxy.example.com", base_path).unwrap(); + assert_eq!( + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(capability), + expected + ); + } +} + +#[cfg(unix)] +#[test] +fn static_snapshot_remains_anchored_after_root_path_replacement() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("site"); + let detached = temp.path().join("detached"); + let replacement = temp.path().join("replacement"); + fs::create_dir(&root).unwrap(); + fs::write(root.join("index.html"), b"original").unwrap(); + fs::create_dir(&replacement).unwrap(); + fs::write(replacement.join("index.html"), b"replacement").unwrap(); + + let directory = open_static_root(&root).unwrap(); + fs::rename(&root, &detached).unwrap(); + symlink(&replacement, &root).unwrap(); + + let mut assets = BTreeMap::new(); + let mut total_files = 0; + let mut total_bytes = 0; + load_static_directory( + directory, + Path::new(""), + &root, + &mut assets, + &mut total_files, + &mut total_bytes, + &WebLimitsConfig::default(), + 0, + ) + .unwrap(); + + assert_eq!(assets["/index.html"].body.as_ref(), b"original"); +} diff --git a/src/config/load/strict_keys.rs b/src/config/load/strict_keys.rs index 30f2480..78fe1ba 100644 --- a/src/config/load/strict_keys.rs +++ b/src/config/load/strict_keys.rs @@ -365,7 +365,13 @@ const WEB_TIMEOUTS_CONFIG_KEYS: &[&str] = &[ "decoy_header_secs", ]; -const WEB_VHOST_CONFIG_KEYS: &[&str] = &["host", "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/memory.rs b/src/config/load/validate_web/memory.rs index 7dca7a6..3a8d161 100644 --- a/src/config/load/validate_web/memory.rs +++ b/src/config/load/validate_web/memory.rs @@ -6,7 +6,8 @@ const WEB_DEBUG_GROUP_SCRATCH_BYTES: usize = 4 * 1024 * 1024; const WEB_CARRIER_LEARNING_ENTRY_BYTES: usize = 512; const WEB_LANE_STATE_BYTES: usize = 512; const WEB_OVERLOAD_CONNECTION_BYTES: usize = 4 * 1024; -const WEB_CAPABILITY_INDEX_ENTRY_BYTES: usize = 32; +// Each profile capability is stored in its vhost and in the global containment table. +const WEB_CAPABILITY_INDEX_ENTRY_BYTES: usize = 64; /// Validates process-wide body, header, queue, static, and debug reservations. pub(super) fn validate(limits: &WebLimitsConfig) -> Result<()> { diff --git a/src/config/load/validate_web/vhosts.rs b/src/config/load/validate_web/vhosts.rs index ec82852..74a17a4 100644 --- a/src/config/load/validate_web/vhosts.rs +++ b/src/config/load/validate_web/vhosts.rs @@ -9,6 +9,10 @@ pub(super) fn validate_vhosts(config: &mut ProxyConfig) -> Result<()> { let mut profile_count = 0usize; for (vhost_idx, vhost) in config.web.vhosts.iter_mut().enumerate() { vhost.host = normalize_web_host(&vhost.host, &format!("web.vhosts[{vhost_idx}].host"))?; + validate_web_base_path( + &vhost.base_path, + &format!("web.vhosts[{vhost_idx}].base_path"), + )?; if !hosts.insert(vhost.host.clone()) { return config_error(&format!("duplicate WEB vhost host `{}`", vhost.host)); } @@ -75,6 +79,23 @@ pub(super) fn validate_vhosts(config: &mut ProxyConfig) -> Result<()> { Ok(()) } +fn validate_web_base_path(value: &str, field: &str) -> Result<()> { + let valid = value.len() <= 128 + && !value.starts_with('/') + && !value.ends_with('/') + && value.split('/').all(|segment| { + let mut bytes = segment.bytes(); + 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 { + return Ok(()); + } + config_error(&format!( + "{field} must be empty or contain at most 128 ASCII bytes in slash-separated [A-Za-z0-9][A-Za-z0-9_-]* segments" + )) +} + pub(super) fn normalize_web_host(value: &str, field: &str) -> Result { let input = value.trim(); if input.is_empty() diff --git a/src/config/tests/load_basic_tests/web_tests.rs b/src/config/tests/load_basic_tests/web_tests.rs index 0e078b1..5246d38 100644 --- a/src/config/tests/load_basic_tests/web_tests.rs +++ b/src/config/tests/load_basic_tests/web_tests.rs @@ -40,9 +40,11 @@ fn web_config_builds_canonical_runtime_snapshot() { .vhosts .get("proxy.example.com") .expect("canonical WEB vhost"); + assert_eq!(vhost.base, "/"); assert_eq!(vhost.profiles.len(), 1); assert_eq!(vhost.capabilities.len(), vhost.profiles.len()); assert_eq!(vhost.capabilities[0], vhost.profiles[0].capability); + assert_eq!(runtime.capabilities.as_ref(), vhost.capabilities.as_ref()); assert_eq!(vhost.decoy_fasttrack_mode, WebDecoyFastTrackMode::Off); assert_eq!(vhost.profiles[0].user, "alice"); assert_eq!(vhost.profiles[0].secret_mode, WebSecretMode::Dd); @@ -59,6 +61,61 @@ 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()] { + let configured = WEB_CONFIG.replace( + "host = \"Proxy.Example.COM\"", + &format!("host = \"Proxy.Example.COM\"\nbase_path = \"{base_path}\""), + ); + let config = load_config_from_temp_toml(&configured); + assert_eq!(config.web.vhosts[0].base_path, base_path); + assert_eq!( + config.web.runtime.as_ref().unwrap().vhosts["proxy.example.com"].base, + format!("/{base_path}/") + ); + } + + let strict = format!( + "[general]\nconfig_strict = true\n{}", + WEB_CONFIG.replace( + "host = \"Proxy.Example.COM\"", + "host = \"Proxy.Example.COM\"\nbase_path = \"relay\"", + ) + ); + assert_eq!( + load_config_from_temp_toml(&strict).web.vhosts[0].base_path, + "relay" + ); +} + +#[test] +fn web_base_path_rejects_noncanonical_forms() { + let oversized = "a".repeat(129); + for base_path in [ + "/relay", + "relay/", + "relay//nested", + "-relay", + "_relay", + "relay/.hidden", + "relay/%2fhidden", + "relay path", + "relay/ั‚ะตัั‚", + oversized.as_str(), + ] { + let invalid = WEB_CONFIG.replace( + "host = \"Proxy.Example.COM\"", + &format!("host = \"Proxy.Example.COM\"\nbase_path = \"{base_path}\""), + ); + assert!( + load_config_error_from_temp_toml(&invalid).contains("web.vhosts[0].base_path"), + "base path {base_path:?} was accepted" + ); + } +} + #[test] fn web_decoy_fasttrack_mode_is_typed_and_defaults_off() { let defaults = ProxyConfig::default(); diff --git a/src/config/types/web.rs b/src/config/types/web.rs index 70fb062..3f88ce5 100644 --- a/src/config/types/web.rs +++ b/src/config/types/web.rs @@ -71,6 +71,9 @@ pub enum WebDecoyConfig { pub struct WebVhostConfig { /// Canonical lowercase ACE hostname used by Telegram Desktop. pub host: String, + /// Optional canonical WEB endpoint prefix without surrounding slashes. + #[serde(default)] + pub base_path: String, /// Stable public destination tuple used by inner relay routing and KDF metadata. pub public_addr: SocketAddr, /// Ordinary-site fallback for this hostname. diff --git a/src/config/types/web/runtime.rs b/src/config/types/web/runtime.rs index 3922889..58b79ff 100644 --- a/src/config/types/web/runtime.rs +++ b/src/config/types/web/runtime.rs @@ -7,6 +7,8 @@ pub(crate) struct WebRuntimeConfig { pub(crate) vhosts: BTreeMap>, /// Flat profile inventory used by startup link emission. pub(crate) profiles: Vec>, + /// Complete active capability table used to contain misplaced credentials. + pub(crate) capabilities: Box<[[u8; 32]]>, } /// Precomputed immutable virtual-host data. @@ -14,6 +16,8 @@ pub(crate) struct WebRuntimeConfig { pub(crate) struct WebRuntimeVhost { /// Canonical lowercase ACE hostname. pub(crate) host: String, + /// Exact slash-delimited endpoint base, including the trailing slash. + pub(crate) base: String, /// Restart-frozen decoy capability-scan policy. pub(crate) decoy_fasttrack_mode: WebDecoyFastTrackMode, /// Immutable ordinary-site fallback snapshot. diff --git a/src/maestro/helpers/runtime.rs b/src/maestro/helpers/runtime.rs index 725cb61..78d4dc0 100644 --- a/src/maestro/helpers/runtime.rs +++ b/src/maestro/helpers/runtime.rs @@ -1,5 +1,6 @@ use std::time::Duration; +use base64::Engine as _; use tokio::sync::watch; use tracing::{debug, error, info, warn}; @@ -80,19 +81,57 @@ pub(crate) fn print_web_proxy_links(config: &ProxyConfig) { let Some(secret) = config.access.users.get(&profile.user) else { continue; }; - let prefix = match profile.secret_mode { - crate::config::WebSecretMode::Plain => "", - crate::config::WebSecretMode::Dd => "dd", + let Some(vhost) = config + .web + .vhosts + .iter() + .find(|vhost| vhost.host == profile.host) + else { + continue; }; print_maestro_line(format!( "User: {} ({:?})", profile.user, profile.secret_mode )); - print_maestro_line(format!( - "WEB: tg://webproxy?server={}&secret={prefix}{secret}", - profile.host, + if let Some(link) = format_web_proxy_link( + &profile.host, + &vhost.base_path, + secret, + profile.secret_mode, + ) { + print_maestro_line(format!("WEB: {link}")); + } + } +} + +fn format_web_proxy_link( + host: &str, + base_path: &str, + secret: &str, + mode: crate::config::WebSecretMode, +) -> Option { + if base_path.is_empty() { + let prefix = match mode { + crate::config::WebSecretMode::Plain => "", + crate::config::WebSecretMode::Dd => "dd", + }; + return Some(format!( + "tg://webproxy?server={host}&secret={prefix}{secret}" )); } + let decoded = hex::decode(secret).ok()?; + let mut marked = Vec::with_capacity(decoded.len() + 2); + marked.push(0x70); + if mode == crate::config::WebSecretMode::Dd { + marked.push(0xdd); + } + marked.extend_from_slice(&decoded); + 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}" + )) } /// Durably replaces one Beobachten snapshot without following Unix symlinks. @@ -367,3 +406,49 @@ pub(crate) async fn load_startup_proxy_config_snapshot( } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::WebSecretMode; + + const SECRET: &str = "000102030405060708090a0b0c0d0e0f"; + + #[test] + fn root_web_proxy_links_keep_the_legacy_secret_form() { + assert_eq!( + format_web_proxy_link("proxy.example.com", "", SECRET, WebSecretMode::Plain), + Some(format!( + "tg://webproxy?server=proxy.example.com&secret={SECRET}" + )) + ); + assert_eq!( + format_web_proxy_link("proxy.example.com", "", SECRET, WebSecretMode::Dd), + Some(format!( + "tg://webproxy?server=proxy.example.com&secret=dd{SECRET}" + )) + ); + } + + #[test] + fn path_web_proxy_links_use_the_tdesktop_marker() { + assert_eq!( + format_web_proxy_link( + "proxy.example.com", + "dobry-cola/super_app", + SECRET, + WebSecretMode::Plain, + ), + Some("tg://webproxy?server=proxy.example.com%2Fdobry-cola%2Fsuper_app&secret=cAABAgMEBQYHCAkKCwwNDg8".to_string()) + ); + assert_eq!( + format_web_proxy_link( + "proxy.example.com", + "dobry-cola/super_app", + SECRET, + WebSecretMode::Dd, + ), + Some("tg://webproxy?server=proxy.example.com%2Fdobry-cola%2Fsuper_app&secret=cN0AAQIDBAUGBwgJCgsMDQ4P".to_string()) + ); + } +} diff --git a/src/web/bridge.rs b/src/web/bridge.rs index 7c637b4..1281b00 100644 --- a/src/web/bridge.rs +++ b/src/web/bridge.rs @@ -17,6 +17,7 @@ pub(crate) struct BridgePage { #[allow(clippy::too_many_arguments)] pub(crate) fn render( host: &str, + base: &str, bootstrap: &str, batch_limit: usize, queue_limit: usize, @@ -55,6 +56,7 @@ pub(crate) fn render( } else { String::new() }; + let base_prefix = base.strip_suffix('/').unwrap_or(base); let body = DOCUMENT .replace("__DIAGNOSTIC_RUNTIME__\n", &diagnostic_script) .replace("__RESPONSE_RUNTIME__", RESPONSE_RUNTIME) @@ -120,6 +122,7 @@ pub(crate) fn render( ) .replace("__NONCE__", &nonce) .replace("__HOST__", host) + .replace("__BASE_PREFIX__", base_prefix) .replace("__BOOTSTRAP__", bootstrap) .replace("__BATCH_LIMIT__", &batch_limit.to_string()) .replace("__QUEUE_LIMIT__", &queue_limit.to_string()) diff --git a/src/web/bridge/diagnostic.js b/src/web/bridge/diagnostic.js index 101301b..781e1a1 100644 --- a/src/web/bridge/diagnostic.js +++ b/src/web/bridge/diagnostic.js @@ -1,6 +1,6 @@ (()=>{'use strict'; let bootstrap="__BOOTSTRAP__",hello=false,emitted=0,boundaryTimer=null; -const relayOrigin='https://__HOST__',requestMs=__BRIDGE_REQUEST_SECS__*1000; +const relayOrigin='https://__HOST__',relayBase=relayOrigin+'__BASE_PREFIX__',requestMs=__BRIDGE_REQUEST_SECS__*1000; function eventBit(event){ if(event==='runtime_started')return 1;if(event==='status_posted')return 2;if(event==='hello_received')return 4;if(event==='boundary_timeout')return 8; if(event==='hello_timeout')return 16;if(event==='client_close_before_hello')return 32;if(event==='document_unloaded_before_hello')return 64; @@ -12,7 +12,7 @@ function report(event){ let timer=null; try{ const controller=new AbortController(),body=JSON.stringify({v:1,event});timer=setTimeout(()=>controller.abort(),requestMs); - fetch(relayOrigin+'/api/v1/diagnostic',{method:'POST',body,signal:controller.signal,keepalive:true,mode:'same-origin',credentials:'omit',cache:'no-store',redirect:'error',referrerPolicy:'no-referrer',headers:{Authorization:'Bearer '+bootstrap,'Content-Type':'application/json'}}) + fetch(relayBase+'/api/v1/diagnostic',{method:'POST',body,signal:controller.signal,keepalive:true,mode:'same-origin',credentials:'omit',cache:'no-store',redirect:'error',referrerPolicy:'no-referrer',headers:{Authorization:'Bearer '+bootstrap,'Content-Type':'application/json'}}) .then(discard,()=>{}).then(()=>clearTimeout(timer),()=>clearTimeout(timer)); }catch(error){if(timer)clearTimeout(timer)} } diff --git a/src/web/bridge/request.js b/src/web/bridge/request.js index 26e52e9..27bd638 100644 --- a/src/web/bridge/request.js +++ b/src/web/bridge/request.js @@ -40,7 +40,7 @@ function create(settings){ const timer=setTimeout(()=>{timedOut=true;controller.abort()},Math.max(1,Math.min(attemptLimit,remaining))); let response=null,wait=0; try{ - const fetched=await fetch(settings.origin()+path,requestOptions); + const fetched=await fetch(settings.base()+path,requestOptions); if(retryableStatus(fetched.status)){ lastReason='http';wait=retryAfterMs(fetched);settings.cancel(fetched); }else{ diff --git a/src/web/bridge/runtime.js b/src/web/bridge/runtime.js index c97e2cb..78753ca 100644 --- a/src/web/bridge/runtime.js +++ b/src/web/bridge/runtime.js @@ -1,6 +1,6 @@ (()=>{'use strict'; let bootstrap="__BOOTSTRAP__"; -const relayOrigin='https://__HOST__',carrierCapabilities='https,https-lanes,websocket,websocket-lanes'; +const relayOrigin='https://__HOST__',relayBase=relayOrigin+'__BASE_PREFIX__',carrierCapabilities='https,https-lanes,websocket,websocket-lanes'; __DIAGNOSTIC_BINDING__; __DIAGNOSTIC_RUNTIME_STARTED__; const responseBody=globalThis.TelemtBridgeResponse;if(!responseBody)throw new Error('missing response runtime'); @@ -26,9 +26,9 @@ const canonicalFailures=['timeout','network','upgrade','http','protocol']; const failure=(reason,message)=>Object.assign(new Error(message||reason),{telemtReason:reason}); const failureReason=(error,fallback)=>error&&canonicalFailures.includes(error.telemtReason)?error.telemtReason:fallback; const status=__STATUS_FUNCTION__; -const socketURL=()=>relayOrigin.replace(/^https:/,'wss:')+'/api/v1/ws'; +const socketURL=()=>relayBase.replace(/^https:/,'wss:')+'/api/v1/ws'; const requestClient=requestSupport.create({ - origin:()=>relayOrigin,closed:()=>closed,retryMs:()=>bridgeRetryMs,longPollMs:()=>longPollMs,requestMs:()=>bridgeRequestMs, + base:()=>relayBase,closed:()=>closed,retryMs:()=>bridgeRetryMs,longPollMs:()=>longPollMs,requestMs:()=>bridgeRequestMs, batchLimit:()=>batchLimit,read:(response,limit,exact,signal)=>responseBody.read(response,limit,exact,signal),cancel:responseBody.cancel, failure,reason:failureReason,retrying:()=>status('reconnecting') }); @@ -478,7 +478,7 @@ async function pollLane(lane){ } function deleteSession(){ const token=cleanupToken||sessionToken,headers=canonicalFailures.includes(terminalFailure)?{'X-Carrier-Failure':terminalFailure}:null; - if(token)fetch(relayOrigin+'/api/v1/session',options('DELETE',token,null,headers,undefined,true)).catch(()=>{}); + if(token)fetch(relayBase+'/api/v1/session',options('DELETE',token,null,headers,undefined,true)).catch(()=>{}); } function close(notifyServer){ if(closed)return;closed=true;if(recoveryController)recoveryController.cancel();rejectRecoveryCommit(failure('network','bridge closed'));if(helloTimer)clearTimeout(helloTimer);helloTimer=null;if(carrierTimer)clearTimeout(carrierTimer);clearProbeTimer();if(schedulerTimer)clearTimeout(schedulerTimer);schedulerTimer=null;if(attemptController)attemptController.abort();if(pollController)pollController.abort(); diff --git a/src/web/bridge/tests.rs b/src/web/bridge/tests.rs index 251e566..93be9f2 100644 --- a/src/web/bridge/tests.rs +++ b/src/web/bridge/tests.rs @@ -3,6 +3,7 @@ use super::*; fn render_page(bootstrap: &str, candidate_count: usize) -> BridgePage { render( "proxy.example.com", + "/", bootstrap, 2 * 1024 * 1024, 32 * 1024 * 1024, @@ -26,6 +27,7 @@ fn render_page(bootstrap: &str, candidate_count: usize) -> BridgePage { fn render_diagnostic_page(bootstrap: &str) -> BridgePage { render( "proxy.example.com", + "/", bootstrap, 2 * 1024 * 1024, 32 * 1024 * 1024, @@ -72,6 +74,43 @@ fn rendered_page_contains_bounded_negotiation_contract() { ); } +#[test] +fn rendered_page_resolves_carriers_against_the_exact_base_path() { + let page = render( + "proxy.example.com", + "/Dobry-Cola/super_app/", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + 2 * 1024 * 1024, + 32 * 1024 * 1024, + 16 * 1024, + 1024, + true, + 4, + [3, 5, 8, 12], + 25, + 10, + 90, + 15, + 15, + 120, + 0, + true, + &SecureRandom::new(), + ); + + 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("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("__BASE_PREFIX__")); +} + #[test] fn rendered_page_preserves_the_ios_bootstrap_literal() { let bootstrap = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"; @@ -86,6 +125,7 @@ fn rendered_page_preserves_the_ios_bootstrap_literal() { fn rendered_page_embeds_the_configured_bridge_timing_policy() { let page = render( "proxy.example.com", + "/", "GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG", 2 * 1024 * 1024, 32 * 1024 * 1024, @@ -138,6 +178,7 @@ fn effective_deadline_formula_uses_the_final_checkpoint() { fn disabled_negotiation_does_not_arm_a_carrier_deadline() { let page = render( "proxy.example.com", + "/", "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD", 2 * 1024 * 1024, 32 * 1024 * 1024, @@ -284,7 +325,7 @@ fn enabled_bridge_diagnostics_use_the_https_sideband_only() { let page = render_diagnostic_page("JJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJ"); assert!(!page.body.contains("__")); - assert!(page.body.contains("fetch(relayOrigin+'/api/v1/diagnostic'")); + assert!(page.body.contains("fetch(relayBase+'/api/v1/diagnostic'")); assert!(page.body.contains("JSON.stringify({v:1,event})")); assert!(page.body.contains("'Content-Type':'application/json'")); assert!(page.body.contains("keepalive:true")); diff --git a/src/web/http.rs b/src/web/http.rs index 182f986..31a0181 100644 --- a/src/web/http.rs +++ b/src/web/http.rs @@ -28,6 +28,8 @@ mod activity; mod body; // Canonical capability parsing and complete scans remain isolated from HTTP routing. mod capability; +// Authentic credential containment stays independent from carrier routing. +mod secrets; // Decoy routing and upstream proxying are isolated from carrier authentication. mod decoy; // Authenticated generated-bridge diagnostics remain outside carrier framing. @@ -71,13 +73,13 @@ type BoxError = Box; type HttpBody = UnsyncBoxBody; type HttpResponse = Response; -const TRANSPORT_PATHS: [&str; 4] = [ - "/api/v1/session", - "/api/v1/up", - "/api/v1/down", - "/api/v1/diagnostic", +const TRANSPORT_SUFFIXES: [&str; 4] = [ + "api/v1/session", + "api/v1/up", + "api/v1/down", + "api/v1/diagnostic", ]; -const WEBSOCKET_PATH: &str = "/api/v1/ws"; +const WEBSOCKET_SUFFIX: &str = "api/v1/ws"; /// Serves one bounded HTTP/1.1 connection accepted from an external TLS terminator. pub(crate) async fn serve_connection( @@ -185,8 +187,9 @@ async fn handle_request( let Some(vhost) = web_runtime.vhosts.get(host).cloned() else { return generic_not_found(); }; - let path = request.uri().path(); - if path == WEBSOCKET_PATH { + secrets::mark_internal_credential(&mut request, web_runtime, &runtime); + let suffix = request.uri().path().strip_prefix(&vhost.base); + if suffix == Some(WEBSOCKET_SUFFIX) { return websocket::handle( request, peer, @@ -197,7 +200,7 @@ async fn handle_request( ) .await; } - if TRANSPORT_PATHS.contains(&path) { + if suffix.is_some_and(|suffix| TRANSPORT_SUFFIXES.contains(&suffix)) { return handle_api( request, peer, @@ -208,7 +211,7 @@ async fn handle_request( ) .await; } - if path == "/" && matches!(*request.method(), Method::GET | Method::HEAD) { + if suffix == Some("") && matches!(*request.method(), Method::GET | Method::HEAD) { return handle_root( request, peer, @@ -369,6 +372,7 @@ async fn handle_root( } let page = bridge::render( &vhost.host, + &vhost.base, &bootstrap.token, config.web.limits.carrier_batch_bytes, config.web.limits.pending_bytes_per_session, @@ -440,11 +444,13 @@ async fn handle_api( let Some(token_hash) = bearer_token_hash(&request) else { return serve_decoy(request, vhost, true, &runtime).await; }; - match request.uri().path() { - "/api/v1/session" => handle_session(request, runtime, vhost, token_hash, client_ip).await, - "/api/v1/up" => handle_up(request, runtime, vhost, token_hash).await, - "/api/v1/down" => handle_down(request, runtime, vhost, token_hash).await, - "/api/v1/diagnostic" => { + match request.uri().path().strip_prefix(&vhost.base) { + Some("api/v1/session") => { + handle_session(request, runtime, vhost, token_hash, client_ip).await + } + Some("api/v1/up") => handle_up(request, runtime, vhost, token_hash).await, + Some("api/v1/down") => handle_down(request, runtime, vhost, token_hash).await, + Some("api/v1/diagnostic") => { diagnostic::handle(request, runtime, vhost, token_hash, client_ip).await } _ => serve_decoy(request, vhost, true, &runtime).await, diff --git a/src/web/http/base_path_tests.rs b/src/web/http/base_path_tests.rs new file mode 100644 index 0000000..d6d66bb --- /dev/null +++ b/src/web/http/base_path_tests.rs @@ -0,0 +1,431 @@ +use super::*; + +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" + ) + .into_bytes() +} + +fn bridge_token(body: &[u8]) -> String { + std::str::from_utf8(body) + .unwrap() + .split_once("bootstrap=\"") + .and_then(|(_, suffix)| suffix.split_once('"')) + .map(|(token, _)| token.to_string()) + .unwrap() +} + +async fn request_without_body( + listener: &TcpListener, + runtime: &Arc, + request_head: &[u8], +) -> Vec { + let addr = listener.local_addr().unwrap(); + let (accepted, client) = tokio::join!(listener.accept(), TcpStream::connect(addr)); + let (server, peer) = accepted.unwrap(); + let mut client = client.unwrap(); + let permit = runtime.try_http_connection().unwrap(); + let task = tokio::spawn(serve_connection( + server, + peer, + WebClientIpSource::XForwardedFor, + Arc::from(["127.0.0.1/32".parse().unwrap()]), + Arc::clone(runtime), + CancellationToken::new(), + permit, + )); + client.write_all(request_head).await.unwrap(); + let mut response = Vec::new(); + tokio::time::timeout( + std::time::Duration::from_secs(2), + client.read_to_end(&mut response), + ) + .await + .expect("private rejection waited for the request body") + .unwrap(); + task.await.unwrap(); + response +} + +#[tokio::test] +async fn base_path_routes_only_the_exact_prefixed_contract() { + let capability = [21u8; 32]; + let config = runtime_config_with_base(capability, WebCarrier::Https, "/dobry-cola/"); + let generation = test_runtime_generation(1, config); + let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(Arc::clone(&generation)))); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(capability); + + let response = request( + &listener, + &runtime, + bridge_request(&format!("/dobry-cola/?bridge={encoded}")), + ) + .await; + let (headers, body) = split_response(&response); + assert!(headers.starts_with(b"HTTP/1.1 200")); + assert!(std::str::from_utf8(body) + .unwrap() + .contains("relayBase=relayOrigin+'/dobry-cola'")); + + for path in [ + format!("/?bridge={encoded}"), + format!("/dobry-cola?bridge={encoded}"), + format!("/dobry-cola/nested/?bridge={encoded}"), + ] { + let response = request(&listener, &runtime, bridge_request(&path)).await; + let (headers, body) = split_response(&response); + assert!(headers.starts_with(b"HTTP/1.1 404")); + assert_eq!(response_header(headers, "cache-control"), "no-store"); + assert_eq!(body, b"not found\n"); + } + + for path in [ + "/", + "/api/v1/session", + "/dobry-cola/unknown?q=1", + "/dobry-cola//api/v1/up", + "/dobry-cola%2Fapi/v1/up", + ] { + let response = request(&listener, &runtime, bridge_request(path)).await; + let (_, body) = split_response(&response); + assert_eq!(body, b"decoy"); + } + + runtime.shutdown().await; + generation.stop_sessions().await; + generation.stop_background_tasks().await; +} + +#[tokio::test] +async fn prefixed_https_carrier_creates_uses_and_closes_a_session() { + let capability = [22u8; 32]; + let mut config = runtime_config_with_base(capability, WebCarrier::Https, "/relay/nested/"); + config.web.timeouts.long_poll_secs = 1; + let generation = test_runtime_generation(1, config); + let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(Arc::clone(&generation)))); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(capability); + + let bridge = request( + &listener, + &runtime, + bridge_request(&format!("/relay/nested/?bridge={encoded}")), + ) + .await; + let bootstrap = bridge_token(split_response(&bridge).1); + let hello = frame::encode(FrameType::Hello, 0, &[1]); + let mut create = format!( + "POST /relay/nested/api/v1/session HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {bootstrap}\r\nContent-Type: application/octet-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + hello.len() + ) + .into_bytes(); + create.extend_from_slice(&hello); + let created = request(&listener, &runtime, create).await; + let (headers, _) = split_response(&created); + assert!(headers.starts_with(b"HTTP/1.1 200")); + let session = response_header(headers, "x-session-token").to_string(); + + let misplaced = format!( + "GET /wrong HTTP/1.1\r\nHost: proxy.example.com\r\nAuthorization: Bearer {session}\r\nConnection: close\r\n\r\n" + ) + .into_bytes(); + let misplaced = request(&listener, &runtime, misplaced).await; + let (headers, body) = split_response(&misplaced); + assert!(headers.starts_with(b"HTTP/1.1 404")); + assert_eq!(response_header(headers, "cache-control"), "no-store"); + assert_eq!(body, b"not found\n"); + + let pong = frame::encode(FrameType::Pong, 0, &[]); + let mut uplink = format!( + "POST /relay/nested/api/v1/up HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {session}\r\nContent-Type: application/octet-stream\r\nX-Up-Seq: 1\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + pong.len() + ) + .into_bytes(); + uplink.extend_from_slice(&pong); + assert!( + request(&listener, &runtime, uplink) + .await + .starts_with(b"HTTP/1.1 204") + ); + + let downlink = format!( + "POST /relay/nested/api/v1/down HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {session}\r\nX-Down-Cursor: 0\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ) + .into_bytes(); + assert!( + request(&listener, &runtime, downlink) + .await + .starts_with(b"HTTP/1.1 204") + ); + + let close = format!( + "DELETE /relay/nested/api/v1/session HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nAuthorization: Bearer {session}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ) + .into_bytes(); + assert!( + request(&listener, &runtime, close) + .await + .starts_with(b"HTTP/1.1 204") + ); + + runtime.shutdown().await; + generation.stop_sessions().await; + generation.stop_background_tasks().await; +} + +#[tokio::test] +async fn authentic_credentials_never_reach_the_decoy() { + let capability = [23u8; 32]; + let config = runtime_config_with_base(capability, WebCarrier::Https, "/relay/"); + let generation = test_runtime_generation(1, config); + let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(Arc::clone(&generation)))); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(capability); + let bridge = request( + &listener, + &runtime, + bridge_request(&format!("/relay/?bridge={encoded}")), + ) + .await; + let bootstrap = bridge_token(split_response(&bridge).1); + let escaped = format!("%{:02X}{}", bootstrap.as_bytes()[0], &bootstrap[1..]); + + for raw in [ + format!( + "GET /wrong/{bootstrap} HTTP/1.1\r\nHost: proxy.example.com\r\nConnection: close\r\n\r\n" + ), + format!( + "GET /wrong/{escaped} HTTP/1.1\r\nHost: proxy.example.com\r\nConnection: close\r\n\r\n" + ), + format!( + "GET /wrong HTTP/1.1\r\nHost: proxy.example.com\r\nCookie: opaque={bootstrap}\r\nConnection: close\r\n\r\n" + ), + format!( + "GET /wrong HTTP/1.1\r\nHost: proxy.example.com\r\nReferer: https://example.invalid/{encoded}\r\nConnection: close\r\n\r\n" + ), + ] { + let response = request(&listener, &runtime, raw.into_bytes()).await; + let (headers, body) = split_response(&response); + assert!(headers.starts_with(b"HTTP/1.1 404")); + assert_eq!(response_header(headers, "cache-control"), "no-store"); + assert_eq!(body, b"not found\n"); + } + + let mut forged = bootstrap.into_bytes(); + forged[0] = if forged[0] == b'A' { b'B' } else { b'A' }; + let forged = String::from_utf8(forged).unwrap(); + let response = request( + &listener, + &runtime, + bridge_request(&format!("/wrong/{forged}")), + ) + .await; + assert_eq!( + split_response(&response).1, + b"decoy" + ); + + runtime.shutdown().await; + generation.stop_sessions().await; + generation.stop_background_tasks().await; +} + +#[tokio::test] +async fn misplaced_process_token_is_rejected_without_reading_the_body() { + let capability = [24u8; 32]; + let config = runtime_config_with_base(capability, WebCarrier::Https, "/relay/"); + let generation = test_runtime_generation(1, config); + let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(Arc::clone(&generation)))); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(capability); + let bridge = request( + &listener, + &runtime, + bridge_request(&format!("/relay/?bridge={encoded}")), + ) + .await; + let bootstrap = bridge_token(split_response(&bridge).1); + let head = format!( + "POST /wrong HTTP/1.1\r\nHost: proxy.example.com\r\nAuthorization: Bearer {bootstrap}\r\nContent-Length: 1048576\r\nConnection: close\r\n\r\n" + ); + + let response = request_without_body(&listener, &runtime, head.as_bytes()).await; + let (headers, body) = split_response(&response); + assert!(headers.starts_with(b"HTTP/1.1 404")); + assert_eq!(response_header(headers, "cache-control"), "no-store"); + assert_eq!(body, b"not found\n"); + + runtime.shutdown().await; + generation.stop_sessions().await; + generation.stop_background_tasks().await; +} + +#[tokio::test] +async fn process_token_provenance_survives_registry_expiry() { + let capability = [25u8; 32]; + let mut config = runtime_config_with_base(capability, WebCarrier::Https, "/relay/"); + config.web.timeouts.bootstrap_lifetime_secs = 1; + let generation = test_runtime_generation(1, config); + let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(Arc::clone(&generation)))); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(capability); + let bridge = request( + &listener, + &runtime, + bridge_request(&format!("/relay/?bridge={encoded}")), + ) + .await; + let bootstrap = bridge_token(split_response(&bridge).1); + + tokio::time::timeout(std::time::Duration::from_secs(4), async { + loop { + let status = serde_json::to_value(runtime.try_status()).unwrap(); + if status["manager"]["bootstraps"] == 0 { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + }) + .await + .expect("bootstrap registry entry did not expire"); + + let response = request( + &listener, + &runtime, + bridge_request(&format!("/wrong/{bootstrap}")), + ) + .await; + let (headers, body) = split_response(&response); + assert!(headers.starts_with(b"HTTP/1.1 404")); + assert_eq!(response_header(headers, "cache-control"), "no-store"); + assert_eq!(body, b"not found\n"); + + runtime.shutdown().await; + generation.stop_sessions().await; + generation.stop_background_tasks().await; +} + +#[tokio::test] +async fn generation_swap_switches_base_path_and_capability_together() { + let initial_capability = [26u8; 32]; + let mut initial = + runtime_config_with_base(initial_capability, WebCarrier::Https, "/old-path/"); + initial.web.limits.max_bootstraps_per_ip = 2; + let generation = test_runtime_generation(1, initial); + let active = Arc::new(ArcSwap::from(Arc::clone(&generation))); + let runtime = WebProcessRuntime::start(Arc::clone(&active)); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let initial_encoded = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(initial_capability); + + let old_bridge = request( + &listener, + &runtime, + bridge_request(&format!("/old-path/?bridge={initial_encoded}")), + ) + .await; + assert!(old_bridge.starts_with(b"HTTP/1.1 200")); + + let replacement_capability = [27u8; 32]; + let replacement = test_runtime_generation( + 2, + runtime_config_with_base( + replacement_capability, + WebCarrier::Https, + "/new-path/", + ), + ); + active.store(Arc::clone(&replacement)); + let stale = request( + &listener, + &runtime, + bridge_request(&format!("/old-path/?bridge={initial_encoded}")), + ) + .await; + assert_eq!( + split_response(&stale).1, + b"decoy" + ); + + let replacement_encoded = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(replacement_capability); + let current = request( + &listener, + &runtime, + bridge_request(&format!("/new-path/?bridge={replacement_encoded}")), + ) + .await; + let (headers, body) = split_response(¤t); + assert!(headers.starts_with(b"HTTP/1.1 200")); + assert!(std::str::from_utf8(body) + .unwrap() + .contains("relayBase=relayOrigin+'/new-path'")); + + runtime.shutdown().await; + generation.stop_sessions().await; + generation.stop_background_tasks().await; + replacement.stop_sessions().await; + replacement.stop_background_tasks().await; +} + +#[tokio::test] +async fn prefixed_decoy_request_keeps_its_original_path_and_query() { + let site = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let site_addr = site.local_addr().unwrap(); + let site_task = tokio::spawn(async move { + let (mut stream, _) = site.accept().await.unwrap(); + let mut request = vec![0; 4096]; + let read = stream.read(&mut request).await.unwrap(); + request.truncate(read); + stream + .write_all( + b"HTTP/1.1 404 Not Found\r\nContent-Length: 4\r\nConnection: close\r\n\r\nsite", + ) + .await + .unwrap(); + request + }); + + let capability = [28u8; 32]; + let mut config = runtime_config_with_base(capability, WebCarrier::Https, "/relay/"); + let profile = Arc::clone(&config.web.runtime.as_ref().unwrap().profiles[0]); + let vhost = Arc::new(WebRuntimeVhost { + host: "proxy.example.com".to_string(), + base: "/relay/".to_string(), + decoy_fasttrack_mode: WebDecoyFastTrackMode::Off, + decoy: WebRuntimeDecoy::HttpUpstream { + addr: site_addr, + authority: "decoy.internal".to_string(), + }, + decoy_header_secs: 1, + profiles: vec![Arc::clone(&profile)], + capabilities: vec![capability].into_boxed_slice(), + }); + let mut vhosts = BTreeMap::new(); + vhosts.insert("proxy.example.com".to_string(), vhost); + config.web.runtime = Some(Arc::new(WebRuntimeConfig { + vhosts, + profiles: vec![profile], + capabilities: vec![capability].into_boxed_slice(), + })); + let generation = test_runtime_generation(1, config); + let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(Arc::clone(&generation)))); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + + let response = request( + &listener, + &runtime, + bridge_request("/relay/ordinary?q=1"), + ) + .await; + assert!(response.starts_with(b"HTTP/1.1 404")); + assert_eq!(split_response(&response).1, b"site"); + let forwarded = site_task.await.unwrap(); + assert!(forwarded.starts_with(b"GET /relay/ordinary?q=1 HTTP/1.1\r\n")); + + runtime.shutdown().await; + generation.stop_sessions().await; + generation.stop_background_tasks().await; +} diff --git a/src/web/http/capability.rs b/src/web/http/capability.rs index b781e2f..a32d323 100644 --- a/src/web/http/capability.rs +++ b/src/web/http/capability.rs @@ -32,28 +32,35 @@ pub(super) fn bridge_candidate(query: Option<&str>) -> BridgeCandidate { let Some(value) = query.and_then(|query| query.strip_prefix("bridge=")) else { return BridgeCandidate::NonCanonical; }; + canonical_credential(value.as_bytes()) + .map(BridgeCandidate::Canonical) + .unwrap_or(BridgeCandidate::NonCanonical) +} + +/// Decodes one exact canonical 32-byte base64url credential. +pub(super) fn canonical_credential(value: &[u8]) -> Option<[u8; 32]> { if value.len() != 43 { - return BridgeCandidate::NonCanonical; + return None; } let mut decoded = [0u8; 32]; let Ok(decoded_len) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode_slice(value, &mut decoded) else { - return BridgeCandidate::NonCanonical; + return None; }; let mut canonical = [0u8; 43]; let Ok(encoded_len) = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode_slice(decoded, &mut canonical) else { - return BridgeCandidate::NonCanonical; + return None; }; if decoded_len != decoded.len() || encoded_len != canonical.len() - || !bool::from(canonical.ct_eq(value.as_bytes())) + || !bool::from(canonical.ct_eq(value)) { - return BridgeCandidate::NonCanonical; + return None; } - BridgeCandidate::Canonical(decoded) + Some(decoded) } /// Internal result of one complete capability-table scan. diff --git a/src/web/http/decoy.rs b/src/web/http/decoy.rs index 66fb139..d7dfe07 100644 --- a/src/web/http/decoy.rs +++ b/src/web/http/decoy.rs @@ -29,6 +29,9 @@ where B: hyper::body::Body + Send + 'static, B::Error: Error + Send + Sync + 'static, { + if super::secrets::has_internal_credential(&request) { + return super::response::private_not_found(); + } super::set_trace_route(&request, crate::web::trace::TraceRoute::Decoy); if sanitize_transport { sanitize_transport_request(&mut request); diff --git a/src/web/http/operator_lifecycle_tests.rs b/src/web/http/operator_lifecycle_tests.rs index 5a00031..e6191a0 100644 --- a/src/web/http/operator_lifecycle_tests.rs +++ b/src/web/http/operator_lifecycle_tests.rs @@ -153,12 +153,9 @@ async fn pause_preserves_decoy_retry_and_exact_session_replay() { .into_bytes(); let decoy = request(&listener, &runtime, decoy).await; let (decoy_headers, decoy_body) = split_response(&decoy); - assert!(decoy_headers.starts_with(b"HTTP/1.1 200")); - assert!( - !decoy_body - .windows(11) - .any(|window| window == b"bootstrap=\"") - ); + assert!(decoy_headers.starts_with(b"HTTP/1.1 404")); + assert_eq!(response_header(decoy_headers, "cache-control"), "no-store"); + assert_eq!(decoy_body, b"not found\n"); runtime.resume_operator().await.unwrap(); let created = request(&listener, &runtime, create_request(&bootstrap, &hello)).await; diff --git a/src/web/http/recovery_tests.rs b/src/web/http/recovery_tests.rs index 68fb2b3..f6448ff 100644 --- a/src/web/http/recovery_tests.rs +++ b/src/web/http/recovery_tests.rs @@ -195,12 +195,12 @@ async fn malformed_or_over_capacity_recovery_is_indistinguishable_from_decoy() { let malformed = recover(&listener, &runtime, &encoded, "Bearer malformed").await; let (malformed_headers, malformed_body) = split_response(&malformed); - assert!(malformed_headers.starts_with(b"HTTP/1.1 200")); + assert!(malformed_headers.starts_with(b"HTTP/1.1 404")); assert_eq!( response_header(malformed_headers, "cache-control"), "no-store" ); - assert_eq!(malformed_body, b"decoy"); + assert_eq!(malformed_body, b"not found\n"); let invalid_capability = recover( &listener, @@ -209,7 +209,10 @@ async fn malformed_or_over_capacity_recovery_is_indistinguishable_from_decoy() { &format!("Bearer {}", "U".repeat(43)), ) .await; - assert_eq!(invalid_capability, malformed); + let (invalid_headers, invalid_body) = split_response(&invalid_capability); + assert!(invalid_headers.starts_with(b"HTTP/1.1 200")); + assert_eq!(response_header(invalid_headers, "cache-control"), "no-store"); + assert_eq!(invalid_body, b"decoy"); let malformed_accept = request( &listener, @@ -231,12 +234,12 @@ async fn malformed_or_over_capacity_recovery_is_indistinguishable_from_decoy() { ) .await; let (capacity_headers, capacity_body) = split_response(&over_capacity); - assert!(capacity_headers.starts_with(b"HTTP/1.1 200")); + assert!(capacity_headers.starts_with(b"HTTP/1.1 404")); assert_eq!( response_header(capacity_headers, "cache-control"), "no-store" ); - assert_eq!(capacity_body, b"decoy"); + assert_eq!(capacity_body, b"not found\n"); runtime.shutdown().await; generation.stop_sessions().await; diff --git a/src/web/http/response.rs b/src/web/http/response.rs index 731c85c..8db4163 100644 --- a/src/web/http/response.rs +++ b/src/web/http/response.rs @@ -62,6 +62,15 @@ pub(super) fn generic_not_found() -> HttpResponse { full_response(StatusCode::NOT_FOUND, Bytes::from_static(b"not found\n")) } +/// Builds a non-cacheable local rejection for misplaced internal credentials. +pub(super) fn private_not_found() -> HttpResponse { + let mut response = generic_not_found(); + response + .headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + response +} + /// Builds one in-memory response with an exact content length. pub(super) fn full_response(status: StatusCode, body: Bytes) -> HttpResponse { let length = body.len(); diff --git a/src/web/http/runtime_test_support.rs b/src/web/http/runtime_test_support.rs new file mode 100644 index 0000000..54c4ca1 --- /dev/null +++ b/src/web/http/runtime_test_support.rs @@ -0,0 +1,18 @@ +use super::*; + +/// Builds a static-decoy runtime with one explicit WEB endpoint base. +pub(in crate::web::http) fn runtime_config_with_base( + capability: [u8; 32], + carrier: WebCarrier, + base: &str, +) -> ProxyConfig { + runtime_config_with_carriers_and_deadlines( + capability, + carrier, + false, + true, + Arc::from([carrier]), + TEST_CARRIER_DEADLINES_SECS, + base, + ) +} diff --git a/src/web/http/secrets.rs b/src/web/http/secrets.rs new file mode 100644 index 0000000..2628c1d --- /dev/null +++ b/src/web/http/secrets.rs @@ -0,0 +1,86 @@ +use hyper::Request; + +use super::capability::{canonical_credential, scan_capabilities}; +use crate::config::WebRuntimeConfig; +use crate::web::manager::WebProcessRuntime; + +/// Request extension proving that metadata contains an authentic internal credential. +#[derive(Clone, Copy)] +struct InternalCredential; + +/// Marks requests whose metadata contains a capability or process token. +pub(super) fn mark_internal_credential( + request: &mut Request, + config: &WebRuntimeConfig, + runtime: &WebProcessRuntime, +) { + let uri = request.uri(); + let uri_contains = uri + .authority() + .is_some_and(|authority| contains_secret(authority.as_str().as_bytes(), config, runtime)) + || uri.path_and_query().is_some_and(|path| { + contains_secret(path.as_str().as_bytes(), config, runtime) + }); + let headers_contain = request.headers().iter().any(|(name, value)| { + contains_secret(name.as_str().as_bytes(), config, runtime) + || contains_secret(value.as_bytes(), config, runtime) + }); + if uri_contains || headers_contain { + request.extensions_mut().insert(InternalCredential); + } +} + +/// Returns whether request metadata was authenticated before routing. +pub(super) fn has_internal_credential(request: &Request) -> bool { + request.extensions().get::().is_some() +} + +fn contains_secret(text: &[u8], config: &WebRuntimeConfig, runtime: &WebProcessRuntime) -> bool { + let mut window = [0; 43]; + let mut run_len = 0usize; + let mut offset = 0usize; + while offset < text.len() { + let (byte, consumed) = if text[offset] == b'%' && offset + 2 < text.len() { + match (hex_value(text[offset + 1]), hex_value(text[offset + 2])) { + (Some(high), Some(low)) => ((high << 4) | low, 3), + _ => (text[offset], 1), + } + } else { + (text[offset], 1) + }; + offset += consumed; + if base64url_byte(byte) { + if run_len < window.len() { + window[run_len] = byte; + run_len += 1; + } else { + window.copy_within(1.., 0); + window[42] = byte; + } + if run_len >= window.len() + && canonical_credential(&window).is_some_and(|candidate| { + bool::from(scan_capabilities(&config.capabilities, &candidate).matched) + || runtime.authentic_token(&candidate) + }) + { + return true; + } + } else { + run_len = 0; + } + } + false +} + +fn base64url_byte(value: u8) -> bool { + value.is_ascii_alphanumeric() || matches!(value, b'-' | b'_') +} + +fn hex_value(value: u8) -> Option { + match value { + b'0'..=b'9' => Some(value - b'0'), + b'a'..=b'f' => Some(value - b'a' + 10), + b'A'..=b'F' => Some(value - b'A' + 10), + _ => None, + } +} diff --git a/src/web/http/tests.rs b/src/web/http/tests.rs index 1b1d2f3..76abb0a 100644 --- a/src/web/http/tests.rs +++ b/src/web/http/tests.rs @@ -49,11 +49,18 @@ mod recovery_tests; // Decoy fast-track routing and telemetry remain isolated from carrier protocol scenarios. #[path = "decoy_fasttrack_tests.rs"] mod decoy_fasttrack_tests; +// Base-path routing and credential containment share reference-contract coverage. +#[path = "base_path_tests.rs"] +mod base_path_tests; // Raw response parsing helpers are shared by the HTTP integration test modules. #[path = "response_test_support.rs"] mod response_test_support; +// Alternate runtime fixtures remain separate from the main integration scenarios. +#[path = "runtime_test_support.rs"] +mod runtime_test_support; pub(super) use response_test_support::{response_header, split_response}; +pub(super) use runtime_test_support::runtime_config_with_base; const TEST_CARRIER_DEADLINES_SECS: [u64; 4] = [3, 5, 8, 12]; @@ -86,6 +93,7 @@ fn runtime_config_with_carriers( carrier_learning, carriers, TEST_CARRIER_DEADLINES_SECS, + "/", ) } @@ -104,6 +112,7 @@ pub(super) fn negotiation_runtime_config_with_deadlines( carrier_learning, carriers, carrier_negotiation_deadlines_secs, + "/", ) } @@ -114,6 +123,7 @@ fn runtime_config_with_carriers_and_deadlines( carrier_learning: bool, carriers: Arc<[WebCarrier]>, carrier_negotiation_deadlines_secs: [u64; 4], + base: &str, ) -> ProxyConfig { let profile = Arc::new(WebRuntimeProfile { host: "proxy.example.com".to_string(), @@ -147,6 +157,7 @@ fn runtime_config_with_carriers_and_deadlines( }); let vhost = Arc::new(WebRuntimeVhost { host: "proxy.example.com".to_string(), + base: base.to_string(), decoy_fasttrack_mode: WebDecoyFastTrackMode::Off, decoy: WebRuntimeDecoy::StaticDirectory(Arc::clone(&site)), decoy_header_secs: 1, @@ -159,6 +170,7 @@ fn runtime_config_with_carriers_and_deadlines( "other.example.com".to_string(), Arc::new(WebRuntimeVhost { host: "other.example.com".to_string(), + base: "/".to_string(), decoy_fasttrack_mode: WebDecoyFastTrackMode::Off, decoy: WebRuntimeDecoy::StaticDirectory(site), decoy_header_secs: 1, @@ -181,6 +193,7 @@ fn runtime_config_with_carriers_and_deadlines( config.web.runtime = Some(Arc::new(WebRuntimeConfig { vhosts, profiles: vec![profile], + capabilities: vec![capability].into_boxed_slice(), })); config } @@ -328,12 +341,12 @@ async fn rejected_bridge_bootstrap_falls_back_to_uncacheable_static_index() { let fallback_response = request(&listener, &runtime, bridge_request()).await; let (fallback_headers, fallback_body) = split_response(&fallback_response); - assert!(fallback_headers.starts_with(b"HTTP/1.1 200")); + assert!(fallback_headers.starts_with(b"HTTP/1.1 404")); assert_eq!( response_header(fallback_headers, "cache-control"), "no-store" ); - assert_eq!(fallback_body, b"decoy"); + assert_eq!(fallback_body, b"not found\n"); runtime.shutdown().await; generation.stop_sessions().await; diff --git a/src/web/http/websocket/tests.rs b/src/web/http/websocket/tests.rs index 029d14e..c780c52 100644 --- a/src/web/http/websocket/tests.rs +++ b/src/web/http/websocket/tests.rs @@ -14,7 +14,9 @@ use tokio_util::sync::CancellationToken; use crate::maestro::generation::{RuntimeGeneration, test_runtime_generation}; use crate::web::frame::{self, FrameType}; -use crate::web::http::tests::{negotiation_runtime_config, runtime_config}; +use crate::web::http::tests::{ + negotiation_runtime_config, runtime_config, runtime_config_with_base, +}; use crate::web::manager::{ CarrierCapabilities, CarrierClientClass, CarrierFailure, CarrierRequest, WebProcessRuntime, }; @@ -210,6 +212,15 @@ async fn upgrade( listener: &TcpListener, runtime: &Arc, protocol: &str, +) -> WebSocketStream { + upgrade_at(listener, runtime, "/api/v1/ws", protocol).await +} + +async fn upgrade_at( + listener: &TcpListener, + runtime: &Arc, + path: &str, + protocol: &str, ) -> WebSocketStream { let addr = listener.local_addr().unwrap(); let (accepted, client) = tokio::join!(listener.accept(), TcpStream::connect(addr)); @@ -226,7 +237,7 @@ async fn upgrade( permit, )); let request = format!( - "GET /api/v1/ws HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Protocol: {protocol}\r\nCookie: browser-state=allowed\r\n\r\n" + "GET {path} HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Protocol: {protocol}\r\nCookie: browser-state=allowed\r\n\r\n" ); client.write_all(request.as_bytes()).await.unwrap(); let mut response = Vec::new(); @@ -244,6 +255,38 @@ async fn upgrade( WebSocketStream::from_raw_socket(client, Role::Client, None).await } +#[tokio::test] +async fn prefixed_websocket_route_upgrades_at_the_exact_base() { + let live = live_runtime_from_config( + runtime_config_with_base([31; 32], WebCarrier::Websocket, "/relay/nested/"), + 1, + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let (session, _) = create_session(&live.runtime); + let protocol = format!("tproxy-v1.{session}"); + let mut socket = upgrade_at( + &listener, + &live.runtime, + "/relay/nested/api/v1/ws", + &protocol, + ) + .await; + + socket + .send(Message::Ping(Bytes::from_static(b"prefixed"))) + .await + .unwrap(); + let response = tokio::time::timeout(Duration::from_secs(2), socket.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(response, Message::Pong(Bytes::from_static(b"prefixed"))); + + let _ = socket.close(None).await; + live.shutdown().await; +} + fn masked_message(opcode: u8, payload: &[u8], mask: [u8; 4]) -> Vec { masked_frame(true, opcode, payload, mask) } diff --git a/src/web/manager.rs b/src/web/manager.rs index d46fbb8..4f2ceb0 100644 --- a/src/web/manager.rs +++ b/src/web/manager.rs @@ -5,12 +5,17 @@ use std::sync::atomic::AtomicU64; use std::time::Duration; use arc_swap::ArcSwap; +use hmac::{Hmac, Mac}; use parking_lot::Mutex; +use sha2::Sha256; +use subtle::ConstantTimeEq; use tokio::sync::{Notify, OwnedSemaphorePermit, Semaphore, TryAcquireError}; use tokio_util::sync::CancellationToken; use tokio_util::task::TaskTracker; +use zeroize::Zeroizing; use crate::config::{WebCarrier, WebLimitsConfig}; +use crate::crypto::SecureRandom; use crate::maestro::generation::RuntimeGeneration; use crate::web::telemetry::{WebRejectionReason, WebTelemetry}; use crate::web::trace::WebTraceStore; @@ -67,6 +72,58 @@ pub(crate) use websocket::{WebSocketConnection, WebSocketKind}; const TOKEN_BYTES: usize = 32; const CLEANUP_INTERVAL: Duration = Duration::from_secs(1); +const TOKEN_NONCE_BYTES: usize = 16; +const BOOTSTRAP_TOKEN_CONTEXT: &[u8] = b"telemt-web-bootstrap-token-v1\0"; +const SESSION_TOKEN_CONTEXT: &[u8] = b"telemt-web-session-token-v1\0"; + +/// Distinguishes process-authenticated WEB credential domains. +#[derive(Clone, Copy)] +enum TokenKind { + Bootstrap, + Session, +} + +struct TokenAuthenticator { + key: Zeroizing<[u8; 32]>, +} + +impl TokenAuthenticator { + fn new(rng: &SecureRandom) -> Self { + let mut key = Zeroizing::new([0; 32]); + rng.fill(key.as_mut()); + Self { key } + } + + fn issue(&self, kind: TokenKind, nonce: [u8; TOKEN_NONCE_BYTES]) -> [u8; TOKEN_BYTES] { + let mut token = [0; TOKEN_BYTES]; + token[..TOKEN_NONCE_BYTES].copy_from_slice(&nonce); + let tag = self.tag(kind, &nonce); + token[TOKEN_NONCE_BYTES..].copy_from_slice(&tag[..TOKEN_NONCE_BYTES]); + token + } + + fn authentic(&self, token: &[u8; TOKEN_BYTES]) -> bool { + let nonce = &token[..TOKEN_NONCE_BYTES]; + let tag = &token[TOKEN_NONCE_BYTES..]; + 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), + ) + } + + fn tag(&self, kind: TokenKind, nonce: &[u8]) -> [u8; 32] { + let mut mac = Hmac::::new_from_slice(self.key.as_ref()) + .expect("HMAC accepts every WEB token key length"); + mac.update(match kind { + TokenKind::Bootstrap => BOOTSTRAP_TOKEN_CONTEXT, + TokenKind::Session => SESSION_TOKEN_CONTEXT, + }); + mac.update(nonce); + mac.finalize().into_bytes().into() + } +} /// Stable hash key used for bootstrap and session credentials. pub(crate) type TokenHash = [u8; TOKEN_BYTES]; @@ -162,6 +219,7 @@ pub(crate) struct WebProcessRuntime { runtime_instance: Arc, active_runtime: Arc>, trace: Arc, + token_authenticator: TokenAuthenticator, limits: WebLimitsConfig, state: Mutex, stream_admission: Mutex, @@ -205,6 +263,7 @@ impl WebProcessRuntime { ) -> Arc { let initial_generation = active_runtime.load_full(); let config = initial_generation.config(); + let token_authenticator = TokenAuthenticator::new(&initial_generation.rng); trace.apply_policy(initial_generation.id, &config.web.debug); let limits = config.web.limits.clone(); let learning_capacity = limits.max_carrier_learning_entries; @@ -231,6 +290,7 @@ impl WebProcessRuntime { runtime_instance, active_runtime, trace, + token_authenticator, http_connections: Arc::new(Semaphore::new(limits.max_http_connections)), http_overload_connections: Arc::new(Semaphore::new( limits.max_http_overload_connections, @@ -302,6 +362,11 @@ impl WebProcessRuntime { &self.telemetry } + /// Returns whether one canonical raw credential was minted by this process. + pub(crate) fn authentic_token(&self, token: &[u8; TOKEN_BYTES]) -> bool { + self.token_authenticator.authentic(token) + } + /// Returns whether terminal process shutdown has started. pub(crate) fn is_shutdown(&self) -> bool { self.shutdown.is_cancelled() diff --git a/src/web/manager/credentials.rs b/src/web/manager/credentials.rs index 3e082a4..18f94e2 100644 --- a/src/web/manager/credentials.rs +++ b/src/web/manager/credentials.rs @@ -9,7 +9,9 @@ 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, 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}; @@ -123,7 +125,12 @@ impl WebProcessRuntime { .record_rejection(WebRejectionReason::BootstrapCapacity); return Err(ManagerError::Limit); } - let Some((token, hash)) = new_unique_token(generation, &state) else { + let Some((token, hash)) = new_unique_token( + generation, + &state, + &self.token_authenticator, + TokenKind::Bootstrap, + ) else { self.record_limit_hit(); self.telemetry .record_rejection(WebRejectionReason::BootstrapCapacity); diff --git a/src/web/manager/session_creation.rs b/src/web/manager/session_creation.rs index 696778f..ce822c4 100644 --- a/src/web/manager/session_creation.rs +++ b/src/web/manager/session_creation.rs @@ -13,7 +13,7 @@ use super::state::{ profile_key, remember_closed_token_locked, remove_expired_locked, }; use super::{ - CarrierLearningContext, CarrierRequest, CreateResult, ManagerError, TokenHash, + CarrierLearningContext, CarrierRequest, CreateResult, ManagerError, TokenHash, TokenKind, WebProcessRuntime, }; use crate::config::{WebCarrier, WebRuntimeProfile}; @@ -316,7 +316,12 @@ impl WebProcessRuntime { if !admit_initial(self, &mut state, now, client_ip, profile_key, &profile) { return Err(ManagerError::Limit); } - let Some((session_token, session_hash)) = new_unique_token(&generation, &state) else { + let Some((session_token, session_hash)) = new_unique_token( + &generation, + &state, + &self.token_authenticator, + TokenKind::Session, + ) else { self.record_limit_hit(); self.telemetry .record_rejection(crate::web::telemetry::WebRejectionReason::SessionCapacity); diff --git a/src/web/manager/session_creation/replacement.rs b/src/web/manager/session_creation/replacement.rs index 8bec7c7..170f3e6 100644 --- a/src/web/manager/session_creation/replacement.rs +++ b/src/web/manager/session_creation/replacement.rs @@ -60,7 +60,12 @@ impl WebProcessRuntime { self.cancel_replacement(bootstrap_hash, &replacement.old_session); return Err(ManagerError::Closed); }; - let Some((session_token, session_hash)) = new_unique_token(&generation, &state) else { + let Some((session_token, session_hash)) = new_unique_token( + &generation, + &state, + &self.token_authenticator, + TokenKind::Session, + ) else { self.record_limit_hit(); self.telemetry .record_rejection(crate::web::telemetry::WebRejectionReason::SessionCapacity); diff --git a/src/web/manager/state.rs b/src/web/manager/state.rs index ca16949..1efb7a7 100644 --- a/src/web/manager/state.rs +++ b/src/web/manager/state.rs @@ -7,7 +7,9 @@ use base64::Engine as _; use sha2::{Digest, Sha256}; use zeroize::Zeroizing; -use super::{CarrierRequest, ProfileKey, TOKEN_BYTES, TokenHash}; +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; @@ -233,10 +235,13 @@ impl Default for ManagerState { pub(super) fn new_unique_token( generation: &RuntimeGeneration, state: &ManagerState, + authenticator: &TokenAuthenticator, + kind: TokenKind, ) -> Option<(String, TokenHash)> { for _ in 0..8 { - let mut raw = [0u8; TOKEN_BYTES]; - generation.rng.fill(&mut raw); + let mut nonce = [0u8; 16]; + generation.rng.fill(&mut nonce); + let raw = authenticator.issue(kind, nonce); let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw); let hash = Sha256::digest(raw).into(); if !state.bootstraps.contains_key(&hash) diff --git a/src/web/session/backend_tests.rs b/src/web/session/backend_tests.rs index 3f04878..238aace 100644 --- a/src/web/session/backend_tests.rs +++ b/src/web/session/backend_tests.rs @@ -111,6 +111,7 @@ fn test_runtime_with_dc( config.web.runtime = Some(Arc::new(WebRuntimeConfig { vhosts: BTreeMap::new(), profiles: vec![Arc::clone(&profile)], + capabilities: vec![[7; 32]].into_boxed_slice(), })); config.rebuild_runtime_user_auth().unwrap(); let limits = config.web.limits.clone(); diff --git a/src/web/session/websocket/tests.rs b/src/web/session/websocket/tests.rs index ec245aa..f914249 100644 --- a/src/web/session/websocket/tests.rs +++ b/src/web/session/websocket/tests.rs @@ -70,6 +70,7 @@ fn runtime(admission: bool) -> TestRuntime { config.web.runtime = Some(Arc::new(WebRuntimeConfig { vhosts: BTreeMap::new(), profiles: vec![Arc::clone(&profile)], + capabilities: vec![[7; 32]].into_boxed_slice(), })); config.rebuild_runtime_user_auth().unwrap(); let limits = config.web.limits.clone();