use std::net::{Ipv4Addr, Ipv6Addr};
#[derive(Debug, Clone)]
pub struct SsrfGuard {
pub allowed_schemes: Vec<String>,
pub blocked_hosts: Vec<String>,
pub resolve_dns: bool,
pub check_private_ips: bool,
}
impl Default for SsrfGuard {
fn default() -> Self {
Self {
allowed_schemes: vec!["http".to_owned(), "https".to_owned(), "mailto".to_owned()],
blocked_hosts: vec!["localhost".to_owned()],
resolve_dns: true,
check_private_ips: true,
}
}
}
impl SsrfGuard {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn permissive() -> Self {
Self {
allowed_schemes: vec!["http".to_owned(), "https".to_owned(), "mailto".to_owned()],
blocked_hosts: Vec::new(),
resolve_dns: false,
check_private_ips: false,
}
}
pub fn check_url(&self, url: &str) -> Result<(), String> {
let (scheme, rest) = url
.split_once(':')
.ok_or_else(|| "missing scheme".to_owned())?;
let scheme_lower = scheme.to_ascii_lowercase();
if !self.allowed_schemes.iter().any(|s| s == &scheme_lower) {
return Err(format!("scheme '{scheme}' not allowed"));
}
if scheme_lower == "mailto" {
return Ok(());
}
let rest = rest.strip_prefix("//").unwrap_or(rest);
let host_raw = rest.split('/').next().unwrap_or(rest);
let host = if host_raw.starts_with('[') {
host_raw
.split(']')
.next()
.and_then(|h| h.strip_prefix('['))
.unwrap_or(host_raw)
} else {
host_raw.split(':').next().unwrap_or(host_raw)
};
if host.is_empty() {
return Err("empty host".to_owned());
}
let host_lower = host.to_ascii_lowercase();
if self.blocked_hosts.iter().any(|h| h == &host_lower) {
return Err(format!("blocked host: {host}"));
}
if self.check_private_ips {
if let Ok(v4) = host.parse::<Ipv4Addr>()
&& Self::is_blocked_ipv4(v4)
{
return Err(format!("blocked IPv4: {v4}"));
}
if let Ok(v6) = host.parse::<Ipv6Addr>()
&& Self::is_blocked_ipv6(v6)
{
return Err(format!("blocked IPv6: {v6}"));
}
if self.resolve_dns {
use std::net::ToSocketAddrs;
if let Ok(addrs) = (host, 0u16).to_socket_addrs() {
for addr in addrs {
match addr.ip() {
std::net::IpAddr::V4(v4) => {
if Self::is_blocked_ipv4(v4) {
return Err(format!("DNS resolved to blocked IPv4: {v4}"));
}
}
std::net::IpAddr::V6(v6) => {
if Self::is_blocked_ipv6(v6) {
return Err(format!("DNS resolved to blocked IPv6: {v6}"));
}
}
}
}
}
}
}
Ok(())
}
fn is_blocked_ipv4(ip: Ipv4Addr) -> bool {
let o = ip.octets();
o[0] == 127 || o[0] == 10 || (o[0] == 172 && (16..=31).contains(&o[1])) || (o[0] == 192 && o[1] == 168) || (o[0] == 169 && o[1] == 254) || (o[0] == 100 && (64..=127).contains(&o[1])) || o[0] == 0 }
fn is_blocked_ipv6(ip: Ipv6Addr) -> bool {
if ip.is_loopback() || ip.is_unspecified() {
return true;
}
let s = ip.segments();
(s[0] & 0xfe00) == 0xfc00 || (s[0] & 0xffc0) == 0xfe80 || (s[0] & 0xff00) == 0xff00 }
}
#[derive(Debug, Clone)]
pub struct PackageLimits {
pub max_total_uncompressed: u64,
pub max_single_uncompressed: u64,
pub max_compression_ratio: u64,
pub max_entries: usize,
pub max_filename_len: usize,
}
impl Default for PackageLimits {
fn default() -> Self {
Self {
max_total_uncompressed: 100 * 1024 * 1024, max_single_uncompressed: 50 * 1024 * 1024, max_compression_ratio: 100, max_entries: 10_000,
max_filename_len: 256,
}
}
}
impl PackageLimits {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn validate_archive<R: std::io::Read + std::io::Seek>(
&self,
archive: &mut zip::ZipArchive<R>,
) -> Result<(), String> {
let len = archive.len();
if len > self.max_entries {
return Err(format!("too many entries: {len} > {}", self.max_entries));
}
let mut total_uncompressed: u64 = 0;
let mut total_compressed: u64 = 0;
for i in 0..len {
let entry = archive.by_index(i).map_err(|e| e.to_string())?;
let name = entry.name();
if name.len() > self.max_filename_len {
return Err(format!(
"filename too long: {} bytes (max {})",
name.len(),
self.max_filename_len
));
}
if name.contains("..") || name.starts_with('/') {
return Err(format!("suspicious path: {name}"));
}
let size = entry.size();
let compressed = entry.compressed_size();
if size > self.max_single_uncompressed {
return Err(format!(
"entry '{name}' too large: {size} bytes (max {})",
self.max_single_uncompressed
));
}
if compressed > 0 && size / compressed > self.max_compression_ratio {
return Err(format!(
"entry '{name}' compression ratio too high: {}x (max {}x)",
size / compressed,
self.max_compression_ratio
));
}
total_uncompressed = total_uncompressed.saturating_add(size);
total_compressed = total_compressed.saturating_add(compressed);
}
if total_uncompressed > self.max_total_uncompressed {
return Err(format!(
"total uncompressed too large: {total_uncompressed} bytes (max {})",
self.max_total_uncompressed
));
}
if total_compressed > 0
&& total_uncompressed / total_compressed > self.max_compression_ratio
{
return Err(format!(
"overall compression ratio too high: {}x (max {}x)",
total_uncompressed / total_compressed,
self.max_compression_ratio
));
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct SecurityPolicy {
pub ssrf: SsrfGuard,
pub limits: PackageLimits,
}
impl Default for SecurityPolicy {
fn default() -> Self {
Self {
ssrf: SsrfGuard::new(),
limits: PackageLimits::new(),
}
}
}
impl SecurityPolicy {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn permissive() -> Self {
Self {
ssrf: SsrfGuard::permissive(),
limits: PackageLimits {
max_total_uncompressed: u64::MAX,
max_single_uncompressed: u64::MAX,
max_compression_ratio: u64::MAX,
max_entries: usize::MAX,
max_filename_len: usize::MAX,
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn check_url_blocks_localhost_literal() {
let guard = SsrfGuard::new();
let err = guard.check_url("http://localhost/admin").unwrap_err();
assert!(err.contains("blocked host"), "error: {err}");
}
#[test]
fn check_url_blocks_private_ip_10_x() {
let guard = SsrfGuard {
resolve_dns: false,
..SsrfGuard::new()
};
let err = guard.check_url("http://10.0.0.1/secret").unwrap_err();
assert!(err.contains("blocked IPv4"), "error: {err}");
}
#[test]
fn check_url_blocks_private_ip_192_168() {
let guard = SsrfGuard {
resolve_dns: false,
..SsrfGuard::new()
};
let err = guard.check_url("http://192.168.1.1/internal").unwrap_err();
assert!(err.contains("blocked IPv4"), "error: {err}");
}
#[test]
fn check_url_blocks_private_ip_172_16() {
let guard = SsrfGuard {
resolve_dns: false,
..SsrfGuard::new()
};
let err = guard.check_url("http://172.16.0.1/api").unwrap_err();
assert!(err.contains("blocked IPv4"), "error: {err}");
}
#[test]
fn check_url_blocks_ipv6_loopback() {
let guard = SsrfGuard {
resolve_dns: false,
..SsrfGuard::new()
};
let err = guard.check_url("http://[::1]/admin").unwrap_err();
assert!(
err.contains("blocked IPv6") || err.contains("blocked host"),
"error: {err}"
);
}
#[test]
fn check_url_blocks_link_local_169_254() {
let guard = SsrfGuard {
resolve_dns: false,
..SsrfGuard::new()
};
let err = guard
.check_url("http://169.254.169.254/metadata")
.unwrap_err();
assert!(err.contains("blocked IPv4"), "error: {err}");
}
#[test]
fn check_url_rejects_ftp_scheme() {
let guard = SsrfGuard::new();
let err = guard.check_url("ftp://example.com/file.txt").unwrap_err();
assert!(err.contains("scheme"), "error: {err}");
}
#[test]
fn check_url_rejects_empty_host() {
let guard = SsrfGuard {
resolve_dns: false,
..SsrfGuard::new()
};
let err = guard.check_url("http:///path").unwrap_err();
assert!(err.contains("empty host"), "error: {err}");
}
#[test]
fn check_url_rejects_missing_scheme() {
let guard = SsrfGuard::new();
let err = guard.check_url("example.com/page").unwrap_err();
assert!(err.contains("missing scheme"), "error: {err}");
}
#[test]
fn check_url_allows_https_external() {
let guard = SsrfGuard {
resolve_dns: false,
..SsrfGuard::new()
};
guard
.check_url("https://example.com/page")
.expect("https://example.com should be allowed");
}
#[test]
fn check_url_allows_mailto() {
let guard = SsrfGuard::new();
guard
.check_url("mailto:user@example.com")
.expect("mailto: should be allowed");
}
#[test]
fn check_url_blocks_carrier_grade_nat() {
let guard = SsrfGuard {
resolve_dns: false,
..SsrfGuard::new()
};
let err = guard.check_url("http://100.64.0.1/internal").unwrap_err();
assert!(err.contains("blocked IPv4"), "error: {err}");
}
#[test]
fn check_url_blocks_zero_network() {
let guard = SsrfGuard {
resolve_dns: false,
..SsrfGuard::new()
};
let err = guard.check_url("http://0.0.0.0/admin").unwrap_err();
assert!(err.contains("blocked IPv4"), "error: {err}");
}
#[test]
fn permissive_guard_allows_private_ip() {
let guard = SsrfGuard::permissive();
guard
.check_url("http://192.168.1.1/api")
.expect("permissive guard should allow private IPs");
}
#[test]
fn permissive_guard_still_blocks_unknown_scheme() {
let guard = SsrfGuard::permissive();
let err = guard.check_url("ftp://example.com").unwrap_err();
assert!(err.contains("scheme"), "error: {err}");
}
#[test]
fn check_url_with_port() {
let guard = SsrfGuard {
resolve_dns: false,
..SsrfGuard::new()
};
guard
.check_url("https://example.com:8080/api")
.expect("external host with port should be allowed");
}
fn build_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
use std::io::Write;
let mut buf = Vec::new();
{
let w = std::io::Cursor::new(&mut buf);
let mut zip = zip::ZipWriter::new(w);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored);
for (name, data) in entries {
zip.start_file(*name, options).unwrap();
zip.write_all(data).unwrap();
}
zip.finish().unwrap();
}
buf
}
#[test]
fn validate_archive_passes_normal() {
let data = build_zip(&[
("word/document.xml", b"<document/>"),
("word/styles.xml", b"<styles/>"),
]);
let reader = std::io::Cursor::new(data);
let mut archive = zip::ZipArchive::new(reader).unwrap();
let limits = PackageLimits::new();
limits
.validate_archive(&mut archive)
.expect("normal archive should pass");
}
#[test]
fn validate_archive_rejects_too_many_entries() {
let mut entries: Vec<(&str, Vec<u8>)> = Vec::new();
for i in 0..50 {
entries.push((
Box::leak(format!("file{i}.txt").into_boxed_str()) as &str,
b"hello".to_vec(),
));
}
let entry_refs: Vec<(&str, &[u8])> =
entries.iter().map(|(n, d)| (*n, d.as_slice())).collect();
let data = build_zip(&entry_refs);
let reader = std::io::Cursor::new(data);
let mut archive = zip::ZipArchive::new(reader).unwrap();
let limits = PackageLimits {
max_entries: 10,
..PackageLimits::new()
};
let err = limits.validate_archive(&mut archive).unwrap_err();
assert!(err.contains("too many entries"), "error: {err}");
}
#[test]
fn validate_archive_rejects_zip_slip() {
let data = build_zip(&[("word/../../../etc/passwd", b"root:x:0:0")]);
let reader = std::io::Cursor::new(data);
let mut archive = zip::ZipArchive::new(reader).unwrap();
let limits = PackageLimits::new();
let err = limits.validate_archive(&mut archive).unwrap_err();
assert!(err.contains("suspicious path"), "error: {err}");
}
#[test]
fn validate_archive_rejects_absolute_path() {
let data = build_zip(&[("/etc/passwd", b"root:x:0:0")]);
let reader = std::io::Cursor::new(data);
let mut archive = zip::ZipArchive::new(reader).unwrap();
let limits = PackageLimits::new();
let err = limits.validate_archive(&mut archive).unwrap_err();
assert!(err.contains("suspicious path"), "error: {err}");
}
#[test]
fn validate_archive_rejects_large_entry() {
let big_data = vec![0u8; 200];
let data = build_zip(&[("big.bin", big_data.as_slice())]);
let reader = std::io::Cursor::new(data);
let mut archive = zip::ZipArchive::new(reader).unwrap();
let limits = PackageLimits {
max_single_uncompressed: 100,
..PackageLimits::new()
};
let err = limits.validate_archive(&mut archive).unwrap_err();
assert!(err.contains("too large"), "error: {err}");
}
#[test]
fn validate_archive_rejects_high_compression_ratio() {
use std::io::Write;
let big_data = vec![0u8; 100_000];
let mut buf = Vec::new();
{
let w = std::io::Cursor::new(&mut buf);
let mut zip = zip::ZipWriter::new(w);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated)
.compression_level(Some(9));
zip.start_file("bomb.bin", options).unwrap();
zip.write_all(&big_data).unwrap();
zip.finish().unwrap();
}
let reader = std::io::Cursor::new(buf);
let mut archive = zip::ZipArchive::new(reader).unwrap();
let limits = PackageLimits {
max_compression_ratio: 2,
..PackageLimits::new()
};
let err = limits.validate_archive(&mut archive).unwrap_err();
assert!(err.contains("compression ratio too high"), "error: {err}");
}
#[test]
fn validate_archive_rejects_filename_too_long() {
let long_name = format!("{}.xml", "a".repeat(300));
let name_ref: &str = Box::leak(long_name.into_boxed_str());
let data = build_zip(&[(name_ref, b"<data/>")]);
let reader = std::io::Cursor::new(data);
let mut archive = zip::ZipArchive::new(reader).unwrap();
let limits = PackageLimits {
max_filename_len: 100,
..PackageLimits::new()
};
let err = limits.validate_archive(&mut archive).unwrap_err();
assert!(err.contains("filename too long"), "error: {err}");
}
#[test]
fn validate_archive_rejects_total_too_large() {
let a = vec![0u8; 60];
let b = vec![0u8; 60];
let data = build_zip(&[("a.bin", a.as_slice()), ("b.bin", b.as_slice())]);
let reader = std::io::Cursor::new(data);
let mut archive = zip::ZipArchive::new(reader).unwrap();
let limits = PackageLimits {
max_total_uncompressed: 100,
..PackageLimits::new()
};
let err = limits.validate_archive(&mut archive).unwrap_err();
assert!(err.contains("total uncompressed too large"), "error: {err}");
}
#[test]
fn security_policy_default_is_conservative() {
let policy = SecurityPolicy::new();
assert!(policy.ssrf.check_url("http://localhost/x").is_err());
assert_eq!(policy.limits.max_total_uncompressed, 100 * 1024 * 1024);
}
#[test]
fn security_policy_permissive_relaxes_limits() {
let policy = SecurityPolicy::permissive();
assert!(policy.ssrf.check_url("ftp://x.com").is_err());
assert!(policy.ssrf.check_url("http://10.0.0.1/x").is_ok());
assert_eq!(policy.limits.max_entries, usize::MAX);
}
#[test]
fn xxe_external_entity_not_resolved() {
let xxe = br#"<?xml version="1.0"?>
<!DOCTYPE w:document [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body><w:p><w:r><w:t>&xxe;</w:t></w:r></w:p></w:body>
</w:document>"#;
let mut reader = crate::DocxSaxReader::from_reader(&xxe[..]);
let blocks = reader.read_blocks().expect("should parse without panic");
let all = format!("{blocks:?}");
assert!(
!all.contains("root:") && !all.contains("/etc/passwd"),
"external entity must not be resolved: {all}"
);
}
#[test]
fn xxe_internal_entity_not_resolved() {
let xxe = br#"<?xml version="1.0"?>
<!DOCTYPE w:document [ <!ENTITY secret "LEAKED"> ]>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body><w:p><w:r><w:t>&secret;</w:t></w:r></w:p></w:body>
</w:document>"#;
let mut reader = crate::DocxSaxReader::from_reader(&xxe[..]);
let blocks = reader.read_blocks().expect("should parse without panic");
let all = format!("{blocks:?}");
assert!(
!all.contains("LEAKED"),
"internal entity must not be expanded: {all}"
);
}
#[test]
fn xxe_billion_laughs_does_not_amplify() {
let xxe = br#"<?xml version="1.0"?>
<!DOCTYPE w:document [
<!ENTITY a "aaaaaaaaaa"><!ENTITY b "&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;">
<!ENTITY c "&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;">
]>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body><w:p><w:r><w:t>&c;</w:t></w:r></w:p></w:body>
</w:document>"#;
let mut reader = crate::DocxSaxReader::from_reader(&xxe[..]);
let blocks = reader.read_blocks().expect("should parse without panic");
let all = format!("{blocks:?}");
assert!(
!all.contains("aaaaaaaaaa"),
"entity amplification must be prevented: {all}"
);
}
}