use std::collections::HashSet; use super::*; const WEB_FRAME_HEADER_BYTES: usize = 8; const WEB_QUEUE_ITEM_COST: usize = 256; const WEB_CONTROL_EXTRA_ITEMS: usize = 16; const WEB_CONTROL_ITEMS_PER_STREAM: usize = 3; const WEB_INITIAL_STREAM_WINDOW: usize = 4 * 1024 * 1024; const MAX_WEB_HEADER_BYTES: usize = 64 * 1024; const MAX_WEB_BODY_BYTES: usize = 16 * 1024 * 1024; const MAX_WEB_FRAME_BYTES: usize = 1024 * 1024; const MAX_WEB_FRAMES_PER_BODY: usize = 4096; const MAX_WEB_TOMBSTONES_PER_SESSION: usize = 4096; const MAX_WEB_MEMORY_ENVELOPE_BYTES: usize = 4 * 1024 * 1024 * 1024; /// Validates WEB policy and resource bounds before building runtime state. pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> { let web_listener_count = config .server .listeners .iter() .filter(|listener| listener.transport == ListenerTransport::Web) .count(); let eligible_web_listener_count = config .server .listeners .iter() .filter(|listener| listener.transport == ListenerTransport::Web) .filter(|listener| { (listener.ip.is_ipv4() && config.network.ipv4) || (listener.ip.is_ipv6() && config.network.ipv6 != Some(false)) }) .count(); for (idx, listener) in config.server.listeners.iter().enumerate() { match listener.transport { ListenerTransport::Mtproxy => { if !listener.web_trusted_proxy_cidrs.is_empty() { return Err(ProxyError::Config(format!( "server.listeners[{idx}].web_trusted_proxy_cidrs is only valid for transport=web" ))); } } ListenerTransport::Web => validate_web_listener(config, idx, listener)?, } } if config.web.enabled && eligible_web_listener_count == 0 { return Err(ProxyError::Config( "web.enabled requires at least one network-eligible server.listeners entry with transport=web" .to_string(), )); } if web_listener_count > 0 && config.web.vhosts.is_empty() { return Err(ProxyError::Config( "WEB listeners require at least one [[web.vhosts]] entry".to_string(), )); } validate_limits(&config.web.limits)?; if config.web.carrier == WebCarrier::HttpsLanes && config.web.limits.max_http_handlers < 2 { return config_error("web.carrier=https-lanes requires web.limits.max_http_handlers >= 2"); } validate_timeouts(&config.web.timeouts)?; validate_vhosts(config)?; Ok(()) } fn validate_web_listener( config: &ProxyConfig, idx: usize, listener: &ListenerConfig, ) -> Result<()> { if listener.web_trusted_proxy_cidrs.is_empty() { return Err(ProxyError::Config(format!( "server.listeners[{idx}].web_trusted_proxy_cidrs must be non-empty for transport=web" ))); } if listener .web_trusted_proxy_cidrs .iter() .any(|network| network.prefix() == 0) { return Err(ProxyError::Config(format!( "server.listeners[{idx}].web_trusted_proxy_cidrs must not contain a /0 network" ))); } let proxy_protocol = listener .proxy_protocol .unwrap_or(config.server.proxy_protocol); if proxy_protocol { return Err(ProxyError::Config(format!( "server.listeners[{idx}].proxy_protocol must be false for transport=web; WEB identity is accepted only from the configured L7 header" ))); } if listener.reuse_allow { return Err(ProxyError::Config(format!( "server.listeners[{idx}].reuse_allow is not supported for transport=web without external session affinity" ))); } if listener.client_mss.is_some() || listener.synlimit != SynLimitMode::Off || listener.announce.is_some() || listener.announce_ip.is_some() { return Err(ProxyError::Config(format!( "server.listeners[{idx}] WEB transport does not accept client_mss, synlimit, announce, or announce_ip" ))); } Ok(()) } fn validate_limits(limits: &WebLimitsConfig) -> Result<()> { if !(8192..=MAX_WEB_HEADER_BYTES).contains(&limits.max_header_bytes) { return config_error("web.limits.max_header_bytes must be within [8192, 65536]"); } if !(WEB_FRAME_HEADER_BYTES..=MAX_WEB_BODY_BYTES).contains(&limits.max_body_bytes) { return config_error("web.limits.max_body_bytes must be within [8, 16777216]"); } if !(1..=MAX_WEB_FRAME_BYTES).contains(&limits.max_frame_payload_bytes) { return config_error("web.limits.max_frame_payload_bytes must be within [1, 1048576]"); } if !(1..=MAX_WEB_FRAMES_PER_BODY).contains(&limits.max_frames_per_body) { return config_error("web.limits.max_frames_per_body must be within [1, 4096]"); } if !(1..=MAX_WEB_TOMBSTONES_PER_SESSION).contains(&limits.max_tombstones_per_session) { return config_error("web.limits.max_tombstones_per_session must be within [1, 4096]"); } if limits.carrier_batch_bytes > limits.max_body_bytes || limits.carrier_batch_bytes < limits .max_frame_payload_bytes .saturating_add(WEB_FRAME_HEADER_BYTES) { return config_error( "web.limits.carrier_batch_bytes must fit max_body_bytes and one maximum frame", ); } if limits.max_frame_payload_bytes > WEB_INITIAL_STREAM_WINDOW { return config_error( "web.limits.max_frame_payload_bytes must not exceed the initial stream window", ); } let positive = [ ("max_http_connections", limits.max_http_connections), ("max_http_handlers", limits.max_http_handlers), ("max_body_readers", limits.max_body_readers), ("max_body_bytes_global", limits.max_body_bytes_global), ("max_sessions_global", limits.max_sessions_global), ("max_sessions_per_ip", limits.max_sessions_per_ip), ("max_streams_per_session", limits.max_streams_per_session), ("max_streams_global", limits.max_streams_global), ("max_stream_handshakes", limits.max_stream_handshakes), ("pending_bytes_per_session", limits.pending_bytes_per_session), ("pending_bytes_global", limits.pending_bytes_global), ("pending_items_per_session", limits.pending_items_per_session), ("pending_items_global", limits.pending_items_global), ("control_bytes_per_session", limits.control_bytes_per_session), ("control_bytes_global", limits.control_bytes_global), ("max_bootstraps_global", limits.max_bootstraps_global), ("max_bootstraps_per_ip", limits.max_bootstraps_per_ip), ("max_vhosts", limits.max_vhosts), ("max_profiles", limits.max_profiles), ("max_static_files", limits.max_static_files), ("max_static_file_bytes", limits.max_static_file_bytes), ("max_static_bytes", limits.max_static_bytes), ("memory_envelope_bytes", limits.memory_envelope_bytes), ]; if let Some((field, _)) = positive.into_iter().find(|(_, value)| *value == 0) { return config_error(&format!("web.limits.{field} must be > 0")); } for (field, value) in [ ("max_http_connections", limits.max_http_connections), ("max_http_handlers", limits.max_http_handlers), ("max_body_readers", limits.max_body_readers), ("max_body_bytes_global", limits.max_body_bytes_global), ("max_stream_handshakes", limits.max_stream_handshakes), ] { if value > tokio::sync::Semaphore::MAX_PERMITS { return config_error(&format!("web.limits.{field} exceeds Tokio semaphore capacity")); } } let rates = [ ("new_bootstraps_per_minute", limits.new_bootstraps_per_minute), ("new_bootstraps_burst", limits.new_bootstraps_burst), ("new_sessions_per_minute", limits.new_sessions_per_minute), ("new_sessions_burst", limits.new_sessions_burst), ("new_streams_per_minute", limits.new_streams_per_minute), ("new_streams_burst", limits.new_streams_burst), ]; if let Some((field, _)) = rates.into_iter().find(|(_, value)| *value == 0) { return config_error(&format!("web.limits.{field} must be > 0")); } if limits.max_streams_per_session > u16::MAX as usize { return config_error("web.limits.max_streams_per_session must fit synthetic source ports"); } if limits.max_sessions_per_ip > limits.max_sessions_global || limits.max_streams_per_session > limits.max_streams_global || limits.max_stream_handshakes > limits.max_streams_global || limits.max_bootstraps_per_ip > limits.max_bootstraps_global || limits.max_http_handlers > limits.max_http_connections || limits.max_body_readers > limits.max_http_handlers || limits.pending_bytes_per_session > limits.pending_bytes_global || limits.pending_items_per_session > limits.pending_items_global || limits.control_bytes_per_session > limits.control_bytes_global || limits.control_bytes_per_session > limits.pending_bytes_per_session || limits.control_bytes_global > limits.pending_bytes_global || limits.max_static_file_bytes > limits.max_static_bytes { return config_error("web.limits per-owner ceilings must not exceed global ceilings"); } let control_items_per_session = WEB_CONTROL_EXTRA_ITEMS .checked_add( limits .max_streams_per_session .checked_mul(WEB_CONTROL_ITEMS_PER_STREAM) .ok_or_else(|| { ProxyError::Config( "web.limits control item reservation overflowed usize".to_string(), ) })?, ) .ok_or_else(|| { ProxyError::Config("web.limits control item reservation overflowed usize".to_string()) })?; let control_items_global = control_items_per_session .checked_mul(limits.max_sessions_global) .ok_or_else(|| { ProxyError::Config("web.limits global control reservation overflowed usize".to_string()) })?; let control_frame_cost = WEB_FRAME_HEADER_BYTES + 4 + WEB_QUEUE_ITEM_COST; let required_control_bytes_per_session = control_items_per_session .checked_mul(control_frame_cost) .ok_or_else(|| { ProxyError::Config("web.limits control byte reservation overflowed usize".to_string()) })?; let required_control_bytes_global = control_items_global .checked_mul(control_frame_cost) .ok_or_else(|| { ProxyError::Config("web.limits global control byte reservation overflowed usize".to_string()) })?; if control_items_per_session >= limits.pending_items_per_session || control_items_global >= limits.pending_items_global || required_control_bytes_per_session > limits.control_bytes_per_session || required_control_bytes_global > limits.control_bytes_global { return config_error( "web.limits control reserves must cover bounded control frames and leave data capacity", ); } let uplink_bytes = limits .max_frames_per_body .checked_mul(WEB_QUEUE_ITEM_COST) .and_then(|value| value.checked_add(limits.max_body_bytes)) .ok_or_else(|| { ProxyError::Config("web.limits uplink reservation overflowed usize".to_string()) })?; let minimum_downlink_frame_bytes = WEB_FRAME_HEADER_BYTES + 1 + WEB_QUEUE_ITEM_COST; let session_required_bytes = limits .control_bytes_per_session .checked_add(uplink_bytes) .and_then(|value| value.checked_add(minimum_downlink_frame_bytes)) .ok_or_else(|| { ProxyError::Config("web.limits session reservation overflowed usize".to_string()) })?; let session_required_items = control_items_per_session .checked_add(limits.max_frames_per_body) .and_then(|value| value.checked_add(1)) .ok_or_else(|| { ProxyError::Config("web.limits session item reservation overflowed usize".to_string()) })?; let global_required_bytes = limits .control_bytes_global .checked_add(uplink_bytes) .and_then(|value| value.checked_add(minimum_downlink_frame_bytes)) .ok_or_else(|| { ProxyError::Config("web.limits global reservation overflowed usize".to_string()) })?; let global_required_items = control_items_global .checked_add(limits.max_frames_per_body) .and_then(|value| value.checked_add(1)) .ok_or_else(|| { ProxyError::Config("web.limits global item reservation overflowed usize".to_string()) })?; if session_required_bytes > limits.pending_bytes_per_session || session_required_items > limits.pending_items_per_session || global_required_bytes > limits.pending_bytes_global || global_required_items > limits.pending_items_global { return config_error( "web.limits pending ceilings must preserve one uplink batch and downlink progress", ); } let body_reservation = limits .max_body_readers .checked_mul(limits.max_body_bytes) .ok_or_else(|| { ProxyError::Config("web.limits body reader reservation overflowed usize".to_string()) })?; if body_reservation > limits.max_body_bytes_global || limits.max_body_bytes_global > u32::MAX as usize { return config_error( "web.limits max_body_readers * max_body_bytes must fit max_body_bytes_global and u32", ); } let http_header_reservation = limits .max_http_connections .checked_mul(limits.max_header_bytes) .ok_or_else(|| { ProxyError::Config("web.limits HTTP header reservations overflow usize".to_string()) })?; let reserved = limits .pending_bytes_global .checked_add(limits.max_body_bytes_global) .and_then(|value| value.checked_add(limits.max_static_bytes)) .and_then(|value| value.checked_add(http_header_reservation)) .ok_or_else(|| ProxyError::Config("web.limits byte ceilings overflow usize".to_string()))?; if reserved > limits.memory_envelope_bytes || limits.memory_envelope_bytes > MAX_WEB_MEMORY_ENVELOPE_BYTES { return config_error( "web.limits memory reservations must fit memory_envelope_bytes within 4 GiB", ); } Ok(()) } fn validate_timeouts(timeouts: &WebTimeoutsConfig) -> Result<()> { let values = [ ("header_secs", timeouts.header_secs), ("body_secs", timeouts.body_secs), ("stream_handshake_secs", timeouts.stream_handshake_secs), ("long_poll_secs", timeouts.long_poll_secs), ("bootstrap_lifetime_secs", timeouts.bootstrap_lifetime_secs), ("reconnect_grace_secs", timeouts.reconnect_grace_secs), ("http_idle_secs", timeouts.http_idle_secs), ("shutdown_secs", timeouts.shutdown_secs), ("decoy_header_secs", timeouts.decoy_header_secs), ]; if let Some((field, _)) = values .into_iter() .find(|(_, value)| !(1..=3600).contains(value)) { return config_error(&format!("web.timeouts.{field} must be within [1, 3600]")); } let request_deadline = timeouts .header_secs .max(timeouts.body_secs) .max(timeouts.long_poll_secs) .max(timeouts.decoy_header_secs); if request_deadline >= timeouts.http_idle_secs { return config_error("web.timeouts request deadlines must be lower than http_idle_secs"); } Ok(()) } fn validate_vhosts(config: &mut ProxyConfig) -> Result<()> { let limits = &config.web.limits; if config.web.vhosts.len() > limits.max_vhosts { return config_error("web.vhosts exceeds web.limits.max_vhosts"); } let mut hosts = HashSet::with_capacity(config.web.vhosts.len()); 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"), )?; if !hosts.insert(vhost.host.clone()) { return config_error(&format!("duplicate WEB vhost host `{}`", vhost.host)); } if vhost.public_addr.port() != 443 || vhost.public_addr.ip().is_unspecified() { return config_error(&format!( "web.vhosts[{vhost_idx}].public_addr must be a concrete socket address on port 443" )); } if config.web.enabled && vhost.profiles.is_empty() { return config_error(&format!( "web.vhosts[{vhost_idx}].profiles must be non-empty when web.enabled=true" )); } validate_decoy(vhost_idx, &vhost.decoy)?; let mut profiles = HashSet::with_capacity(vhost.profiles.len()); for (profile_idx, profile) in vhost.profiles.iter().enumerate() { if !config.access.users.contains_key(&profile.user) { return config_error(&format!( "web.vhosts[{vhost_idx}].profiles[{profile_idx}].user references unknown access user `{}`", profile.user )); } if !profiles.insert((profile.user.as_str(), profile.secret_mode)) { return config_error(&format!( "duplicate WEB profile for user `{}` in vhost `{}`", profile.user, vhost.host )); } let max_streams = profile.max_streams.unwrap_or(limits.max_streams_global); let max_streams_per_session = profile .max_streams_per_session .unwrap_or(limits.max_streams_per_session); if profile.max_sessions == Some(0) || profile.max_sessions.is_some_and(|value| value > limits.max_sessions_global) || profile.max_streams == Some(0) || profile .max_streams .is_some_and(|value| value > limits.max_streams_global) || profile.max_streams_per_session == Some(0) || profile .max_streams_per_session .is_some_and(|value| value > limits.max_streams_per_session) || max_streams_per_session > max_streams { return config_error(&format!( "web.vhosts[{vhost_idx}].profiles[{profile_idx}] limits must be non-zero and within global WEB limits" )); } profile_count = profile_count.checked_add(1).ok_or_else(|| { ProxyError::Config("WEB profile count overflowed usize".to_string()) })?; } } if profile_count > limits.max_profiles { return config_error("WEB profiles exceed web.limits.max_profiles"); } Ok(()) } fn normalize_web_host(value: &str, field: &str) -> Result { let input = value.trim(); if input.is_empty() || input.ends_with('.') || input .chars() .any(|character| matches!(character, ':' | '/' | '?' | '#' | '@')) { return config_error(&format!( "{field} must be a hostname without a port, path, credentials, or trailing dot" )); } let host = normalize_domain_to_ascii(input, field)?; if host.len() > 253 || !host.contains('.') || host.parse::().is_ok() || web_host_last_label_is_numeric(&host) { return config_error(&format!( "{field} must be a non-IP fully-qualified hostname accepted by Telegram Desktop" )); } for label in host.split('.') { if label.is_empty() || label.len() > 63 || label.starts_with('-') || label.ends_with('-') || !label .bytes() .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') { return config_error(&format!( "{field} contains a hostname label rejected by Telegram Desktop" )); } } Ok(host) } fn web_host_last_label_is_numeric(host: &str) -> bool { let label = host.rsplit('.').next().unwrap_or_default(); let digits = label .strip_prefix("0x") .or_else(|| label.strip_prefix("0X")); if let Some(digits) = digits { return digits.bytes().all(|byte| byte.is_ascii_hexdigit()); } label.bytes().all(|byte| byte.is_ascii_digit()) } fn validate_decoy(vhost_idx: usize, decoy: &WebDecoyConfig) -> Result<()> { match decoy { WebDecoyConfig::HttpUpstream { upstream } => { let parsed = url::Url::parse(upstream).map_err(|error| { ProxyError::Config(format!( "web.vhosts[{vhost_idx}].decoy.upstream is invalid: {error}" )) })?; if parsed.scheme() != "http" || parsed.host_str().is_none() || !parsed.username().is_empty() || parsed.password().is_some() || parsed.query().is_some() || parsed.fragment().is_some() || parsed.path() != "/" || parsed.port() == Some(0) { return config_error(&format!( "web.vhosts[{vhost_idx}].decoy.upstream must be an http origin without credentials, path, query, or fragment" )); } let ip = match parsed.host() { Some(url::Host::Ipv4(ip)) => IpAddr::V4(ip), Some(url::Host::Ipv6(ip)) => IpAddr::V6(ip), _ => { return config_error(&format!( "web.vhosts[{vhost_idx}].decoy.upstream host must be a loopback or private IP literal" )); } }; let private = match ip { IpAddr::V4(ip) => ip.is_loopback() || ip.is_private() || ip.is_link_local(), IpAddr::V6(ip) => { ip.is_loopback() || ip.is_unique_local() || ip.is_unicast_link_local() } }; if !private { return config_error(&format!( "web.vhosts[{vhost_idx}].decoy.upstream must remain inside loopback or a private network" )); } } WebDecoyConfig::StaticDirectory { directory, index } => { if !directory.is_absolute() { return config_error(&format!( "web.vhosts[{vhost_idx}].decoy.directory must be absolute" )); } if index.is_empty() || index.contains('\\') || std::path::Path::new(index).components().count() != 1 || matches!(index.as_str(), "." | "..") { return config_error(&format!( "web.vhosts[{vhost_idx}].decoy.index must be one safe file name" )); } } } Ok(()) } fn config_error(message: &str) -> Result { Err(ProxyError::Config(message.to_string())) } #[cfg(test)] mod tests;