feat(smart-proxy): add hot-reloadable global ingress security policy across Rust and TypeScript proxy layers
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use base64::Engine;
|
||||
|
||||
/// Basic auth validator.
|
||||
pub struct BasicAuthValidator {
|
||||
|
||||
@@ -21,7 +21,7 @@ struct DomainScopedEntry {
|
||||
}
|
||||
|
||||
/// Represents an IP pattern for matching.
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone)]
|
||||
enum IpPattern {
|
||||
/// Exact IP match
|
||||
Exact(IpAddr),
|
||||
@@ -31,6 +31,37 @@ enum IpPattern {
|
||||
Wildcard,
|
||||
}
|
||||
|
||||
/// Compiled block list for early ingress filtering.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IpBlockList {
|
||||
block_list: Vec<IpPattern>,
|
||||
}
|
||||
|
||||
impl IpBlockList {
|
||||
pub fn new(block_list: &[String]) -> Self {
|
||||
Self {
|
||||
block_list: block_list.iter().map(|s| IpPattern::parse(s)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
block_list: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.block_list.is_empty()
|
||||
}
|
||||
|
||||
pub fn is_blocked(&self, ip: &IpAddr) -> bool {
|
||||
let normalized = IpFilter::normalize_ip(ip);
|
||||
self.block_list
|
||||
.iter()
|
||||
.any(|pattern| pattern.matches(&normalized))
|
||||
}
|
||||
}
|
||||
|
||||
impl IpPattern {
|
||||
fn parse(s: &str) -> Self {
|
||||
let s = s.trim();
|
||||
@@ -68,8 +99,7 @@ fn domain_matches_pattern(pattern: &str, domain: &str) -> bool {
|
||||
}
|
||||
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)
|
||||
d.len() > suffix.len() && d[d.len() - suffix.len()..].eq_ignore_ascii_case(suffix)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
@@ -127,7 +157,11 @@ impl IpFilter {
|
||||
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)) {
|
||||
if entry
|
||||
.domains
|
||||
.iter()
|
||||
.any(|d| domain_matches_pattern(d, req_domain))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -212,10 +246,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_block_trumps_allow() {
|
||||
let filter = IpFilter::new(
|
||||
&[plain("10.0.0.0/8")],
|
||||
&["10.0.0.5".to_string()],
|
||||
);
|
||||
let filter = IpFilter::new(&[plain("10.0.0.0/8")], &["10.0.0.5".to_string()]);
|
||||
let blocked: IpAddr = "10.0.0.5".parse().unwrap();
|
||||
let allowed: IpAddr = "10.0.0.6".parse().unwrap();
|
||||
assert!(!filter.is_allowed(&blocked));
|
||||
@@ -255,30 +286,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_domain_scoped_allows_matching_domain() {
|
||||
let filter = IpFilter::new(
|
||||
&[scoped("10.8.0.2", &["outline.abc.xyz"])],
|
||||
&[],
|
||||
);
|
||||
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 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 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));
|
||||
@@ -286,10 +308,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_domain_scoped_wildcard_domain() {
|
||||
let filter = IpFilter::new(
|
||||
&[scoped("10.8.0.2", &["*.abc.xyz"])],
|
||||
&[],
|
||||
);
|
||||
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")));
|
||||
@@ -300,8 +319,8 @@ mod tests {
|
||||
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
|
||||
plain("1.2.3.4"), // full route access
|
||||
scoped("10.8.0.2", &["outline.abc.xyz"]), // scoped access
|
||||
],
|
||||
&[],
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use jsonwebtoken::{decode, DecodingKey, Validation, Algorithm};
|
||||
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// JWT claims (minimal structure).
|
||||
@@ -160,10 +160,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_extract_token_bearer() {
|
||||
assert_eq!(
|
||||
JwtValidator::extract_token("Bearer abc123"),
|
||||
Some("abc123")
|
||||
);
|
||||
assert_eq!(JwtValidator::extract_token("Bearer abc123"), Some("abc123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
//!
|
||||
//! IP filtering, rate limiting, and authentication for RustProxy.
|
||||
|
||||
pub mod ip_filter;
|
||||
pub mod rate_limiter;
|
||||
pub mod basic_auth;
|
||||
pub mod ip_filter;
|
||||
pub mod jwt_auth;
|
||||
pub mod rate_limiter;
|
||||
|
||||
pub use ip_filter::*;
|
||||
pub use rate_limiter::*;
|
||||
pub use basic_auth::*;
|
||||
pub use ip_filter::*;
|
||||
pub use jwt_auth::*;
|
||||
pub use rate_limiter::*;
|
||||
|
||||
@@ -79,7 +79,7 @@ mod tests {
|
||||
assert!(limiter.check("client-a"));
|
||||
assert!(limiter.check("client-a"));
|
||||
assert!(!limiter.check("client-a")); // blocked
|
||||
// Different key should still be allowed
|
||||
// Different key should still be allowed
|
||||
assert!(limiter.check("client-b"));
|
||||
assert!(limiter.check("client-b"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user