diff --git a/src/intake/mod.rs b/src/intake/mod.rs index cb1e0ca..279c013 100644 --- a/src/intake/mod.rs +++ b/src/intake/mod.rs @@ -14,7 +14,7 @@ pub use tinydocs_bus::intake::{ const MAX_INPUT: usize = 64 * 1024 * 1024; const MAX_EXPANDED: u64 = 32 * 1024 * 1024; -const MAX_PART: u64 = 8 * 1024 * 1024; +pub(crate) const MAX_PART: u64 = 8 * 1024 * 1024; const MAX_ENTRIES: usize = 2048; const MAX_SHARED_STRINGS: usize = 65_536; const MAX_SHARED_TEXT_BYTES: usize = 4 * 1024 * 1024; @@ -46,6 +46,16 @@ pub fn extract(bytes: &[u8], spec: &ExtractDocumentSpec) -> Result Result> { + zip_admission::zip_preflight(bytes)?; + zip_admission::open_admitted_zip(bytes) +} fn extract_office(bytes: &[u8], spec: &ExtractDocumentSpec) -> Result { zip_admission::zip_preflight(bytes)?; let mut archive = zip_admission::open_admitted_zip(bytes)?; diff --git a/src/markdown/README.md b/src/markdown/README.md index a306e5a..2e5dc30 100644 --- a/src/markdown/README.md +++ b/src/markdown/README.md @@ -6,8 +6,12 @@ retains TinyMemory OfficeConverter semantics, including form-feed PDF page boundaries, numeric slide order, and `sheet | cell | cell` spreadsheet rows. The implementation is adapted from TinyMemory under GPL-3.0-only. -Input is limited to 32 MiB. Office archives are admitted only when their -aggregate declared expansion is at most 64 MiB; spreadsheet ranges are checked +Input is limited to 32 MiB. Office archives first pass the same bounded ZIP +admission as [intake](../intake/README.md) (member count, central-directory +size, names, no fallback to embedded footers), then are admitted only when no +member declares more than 8 MiB and their aggregate declared expansion is at +most 64 MiB. Encrypted members are skipped rather than refused, so a deck with +encrypted notes still yields its slides. Spreadsheet ranges are checked with a sparse scan before allocating a dense grid of at most one million cells. An entirely empty or scanned document returns `ExtractionFailed`. diff --git a/src/markdown/mod_tests.rs b/src/markdown/mod_tests.rs index ae53538..87d2c30 100644 --- a/src/markdown/mod_tests.rs +++ b/src/markdown/mod_tests.rs @@ -407,3 +407,56 @@ fn a_readable_archive_without_a_workbook_is_an_extraction_error() { let bytes = package(&[("unrelated.xml", "")]); assert!(refusal("not-a-workbook.xlsx", &bytes).contains("could not be read")); } + +#[test] +fn a_part_over_intakes_member_cap_is_refused() { + // Compresses to a few KiB but declares 12 MiB, past intake's 8 MiB member + // cap though under the 64 MiB aggregate one. + let text = format!("{}", "a".repeat(12 * 1024 * 1024)); + let bytes = package(&[("word/document.xml", &text)]); + let reason = refusal("big-part.docx", &bytes); + assert!(reason.contains("expands"), "{reason}"); +} + +#[test] +fn an_archive_over_intakes_member_count_is_refused() { + let names: Vec = (0..3000).map(|i| format!("pad/{i}.xml")).collect(); + let mut parts: Vec<(&str, &str)> = names.iter().map(|name| (name.as_str(), "

")).collect(); + parts.push(("word/document.xml", "many")); + let reason = refusal("many.docx", &package(&parts)); + assert!(reason.contains("admission count limit"), "{reason}"); +} + +#[test] +fn a_nested_archive_never_yields_its_text_when_the_primary_directory_is_invalid() { + let inner = package(&[( + "word/document.xml", + "nested secret", + )]); + let mut buffer = std::io::Cursor::new(Vec::new()); + let mut writer = zip::ZipWriter::new(&mut buffer); + writer + .start_file("nested.zip", zip::write::SimpleFileOptions::default()) + .unwrap(); + std::io::Write::write_all(&mut writer, &inner).unwrap(); + writer + .start_file( + "word/document.xml", + zip::write::SimpleFileOptions::default(), + ) + .unwrap(); + std::io::Write::write_all(&mut writer, b"outer").unwrap(); + writer.finish().unwrap(); + let mut bytes = buffer.into_inner(); + // Give the primary directory's first record an unknown method (99), so a + // lenient reader is tempted to fall back to the nested archive's footer. + let end = bytes.len() - 22; + let size = u32::from_le_bytes(bytes[end + 12..end + 16].try_into().unwrap()) as usize; + let central = end - size; + bytes[central + 10..central + 12].copy_from_slice(&99u16.to_le_bytes()); + // The record belongs to a member DOCX never reads, so the outer document + // converts; the nested archive must never be read in its place. + let markdown = convert("nested.docx", &bytes).unwrap(); + assert!(markdown.contains("outer"), "{markdown}"); + assert!(!markdown.contains("nested secret"), "{markdown}"); +} diff --git a/src/markdown/ooxml.rs b/src/markdown/ooxml.rs index 041920f..c4c7a2e 100644 --- a/src/markdown/ooxml.rs +++ b/src/markdown/ooxml.rs @@ -1,7 +1,7 @@ //! Word documents and `PowerPoint` decks: zip archives of XML, walked directly. use std::fmt::Display; -use std::io::{Cursor, Read, Seek}; +use std::io::{Read, Seek}; use std::str; use quick_xml::events::Event; @@ -9,6 +9,7 @@ use zip::ZipArchive; use super::{MAX_DECOMPRESSED_BYTES, unreadable}; use crate::Result; +use crate::intake::{MAX_PART, admitted_archive}; // ZIP general-purpose bit 11 marks UTF-8 names. Inspect it through zip's // stable flags value instead of naming its unstable flag enum. @@ -59,14 +60,17 @@ pub(super) fn pptx(bytes: &[u8]) -> Result { Ok(read_parts(&mut archive, &names, "a:p", "a:t")) } -/// Opens an Office archive, refusing one whose declared expansion exceeds +/// Opens an Office archive through intake's ZIP admission, refusing one with +/// a part declared over [`MAX_PART`] or a declared expansion over /// [`MAX_DECOMPRESSED_BYTES`] before any entry data is read. /// /// `noun` names the document in the refusal ("the spreadsheet expands…"). -pub(super) fn open<'a>(bytes: &'a [u8], noun: &str) -> Result>> { - let mut archive = ZipArchive::new(Cursor::new(bytes)) +pub(super) fn open<'a>(bytes: &'a [u8], noun: &str) -> Result> { + let mut archive = admitted_archive(bytes) .map_err(|error| unreadable(&format!("the {noun} is not a readable archive: {error}")))?; - if declared_uncompressed(&mut archive).is_none_or(|total| total > MAX_DECOMPRESSED_BYTES) { + if declared_sizes(&mut archive) + .is_none_or(|(largest, total)| largest > MAX_PART || total > MAX_DECOMPRESSED_BYTES) + { return Err(unreadable(&format!( "the {noun} expands beyond the size this build can read safely" ))); @@ -74,15 +78,16 @@ pub(super) fn open<'a>(bytes: &'a [u8], noun: &str) -> Result(archive: &mut ZipArchive) -> Option { - (0..archive.len()).try_fold(0u64, |total, index| { +/// The largest and the summed uncompressed sizes the entries declare, +/// touching only the central directory — so this stays cheap however far the +/// archive expands. `None` means an entry could not be inspected, which +/// callers refuse. +fn declared_sizes(archive: &mut ZipArchive) -> Option<(u64, u64)> { + (0..archive.len()).try_fold((0u64, 0u64), |(largest, total), index| { archive .by_index_data(index) .ok() - .map(|file| total.saturating_add(file.size())) + .map(|file| (largest.max(file.size()), total.saturating_add(file.size()))) }) } @@ -109,11 +114,7 @@ fn read_parts( // Capped as well as pre-checked: an archive that declares small sizes // but streams more cannot force an unbounded allocation either. let mut xml = String::new(); - if file - .take(MAX_DECOMPRESSED_BYTES) - .read_to_string(&mut xml) - .is_err() - { + if file.take(MAX_PART).read_to_string(&mut xml).is_err() { continue; } out.push_str(&xml_text(&xml, paragraph_tag, text_tag));