1use std::net::{Ipv4Addr, Ipv6Addr};
10
11#[derive(Debug, Clone)]
32pub struct SsrfGuard {
33 pub allowed_schemes: Vec<String>,
35 pub blocked_hosts: Vec<String>,
37 pub resolve_dns: bool,
40 pub check_private_ips: bool,
43}
44
45impl Default for SsrfGuard {
46 fn default() -> Self {
47 Self {
48 allowed_schemes: vec!["http".to_owned(), "https".to_owned(), "mailto".to_owned()],
49 blocked_hosts: vec!["localhost".to_owned()],
50 resolve_dns: true,
51 check_private_ips: true,
52 }
53 }
54}
55
56impl SsrfGuard {
57 #[must_use]
62 pub fn new() -> Self {
63 Self::default()
64 }
65
66 #[must_use]
72 pub fn permissive() -> Self {
73 Self {
74 allowed_schemes: vec!["http".to_owned(), "https".to_owned(), "mailto".to_owned()],
75 blocked_hosts: Vec::new(),
76 resolve_dns: false,
77 check_private_ips: false,
78 }
79 }
80
81 pub fn check_url(&self, url: &str) -> Result<(), String> {
87 let (scheme, rest) = url
90 .split_once(':')
91 .ok_or_else(|| "missing scheme".to_owned())?;
92
93 let scheme_lower = scheme.to_ascii_lowercase();
94 if !self.allowed_schemes.iter().any(|s| s == &scheme_lower) {
95 return Err(format!("scheme '{scheme}' not allowed"));
96 }
97
98 if scheme_lower == "mailto" {
100 return Ok(());
101 }
102
103 let rest = rest.strip_prefix("//").unwrap_or(rest);
106
107 let host_raw = rest.split('/').next().unwrap_or(rest);
108 let host = if host_raw.starts_with('[') {
110 host_raw
111 .split(']')
112 .next()
113 .and_then(|h| h.strip_prefix('['))
114 .unwrap_or(host_raw)
115 } else {
116 host_raw.split(':').next().unwrap_or(host_raw)
117 };
118 if host.is_empty() {
119 return Err("empty host".to_owned());
120 }
121
122 let host_lower = host.to_ascii_lowercase();
123 if self.blocked_hosts.iter().any(|h| h == &host_lower) {
124 return Err(format!("blocked host: {host}"));
125 }
126
127 if self.check_private_ips {
128 if let Ok(v4) = host.parse::<Ipv4Addr>()
129 && Self::is_blocked_ipv4(v4)
130 {
131 return Err(format!("blocked IPv4: {v4}"));
132 }
133 if let Ok(v6) = host.parse::<Ipv6Addr>()
134 && Self::is_blocked_ipv6(v6)
135 {
136 return Err(format!("blocked IPv6: {v6}"));
137 }
138
139 if self.resolve_dns {
140 use std::net::ToSocketAddrs;
142 if let Ok(addrs) = (host, 0u16).to_socket_addrs() {
143 for addr in addrs {
144 match addr.ip() {
145 std::net::IpAddr::V4(v4) => {
146 if Self::is_blocked_ipv4(v4) {
147 return Err(format!("DNS resolved to blocked IPv4: {v4}"));
148 }
149 }
150 std::net::IpAddr::V6(v6) => {
151 if Self::is_blocked_ipv6(v6) {
152 return Err(format!("DNS resolved to blocked IPv6: {v6}"));
153 }
154 }
155 }
156 }
157 }
158 }
159 }
160
161 Ok(())
162 }
163
164 fn is_blocked_ipv4(ip: Ipv4Addr) -> bool {
175 let o = ip.octets();
176 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 }
184
185 fn is_blocked_ipv6(ip: Ipv6Addr) -> bool {
194 if ip.is_loopback() || ip.is_unspecified() {
195 return true;
196 }
197 let s = ip.segments();
198 (s[0] & 0xfe00) == 0xfc00 || (s[0] & 0xffc0) == 0xfe80 || (s[0] & 0xff00) == 0xff00 }
202}
203
204#[derive(Debug, Clone)]
221pub struct PackageLimits {
222 pub max_total_uncompressed: u64,
225 pub max_single_uncompressed: u64,
228 pub max_compression_ratio: u64,
231 pub max_entries: usize,
234 pub max_filename_len: usize,
237}
238
239impl Default for PackageLimits {
240 fn default() -> Self {
241 Self {
242 max_total_uncompressed: 100 * 1024 * 1024, max_single_uncompressed: 50 * 1024 * 1024, max_compression_ratio: 100, max_entries: 10_000,
246 max_filename_len: 256,
247 }
248 }
249}
250
251impl PackageLimits {
252 #[must_use]
254 pub fn new() -> Self {
255 Self::default()
256 }
257
258 pub fn validate_archive<R: std::io::Read + std::io::Seek>(
273 &self,
274 archive: &mut zip::ZipArchive<R>,
275 ) -> Result<(), String> {
276 let len = archive.len();
277 if len > self.max_entries {
278 return Err(format!("too many entries: {len} > {}", self.max_entries));
279 }
280
281 let mut total_uncompressed: u64 = 0;
282 let mut total_compressed: u64 = 0;
283
284 for i in 0..len {
285 let entry = archive.by_index(i).map_err(|e| e.to_string())?;
286 let name = entry.name();
287
288 if name.len() > self.max_filename_len {
289 return Err(format!(
290 "filename too long: {} bytes (max {})",
291 name.len(),
292 self.max_filename_len
293 ));
294 }
295
296 if name.contains("..") || name.starts_with('/') {
298 return Err(format!("suspicious path: {name}"));
299 }
300
301 let size = entry.size();
302 let compressed = entry.compressed_size();
303
304 if size > self.max_single_uncompressed {
305 return Err(format!(
306 "entry '{name}' too large: {size} bytes (max {})",
307 self.max_single_uncompressed
308 ));
309 }
310
311 if compressed > 0 && size / compressed > self.max_compression_ratio {
312 return Err(format!(
313 "entry '{name}' compression ratio too high: {}x (max {}x)",
314 size / compressed,
315 self.max_compression_ratio
316 ));
317 }
318
319 total_uncompressed = total_uncompressed.saturating_add(size);
320 total_compressed = total_compressed.saturating_add(compressed);
321 }
322
323 if total_uncompressed > self.max_total_uncompressed {
324 return Err(format!(
325 "total uncompressed too large: {total_uncompressed} bytes (max {})",
326 self.max_total_uncompressed
327 ));
328 }
329
330 if total_compressed > 0
331 && total_uncompressed / total_compressed > self.max_compression_ratio
332 {
333 return Err(format!(
334 "overall compression ratio too high: {}x (max {}x)",
335 total_uncompressed / total_compressed,
336 self.max_compression_ratio
337 ));
338 }
339
340 Ok(())
341 }
342}
343
344#[derive(Debug, Clone)]
362pub struct SecurityPolicy {
363 pub ssrf: SsrfGuard,
365 pub limits: PackageLimits,
367}
368
369impl Default for SecurityPolicy {
370 fn default() -> Self {
371 Self {
372 ssrf: SsrfGuard::new(),
373 limits: PackageLimits::new(),
374 }
375 }
376}
377
378impl SecurityPolicy {
379 #[must_use]
384 pub fn new() -> Self {
385 Self::default()
386 }
387
388 #[must_use]
394 pub fn permissive() -> Self {
395 Self {
396 ssrf: SsrfGuard::permissive(),
397 limits: PackageLimits {
399 max_total_uncompressed: u64::MAX,
400 max_single_uncompressed: u64::MAX,
401 max_compression_ratio: u64::MAX,
402 max_entries: usize::MAX,
403 max_filename_len: usize::MAX,
404 },
405 }
406 }
407}
408
409#[cfg(test)]
414mod tests {
415 use super::*;
416
417 #[test]
422 fn check_url_blocks_localhost_literal() {
423 let guard = SsrfGuard::new();
424 let err = guard.check_url("http://localhost/admin").unwrap_err();
425 assert!(err.contains("blocked host"), "error: {err}");
426 }
427
428 #[test]
429 fn check_url_blocks_private_ip_10_x() {
430 let guard = SsrfGuard {
431 resolve_dns: false,
432 ..SsrfGuard::new()
433 };
434 let err = guard.check_url("http://10.0.0.1/secret").unwrap_err();
435 assert!(err.contains("blocked IPv4"), "error: {err}");
436 }
437
438 #[test]
439 fn check_url_blocks_private_ip_192_168() {
440 let guard = SsrfGuard {
441 resolve_dns: false,
442 ..SsrfGuard::new()
443 };
444 let err = guard.check_url("http://192.168.1.1/internal").unwrap_err();
445 assert!(err.contains("blocked IPv4"), "error: {err}");
446 }
447
448 #[test]
449 fn check_url_blocks_private_ip_172_16() {
450 let guard = SsrfGuard {
451 resolve_dns: false,
452 ..SsrfGuard::new()
453 };
454 let err = guard.check_url("http://172.16.0.1/api").unwrap_err();
455 assert!(err.contains("blocked IPv4"), "error: {err}");
456 }
457
458 #[test]
459 fn check_url_blocks_ipv6_loopback() {
460 let guard = SsrfGuard {
461 resolve_dns: false,
462 ..SsrfGuard::new()
463 };
464 let err = guard.check_url("http://[::1]/admin").unwrap_err();
465 assert!(
466 err.contains("blocked IPv6") || err.contains("blocked host"),
467 "error: {err}"
468 );
469 }
470
471 #[test]
472 fn check_url_blocks_link_local_169_254() {
473 let guard = SsrfGuard {
474 resolve_dns: false,
475 ..SsrfGuard::new()
476 };
477 let err = guard
478 .check_url("http://169.254.169.254/metadata")
479 .unwrap_err();
480 assert!(err.contains("blocked IPv4"), "error: {err}");
481 }
482
483 #[test]
484 fn check_url_rejects_ftp_scheme() {
485 let guard = SsrfGuard::new();
486 let err = guard.check_url("ftp://example.com/file.txt").unwrap_err();
487 assert!(err.contains("scheme"), "error: {err}");
488 }
489
490 #[test]
491 fn check_url_rejects_empty_host() {
492 let guard = SsrfGuard {
493 resolve_dns: false,
494 ..SsrfGuard::new()
495 };
496 let err = guard.check_url("http:///path").unwrap_err();
497 assert!(err.contains("empty host"), "error: {err}");
498 }
499
500 #[test]
501 fn check_url_rejects_missing_scheme() {
502 let guard = SsrfGuard::new();
503 let err = guard.check_url("example.com/page").unwrap_err();
504 assert!(err.contains("missing scheme"), "error: {err}");
505 }
506
507 #[test]
508 fn check_url_allows_https_external() {
509 let guard = SsrfGuard {
510 resolve_dns: false,
511 ..SsrfGuard::new()
512 };
513 guard
514 .check_url("https://example.com/page")
515 .expect("https://example.com should be allowed");
516 }
517
518 #[test]
519 fn check_url_allows_mailto() {
520 let guard = SsrfGuard::new();
521 guard
522 .check_url("mailto:user@example.com")
523 .expect("mailto: should be allowed");
524 }
525
526 #[test]
527 fn check_url_blocks_carrier_grade_nat() {
528 let guard = SsrfGuard {
529 resolve_dns: false,
530 ..SsrfGuard::new()
531 };
532 let err = guard.check_url("http://100.64.0.1/internal").unwrap_err();
533 assert!(err.contains("blocked IPv4"), "error: {err}");
534 }
535
536 #[test]
537 fn check_url_blocks_zero_network() {
538 let guard = SsrfGuard {
539 resolve_dns: false,
540 ..SsrfGuard::new()
541 };
542 let err = guard.check_url("http://0.0.0.0/admin").unwrap_err();
543 assert!(err.contains("blocked IPv4"), "error: {err}");
544 }
545
546 #[test]
547 fn permissive_guard_allows_private_ip() {
548 let guard = SsrfGuard::permissive();
549 guard
550 .check_url("http://192.168.1.1/api")
551 .expect("permissive guard should allow private IPs");
552 }
553
554 #[test]
555 fn permissive_guard_still_blocks_unknown_scheme() {
556 let guard = SsrfGuard::permissive();
557 let err = guard.check_url("ftp://example.com").unwrap_err();
558 assert!(err.contains("scheme"), "error: {err}");
559 }
560
561 #[test]
562 fn check_url_with_port() {
563 let guard = SsrfGuard {
564 resolve_dns: false,
565 ..SsrfGuard::new()
566 };
567 guard
568 .check_url("https://example.com:8080/api")
569 .expect("external host with port should be allowed");
570 }
571
572 fn build_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
578 use std::io::Write;
579 let mut buf = Vec::new();
580 {
581 let w = std::io::Cursor::new(&mut buf);
582 let mut zip = zip::ZipWriter::new(w);
583 let options = zip::write::SimpleFileOptions::default()
584 .compression_method(zip::CompressionMethod::Stored);
585 for (name, data) in entries {
586 zip.start_file(*name, options).unwrap();
587 zip.write_all(data).unwrap();
588 }
589 zip.finish().unwrap();
590 }
591 buf
592 }
593
594 #[test]
595 fn validate_archive_passes_normal() {
596 let data = build_zip(&[
597 ("word/document.xml", b"<document/>"),
598 ("word/styles.xml", b"<styles/>"),
599 ]);
600 let reader = std::io::Cursor::new(data);
601 let mut archive = zip::ZipArchive::new(reader).unwrap();
602 let limits = PackageLimits::new();
603 limits
604 .validate_archive(&mut archive)
605 .expect("normal archive should pass");
606 }
607
608 #[test]
609 fn validate_archive_rejects_too_many_entries() {
610 let mut entries: Vec<(&str, Vec<u8>)> = Vec::new();
611 for i in 0..50 {
612 entries.push((
613 Box::leak(format!("file{i}.txt").into_boxed_str()) as &str,
615 b"hello".to_vec(),
616 ));
617 }
618 let entry_refs: Vec<(&str, &[u8])> =
619 entries.iter().map(|(n, d)| (*n, d.as_slice())).collect();
620 let data = build_zip(&entry_refs);
621 let reader = std::io::Cursor::new(data);
622 let mut archive = zip::ZipArchive::new(reader).unwrap();
623
624 let limits = PackageLimits {
625 max_entries: 10,
626 ..PackageLimits::new()
627 };
628 let err = limits.validate_archive(&mut archive).unwrap_err();
629 assert!(err.contains("too many entries"), "error: {err}");
630 }
631
632 #[test]
633 fn validate_archive_rejects_zip_slip() {
634 let data = build_zip(&[("word/../../../etc/passwd", b"root:x:0:0")]);
635 let reader = std::io::Cursor::new(data);
636 let mut archive = zip::ZipArchive::new(reader).unwrap();
637 let limits = PackageLimits::new();
638 let err = limits.validate_archive(&mut archive).unwrap_err();
639 assert!(err.contains("suspicious path"), "error: {err}");
640 }
641
642 #[test]
643 fn validate_archive_rejects_absolute_path() {
644 let data = build_zip(&[("/etc/passwd", b"root:x:0:0")]);
645 let reader = std::io::Cursor::new(data);
646 let mut archive = zip::ZipArchive::new(reader).unwrap();
647 let limits = PackageLimits::new();
648 let err = limits.validate_archive(&mut archive).unwrap_err();
649 assert!(err.contains("suspicious path"), "error: {err}");
650 }
651
652 #[test]
653 fn validate_archive_rejects_large_entry() {
654 let big_data = vec![0u8; 200];
656 let data = build_zip(&[("big.bin", big_data.as_slice())]);
657 let reader = std::io::Cursor::new(data);
658 let mut archive = zip::ZipArchive::new(reader).unwrap();
659
660 let limits = PackageLimits {
661 max_single_uncompressed: 100,
662 ..PackageLimits::new()
663 };
664 let err = limits.validate_archive(&mut archive).unwrap_err();
665 assert!(err.contains("too large"), "error: {err}");
666 }
667
668 #[test]
669 fn validate_archive_rejects_high_compression_ratio() {
670 use std::io::Write;
672 let big_data = vec![0u8; 100_000];
673 let mut buf = Vec::new();
674 {
675 let w = std::io::Cursor::new(&mut buf);
676 let mut zip = zip::ZipWriter::new(w);
677 let options = zip::write::SimpleFileOptions::default()
678 .compression_method(zip::CompressionMethod::Deflated)
679 .compression_level(Some(9));
680 zip.start_file("bomb.bin", options).unwrap();
681 zip.write_all(&big_data).unwrap();
682 zip.finish().unwrap();
683 }
684
685 let reader = std::io::Cursor::new(buf);
686 let mut archive = zip::ZipArchive::new(reader).unwrap();
687
688 let limits = PackageLimits {
690 max_compression_ratio: 2,
691 ..PackageLimits::new()
692 };
693 let err = limits.validate_archive(&mut archive).unwrap_err();
694 assert!(err.contains("compression ratio too high"), "error: {err}");
695 }
696
697 #[test]
698 fn validate_archive_rejects_filename_too_long() {
699 let long_name = format!("{}.xml", "a".repeat(300));
700 let name_ref: &str = Box::leak(long_name.into_boxed_str());
701 let data = build_zip(&[(name_ref, b"<data/>")]);
702 let reader = std::io::Cursor::new(data);
703 let mut archive = zip::ZipArchive::new(reader).unwrap();
704
705 let limits = PackageLimits {
706 max_filename_len: 100,
707 ..PackageLimits::new()
708 };
709 let err = limits.validate_archive(&mut archive).unwrap_err();
710 assert!(err.contains("filename too long"), "error: {err}");
711 }
712
713 #[test]
714 fn validate_archive_rejects_total_too_large() {
715 let a = vec![0u8; 60];
716 let b = vec![0u8; 60];
717 let data = build_zip(&[("a.bin", a.as_slice()), ("b.bin", b.as_slice())]);
718 let reader = std::io::Cursor::new(data);
719 let mut archive = zip::ZipArchive::new(reader).unwrap();
720
721 let limits = PackageLimits {
722 max_total_uncompressed: 100,
723 ..PackageLimits::new()
724 };
725 let err = limits.validate_archive(&mut archive).unwrap_err();
726 assert!(err.contains("total uncompressed too large"), "error: {err}");
727 }
728
729 #[test]
734 fn security_policy_default_is_conservative() {
735 let policy = SecurityPolicy::new();
736 assert!(policy.ssrf.check_url("http://localhost/x").is_err());
738 assert_eq!(policy.limits.max_total_uncompressed, 100 * 1024 * 1024);
740 }
741
742 #[test]
743 fn security_policy_permissive_relaxes_limits() {
744 let policy = SecurityPolicy::permissive();
745 assert!(policy.ssrf.check_url("ftp://x.com").is_err());
747 assert!(policy.ssrf.check_url("http://10.0.0.1/x").is_ok());
749 assert_eq!(policy.limits.max_entries, usize::MAX);
751 }
752}