use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::path::PathBuf;
use solid_pod_rs::security::dotfile::{DotfileAllowlist, ENV_DOTFILE_ALLOWLIST};
use solid_pod_rs::security::ssrf::{
IpClass, SsrfError, SsrfPolicy, ENV_SSRF_ALLOWLIST, ENV_SSRF_ALLOW_LINK_LOCAL,
ENV_SSRF_ALLOW_LOOPBACK, ENV_SSRF_ALLOW_PRIVATE, ENV_SSRF_DENYLIST,
};
use tokio::sync::Mutex;
use url::Url;
static ENV_GUARD: Mutex<()> = Mutex::const_new(());
fn clear_ssrf_env() {
for key in [
ENV_SSRF_ALLOW_PRIVATE,
ENV_SSRF_ALLOW_LOOPBACK,
ENV_SSRF_ALLOW_LINK_LOCAL,
ENV_SSRF_ALLOWLIST,
ENV_SSRF_DENYLIST,
] {
std::env::remove_var(key);
}
}
fn clear_dotfile_env() {
std::env::remove_var(ENV_DOTFILE_ALLOWLIST);
}
#[test]
fn f1a_classify_rfc1918_private() {
assert_eq!(
SsrfPolicy::classify(IpAddr::V4(Ipv4Addr::new(10, 1, 2, 3))),
IpClass::Private
);
assert_eq!(
SsrfPolicy::classify(IpAddr::V4(Ipv4Addr::new(172, 20, 0, 5))),
IpClass::Private
);
assert_eq!(
SsrfPolicy::classify(IpAddr::V4(Ipv4Addr::new(192, 168, 10, 20))),
IpClass::Private
);
}
#[test]
fn f1a_classify_loopback_and_public() {
assert_eq!(
SsrfPolicy::classify(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))),
IpClass::Loopback
);
assert_eq!(
SsrfPolicy::classify(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))),
IpClass::Public
);
}
#[test]
fn f1a_classify_cloud_metadata_is_reserved() {
assert_eq!(
SsrfPolicy::classify(IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254))),
IpClass::Reserved
);
}
#[test]
fn f1b_classify_ipv6_link_local() {
let ip: IpAddr = "fe80::1".parse::<Ipv6Addr>().unwrap().into();
assert_eq!(SsrfPolicy::classify(ip), IpClass::LinkLocal);
}
#[test]
fn f1b_classify_ipv6_ula_private() {
let ip: IpAddr = "fc00::1".parse::<Ipv6Addr>().unwrap().into();
assert_eq!(SsrfPolicy::classify(ip), IpClass::Private);
let ip2: IpAddr = "fd12:3456::1".parse::<Ipv6Addr>().unwrap().into();
assert_eq!(SsrfPolicy::classify(ip2), IpClass::Private);
}
#[test]
fn f1b_classify_ipv6_loopback() {
assert_eq!(
SsrfPolicy::classify(IpAddr::V6(Ipv6Addr::LOCALHOST)),
IpClass::Loopback
);
}
#[test]
fn f1b_classify_ipv6_public() {
let ip: IpAddr = "2606:4700:4700::1111".parse::<Ipv6Addr>().unwrap().into();
assert_eq!(SsrfPolicy::classify(ip), IpClass::Public);
}
#[tokio::test]
async fn f1c_allowlist_permits_loopback() {
let _g = ENV_GUARD.lock().await;
clear_ssrf_env();
std::env::set_var(ENV_SSRF_ALLOWLIST, "localhost");
let policy = SsrfPolicy::from_env();
let url = Url::parse("http://localhost/").unwrap();
match policy.resolve_and_check(&url).await {
Ok(ip) => {
assert!(
ip.is_loopback(),
"localhost must resolve to a loopback address; got {ip}"
);
}
Err(SsrfError::DnsFailure { .. }) | Err(SsrfError::NoAddresses { .. }) => {
eprintln!("f1c skipped: DNS resolution for 'localhost' unavailable");
}
Err(e) => panic!("allowlist should have permitted localhost: {e}"),
}
clear_ssrf_env();
}
#[tokio::test]
async fn f1c_no_allowlist_blocks_loopback() {
let _g = ENV_GUARD.lock().await;
clear_ssrf_env();
let policy = SsrfPolicy::from_env();
let url = Url::parse("http://127.0.0.1/").unwrap();
let result = policy.resolve_and_check(&url).await;
match result {
Err(SsrfError::BlockedClass {
class: IpClass::Loopback,
..
}) => {}
other => panic!("expected BlockedClass(Loopback), got {other:?}"),
}
}
#[tokio::test]
async fn f1d_denylist_overrides_public() {
let _g = ENV_GUARD.lock().await;
clear_ssrf_env();
std::env::set_var(ENV_SSRF_DENYLIST, "1.0.0.1");
let policy = SsrfPolicy::from_env();
let url = Url::parse("http://1.0.0.1/").unwrap();
let result = policy.resolve_and_check(&url).await;
match result {
Err(SsrfError::Denylisted { .. }) => {}
other => panic!("expected Denylisted, got {other:?}"),
}
clear_ssrf_env();
}
#[tokio::test]
async fn f1e_loopback_url_rejected_when_default() {
let _g = ENV_GUARD.lock().await;
clear_ssrf_env();
let policy = SsrfPolicy::from_env();
let url = Url::parse("http://foo.localhost/").unwrap();
let result = policy.resolve_and_check(&url).await;
match result {
Err(SsrfError::BlockedClass {
class: IpClass::Loopback,
..
}) => {}
Err(SsrfError::DnsFailure { .. }) | Err(SsrfError::NoAddresses { .. }) => {
eprintln!("f1e: resolver does not honour RFC 6761 .localhost; DNS failure observed");
}
other => panic!("expected BlockedClass(Loopback) or DnsFailure, got {other:?}"),
}
}
#[tokio::test]
async fn f1_missing_host_is_rejected() {
let policy = SsrfPolicy::new();
let url = Url::parse("data:text/plain,hello").unwrap();
match policy.resolve_and_check(&url).await {
Err(SsrfError::MissingHost(_)) => {}
other => panic!("expected MissingHost, got {other:?}"),
}
}
#[test]
fn f2a_default_allowlist_permits_acl_and_meta() {
let _g = ENV_GUARD.blocking_lock();
clear_dotfile_env();
let al = DotfileAllowlist::default();
assert!(al.is_allowed(&PathBuf::from("/resource/.acl")));
assert!(al.is_allowed(&PathBuf::from("/resource/.meta")));
assert!(al.is_allowed(&PathBuf::from("/.acl")));
assert!(al.is_allowed(&PathBuf::from("/.meta")));
}
#[test]
fn f2b_default_allowlist_blocks_env() {
let _g = ENV_GUARD.blocking_lock();
clear_dotfile_env();
let al = DotfileAllowlist::default();
assert!(!al.is_allowed(&PathBuf::from("/.env")));
assert!(!al.is_allowed(&PathBuf::from("/.git")));
assert!(!al.is_allowed(&PathBuf::from("/a/b/.secret")));
}
#[test]
fn f2c_env_allowlist_permits_listed_entries() {
let _g = ENV_GUARD.blocking_lock();
clear_dotfile_env();
std::env::set_var(ENV_DOTFILE_ALLOWLIST, ".env,.config");
let al = DotfileAllowlist::from_env();
assert!(al.is_allowed(&PathBuf::from("/.env")));
assert!(al.is_allowed(&PathBuf::from("/.config")));
assert!(!al.is_allowed(&PathBuf::from("/.acl")));
clear_dotfile_env();
}
#[test]
fn f2d_nested_dotfile_rejected() {
let _g = ENV_GUARD.blocking_lock();
clear_dotfile_env();
let al = DotfileAllowlist::default();
assert!(!al.is_allowed(&PathBuf::from("foo/.secret")));
assert!(!al.is_allowed(&PathBuf::from("/a/b/c/.oops/d")));
}
#[test]
fn f2_env_without_dot_prefix_is_normalised() {
let _g = ENV_GUARD.blocking_lock();
clear_dotfile_env();
std::env::set_var(ENV_DOTFILE_ALLOWLIST, "notifications");
let al = DotfileAllowlist::from_env();
assert!(al.is_allowed(&PathBuf::from("/.notifications")));
clear_dotfile_env();
}