191 lines
6.7 KiB
Rust
Executable File
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");
|
|
}
|
|
}
|
|
}
|