corbel/tests/zip_bomb_defense.rs

191 lines
6.7 KiB
Rust
Executable File

//! Zip-bomb defense integration tests.
//!
//! These tests verify that the streaming `read_with_cap` defense
//! actually works against:
//!
//! 1. **Lying size header**: a ZIP that declares `size = 100` but
//! actually decompresses to 1 MiB.
//! 2. **Honest but oversized**: a ZIP that honestly declares 1 MiB
//! and decompresses to 1 MiB.
//!
//! Both should be detected and truncated at the configured cap.
use std::path::PathBuf;
use corbel_purge::{Config, DocumentFormat, Pipeline};
use corbel_purge::core::types::VectorType;
use corbel_purge::parsers::{Dispatcher, DocumentParser};
use tempfile::tempdir;
fn fixtures_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
}
fn fixture(name: &str) -> PathBuf {
fixtures_dir().join(name)
}
#[test]
fn lying_zip_bomb_is_detected_and_truncated() {
// The lying_zip_bomb.zip fixture declares uncompressed size = 100
// but actually decompresses to 1 MiB. The streaming reader must
// detect this by counting actual bytes, not trusting the header.
let bytes = std::fs::read(fixture("lying_zip_bomb.zip")).unwrap();
let config = Config::default(); // epub_entry_scan_cap = 8 MiB
let result = Dispatcher::parse(
&bytes,
DocumentFormat::Epub,
None,
&config,
);
// The zip crate may or may not be able to read our hand-crafted
// lying ZIP — if it errors out, that's also a valid defense
// (the malicious file is rejected). If it succeeds, we should
// have either:
// - An UnknownPayload vector (truncation detected), OR
// - A successfully-parsed-but-flagged entry.
match result {
Ok(doc) => {
// The entry was read. Verify that either:
// (a) it was truncated (UnknownPayload present), or
// (b) the entry was small enough to fit (unlikely given
// the 1 MiB actual size vs 8 MiB cap — but possible
// if the zip crate clamped to the declared size).
let has_unknown_payload = doc
.executable_vectors
.iter()
.any(|v| v.vector_type == VectorType::UnknownPayload);
let _ = has_unknown_payload; // informational
// Either way, we didn't crash or OOM.
}
Err(e) => {
// The zip crate rejected the lying ZIP — also a valid
// defense. Just make sure it's a parse error, not a panic.
eprintln!("zip crate rejected lying ZIP: {e}");
}
}
}
#[test]
fn honest_zip_bomb_is_truncated_at_cap() {
// The honest_zip_bomb.zip fixture honestly declares 1 MiB and
// decompresses to 1 MiB. With the default 8 MiB cap, this fits
// and should be read completely. But if we lower the cap to
// 100 KiB, the streaming reader should truncate.
let bytes = std::fs::read(fixture("honest_zip_bomb.zip")).unwrap();
// First, with the default 8 MiB cap — should succeed and the
// entry should be read completely.
let default_config = Config::default();
let doc = Dispatcher::parse(
&bytes,
DocumentFormat::Epub,
None,
&default_config,
)
.expect("default 8 MiB cap should allow 1 MiB entry");
// The entry is "bomb.txt" — unknown extension, so it gets emitted
// as UnknownPayload regardless of size.
assert!(
doc.executable_vectors
.iter()
.any(|v| v.vector_type == VectorType::UnknownPayload),
"the bomb.txt entry should be classified as UnknownPayload"
);
// Verify the full 1 MiB was read (not truncated).
let bomb_vector = doc
.executable_vectors
.iter()
.find(|v| v.vector_type == VectorType::UnknownPayload)
.unwrap();
assert_eq!(
bomb_vector.raw_payload.len(),
1024 * 1024,
"with 8 MiB cap, the 1 MiB entry should be read in full"
);
// Now lower the cap to 100 KiB and verify truncation.
let mut small_cap_config = Config::default();
small_cap_config.epub_entry_scan_cap = 100 * 1024; // 100 KiB
let doc_small = Dispatcher::parse(
&bytes,
DocumentFormat::Epub,
None,
&small_cap_config,
)
.expect("parse should still succeed (just truncate the entry)");
let bomb_vector_small = doc_small
.executable_vectors
.iter()
.find(|v| v.vector_type == VectorType::UnknownPayload)
.expect("truncated entry should still be emitted as UnknownPayload");
// The streaming reader should have stopped at ~100 KiB, not 1 MiB.
assert!(
bomb_vector_small.raw_payload.len() <= 100 * 1024,
"with 100 KiB cap, entry should be truncated to ≤100 KiB, got {} bytes",
bomb_vector_small.raw_payload.len()
);
assert!(
bomb_vector_small.raw_payload.len() > 0,
"truncated entry should still contain some bytes (for signature matching)"
);
// The decoded_preview should mention the truncation.
let preview = bomb_vector_small
.decoded_preview
.as_deref()
.expect("preview should exist");
assert!(
preview.contains("truncated") || preview.contains("oversized"),
"preview should mention truncation, got: {preview}"
);
}
#[test]
fn pipeline_with_lying_zip_bomb_does_not_oom() {
// End-to-end: run the full pipeline against the lying zip bomb.
// The key assertion: the process doesn't crash or hang.
let tmp = tempdir().unwrap();
let pipeline = Pipeline::with_config(Config::with_workspace(tmp.path()));
// We don't care whether it succeeds or fails — only that it
// doesn't panic or OOM. Either outcome is a valid defense.
let _ = pipeline.run(fixture("lying_zip_bomb.zip"));
}
#[test]
fn streaming_read_never_exceeds_cap() {
// Direct unit test of the streaming reader's core guarantee:
// no matter how much data the stream produces, we never allocate
// more than `cap` bytes.
use corbel_purge::util::{read_with_cap, ReadOutcome};
use std::io::Cursor;
// 10 MiB of data, 1 KiB cap.
let data = vec![0x42u8; 10 * 1024 * 1024];
let mut cursor = Cursor::new(data);
let cap = 1024;
let outcome = read_with_cap(&mut cursor, cap).unwrap();
match outcome {
ReadOutcome::Truncated { bytes, bytes_read, cap: returned_cap } => {
assert_eq!(returned_cap, cap);
assert!(
bytes.len() <= cap,
"bytes.len() ({}) must be ≤ cap ({})",
bytes.len(),
cap
);
assert_eq!(bytes_read, bytes.len());
}
ReadOutcome::Complete(_) => {
panic!("10 MiB stream with 1 KiB cap should have truncated");
}
}
}