From 8db566dbe92da3b963e00ca65681352cc0eeed78 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:58:39 +0300 Subject: [PATCH] TLS Validator Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> --- src/config/load.rs | 36 +++++++++ src/config/types.rs | 13 ++++ src/error.rs | 3 + src/proxy/client.rs | 17 ++++- src/proxy/handshake.rs | 48 +++++++++--- src/proxy/tests/handshake_security_tests.rs | 83 +++++++++++++++++++++ 6 files changed, 187 insertions(+), 13 deletions(-) diff --git a/src/config/load.rs b/src/config/load.rs index 2a501ea..8355fb4 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -1267,6 +1267,10 @@ mod tests { cfg.server.proxy_protocol_trusted_cidrs, default_proxy_protocol_trusted_cidrs() ); + assert_eq!( + cfg.censorship.unknown_sni_action, + UnknownSniAction::Drop + ); assert_eq!(cfg.server.api.listen, default_api_listen()); assert_eq!(cfg.server.api.whitelist, default_api_whitelist()); assert_eq!( @@ -1403,6 +1407,10 @@ mod tests { server.proxy_protocol_trusted_cidrs, default_proxy_protocol_trusted_cidrs() ); + assert_eq!( + AntiCensorshipConfig::default().unknown_sni_action, + UnknownSniAction::Drop + ); assert_eq!(server.api.listen, default_api_listen()); assert_eq!(server.api.whitelist, default_api_whitelist()); assert_eq!( @@ -1473,6 +1481,34 @@ mod tests { ); } + #[test] + fn unknown_sni_action_parses_and_defaults_to_drop() { + let cfg_default: ProxyConfig = toml::from_str( + r#" + [server] + [general] + [network] + [access] + [censorship] + "#, + ) + .unwrap(); + assert_eq!(cfg_default.censorship.unknown_sni_action, UnknownSniAction::Drop); + + let cfg_mask: ProxyConfig = toml::from_str( + r#" + [server] + [general] + [network] + [access] + [censorship] + unknown_sni_action = "mask" + "#, + ) + .unwrap(); + assert_eq!(cfg_mask.censorship.unknown_sni_action, UnknownSniAction::Mask); + } + #[test] fn dc_overrides_allow_string_and_array() { let toml = r#" diff --git a/src/config/types.rs b/src/config/types.rs index 2d204e2..68ba278 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -1359,6 +1359,14 @@ impl Default for TimeoutsConfig { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum UnknownSniAction { + #[default] + Drop, + Mask, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AntiCensorshipConfig { #[serde(default = "default_tls_domain")] @@ -1368,6 +1376,10 @@ pub struct AntiCensorshipConfig { #[serde(default)] pub tls_domains: Vec, + /// Policy for TLS ClientHello with unknown (non-configured) SNI. + #[serde(default)] + pub unknown_sni_action: UnknownSniAction, + /// Upstream scope used for TLS front metadata fetches. /// Empty value keeps default upstream routing behavior. #[serde(default = "default_tls_fetch_scope")] @@ -1478,6 +1490,7 @@ impl Default for AntiCensorshipConfig { Self { tls_domain: default_tls_domain(), tls_domains: Vec::new(), + unknown_sni_action: UnknownSniAction::Drop, tls_fetch_scope: default_tls_fetch_scope(), mask: default_true(), mask_host: None, diff --git a/src/error.rs b/src/error.rs index d9aeb22..49c8c81 100644 --- a/src/error.rs +++ b/src/error.rs @@ -216,6 +216,9 @@ pub enum ProxyError { #[error("Invalid proxy protocol header")] InvalidProxyProtocol, + #[error("Unknown TLS SNI")] + UnknownTlsSni, + #[error("Proxy error: {0}")] Proxy(String), diff --git a/src/proxy/client.rs b/src/proxy/client.rs index 0becac7..8ce3e96 100644 --- a/src/proxy/client.rs +++ b/src/proxy/client.rs @@ -317,6 +317,13 @@ fn record_handshake_failure_class( record_beobachten_class(beobachten, config, peer_ip, class); } +#[inline] +fn increment_bad_on_unknown_tls_sni(stats: &Stats, error: &ProxyError) { + if matches!(error, ProxyError::UnknownTlsSni) { + stats.increment_connects_bad(); + } +} + fn is_trusted_proxy_source(peer_ip: IpAddr, trusted: &[IpNetwork]) -> bool { if trusted.is_empty() { static EMPTY_PROXY_TRUST_WARNED: OnceLock = OnceLock::new(); @@ -508,7 +515,10 @@ where beobachten.clone(), )); } - HandshakeResult::Error(e) => return Err(e), + HandshakeResult::Error(e) => { + increment_bad_on_unknown_tls_sni(stats.as_ref(), &e); + return Err(e); + } }; debug!(peer = %peer, "Reading MTProto handshake through TLS"); @@ -959,7 +969,10 @@ impl RunningClientHandler { self.beobachten.clone(), )); } - HandshakeResult::Error(e) => return Err(e), + HandshakeResult::Error(e) => { + increment_bad_on_unknown_tls_sni(stats.as_ref(), &e); + return Err(e); + } }; debug!(peer = %peer, "Reading MTProto handshake through TLS"); diff --git a/src/proxy/handshake.rs b/src/proxy/handshake.rs index 55a8a21..8b8c4a3 100644 --- a/src/proxy/handshake.rs +++ b/src/proxy/handshake.rs @@ -16,7 +16,7 @@ use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; use tracing::{debug, trace, warn}; use zeroize::{Zeroize, Zeroizing}; -use crate::config::ProxyConfig; +use crate::config::{ProxyConfig, UnknownSniAction}; use crate::crypto::{AesCtr, SecureRandom, sha256}; use crate::error::{HandshakeResult, ProxyError}; use crate::protocol::constants::*; @@ -510,6 +510,21 @@ fn decode_user_secrets( secrets } +#[inline] +fn find_matching_tls_domain<'a>(config: &'a ProxyConfig, sni: &str) -> Option<&'a str> { + if config.censorship.tls_domain.eq_ignore_ascii_case(sni) { + return Some(config.censorship.tls_domain.as_str()); + } + + for domain in &config.censorship.tls_domains { + if domain.eq_ignore_ascii_case(sni) { + return Some(domain.as_str()); + } + } + + None +} + async fn maybe_apply_server_hello_delay(config: &ProxyConfig) { if config.censorship.server_hello_delay_max_ms == 0 { return; @@ -593,6 +608,25 @@ where } let client_sni = tls::extract_sni_from_client_hello(handshake); + let matched_tls_domain = client_sni + .as_deref() + .and_then(|sni| find_matching_tls_domain(config, sni)); + + if client_sni.is_some() && matched_tls_domain.is_none() { + auth_probe_record_failure(peer.ip(), Instant::now()); + maybe_apply_server_hello_delay(config).await; + debug!( + peer = %peer, + sni = ?client_sni, + action = ?config.censorship.unknown_sni_action, + "TLS handshake rejected by unknown SNI policy" + ); + return match config.censorship.unknown_sni_action { + UnknownSniAction::Drop => HandshakeResult::Error(ProxyError::UnknownTlsSni), + UnknownSniAction::Mask => HandshakeResult::BadClient { reader, writer }, + }; + } + let secrets = decode_user_secrets(config, client_sni.as_deref()); let validation = match tls::validate_tls_handshake_with_replay_window( @@ -633,16 +667,8 @@ where let cached = if config.censorship.tls_emulation { if let Some(cache) = tls_cache.as_ref() { - let selected_domain = if let Some(sni) = client_sni.as_ref() { - if cache.contains_domain(sni).await { - sni.clone() - } else { - config.censorship.tls_domain.clone() - } - } else { - config.censorship.tls_domain.clone() - }; - let cached_entry = cache.get(&selected_domain).await; + let selected_domain = matched_tls_domain.unwrap_or(config.censorship.tls_domain.as_str()); + let cached_entry = cache.get(selected_domain).await; let use_full_cert_payload = cache .take_full_cert_budget_for_ip( peer.ip(), diff --git a/src/proxy/tests/handshake_security_tests.rs b/src/proxy/tests/handshake_security_tests.rs index d06f63e..6796c5c 100644 --- a/src/proxy/tests/handshake_security_tests.rs +++ b/src/proxy/tests/handshake_security_tests.rs @@ -956,6 +956,89 @@ async fn stress_tls_sni_preferred_user_hint_scales_to_large_user_set() { } } +#[tokio::test] +async fn tls_unknown_sni_drop_policy_returns_hard_error() { + let secret = [0x48u8; 16]; + let mut config = test_config_with_secret_hex("48484848484848484848484848484848"); + config.censorship.unknown_sni_action = UnknownSniAction::Drop; + + let replay_checker = ReplayChecker::new(128, Duration::from_secs(60)); + let rng = SecureRandom::new(); + let peer: SocketAddr = "198.51.100.190:44326".parse().unwrap(); + let handshake = + make_valid_tls_client_hello_with_sni_and_alpn(&secret, 0, "unknown.example", &[b"h2"]); + + let result = handle_tls_handshake( + &handshake, + tokio::io::empty(), + tokio::io::sink(), + peer, + &config, + &replay_checker, + &rng, + None, + ) + .await; + + assert!(matches!( + result, + HandshakeResult::Error(ProxyError::UnknownTlsSni) + )); +} + +#[tokio::test] +async fn tls_unknown_sni_mask_policy_falls_back_to_bad_client() { + let secret = [0x49u8; 16]; + let mut config = test_config_with_secret_hex("49494949494949494949494949494949"); + config.censorship.unknown_sni_action = UnknownSniAction::Mask; + + let replay_checker = ReplayChecker::new(128, Duration::from_secs(60)); + let rng = SecureRandom::new(); + let peer: SocketAddr = "198.51.100.191:44326".parse().unwrap(); + let handshake = + make_valid_tls_client_hello_with_sni_and_alpn(&secret, 0, "unknown.example", &[b"h2"]); + + let result = handle_tls_handshake( + &handshake, + tokio::io::empty(), + tokio::io::sink(), + peer, + &config, + &replay_checker, + &rng, + None, + ) + .await; + + assert!(matches!(result, HandshakeResult::BadClient { .. })); +} + +#[tokio::test] +async fn tls_missing_sni_keeps_legacy_auth_path() { + let secret = [0x4Au8; 16]; + let mut config = test_config_with_secret_hex("4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a"); + config.censorship.unknown_sni_action = UnknownSniAction::Drop; + + let replay_checker = ReplayChecker::new(128, Duration::from_secs(60)); + let rng = SecureRandom::new(); + let peer: SocketAddr = "198.51.100.192:44326".parse().unwrap(); + let handshake = make_valid_tls_handshake(&secret, 0); + + let result = handle_tls_handshake( + &handshake, + tokio::io::empty(), + tokio::io::sink(), + peer, + &config, + &replay_checker, + &rng, + None, + ) + .await; + + assert!(matches!(result, HandshakeResult::Success(_))); +} + #[tokio::test] async fn alpn_enforce_rejects_unsupported_client_alpn() { let secret = [0x33u8; 16];