From 99a026627d4e64b52003ec6085d778ef129b09da Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Mon, 6 Apr 2026 12:46:09 +0000 Subject: [PATCH] feat(security): add domain-scoped IP allow list support across HTTP and passthrough filtering --- changelog.md | 7 + .../rustproxy-config/src/security_types.rs | 20 +- .../rustproxy-http/src/request_filter.rs | 13 +- .../src/connection_registry.rs | 2 +- .../rustproxy-passthrough/src/quic_handler.rs | 4 +- .../rustproxy-passthrough/src/tcp_listener.rs | 7 +- .../rustproxy-security/src/ip_filter.rs | 231 +++++++++++++++--- rust/crates/rustproxy/src/lib.rs | 4 +- ts/00_commitinfo_data.ts | 2 +- ts/proxies/smart-proxy/models/route-types.ts | 6 +- .../smart-proxy/utils/route-validator.ts | 17 +- 11 files changed, 256 insertions(+), 57 deletions(-) diff --git a/changelog.md b/changelog.md index 45540bc..6241afe 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # Changelog +## 2026-04-06 - 27.5.0 - feat(security) +add domain-scoped IP allow list support across HTTP and passthrough filtering + +- extend route security types to accept IP allow entries scoped to specific domains +- apply domain-aware IP checks using Host headers for HTTP and SNI context for QUIC and passthrough connections +- preserve compatibility for existing plain allow list entries and add validation and tests for scoped matching + ## 2026-04-04 - 27.4.0 - feat(rustproxy) add HTTP/3 proxy service wiring for QUIC listeners diff --git a/rust/crates/rustproxy-config/src/security_types.rs b/rust/crates/rustproxy-config/src/security_types.rs index 4a6ad10..f539425 100644 --- a/rust/crates/rustproxy-config/src/security_types.rs +++ b/rust/crates/rustproxy-config/src/security_types.rs @@ -103,14 +103,30 @@ pub struct JwtAuthConfig { pub exclude_paths: Option>, } +/// An entry in the IP allow list: either a plain IP/CIDR string +/// or a domain-scoped entry that restricts the IP to specific domains. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum IpAllowEntry { + /// Plain IP/CIDR — allowed for all domains on this route + Plain(String), + /// Domain-scoped — allowed only when the requested domain matches + DomainScoped { + ip: String, + domains: Vec, + }, +} + /// Security options for routes. /// Matches TypeScript: `IRouteSecurity` #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RouteSecurity { - /// IP addresses that are allowed to connect + /// IP addresses that are allowed to connect. + /// Entries can be plain strings (full route access) or objects with + /// `{ ip, domains }` to scope access to specific domains. #[serde(skip_serializing_if = "Option::is_none")] - pub ip_allow_list: Option>, + pub ip_allow_list: Option>, /// IP addresses that are blocked from connecting #[serde(skip_serializing_if = "Option::is_none")] pub ip_block_list: Option>, diff --git a/rust/crates/rustproxy-http/src/request_filter.rs b/rust/crates/rustproxy-http/src/request_filter.rs index 92fc2a2..163f2b7 100644 --- a/rust/crates/rustproxy-http/src/request_filter.rs +++ b/rust/crates/rustproxy-http/src/request_filter.rs @@ -35,13 +35,17 @@ impl RequestFilter { let client_ip = peer_addr.ip(); let request_path = req.uri().path(); - // IP filter + // IP filter (domain-aware: extract Host header for domain-scoped entries) if security.ip_allow_list.is_some() || security.ip_block_list.is_some() { let allow = security.ip_allow_list.as_deref().unwrap_or(&[]); let block = security.ip_block_list.as_deref().unwrap_or(&[]); let filter = IpFilter::new(allow, block); let normalized = IpFilter::normalize_ip(&client_ip); - if !filter.is_allowed(&normalized) { + let host = req.headers() + .get("host") + .and_then(|v| v.to_str().ok()) + .map(|h| h.split(':').next().unwrap_or(h)); + if !filter.is_allowed_for_domain(&normalized, host) { return Some(error_response(StatusCode::FORBIDDEN, "Access denied")); } } @@ -203,14 +207,15 @@ impl RequestFilter { } /// Check IP-based security (for use in passthrough / TCP-level connections). + /// `domain` is the SNI from the TLS handshake (if available) for domain-scoped filtering. /// Returns true if allowed, false if blocked. - pub fn check_ip_security(security: &RouteSecurity, client_ip: &std::net::IpAddr) -> bool { + pub fn check_ip_security(security: &RouteSecurity, client_ip: &std::net::IpAddr, domain: Option<&str>) -> bool { if security.ip_allow_list.is_some() || security.ip_block_list.is_some() { let allow = security.ip_allow_list.as_deref().unwrap_or(&[]); let block = security.ip_block_list.as_deref().unwrap_or(&[]); let filter = IpFilter::new(allow, block); let normalized = IpFilter::normalize_ip(client_ip); - filter.is_allowed(&normalized) + filter.is_allowed_for_domain(&normalized, domain) } else { true } diff --git a/rust/crates/rustproxy-passthrough/src/connection_registry.rs b/rust/crates/rustproxy-passthrough/src/connection_registry.rs index ee360ad..984a30c 100644 --- a/rust/crates/rustproxy-passthrough/src/connection_registry.rs +++ b/rust/crates/rustproxy-passthrough/src/connection_registry.rs @@ -100,7 +100,7 @@ impl ConnectionRegistry { let mut recycled = 0u64; self.connections.retain(|_, entry| { if entry.route_id.as_deref() == Some(route_id) { - if !RequestFilter::check_ip_security(new_security, &entry.source_ip) { + if !RequestFilter::check_ip_security(new_security, &entry.source_ip, entry.domain.as_deref()) { info!( "Terminating connection from {} — IP now blocked on route '{}'", entry.source_ip, route_id diff --git a/rust/crates/rustproxy-passthrough/src/quic_handler.rs b/rust/crates/rustproxy-passthrough/src/quic_handler.rs index 6df497f..b6b379c 100644 --- a/rust/crates/rustproxy-passthrough/src/quic_handler.rs +++ b/rust/crates/rustproxy-passthrough/src/quic_handler.rs @@ -409,10 +409,10 @@ pub async fn quic_accept_loop( } }; - // Check route-level IP security (previously missing for QUIC) + // Check route-level IP security for QUIC (domain from SNI context) if let Some(ref security) = route.security { if !rustproxy_http::request_filter::RequestFilter::check_ip_security( - security, &ip, + security, &ip, ctx.domain, ) { debug!("QUIC connection from {} blocked by route security", real_addr); continue; diff --git a/rust/crates/rustproxy-passthrough/src/tcp_listener.rs b/rust/crates/rustproxy-passthrough/src/tcp_listener.rs index a59f9e3..29ed085 100644 --- a/rust/crates/rustproxy-passthrough/src/tcp_listener.rs +++ b/rust/crates/rustproxy-passthrough/src/tcp_listener.rs @@ -737,10 +737,10 @@ impl TcpListenerManager { }, ); - // Check route-level IP security + // Check route-level IP security (fast path: no SNI available) if let Some(ref security) = quick_match.route.security { if !rustproxy_http::request_filter::RequestFilter::check_ip_security( - security, &peer_addr.ip(), + security, &peer_addr.ip(), None, ) { warn!("Connection from {} blocked by route security", peer_addr); return Ok(()); @@ -929,11 +929,12 @@ impl TcpListenerManager { }, ); - // Check route-level IP security for passthrough connections + // Check route-level IP security for passthrough connections (SNI available) if let Some(ref security) = route_match.route.security { if !rustproxy_http::request_filter::RequestFilter::check_ip_security( security, &peer_addr.ip(), + domain.as_deref(), ) { warn!("Connection from {} blocked by route security", peer_addr); return Ok(()); diff --git a/rust/crates/rustproxy-security/src/ip_filter.rs b/rust/crates/rustproxy-security/src/ip_filter.rs index 3d0f5dc..c7fd2af 100644 --- a/rust/crates/rustproxy-security/src/ip_filter.rs +++ b/rust/crates/rustproxy-security/src/ip_filter.rs @@ -2,12 +2,24 @@ use ipnet::IpNet; use std::net::IpAddr; use std::str::FromStr; +use rustproxy_config::IpAllowEntry; + /// IP filter supporting CIDR ranges, wildcards, and exact matches. +/// Supports domain-scoped allow entries that restrict an IP to specific domains. pub struct IpFilter { + /// Plain allow entries — IP allowed for any domain on the route allow_list: Vec, + /// Domain-scoped allow entries — IP allowed only for matching domains + domain_scoped: Vec, block_list: Vec, } +/// A domain-scoped allow entry: IP + list of allowed domain patterns. +struct DomainScopedEntry { + pattern: IpPattern, + domains: Vec, +} + /// Represents an IP pattern for matching. #[derive(Debug)] enum IpPattern { @@ -31,10 +43,6 @@ impl IpPattern { if let Ok(addr) = IpAddr::from_str(s) { return IpPattern::Exact(addr); } - // Try as CIDR by appending default prefix - if let Ok(addr) = IpAddr::from_str(s) { - return IpPattern::Exact(addr); - } // Fallback: treat as exact, will never match an invalid string IpPattern::Exact(IpAddr::from_str("0.0.0.0").unwrap()) } @@ -48,19 +56,56 @@ impl IpPattern { } } +/// Simple domain pattern matching (exact, `*`, or `*.suffix`). +fn domain_matches_pattern(pattern: &str, domain: &str) -> bool { + let p = pattern.trim(); + let d = domain.trim(); + if p == "*" { + return true; + } + if p.eq_ignore_ascii_case(d) { + return true; + } + if p.starts_with("*.") { + let suffix = &p[1..]; // e.g., ".abc.xyz" + d.len() > suffix.len() + && d[d.len() - suffix.len()..].eq_ignore_ascii_case(suffix) + } else { + false + } +} + impl IpFilter { - /// Create a new IP filter from allow and block lists. - pub fn new(allow_list: &[String], block_list: &[String]) -> Self { + /// Create a new IP filter from allow entries and a block list. + pub fn new(allow_entries: &[IpAllowEntry], block_list: &[String]) -> Self { + let mut allow_list = Vec::new(); + let mut domain_scoped = Vec::new(); + + for entry in allow_entries { + match entry { + IpAllowEntry::Plain(ip) => { + allow_list.push(IpPattern::parse(ip)); + } + IpAllowEntry::DomainScoped { ip, domains } => { + domain_scoped.push(DomainScopedEntry { + pattern: IpPattern::parse(ip), + domains: domains.clone(), + }); + } + } + } + Self { - allow_list: allow_list.iter().map(|s| IpPattern::parse(s)).collect(), + allow_list, + domain_scoped, block_list: block_list.iter().map(|s| IpPattern::parse(s)).collect(), } } - /// Check if an IP is allowed. - /// If allow_list is non-empty, IP must match at least one entry. - /// If block_list is non-empty, IP must NOT match any entry. - pub fn is_allowed(&self, ip: &IpAddr) -> bool { + /// Check if an IP is allowed, considering domain-scoped entries. + /// If `domain` is Some, domain-scoped entries are evaluated against it. + /// If `domain` is None, only plain allow entries are considered. + pub fn is_allowed_for_domain(&self, ip: &IpAddr, domain: Option<&str>) -> bool { // Check block list first if !self.block_list.is_empty() { for pattern in &self.block_list { @@ -70,14 +115,36 @@ impl IpFilter { } } - // If allow list is non-empty, must match at least one - if !self.allow_list.is_empty() { - return self.allow_list.iter().any(|p| p.matches(ip)); + // If there are any allow entries (plain or domain-scoped), IP must match + let has_any_allow = !self.allow_list.is_empty() || !self.domain_scoped.is_empty(); + if has_any_allow { + // Check plain allow list — grants access to entire route + if self.allow_list.iter().any(|p| p.matches(ip)) { + return true; + } + + // Check domain-scoped entries — grants access only if domain matches + if let Some(req_domain) = domain { + for entry in &self.domain_scoped { + if entry.pattern.matches(ip) { + if entry.domains.iter().any(|d| domain_matches_pattern(d, req_domain)) { + return true; + } + } + } + } + + return false; } true } + /// Check if an IP is allowed (backwards-compat wrapper, no domain context). + pub fn is_allowed(&self, ip: &IpAddr) -> bool { + self.is_allowed_for_domain(ip, None) + } + /// Normalize IPv4-mapped IPv6 addresses (::ffff:x.x.x.x -> x.x.x.x) pub fn normalize_ip(ip: &IpAddr) -> IpAddr { match ip { @@ -97,19 +164,28 @@ impl IpFilter { mod tests { use super::*; + fn plain(s: &str) -> IpAllowEntry { + IpAllowEntry::Plain(s.to_string()) + } + + fn scoped(ip: &str, domains: &[&str]) -> IpAllowEntry { + IpAllowEntry::DomainScoped { + ip: ip.to_string(), + domains: domains.iter().map(|s| s.to_string()).collect(), + } + } + #[test] fn test_empty_lists_allow_all() { let filter = IpFilter::new(&[], &[]); let ip: IpAddr = "192.168.1.1".parse().unwrap(); assert!(filter.is_allowed(&ip)); + assert!(filter.is_allowed_for_domain(&ip, Some("example.com"))); } #[test] - fn test_allow_list_exact() { - let filter = IpFilter::new( - &["10.0.0.1".to_string()], - &[], - ); + fn test_plain_allow_list_exact() { + let filter = IpFilter::new(&[plain("10.0.0.1")], &[]); let allowed: IpAddr = "10.0.0.1".parse().unwrap(); let denied: IpAddr = "10.0.0.2".parse().unwrap(); assert!(filter.is_allowed(&allowed)); @@ -117,11 +193,8 @@ mod tests { } #[test] - fn test_allow_list_cidr() { - let filter = IpFilter::new( - &["10.0.0.0/8".to_string()], - &[], - ); + fn test_plain_allow_list_cidr() { + let filter = IpFilter::new(&[plain("10.0.0.0/8")], &[]); let allowed: IpAddr = "10.255.255.255".parse().unwrap(); let denied: IpAddr = "192.168.1.1".parse().unwrap(); assert!(filter.is_allowed(&allowed)); @@ -130,10 +203,7 @@ mod tests { #[test] fn test_block_list() { - let filter = IpFilter::new( - &[], - &["192.168.1.100".to_string()], - ); + let filter = IpFilter::new(&[], &["192.168.1.100".to_string()]); let blocked: IpAddr = "192.168.1.100".parse().unwrap(); let allowed: IpAddr = "192.168.1.101".parse().unwrap(); assert!(!filter.is_allowed(&blocked)); @@ -143,7 +213,7 @@ mod tests { #[test] fn test_block_trumps_allow() { let filter = IpFilter::new( - &["10.0.0.0/8".to_string()], + &[plain("10.0.0.0/8")], &["10.0.0.5".to_string()], ); let blocked: IpAddr = "10.0.0.5".parse().unwrap(); @@ -154,20 +224,14 @@ mod tests { #[test] fn test_wildcard_allow() { - let filter = IpFilter::new( - &["*".to_string()], - &[], - ); + let filter = IpFilter::new(&[plain("*")], &[]); let ip: IpAddr = "1.2.3.4".parse().unwrap(); assert!(filter.is_allowed(&ip)); } #[test] fn test_wildcard_block() { - let filter = IpFilter::new( - &[], - &["*".to_string()], - ); + let filter = IpFilter::new(&[], &["*".to_string()]); let ip: IpAddr = "1.2.3.4".parse().unwrap(); assert!(!filter.is_allowed(&ip)); } @@ -186,4 +250,97 @@ mod tests { let normalized = IpFilter::normalize_ip(&ip); assert_eq!(normalized, ip); } + + // Domain-scoped tests + + #[test] + fn test_domain_scoped_allows_matching_domain() { + let filter = IpFilter::new( + &[scoped("10.8.0.2", &["outline.abc.xyz"])], + &[], + ); + let ip: IpAddr = "10.8.0.2".parse().unwrap(); + assert!(filter.is_allowed_for_domain(&ip, Some("outline.abc.xyz"))); + } + + #[test] + fn test_domain_scoped_denies_non_matching_domain() { + let filter = IpFilter::new( + &[scoped("10.8.0.2", &["outline.abc.xyz"])], + &[], + ); + let ip: IpAddr = "10.8.0.2".parse().unwrap(); + assert!(!filter.is_allowed_for_domain(&ip, Some("app.abc.xyz"))); + } + + #[test] + fn test_domain_scoped_denies_without_domain() { + let filter = IpFilter::new( + &[scoped("10.8.0.2", &["outline.abc.xyz"])], + &[], + ); + let ip: IpAddr = "10.8.0.2".parse().unwrap(); + // Without domain context, domain-scoped entries cannot match + assert!(!filter.is_allowed_for_domain(&ip, None)); + } + + #[test] + fn test_domain_scoped_wildcard_domain() { + let filter = IpFilter::new( + &[scoped("10.8.0.2", &["*.abc.xyz"])], + &[], + ); + let ip: IpAddr = "10.8.0.2".parse().unwrap(); + assert!(filter.is_allowed_for_domain(&ip, Some("outline.abc.xyz"))); + assert!(filter.is_allowed_for_domain(&ip, Some("app.abc.xyz"))); + assert!(!filter.is_allowed_for_domain(&ip, Some("other.com"))); + } + + #[test] + fn test_plain_and_domain_scoped_coexist() { + let filter = IpFilter::new( + &[ + plain("1.2.3.4"), // full route access + scoped("10.8.0.2", &["outline.abc.xyz"]), // scoped access + ], + &[], + ); + + let admin: IpAddr = "1.2.3.4".parse().unwrap(); + let vpn: IpAddr = "10.8.0.2".parse().unwrap(); + let other: IpAddr = "9.9.9.9".parse().unwrap(); + + // Admin IP has full access + assert!(filter.is_allowed_for_domain(&admin, Some("anything.abc.xyz"))); + assert!(filter.is_allowed_for_domain(&admin, Some("outline.abc.xyz"))); + + // VPN IP only has scoped access + assert!(filter.is_allowed_for_domain(&vpn, Some("outline.abc.xyz"))); + assert!(!filter.is_allowed_for_domain(&vpn, Some("app.abc.xyz"))); + + // Unknown IP denied + assert!(!filter.is_allowed_for_domain(&other, Some("outline.abc.xyz"))); + } + + #[test] + fn test_block_trumps_domain_scoped() { + let filter = IpFilter::new( + &[scoped("10.8.0.2", &["outline.abc.xyz"])], + &["10.8.0.2".to_string()], + ); + let ip: IpAddr = "10.8.0.2".parse().unwrap(); + assert!(!filter.is_allowed_for_domain(&ip, Some("outline.abc.xyz"))); + } + + #[test] + fn test_domain_matches_pattern_fn() { + assert!(domain_matches_pattern("example.com", "example.com")); + assert!(domain_matches_pattern("*.abc.xyz", "outline.abc.xyz")); + assert!(domain_matches_pattern("*.abc.xyz", "app.abc.xyz")); + assert!(!domain_matches_pattern("*.abc.xyz", "abc.xyz")); // suffix only, not exact parent + assert!(domain_matches_pattern("*", "anything.com")); + assert!(!domain_matches_pattern("outline.abc.xyz", "app.abc.xyz")); + // Case insensitive + assert!(domain_matches_pattern("*.ABC.XYZ", "outline.abc.xyz")); + } } diff --git a/rust/crates/rustproxy/src/lib.rs b/rust/crates/rustproxy/src/lib.rs index b241720..d28176f 100644 --- a/rust/crates/rustproxy/src/lib.rs +++ b/rust/crates/rustproxy/src/lib.rs @@ -198,7 +198,9 @@ impl RustProxy { }; if let Some(ref allow_list) = default_security.ip_allow_list { - security.ip_allow_list = Some(allow_list.clone()); + security.ip_allow_list = Some( + allow_list.iter().map(|s| rustproxy_config::IpAllowEntry::Plain(s.clone())).collect() + ); } if let Some(ref block_list) = default_security.ip_block_list { security.ip_block_list = Some(block_list.clone()); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 21784e7..23d7709 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartproxy', - version: '27.4.0', + version: '27.5.0', description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.' } diff --git a/ts/proxies/smart-proxy/models/route-types.ts b/ts/proxies/smart-proxy/models/route-types.ts index 8ede6e7..b8a05aa 100644 --- a/ts/proxies/smart-proxy/models/route-types.ts +++ b/ts/proxies/smart-proxy/models/route-types.ts @@ -141,8 +141,10 @@ export interface IRouteAuthentication { * Security options for routes */ export interface IRouteSecurity { - // Access control lists - ipAllowList?: string[]; // IP addresses that are allowed to connect + // Access control lists. + // Entries can be plain IP/CIDR strings (full route access) or + // objects { ip, domains } to scope access to specific domains on this route. + ipAllowList?: Array; ipBlockList?: string[]; // IP addresses that are blocked from connecting // Connection limits diff --git a/ts/proxies/smart-proxy/utils/route-validator.ts b/ts/proxies/smart-proxy/utils/route-validator.ts index 4a47636..d4fcadc 100644 --- a/ts/proxies/smart-proxy/utils/route-validator.ts +++ b/ts/proxies/smart-proxy/utils/route-validator.ts @@ -196,10 +196,19 @@ export class RouteValidator { // Validate IP allow/block lists if (route.security.ipAllowList) { const allowList = Array.isArray(route.security.ipAllowList) ? route.security.ipAllowList : [route.security.ipAllowList]; - - for (const ip of allowList) { - if (!this.isValidIPPattern(ip)) { - errors.push(`Invalid IP pattern in allow list: ${ip}`); + + for (const entry of allowList) { + if (typeof entry === 'string') { + if (!this.isValidIPPattern(entry)) { + errors.push(`Invalid IP pattern in allow list: ${entry}`); + } + } else if (entry && typeof entry === 'object') { + if (!this.isValidIPPattern(entry.ip)) { + errors.push(`Invalid IP pattern in domain-scoped allow entry: ${entry.ip}`); + } + if (!Array.isArray(entry.domains) || entry.domains.length === 0) { + errors.push(`Domain-scoped allow entry for ${entry.ip} must have non-empty domains array`); + } } } }