Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src/intake/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -46,6 +46,16 @@ pub fn extract(bytes: &[u8], spec: &ExtractDocumentSpec) -> Result<ExtractedDocu
}
extract_office(bytes, spec)
}
/// Opens an Office archive through the same bounded central-directory
/// admission as [`extract`], so other readers of untrusted archives cannot
/// fall back to an unadmitted directory either.
#[cfg(feature = "markdown")]
pub(crate) fn admitted_archive(
bytes: &[u8],
) -> Result<zip::ZipArchive<impl std::io::Read + std::io::Seek + '_>> {
zip_admission::zip_preflight(bytes)?;
zip_admission::open_admitted_zip(bytes)
}
fn extract_office(bytes: &[u8], spec: &ExtractDocumentSpec) -> Result<ExtractedDocument> {
zip_admission::zip_preflight(bytes)?;
let mut archive = zip_admission::open_admitted_zip(bytes)?;
Expand Down
8 changes: 6 additions & 2 deletions src/markdown/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
53 changes: 53 additions & 0 deletions src/markdown/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -407,3 +407,56 @@ fn a_readable_archive_without_a_workbook_is_an_extraction_error() {
let bytes = package(&[("unrelated.xml", "<document/>")]);
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!("<w:t>{}</w:t>", "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<String> = (0..3000).map(|i| format!("pad/{i}.xml")).collect();
let mut parts: Vec<(&str, &str)> = names.iter().map(|name| (name.as_str(), "<p/>")).collect();
parts.push(("word/document.xml", "<w:p><w:r><w:t>many</w:t></w:r></w:p>"));
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",
"<w:p><w:r><w:t>nested secret</w:t></w:r></w:p>",
)]);
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"<w:p><w:r><w:t>outer</w:t></w:r></w:p>").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}");
}
33 changes: 17 additions & 16 deletions src/markdown/ooxml.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
//! 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;
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.
Expand Down Expand Up @@ -59,30 +60,34 @@ pub(super) fn pptx(bytes: &[u8]) -> Result<String> {
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<ZipArchive<Cursor<&'a [u8]>>> {
let mut archive = ZipArchive::new(Cursor::new(bytes))
pub(super) fn open<'a>(bytes: &'a [u8], noun: &str) -> Result<ZipArchive<impl Read + Seek + 'a>> {
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"
)));
}
Ok(archive)
}

/// Sums the uncompressed sizes every entry declares, 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_uncompressed<R: Read + Seek>(archive: &mut ZipArchive<R>) -> Option<u64> {
(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<R: Read + Seek>(archive: &mut ZipArchive<R>) -> 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())))
})
}

Expand All @@ -109,11 +114,7 @@ fn read_parts<R: Read + Seek>(
// 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));
Expand Down
Loading