Skip to main content

max / makenotwork

22.3 KB · 610 lines History Blame Raw
1 //! Layer 3: Archive safety checks (ZIP bomb detection).
2 //!
3 //! Inspects ZIP archives for dangerous properties: excessive compression
4 //! ratios, deeply nested archives, path traversal attempts, and
5 //! unreasonable uncompressed sizes.
6
7 use std::io::Cursor;
8
9 use crate::constants;
10 use crate::storage::FileType;
11
12 use super::{ErrorPolicy, LayerResult, LayerVerdict};
13
14 /// In-process deterministic layer. ZIP parser errors fail closed because they
15 /// indicate either a corrupt archive or an evasion attempt — both warrant a
16 /// human look rather than an automatic pass.
17 pub const ERROR_POLICY: ErrorPolicy = ErrorPolicy::FailClosed;
18
19 /// Check a file for ZIP bomb and archive safety issues.
20 /// Runs for any file that has ZIP magic bytes, regardless of claimed type.
21 pub fn check_archive_safety(data: &[u8], file_type: FileType) -> LayerResult {
22 // Only run for downloads (audio/cover shouldn't be archives)
23 // But also catch disguised ZIPs: check magic bytes regardless
24 let is_zip = data.len() >= 4 && data[0] == 0x50 && data[1] == 0x4B && data[2] == 0x03 && data[3] == 0x04;
25
26 if !is_zip {
27 return LayerResult {
28 layer: "archive",
29 verdict: LayerVerdict::Skip,
30 detail: Some("Not a ZIP archive".to_string()),
31 };
32 }
33
34 // If a ZIP is disguised as audio/cover, that's suspicious but layer 1 handles type mismatch
35 if file_type == FileType::Cover {
36 return LayerResult {
37 layer: "archive",
38 verdict: LayerVerdict::Skip,
39 detail: Some("Archive check skipped for cover images".to_string()),
40 };
41 }
42
43 inspect_zip(Cursor::new(data))
44 }
45
46 /// Path-based entry. Opens the spooled file directly so we never have to
47 /// buffer the whole archive; `ZipArchive::new` only needs `Read + Seek`.
48 /// File-type gating happens at the call site (same shape as the buffered
49 /// variant — caller already checked `file_type`).
50 pub fn check_archive_safety_path(path: &std::path::Path, file_type: FileType) -> LayerResult {
51 if file_type == FileType::Cover {
52 return LayerResult {
53 layer: "archive",
54 verdict: LayerVerdict::Skip,
55 detail: Some("Archive check skipped for cover images".to_string()),
56 };
57 }
58
59 let mut file = match std::fs::File::open(path) {
60 Ok(f) => f,
61 Err(e) => {
62 return LayerResult {
63 layer: "archive",
64 verdict: LayerVerdict::Error,
65 detail: Some(format!("open spool {}: {e}", path.display())),
66 };
67 }
68 };
69
70 let mut magic = [0u8; 4];
71 use std::io::{Read, Seek, SeekFrom};
72 let read = file.read(&mut magic).unwrap_or(0);
73 let is_zip = read == 4 && magic == [0x50, 0x4B, 0x03, 0x04];
74 if !is_zip {
75 return LayerResult {
76 layer: "archive",
77 verdict: LayerVerdict::Skip,
78 detail: Some("Not a ZIP archive".to_string()),
79 };
80 }
81 if file.seek(SeekFrom::Start(0)).is_err() {
82 return LayerResult {
83 layer: "archive",
84 verdict: LayerVerdict::Error,
85 detail: Some(format!("seek spool {}", path.display())),
86 };
87 }
88
89 inspect_zip(file)
90 }
91
92 fn inspect_zip<R: std::io::Read + std::io::Seek>(reader: R) -> LayerResult {
93 let mut archive = match zip::ZipArchive::new(reader) {
94 Ok(a) => a,
95 Err(e) => {
96 return LayerResult {
97 layer: "archive",
98 verdict: LayerVerdict::Error,
99 detail: Some(format!("Failed to parse ZIP: {}", e)),
100 };
101 }
102 };
103
104 let mut total_compressed: u64 = 0;
105 let mut total_uncompressed: u64 = 0;
106 let mut nested_archives: u32 = 0;
107
108 for i in 0..archive.len() {
109 let entry = match archive.by_index_raw(i) {
110 Ok(e) => e,
111 Err(e) => {
112 return LayerResult {
113 layer: "archive",
114 verdict: LayerVerdict::Error,
115 detail: Some(format!("Failed to read ZIP entry {}: {}", i, e)),
116 };
117 }
118 };
119
120 let name = entry.name().to_string();
121
122 // Check for path traversal (literal, URL-encoded, and absolute paths)
123 let name_lower = name.to_ascii_lowercase();
124 if name.contains("../") || name.contains("..\\")
125 || name_lower.contains("%2e%2e")
126 || name.starts_with('/') || name.contains('\0')
127 {
128 return LayerResult {
129 layer: "archive",
130 verdict: LayerVerdict::Fail,
131 detail: Some(format!("Path traversal in entry: {}", name)),
132 };
133 }
134
135 total_compressed += entry.compressed_size();
136 let claimed_size = entry.size();
137 drop(entry);
138
139 // Use actual decompressed byte count instead of trusting the claimed size
140 // from the ZIP central directory (which is attacker-controlled).
141 // Also capture the first 8 bytes for nested archive magic detection,
142 // avoiding a second decompression pass.
143 let mut magic_bytes = [0u8; 8];
144 let mut magic_len = 0usize;
145 let actual_size = match archive.by_index(i) {
146 Ok(mut reader) => {
147 let mut counted: u64 = 0;
148 let mut buf = [0u8; 8192];
149 let limit = constants::SCAN_ZIP_MAX_UNCOMPRESSED;
150 loop {
151 match std::io::Read::read(&mut reader, &mut buf) {
152 Ok(0) => break,
153 Ok(n) => {
154 // Capture first 8 bytes for magic detection
155 if magic_len < 8 {
156 let copy = n.min(8 - magic_len);
157 magic_bytes[magic_len..magic_len + copy].copy_from_slice(&buf[..copy]);
158 magic_len += copy;
159 }
160 counted += n as u64;
161 if counted > limit {
162 return LayerResult {
163 layer: "archive",
164 verdict: LayerVerdict::Fail,
165 detail: Some(format!(
166 "Actual decompressed size exceeds {} bytes (possible ZIP bomb)",
167 limit
168 )),
169 };
170 }
171 }
172 Err(_) => {
173 // Decompression errors are suspicious — a crafted deflate
174 // stream can break here to underreport size and bypass the
175 // ratio check. Use a conservative multiplier instead of
176 // trusting the attacker-controlled claimed_size.
177 counted = claimed_size.saturating_mul(10).max(1024 * 1024);
178 break;
179 }
180 }
181 }
182 counted
183 }
184 // If we can't open the entry at all, use conservative estimate
185 Err(_) => claimed_size.saturating_mul(10).max(1024 * 1024),
186 };
187 total_uncompressed += actual_size;
188
189 // Check for nested archives — extension check first, then magic bytes
190 // from the first decompression pass (no re-read needed).
191 let lower_name = name.to_lowercase();
192 let ext_match = lower_name.ends_with(".zip")
193 || lower_name.ends_with(".tar.gz")
194 || lower_name.ends_with(".tgz")
195 || lower_name.ends_with(".7z")
196 || lower_name.ends_with(".rar")
197 || lower_name.ends_with(".tar");
198 if ext_match {
199 nested_archives += 1;
200 } else if actual_size > 0 && magic_len >= 4 {
201 let is_nested = matches!(
202 magic_bytes,
203 [0x50, 0x4B, 0x03, 0x04, ..] // ZIP
204 | [0x1F, 0x8B, ..] // gzip (tar.gz)
205 | [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C, ..] // 7z
206 | [0x52, 0x61, 0x72, 0x21, ..] // RAR
207 );
208 if is_nested {
209 nested_archives += 1;
210 }
211 }
212 }
213
214 // Check total uncompressed size
215 if total_uncompressed > constants::SCAN_ZIP_MAX_UNCOMPRESSED {
216 return LayerResult {
217 layer: "archive",
218 verdict: LayerVerdict::Fail,
219 detail: Some(format!(
220 "Total uncompressed size {} bytes exceeds limit of {} bytes",
221 total_uncompressed,
222 constants::SCAN_ZIP_MAX_UNCOMPRESSED
223 )),
224 };
225 }
226
227 // Check compression ratio (ZIP bomb detection)
228 if total_compressed > 0 {
229 let ratio = total_uncompressed as f64 / total_compressed as f64;
230 if ratio > constants::SCAN_ZIP_MAX_RATIO {
231 return LayerResult {
232 layer: "archive",
233 verdict: LayerVerdict::Fail,
234 detail: Some(format!(
235 "Compression ratio {:.1}x exceeds limit of {:.0}x (possible ZIP bomb)",
236 ratio,
237 constants::SCAN_ZIP_MAX_RATIO
238 )),
239 };
240 }
241 }
242
243 // Check nesting depth
244 if nested_archives > constants::SCAN_ZIP_MAX_DEPTH {
245 return LayerResult {
246 layer: "archive",
247 verdict: LayerVerdict::Fail,
248 detail: Some(format!(
249 "Contains {} nested archives (limit: {})",
250 nested_archives,
251 constants::SCAN_ZIP_MAX_DEPTH
252 )),
253 };
254 }
255
256 LayerResult {
257 layer: "archive",
258 verdict: LayerVerdict::Pass,
259 detail: Some(format!(
260 "{} entries, {:.1}x ratio",
261 archive.len(),
262 if total_compressed > 0 {
263 total_uncompressed as f64 / total_compressed as f64
264 } else {
265 0.0
266 }
267 )),
268 }
269 }
270
271 #[cfg(test)]
272 mod tests {
273 use super::*;
274 use zip::write::SimpleFileOptions;
275
276 fn make_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
277 let buf = Vec::new();
278 let cursor = Cursor::new(buf);
279 let mut writer = zip::ZipWriter::new(cursor);
280 let options =
281 SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
282 for (name, data) in entries {
283 writer.start_file(*name, options).unwrap();
284 std::io::Write::write_all(&mut writer, data).unwrap();
285 }
286 writer.finish().unwrap().into_inner()
287 }
288
289 fn make_compressed_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
290 let buf = Vec::new();
291 let cursor = Cursor::new(buf);
292 let mut writer = zip::ZipWriter::new(cursor);
293 let options =
294 SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
295 for (name, data) in entries {
296 writer.start_file(*name, options).unwrap();
297 std::io::Write::write_all(&mut writer, data).unwrap();
298 }
299 writer.finish().unwrap().into_inner()
300 }
301
302 // -- Skip behavior --
303
304 #[test]
305 fn non_zip_skipped() {
306 let result = check_archive_safety(b"not a zip file", FileType::Download);
307 assert_eq!(result.verdict, LayerVerdict::Skip);
308 }
309
310 #[test]
311 fn audio_non_zip_skipped() {
312 let result = check_archive_safety(b"audio data", FileType::Audio);
313 assert_eq!(result.verdict, LayerVerdict::Skip);
314 }
315
316 #[test]
317 fn cover_zip_skipped() {
318 // A ZIP file claimed as cover should be skipped (layer 1 handles type mismatch)
319 let data = make_zip(&[("test.txt", b"hello")]);
320 let result = check_archive_safety(&data, FileType::Cover);
321 assert_eq!(result.verdict, LayerVerdict::Skip);
322 }
323
324 // -- Valid archives --
325
326 #[test]
327 fn valid_zip_passes() {
328 let data = make_zip(&[("test.txt", b"hello world")]);
329 let result = check_archive_safety(&data, FileType::Download);
330 assert_eq!(result.verdict, LayerVerdict::Pass);
331 }
332
333 #[test]
334 fn empty_zip_passes() {
335 let buf = Vec::new();
336 let cursor = Cursor::new(buf);
337 let writer = zip::ZipWriter::new(cursor);
338 let data = writer.finish().unwrap().into_inner();
339 // Empty ZIPs may not have the PK magic at offset 0, they'd just be
340 // an end-of-central-directory record. If it doesn't start with PK 03 04,
341 // we'll skip it. That's fine.
342 let result = check_archive_safety(&data, FileType::Download);
343 // Either Skip (no local file header) or Pass (valid empty ZIP)
344 assert!(
345 result.verdict == LayerVerdict::Skip || result.verdict == LayerVerdict::Pass,
346 "unexpected verdict: {:?}",
347 result.verdict
348 );
349 }
350
351 #[test]
352 fn multi_entry_zip_passes() {
353 let data = make_zip(&[
354 ("file1.txt", b"content one"),
355 ("subdir/file2.txt", b"content two"),
356 ("readme.md", b"# hello"),
357 ]);
358 let result = check_archive_safety(&data, FileType::Download);
359 assert_eq!(result.verdict, LayerVerdict::Pass);
360 assert!(result.detail.unwrap().contains("3 entries"));
361 }
362
363 // -- Path traversal --
364
365 #[test]
366 fn zip_with_forward_slash_traversal_fails() {
367 let data = make_zip(&[("../../../etc/passwd", b"pwned")]);
368 let result = check_archive_safety(&data, FileType::Download);
369 assert_eq!(result.verdict, LayerVerdict::Fail);
370 assert!(result.detail.unwrap().contains("Path traversal"));
371 }
372
373 #[test]
374 fn zip_with_backslash_traversal_fails() {
375 let data = make_zip(&[("..\\..\\Windows\\System32\\config", b"pwned")]);
376 let result = check_archive_safety(&data, FileType::Download);
377 assert_eq!(result.verdict, LayerVerdict::Fail);
378 assert!(result.detail.unwrap().contains("Path traversal"));
379 }
380
381 #[test]
382 fn zip_with_mid_path_traversal_fails() {
383 let data = make_zip(&[("safe/../../etc/passwd", b"pwned")]);
384 let result = check_archive_safety(&data, FileType::Download);
385 assert_eq!(result.verdict, LayerVerdict::Fail);
386 }
387
388 #[test]
389 fn zip_with_url_encoded_traversal_fails() {
390 // %2e%2e is URL-encoded "..". The check is case-insensitive on the encoding.
391 let data = make_zip(&[("%2E%2E/secrets", b"pwned")]);
392 let result = check_archive_safety(&data, FileType::Download);
393 assert_eq!(result.verdict, LayerVerdict::Fail);
394 assert!(result.detail.unwrap().contains("Path traversal"));
395 }
396
397 #[test]
398 fn zip_with_absolute_path_fails() {
399 let data = make_zip(&[("/etc/passwd", b"pwned")]);
400 let result = check_archive_safety(&data, FileType::Download);
401 assert_eq!(result.verdict, LayerVerdict::Fail);
402 assert!(result.detail.unwrap().contains("Path traversal"));
403 }
404
405 #[test]
406 fn zip_with_null_byte_in_name_fails() {
407 let data = make_zip(&[("legit.txt\0../escape", b"pwned")]);
408 let result = check_archive_safety(&data, FileType::Download);
409 assert_eq!(result.verdict, LayerVerdict::Fail);
410 assert!(result.detail.unwrap().contains("Path traversal"));
411 }
412
413 // -- Nesting detection --
414
415 #[test]
416 fn zip_within_nesting_limit_passes() {
417 // SCAN_ZIP_MAX_DEPTH = 2; the check is `nested > limit`, so 2 entries
418 // with archive extensions sit exactly at the limit and must pass.
419 let data = make_zip(&[
420 ("data.txt", b"content"),
421 ("inner1.zip", b"fake zip content"),
422 ("inner2.zip", b"fake zip content"),
423 ]);
424 let result = check_archive_safety(&data, FileType::Download);
425 assert_eq!(result.verdict, LayerVerdict::Pass);
426 }
427
428 #[test]
429 fn zip_exceeding_nesting_limit_fails() {
430 // SCAN_ZIP_MAX_DEPTH = 2; 3 nested archives trips the limit.
431 let data = make_zip(&[
432 ("inner1.zip", b"fake"),
433 ("inner2.zip", b"fake"),
434 ("inner3.zip", b"fake"),
435 ]);
436 let result = check_archive_safety(&data, FileType::Download);
437 assert_eq!(result.verdict, LayerVerdict::Fail);
438 assert!(result.detail.unwrap().contains("nested archives"));
439 }
440
441 #[test]
442 fn nested_tar_gz_counts() {
443 let data = make_zip(&[
444 ("archive.tar.gz", b"fake"),
445 ("another.tar.gz", b"fake"),
446 ("third.tar.gz", b"fake"),
447 ("fourth.tar.gz", b"fake"),
448 ]);
449 let result = check_archive_safety(&data, FileType::Download);
450 assert_eq!(result.verdict, LayerVerdict::Fail);
451 }
452
453 #[test]
454 fn nested_7z_and_rar_count() {
455 let data = make_zip(&[
456 ("a.7z", b"fake"),
457 ("b.rar", b"fake"),
458 ("c.zip", b"fake"),
459 ("d.7z", b"fake"),
460 ]);
461 let result = check_archive_safety(&data, FileType::Download);
462 assert_eq!(result.verdict, LayerVerdict::Fail);
463 }
464
465 #[test]
466 fn nested_zip_detected_by_magic_without_extension() {
467 // Inner file has innocent name but contains real ZIP magic bytes.
468 // The extension check misses it; the magic-byte fallback must catch it.
469 let inner_zip = make_zip(&[("payload.txt", b"hi")]);
470 let data = make_zip(&[
471 ("a.bin", &inner_zip),
472 ("b.bin", &inner_zip),
473 ("c.bin", &inner_zip),
474 ]);
475 let result = check_archive_safety(&data, FileType::Download);
476 assert_eq!(result.verdict, LayerVerdict::Fail);
477 assert!(result.detail.unwrap().contains("nested archives"));
478 }
479
480 #[test]
481 fn nested_gzip_detected_by_magic_without_extension() {
482 // 1F 8B is gzip magic. No extension hint, must be caught by magic check.
483 let gzip_bytes: &[u8] = &[0x1F, 0x8B, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00];
484 let data = make_zip(&[
485 ("one.dat", gzip_bytes),
486 ("two.dat", gzip_bytes),
487 ("three.dat", gzip_bytes),
488 ]);
489 let result = check_archive_safety(&data, FileType::Download);
490 assert_eq!(result.verdict, LayerVerdict::Fail);
491 assert!(result.detail.unwrap().contains("nested archives"));
492 }
493
494 #[test]
495 fn nested_7z_detected_by_magic_without_extension() {
496 // 7z magic: 37 7A BC AF 27 1C
497 let sevenz_bytes: &[u8] = &[0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C, 0x00, 0x00];
498 let data = make_zip(&[
499 ("alpha.bin", sevenz_bytes),
500 ("beta.bin", sevenz_bytes),
501 ("gamma.bin", sevenz_bytes),
502 ]);
503 let result = check_archive_safety(&data, FileType::Download);
504 assert_eq!(result.verdict, LayerVerdict::Fail);
505 }
506
507 #[test]
508 fn nested_rar_detected_by_magic_without_extension() {
509 // RAR magic: 52 61 72 21 ("Rar!")
510 let rar_bytes: &[u8] = &[0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x00, 0x00];
511 let data = make_zip(&[
512 ("x.bin", rar_bytes),
513 ("y.bin", rar_bytes),
514 ("z.bin", rar_bytes),
515 ]);
516 let result = check_archive_safety(&data, FileType::Download);
517 assert_eq!(result.verdict, LayerVerdict::Fail);
518 }
519
520 #[test]
521 fn non_archive_extensions_ignored() {
522 let data = make_zip(&[
523 ("app.exe", b"binary"),
524 ("readme.txt", b"hello"),
525 ("image.png", b"pixels"),
526 ]);
527 let result = check_archive_safety(&data, FileType::Download);
528 assert_eq!(result.verdict, LayerVerdict::Pass);
529 }
530
531 // -- Compression ratio (ZIP bomb detection) --
532
533 #[test]
534 fn high_compression_ratio_fails() {
535 // Create highly compressible data: repeating zeros compress extremely well
536 // 1MB of zeros should compress to ~1KB with deflate, giving ratio ~1000x
537 let zeros = vec![0u8; 1024 * 1024];
538 let data = make_compressed_zip(&[("bomb.bin", &zeros)]);
539 let result = check_archive_safety(&data, FileType::Download);
540 assert_eq!(
541 result.verdict,
542 LayerVerdict::Fail,
543 "Expected Fail for high compression ratio, got: {:?}",
544 result.detail
545 );
546 assert!(result.detail.unwrap().contains("ZIP bomb"));
547 }
548
549 #[test]
550 fn normal_compression_ratio_passes() {
551 // Random-ish data doesn't compress well, ratio should be ~1x
552 let data_bytes: Vec<u8> = (0..10000).map(|i| (i * 37 + 13) as u8).collect();
553 let data = make_compressed_zip(&[("normal.bin", &data_bytes)]);
554 let result = check_archive_safety(&data, FileType::Download);
555 assert_eq!(result.verdict, LayerVerdict::Pass);
556 }
557
558 // -- Audio file with ZIP magic (disguised archive) --
559
560 #[test]
561 fn zip_disguised_as_audio_checked() {
562 // A ZIP file claimed as Audio should still be checked (not skipped)
563 let data = make_zip(&[("test.txt", b"hello")]);
564 let result = check_archive_safety(&data, FileType::Audio);
565 assert_eq!(result.verdict, LayerVerdict::Pass);
566 }
567
568 #[test]
569 fn zip_disguised_as_audio_with_traversal_fails() {
570 let data = make_zip(&[("../../../etc/passwd", b"pwned")]);
571 let result = check_archive_safety(&data, FileType::Audio);
572 assert_eq!(result.verdict, LayerVerdict::Fail);
573 }
574
575 // -- Corrupted ZIP --
576
577 #[test]
578 fn corrupted_zip_magic_returns_error() {
579 // Valid ZIP magic bytes but garbage after
580 let mut data = vec![0x50, 0x4B, 0x03, 0x04];
581 data.extend_from_slice(&[0xFF; 100]);
582 let result = check_archive_safety(&data, FileType::Download);
583 assert_eq!(result.verdict, LayerVerdict::Error);
584 assert!(result.detail.unwrap().contains("Failed to parse ZIP"));
585 }
586
587 #[test]
588 fn path_entry_matches_buffered_for_non_zip() {
589 let data = b"not a zip at all";
590 let buffered = check_archive_safety(data, FileType::Download);
591 let tmp = tempfile::NamedTempFile::new().unwrap();
592 std::fs::write(tmp.path(), data).unwrap();
593 let path_based = check_archive_safety_path(tmp.path(), FileType::Download);
594 assert_eq!(buffered.verdict, path_based.verdict);
595 assert_eq!(buffered.verdict, LayerVerdict::Skip);
596 }
597
598 #[test]
599 fn path_entry_matches_buffered_for_cover_skip() {
600 let mut data = vec![0x50, 0x4B, 0x03, 0x04];
601 data.extend_from_slice(&[0xFF; 100]);
602 let buffered = check_archive_safety(&data, FileType::Cover);
603 let tmp = tempfile::NamedTempFile::new().unwrap();
604 std::fs::write(tmp.path(), &data).unwrap();
605 let path_based = check_archive_safety_path(tmp.path(), FileType::Cover);
606 assert_eq!(buffered.verdict, path_based.verdict);
607 assert_eq!(buffered.verdict, LayerVerdict::Skip);
608 }
609 }
610