feat(security): add domain-scoped IP allow list support across HTTP and passthrough filtering
This commit is contained in:
@@ -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<IpPattern>,
|
||||
/// Domain-scoped allow entries — IP allowed only for matching domains
|
||||
domain_scoped: Vec<DomainScopedEntry>,
|
||||
block_list: Vec<IpPattern>,
|
||||
}
|
||||
|
||||
/// A domain-scoped allow entry: IP + list of allowed domain patterns.
|
||||
struct DomainScopedEntry {
|
||||
pattern: IpPattern,
|
||||
domains: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user