diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index 22dc90baf..fa8af6f63 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -35,6 +35,8 @@ use story::StoryCtx; /// Most parts (headers, notes, comments, text boxes) we create from one file. const MAX_PARTS: usize = 50_000; +/// Deepest chain of headers/footers whose own sections reference further headers/footers. +const MAX_HF_DEPTH: usize = 4; /// Most graphic work (items plus path segments, see `story::graphic_work`) built for one file's /// charts and diagrams; later ones are left empty. const MAX_GRAPHIC_WORK: usize = 2_000_000; @@ -64,6 +66,8 @@ pub(crate) struct Reader<'p> { pub moves: MoveCtx, media_by_path: HashMap, hf_by_path: HashMap, + /// Header/footer parts being read right now (see [`MAX_HF_DEPTH`]). + hf_depth: usize, pub footnotes: HashMap, pub endnotes: HashMap, /// The note being read (kind, part id): its `w:footnoteRef` / `w:endnoteRef` mark is a @@ -141,6 +145,7 @@ pub(crate) fn read_opened(pkg: &Package) -> Result { moves: MoveCtx::default(), media_by_path: HashMap::new(), hf_by_path: HashMap::new(), + hf_depth: 0, footnotes: HashMap::new(), endnotes: HashMap::new(), current_note: None, @@ -643,7 +648,7 @@ impl Reader<'_> { if let Some(id) = self.hf_by_path.get(path) { return Some(*id); } - if self.doc.parts.len() >= MAX_PARTS { + if self.doc.parts.len() >= MAX_PARTS || self.hf_depth >= MAX_HF_DEPTH { return None; } // Reserve the id first so a (malformed) self-reference can't recurse. @@ -658,7 +663,9 @@ impl Reader<'_> { let mut sc = StoryCtx::default(); sc.in_hf = true; let mut blocks = Blocks::new(); + self.hf_depth += 1; self.read_blocks(&mut sc, &root, &rels, &mut blocks, 0); + self.hf_depth = self.hf_depth.saturating_sub(1); self.flush_pending(&mut sc, &mut blocks); if let Some(p) = self.doc.parts.get_mut(&id) && !blocks.is_empty() diff --git a/crates/docx/tests/malformed.rs b/crates/docx/tests/malformed.rs index f1725d862..439b88245 100644 --- a/crates/docx/tests/malformed.rs +++ b/crates/docx/tests/malformed.rs @@ -238,3 +238,30 @@ proptest! { let _ = read(&z); } } + +#[test] +fn long_header_reference_chain_is_cut_off() { + const HDR_REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header"; + const RELS_NS: &str = "http://schemas.openxmlformats.org/package/2006/relationships"; + let rels_for = |next: usize| { + let rel = format!(r#""#); + format!(r#"{rel}"#) + }; + let sect = r#""#; + let doc = format!("body{sect}"); + // header0 -> header1 -> ... -> header59: every header's last paragraph holds a section that points at the next one. + let mut entries: Vec<(String, Vec)> = vec![ + ("_rels/.rels".into(), ROOT_RELS.as_bytes().to_vec()), + ("word/document.xml".into(), doc.into_bytes()), + ("word/_rels/document.xml.rels".into(), rels_for(0).into_bytes()), + ]; + for i in 0..60 { + let hdr = format!("{sect}h{i}"); + entries.push((format!("word/header{i}.xml"), hdr.into_bytes())); + entries.push((format!("word/_rels/header{i}.xml.rels"), rels_for(i + 1).into_bytes())); + } + let refs: Vec<(&str, &[u8])> = entries.iter().map(|(n, b)| (n.as_str(), b.as_slice())).collect(); + let d = read(&zip(&refs)).unwrap(); + assert!(!d.parts.is_empty()); + assert!(d.parts.len() <= 8, "followed {} chained headers", d.parts.len()); +}