diff --git a/docs/Architecture/API/API.md b/docs/Architecture/API/API.md index 7177200..502453f 100644 --- a/docs/Architecture/API/API.md +++ b/docs/Architecture/API/API.md @@ -178,6 +178,21 @@ Notes: | `data_quota_bytes` | `u64` | no | Per-user traffic quota. | | `max_unique_ips` | `usize` | no | Per-user unique source IP limit. | +### `access.user_source_deny` via API +- In current API surface, per-user deny-list is **not** exposed as a dedicated field in `CreateUserRequest` / `PatchUserRequest`. +- Configure it in `config.toml` under `[access.user_source_deny]` and apply via normal config reload path. +- Runtime behavior after apply: + - auth succeeds for username/secret + - source IP is checked against `access.user_source_deny[username]` + - on match, handshake is rejected with the same fail-closed outcome as invalid auth + +Example config: +```toml +[access.user_source_deny] +alice = ["203.0.113.0/24", "2001:db8:abcd::/48"] +bob = ["198.51.100.42/32"] +``` + ### `RotateSecretRequest` | Field | Type | Required | Description | | --- | --- | --- | --- | diff --git a/docs/Config_params/CONFIG_PARAMS.en.md b/docs/Config_params/CONFIG_PARAMS.en.md index d222e1f..8cd193e 100644 --- a/docs/Config_params/CONFIG_PARAMS.en.md +++ b/docs/Config_params/CONFIG_PARAMS.en.md @@ -2886,6 +2886,7 @@ If your backend or network is very bandwidth-constrained, reduce cap first. If p | [`user_max_unique_ips_global_each`](#user_max_unique_ips_global_each) | `usize` | `0` | | [`user_max_unique_ips_mode`](#user_max_unique_ips_mode) | `"active_window"`, `"time_window"`, or `"combined"` | `"active_window"` | | [`user_max_unique_ips_window_secs`](#user_max_unique_ips_window_secs) | `u64` | `30` | +| [`user_source_deny`](#user_source_deny) | `Map` | `{}` | | [`replay_check_len`](#replay_check_len) | `usize` | `65536` | | [`replay_window_secs`](#replay_window_secs) | `u64` | `120` | | [`ignore_time_skew`](#ignore_time_skew) | `bool` | `false` | @@ -2990,6 +2991,20 @@ If your backend or network is very bandwidth-constrained, reduce cap first. If p [access] user_max_unique_ips_window_secs = 30 ``` +## user_source_deny + - **Constraints / validation**: Table `username -> IpNetwork[]`. Each network must parse as CIDR (for example `203.0.113.0/24` or `2001:db8::/32`). + - **Description**: Per-user source IP/CIDR deny-list applied **after successful auth** in TLS and MTProto handshake paths. A matched source IP is rejected via the same fail-closed path as invalid auth. + - **Example**: + + ```toml + [access.user_source_deny] + alice = ["203.0.113.0/24", "2001:db8:abcd::/48"] + bob = ["198.51.100.42/32"] + ``` + + - **How it works (quick check)**: + - connection from user `alice` and source `203.0.113.55` -> rejected (matches `203.0.113.0/24`) + - connection from user `alice` and source `198.51.100.10` -> allowed by this rule set (no match) ## replay_check_len - **Constraints / validation**: `usize`. - **Description**: Replay-protection storage length (number of entries tracked for duplicate detection). diff --git a/src/config/types.rs b/src/config/types.rs index 912f2b2..38501e6 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -1893,6 +1893,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, @@ -1928,6 +1934,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(), @@ -1939,6 +1946,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