use axum::http::Request;
use std::net::{IpAddr, SocketAddr};
use thiserror::Error;
use tracing::info;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FilterRule {
Ip(IpAddr),
Cidr { addr: IpAddr, prefix_len: u8 },
PathPrefix(String),
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FilterPolicy {
pub deny: Vec<FilterRule>,
pub allow: Vec<FilterRule>,
}
#[derive(Debug, Error)]
pub enum FilterParseError {
#[error("invalid filter entry `{0}`")]
InvalidEntry(String),
#[error("invalid CIDR `{0}`")]
InvalidCidr(String),
}
pub fn policy_from_env() -> Result<Option<FilterPolicy>, FilterParseError> {
let deny = parse_env_list("NAUTALID_FILTER_DENY")?;
let allow = parse_env_list("NAUTALID_FILTER_ALLOW")?;
if deny.is_empty() && allow.is_empty() {
return Ok(None);
}
info!(
deny = deny.len(),
allow = allow.len(),
"nautalid filter policy loaded from environment"
);
Ok(Some(FilterPolicy { deny, allow }))
}
pub fn policy_from_rules(
deny: &[String],
allow: &[String],
) -> Result<FilterPolicy, FilterParseError> {
Ok(FilterPolicy {
deny: deny
.iter()
.map(String::as_str)
.map(parse_rule)
.collect::<Result<_, _>>()?,
allow: allow
.iter()
.map(String::as_str)
.map(parse_rule)
.collect::<Result<_, _>>()?,
})
}
pub fn policy_from_lists(
deny_csv: &str,
allow_csv: &str,
) -> Result<FilterPolicy, FilterParseError> {
Ok(FilterPolicy {
deny: parse_csv_rules(deny_csv)?,
allow: parse_csv_rules(allow_csv)?,
})
}
fn parse_csv_rules(raw: &str) -> Result<Vec<FilterRule>, FilterParseError> {
if raw.trim().is_empty() {
return Ok(Vec::new());
}
raw.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(parse_rule)
.collect()
}
fn parse_env_list(key: &str) -> Result<Vec<FilterRule>, FilterParseError> {
let Ok(raw) = std::env::var(key) else {
return Ok(Vec::new());
};
parse_csv_rules(&raw)
}
fn parse_rule(entry: &str) -> Result<FilterRule, FilterParseError> {
if entry.starts_with('/') {
return Ok(FilterRule::PathPrefix(entry.to_string()));
}
if let Some((net, prefix)) = entry.split_once('/') {
let addr: IpAddr = net
.trim()
.parse()
.map_err(|_| FilterParseError::InvalidCidr(entry.to_string()))?;
let prefix_len: u8 = prefix
.trim()
.parse()
.map_err(|_| FilterParseError::InvalidCidr(entry.to_string()))?;
let max_prefix = if addr.is_ipv4() { 32 } else { 128 };
if prefix_len > max_prefix {
return Err(FilterParseError::InvalidCidr(entry.to_string()));
}
return Ok(FilterRule::Cidr { addr, prefix_len });
}
entry
.parse::<IpAddr>()
.map(FilterRule::Ip)
.map_err(|_| FilterParseError::InvalidEntry(entry.to_string()))
}
fn trust_xforwarded_for() -> bool {
matches!(
std::env::var("NAUTALID_TRUSTED_PROXIES").ok().as_deref(),
Some("1") | Some("true") | Some("yes")
)
}
pub fn client_ip<B>(req: &Request<B>, peer: Option<SocketAddr>) -> Option<IpAddr> {
if trust_xforwarded_for() {
if let Some(xff) = req.headers().get("x-forwarded-for") {
if let Ok(value) = xff.to_str() {
if let Some(first) = value.split(',').next() {
if let Ok(ip) = first.trim().parse() {
return Some(ip);
}
}
}
}
}
peer.map(|a| a.ip())
}
pub fn normalize_request_path(path: &str) -> Option<String> {
if path.contains("..") || path.contains('\0') {
return None;
}
let mut norm = String::with_capacity(path.len().max(1));
let mut prev_slash = false;
for ch in path.chars() {
if ch == '/' {
if !prev_slash {
norm.push('/');
}
prev_slash = true;
} else {
norm.push(ch);
prev_slash = false;
}
}
if norm.is_empty() {
norm.push('/');
}
Some(norm)
}
pub fn policy_allows(policy: &FilterPolicy, ip: Option<IpAddr>, path: &str) -> bool {
let Some(path) = normalize_request_path(path) else {
return false;
};
if policy.deny.iter().any(|rule| rule_matches(rule, ip, &path)) {
return false;
}
if policy.allow.is_empty() {
return true;
}
if ip.is_none()
&& policy
.allow
.iter()
.any(|r| matches!(r, FilterRule::Ip(_) | FilterRule::Cidr { .. }))
{
return false;
}
policy
.allow
.iter()
.any(|rule| rule_matches(rule, ip, &path))
}
fn rule_matches(rule: &FilterRule, ip: Option<IpAddr>, path: &str) -> bool {
match rule {
FilterRule::Ip(expected) => ip == Some(*expected),
FilterRule::Cidr { addr, prefix_len } => {
ip.is_some_and(|i| cidr_contains(*addr, *prefix_len, i))
}
FilterRule::PathPrefix(prefix) => path.starts_with(prefix.as_str()),
}
}
fn cidr_contains(network: IpAddr, prefix_len: u8, addr: IpAddr) -> bool {
match (network, addr) {
(IpAddr::V4(net), IpAddr::V4(a)) => {
let net_bits = u32::from_be_bytes(net.octets());
let addr_bits = u32::from_be_bytes(a.octets());
let mask = if prefix_len >= 32 {
u32::MAX
} else {
u32::MAX << (32 - prefix_len)
};
(net_bits & mask) == (addr_bits & mask)
}
(IpAddr::V6(net), IpAddr::V6(a)) => {
let net_bits = u128::from_be_bytes(net.octets());
let addr_bits = u128::from_be_bytes(a.octets());
let mask = if prefix_len >= 128 {
u128::MAX
} else {
u128::MAX << (128 - prefix_len)
};
(net_bits & mask) == (addr_bits & mask)
}
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
#[test]
fn cidr_v4_match() {
let net = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 0));
assert!(cidr_contains(
net,
8,
IpAddr::V4(Ipv4Addr::new(10, 1, 2, 3))
));
assert!(!cidr_contains(
net,
8,
IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1))
));
}
#[test]
fn rejects_invalid_cidr_prefix() {
assert!(matches!(
parse_rule("10.0.0.0/33"),
Err(FilterParseError::InvalidCidr(_))
));
}
#[test]
fn rejects_path_with_parent_dir() {
let policy = FilterPolicy::default();
assert!(!policy_allows(&policy, None, "/public/../admin"));
}
#[test]
fn xff_ignored_without_trusted_proxies() {
use axum::http::Request;
let req = Request::builder()
.header("x-forwarded-for", "203.0.113.50")
.uri("/")
.body(())
.unwrap();
let peer = SocketAddr::from(([127, 0, 0, 1], 8080));
assert_eq!(
client_ip(&req, Some(peer)),
Some(IpAddr::V4(Ipv4Addr::LOCALHOST))
);
}
#[test]
fn whitelist_requires_match() {
let policy = FilterPolicy {
deny: Vec::new(),
allow: vec![FilterRule::PathPrefix("/public".into())],
};
assert!(policy_allows(
&policy,
Some(IpAddr::V4(Ipv4Addr::LOCALHOST)),
"/public/x"
));
assert!(!policy_allows(
&policy,
Some(IpAddr::V4(Ipv4Addr::LOCALHOST)),
"/secret"
));
}
}