TLS Validator

Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
Alexey 2026-03-23 21:58:39 +03:00
parent bb71de0230
commit 8db566dbe9
No known key found for this signature in database
6 changed files with 187 additions and 13 deletions

View File

@ -1267,6 +1267,10 @@ mod tests {
cfg.server.proxy_protocol_trusted_cidrs, cfg.server.proxy_protocol_trusted_cidrs,
default_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.listen, default_api_listen());
assert_eq!(cfg.server.api.whitelist, default_api_whitelist()); assert_eq!(cfg.server.api.whitelist, default_api_whitelist());
assert_eq!( assert_eq!(
@ -1403,6 +1407,10 @@ mod tests {
server.proxy_protocol_trusted_cidrs, server.proxy_protocol_trusted_cidrs,
default_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.listen, default_api_listen());
assert_eq!(server.api.whitelist, default_api_whitelist()); assert_eq!(server.api.whitelist, default_api_whitelist());
assert_eq!( 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] #[test]
fn dc_overrides_allow_string_and_array() { fn dc_overrides_allow_string_and_array() {
let toml = r#" let toml = r#"

View File

@ -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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AntiCensorshipConfig { pub struct AntiCensorshipConfig {
#[serde(default = "default_tls_domain")] #[serde(default = "default_tls_domain")]
@ -1368,6 +1376,10 @@ pub struct AntiCensorshipConfig {
#[serde(default)] #[serde(default)]
pub tls_domains: Vec<String>, pub tls_domains: Vec<String>,
/// Policy for TLS ClientHello with unknown (non-configured) SNI.
#[serde(default)]
pub unknown_sni_action: UnknownSniAction,
/// Upstream scope used for TLS front metadata fetches. /// Upstream scope used for TLS front metadata fetches.
/// Empty value keeps default upstream routing behavior. /// Empty value keeps default upstream routing behavior.
#[serde(default = "default_tls_fetch_scope")] #[serde(default = "default_tls_fetch_scope")]
@ -1478,6 +1490,7 @@ impl Default for AntiCensorshipConfig {
Self { Self {
tls_domain: default_tls_domain(), tls_domain: default_tls_domain(),
tls_domains: Vec::new(), tls_domains: Vec::new(),
unknown_sni_action: UnknownSniAction::Drop,
tls_fetch_scope: default_tls_fetch_scope(), tls_fetch_scope: default_tls_fetch_scope(),
mask: default_true(), mask: default_true(),
mask_host: None, mask_host: None,

View File

@ -216,6 +216,9 @@ pub enum ProxyError {
#[error("Invalid proxy protocol header")] #[error("Invalid proxy protocol header")]
InvalidProxyProtocol, InvalidProxyProtocol,
#[error("Unknown TLS SNI")]
UnknownTlsSni,
#[error("Proxy error: {0}")] #[error("Proxy error: {0}")]
Proxy(String), Proxy(String),

View File

@ -317,6 +317,13 @@ fn record_handshake_failure_class(
record_beobachten_class(beobachten, config, peer_ip, 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 { fn is_trusted_proxy_source(peer_ip: IpAddr, trusted: &[IpNetwork]) -> bool {
if trusted.is_empty() { if trusted.is_empty() {
static EMPTY_PROXY_TRUST_WARNED: OnceLock<AtomicBool> = OnceLock::new(); static EMPTY_PROXY_TRUST_WARNED: OnceLock<AtomicBool> = OnceLock::new();
@ -508,7 +515,10 @@ where
beobachten.clone(), 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"); debug!(peer = %peer, "Reading MTProto handshake through TLS");
@ -959,7 +969,10 @@ impl RunningClientHandler {
self.beobachten.clone(), 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"); debug!(peer = %peer, "Reading MTProto handshake through TLS");

View File

@ -16,7 +16,7 @@ use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt};
use tracing::{debug, trace, warn}; use tracing::{debug, trace, warn};
use zeroize::{Zeroize, Zeroizing}; use zeroize::{Zeroize, Zeroizing};
use crate::config::ProxyConfig; use crate::config::{ProxyConfig, UnknownSniAction};
use crate::crypto::{AesCtr, SecureRandom, sha256}; use crate::crypto::{AesCtr, SecureRandom, sha256};
use crate::error::{HandshakeResult, ProxyError}; use crate::error::{HandshakeResult, ProxyError};
use crate::protocol::constants::*; use crate::protocol::constants::*;
@ -510,6 +510,21 @@ fn decode_user_secrets(
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) { async fn maybe_apply_server_hello_delay(config: &ProxyConfig) {
if config.censorship.server_hello_delay_max_ms == 0 { if config.censorship.server_hello_delay_max_ms == 0 {
return; return;
@ -593,6 +608,25 @@ where
} }
let client_sni = tls::extract_sni_from_client_hello(handshake); 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 secrets = decode_user_secrets(config, client_sni.as_deref());
let validation = match tls::validate_tls_handshake_with_replay_window( let validation = match tls::validate_tls_handshake_with_replay_window(
@ -633,16 +667,8 @@ where
let cached = if config.censorship.tls_emulation { let cached = if config.censorship.tls_emulation {
if let Some(cache) = tls_cache.as_ref() { if let Some(cache) = tls_cache.as_ref() {
let selected_domain = if let Some(sni) = client_sni.as_ref() { let selected_domain = matched_tls_domain.unwrap_or(config.censorship.tls_domain.as_str());
if cache.contains_domain(sni).await { let cached_entry = cache.get(selected_domain).await;
sni.clone()
} else {
config.censorship.tls_domain.clone()
}
} else {
config.censorship.tls_domain.clone()
};
let cached_entry = cache.get(&selected_domain).await;
let use_full_cert_payload = cache let use_full_cert_payload = cache
.take_full_cert_budget_for_ip( .take_full_cert_budget_for_ip(
peer.ip(), peer.ip(),

View File

@ -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] #[tokio::test]
async fn alpn_enforce_rejects_unsupported_client_alpn() { async fn alpn_enforce_rejects_unsupported_client_alpn() {
let secret = [0x33u8; 16]; let secret = [0x33u8; 16];