From b859fb95c333cafe2832d256382ebab613277f2a Mon Sep 17 00:00:00 2001 From: Konstantin Pichugin Date: Wed, 6 May 2026 19:11:18 +0300 Subject: [PATCH] feat(access): add per-user source IP deny list checks Add access.user_source_deny and enforce it in TLS and MTProto handshake paths after successful authentication to fail closed for blocked source IPs. --- src/config/types.rs | 16 ++++++++++++++++ src/proxy/handshake.rs | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/src/config/types.rs b/src/config/types.rs index 4762083..ebeb629 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -1887,6 +1887,12 @@ pub struct AccessConfig { #[serde(default)] pub cidr_rate_limits: HashMap, + /// Per-username client source IP/CIDR deny list. Checked after successful + /// authentication; matching IPs get the same rejection path as invalid auth + /// (handshake fails closed for that connection). + #[serde(default)] + pub user_source_deny: HashMap>, + #[serde(default)] pub user_max_unique_ips: HashMap, @@ -1922,6 +1928,7 @@ impl Default for AccessConfig { user_data_quota: HashMap::new(), user_rate_limits: HashMap::new(), cidr_rate_limits: HashMap::new(), + user_source_deny: HashMap::new(), user_max_unique_ips: HashMap::new(), user_max_unique_ips_global_each: default_user_max_unique_ips_global_each(), user_max_unique_ips_mode: UserMaxUniqueIpsMode::default(), @@ -1933,6 +1940,15 @@ impl Default for AccessConfig { } } +impl AccessConfig { + /// Returns true if `ip` is contained in any CIDR listed for `username` under `user_source_deny`. + pub fn is_user_source_ip_denied(&self, username: &str, ip: IpAddr) -> bool { + self.user_source_deny + .get(username) + .is_some_and(|nets| nets.iter().any(|n| n.contains(ip))) + } +} + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct RateLimitBps { #[serde(default)] diff --git a/src/proxy/handshake.rs b/src/proxy/handshake.rs index f719349..b49b618 100644 --- a/src/proxy/handshake.rs +++ b/src/proxy/handshake.rs @@ -1450,6 +1450,20 @@ where validated_secret.copy_from_slice(secret); } + if config + .access + .is_user_source_ip_denied(validated_user.as_str(), peer.ip()) + { + auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); + maybe_apply_server_hello_delay(config).await; + warn!( + peer = %peer, + user = %validated_user, + "TLS handshake rejected: client source IP on per-user deny list (access.user_source_deny)" + ); + return HandshakeResult::BadClient { reader, writer }; + } + // Reject known replay digests before expensive cache/domain/ALPN policy work. let digest_half = &validation_digest[..tls::TLS_DIGEST_HALF_LEN]; if replay_checker.check_tls_digest(digest_half) { @@ -1795,6 +1809,20 @@ where let validation = matched_validation.expect("validation must exist when matched"); + if config + .access + .is_user_source_ip_denied(matched_user.as_str(), peer.ip()) + { + auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); + maybe_apply_server_hello_delay(config).await; + warn!( + peer = %peer, + user = %matched_user, + "MTProto handshake rejected: client source IP on per-user deny list (access.user_source_deny)" + ); + return HandshakeResult::BadClient { reader, writer }; + } + // Apply replay tracking only after successful authentication. // // This ordering prevents an attacker from producing invalid handshakes that @@ -1873,6 +1901,17 @@ where .auth_expensive_checks_total .fetch_add(validation_checks as u64, Ordering::Relaxed); + if config.access.is_user_source_ip_denied(user.as_str(), peer.ip()) { + auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); + maybe_apply_server_hello_delay(config).await; + warn!( + peer = %peer, + user = %user, + "MTProto handshake rejected: client source IP on per-user deny list (access.user_source_deny)" + ); + return HandshakeResult::BadClient { reader, writer }; + } + // Apply replay tracking only after successful authentication. // // This ordering prevents an attacker from producing invalid handshakes that