collective

Browse / github.com/storytold/deckcraft / patch

PPTX: parse each tags part once and cap the tags an import copies (#1164)

[Audit] PPTX tags part is re-parsed and copied per reference <!-- deckcraft-audit:F-03 --> Read-only source audit of upstream `main` at [1dc69720db64](https://github.com/storytold/deckcraft/commit/1dc69720db64323370dda061226becb09ad79fd4). **Severity:** High **Category:** DoS / amplification **Confidence:** Confirmed by code reading, memory figure estimated **Audit ID:** F-03 **Evidence.** [`crates/pptx/src/read/mod.rs:193-205`](https://github.com/storytold/deckcraft/blob/1dc69720db64323370dda061226becb09ad79fd4/crates/pptx/src/read/mod.rs#L193-L205), called for every shape (`read/shapes.rs:169`) and every slide (`mod.rs:1100`): ```ru

Status
open
Score
0.00
Adopters
0 other operators
Author
Naseq (imported from pull request #1373, open)
Base
ee875abffa1a31cb760c843bb29e79391a90a766
Size
+128 −6 in 3 files
Review
pending
Shared
2026-10-28
Id
d1f2117ffd40ffd005c0c559

How it went

No other operator has reported using it yet.

Diff

raw · crates/pptx/src/lib.rs crates/pptx/src/read.rs crates/pptx/tests/it/tags.rs

diff --git a/crates/pptx/src/lib.rs b/crates/pptx/src/lib.rs--- a/crates/pptx/src/lib.rs+++ b/crates/pptx/src/lib.rs@@ -14,6 +14,7 @@ mod maps; mod opc; mod png; pub(crate) mod read;+pub use read::MAX_TAG_BYTES; mod tables; mod write; mod xml;diff --git a/crates/pptx/src/read.rs b/crates/pptx/src/read.rs--- a/crates/pptx/src/read.rs+++ b/crates/pptx/src/read.rs@@ -40,6 +40,12 @@ pub struct Imp<'a> {     pub kept_sidecar_bytes: usize,     /// Cached kept sidecars by normalized chart part name.     pub kept_charts: HashMap<String, deckcraft_model::chart::ChartKept>,+    /// Add-in tags parts, parsed once each, by normalized part name.+    pub tags_by_part: HashMap<String, Arc<[(String, String)]>>,+    /// Bytes of tags copied into the model so far (see [`MAX_TAG_BYTES`]).+    pub tag_bytes: usize,+    /// The tag budget ran out and the warning was given.+    pub tags_capped: bool, }  impl Imp<'_> {@@ -196,16 +202,54 @@ fn choice_has_model3d(el: &El, rels: &Rels) -> bool {     rec(el, rels, 0) } +/// Bytes of tags one import copies into the model, every tags part and reference together: a+/// small compressed tags part listed by many shapes, or many times by one, must not grow without+/// bound (#1164). The package budgets (`opc::MAX_XML_PART`, `MAX_TOTAL`, #1283) bound what is+/// parsed; this bounds what is copied from it.+pub const MAX_TAG_BYTES: usize = 64 << 20;+/// What each copied pair costs besides its text, so empty pairs count too.+const TAG_PAIR_COST: usize = std::mem::size_of::<(String, String)>();+ /// Add-in tags named by `holder`'s `p:custDataLst`: the name/value pairs of each tags part.-pub fn tags(imp: &Imp, part: &Part, holder: &El) -> Vec<(String, String)> {+/// Each part is parsed once per import and counted once per holder.+pub fn tags(imp: &mut Imp, part: &Part, holder: &El) -> Vec<(String, String)> {     let Some(list) = holder.child("custDataLst") else { return vec![] };     let mut out = Vec::new();+    let mut seen = HashSet::new();     for t in list.children_named("tags").take(100) {-        let Some(doc) = t.attr("r:id").and_then(|id| part.rels.get(id)).and_then(|r| imp.pkg.xml(&r.target)) else { continue };-        for tag in doc.root.children_named("tag").take(10_000) {-            if let Some(name) = tag.attr("name") {-                out.push((name.to_string(), tag.attr("val").unwrap_or("").to_string()));+        let Some(target) = t.attr("r:id").and_then(|id| part.rels.get(id)).map(|r| opc::norm(&r.target)) else { continue };+        if !seen.insert(target.clone()) {+            continue;+        }+        let parsed = match imp.tags_by_part.get(&target) {+            Some(p) => p.clone(),+            None => {+                let p: Arc<[(String, String)]> = imp+                    .pkg+                    .xml(&target)+                    .map(|doc| {+                        doc.root+                            .children_named("tag")+                            .take(10_000)+                            .filter_map(|tag| Some((tag.attr("name")?.to_string(), tag.attr("val").unwrap_or("").to_string())))+                            .collect()+                    })+                    .unwrap_or_default();+                imp.tags_by_part.insert(target, p.clone());+                p+            }+        };+        for (name, val) in parsed.iter() {+            let n = imp.tag_bytes.saturating_add(TAG_PAIR_COST + name.len() + val.len());+            if n > MAX_TAG_BYTES {+                if !imp.tags_capped {+                    log::warn!("pptx: add-in tags over {MAX_TAG_BYTES} bytes; the rest are dropped");+                    imp.tags_capped = true;+                }+                return out;             }+            imp.tag_bytes = n;+            out.push((name.clone(), val.clone()));         }     }     out@@ -389,6 +433,9 @@ pub fn import_theme(bytes: &[u8]) -> Result<(deckcraft_model::theme::Theme, Vec<         vml_previews: HashMap::new(),         kept_sidecar_bytes: 0,         kept_charts: HashMap::new(),+        tags_by_part: HashMap::new(),+        tag_bytes: 0,+        tags_capped: false,     };     let part = Part::new(&pkg, &theme_path, &doc);     let theme = theme::read_theme(&mut imp, &part, &doc.root);@@ -470,6 +517,9 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>), PptxE         vml_previews: HashMap::new(),         kept_sidecar_bytes: 0,         kept_charts: HashMap::new(),+        tags_by_part: HashMap::new(),+        tag_bytes: 0,+        tags_capped: false,     };     // Slide ids first so hyperlinks anywhere can point at slides.     let mut slide_ids: Vec<(u32, String, SlideId)> = vec![];@@ -598,7 +648,7 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>), PptxE             p.raw_ext = Some(kept);         }     }-    p.tags = tags(&imp, &pres_part, pres_el);+    p.tags = tags(&mut imp, &pres_part, pres_el);     // Custom shows.     if let Some(l) = pres_el.child("custShowLst") {         for cs in l.children_named("custShow") {diff --git a/crates/pptx/tests/it/tags.rs b/crates/pptx/tests/it/tags.rs--- a/crates/pptx/tests/it/tags.rs+++ b/crates/pptx/tests/it/tags.rs@@ -36,3 +36,74 @@ fn tags_survive_round_trip() {     assert_eq!(first.tags, vec![t("THINKCELLSHAPEDONOTDELETE", "t!abc")]);     assert!(back.slides.iter().skip(1).all(|s| s.tags.is_empty())); }++/// #1164: the same tags part listed over and over is read once, and the tags one import copies+/// into the model are capped however often a shape lists them.+#[test]+fn a_tags_part_listed_many_times_is_copied_once_and_capped() {+    let mut s = Session::new();+    deckcraft_engine::sample::open_sample(&mut s).expect("sample");+    for _ in 0..6 {+        s.execute("shape.insert", &serde_json::json!({"preset": "rect", "rect": [10, 10, 50, 50]})).expect("shape");+    }+    let mut p = s.doc().expect("doc").doc.as_ref().clone();+    let slide = Arc::make_mut(&mut p.slides[0]);+    // A quarter of the budget with its name and pair cost: the fifth shape listing it goes over.+    let pair = std::mem::size_of::<(String, String)>();+    slide.shapes[0].tags = vec![t("BIG", &"x".repeat(deckcraft_pptx::MAX_TAG_BYTES / 4 - 3 - pair))];+    let bytes = deckcraft_pptx::export(&p).expect("export");++    // Every shape on the first slide lists that one tags part 100 times.+    let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");+    let mut xml = String::new();+    zip.by_name("ppt/slides/slide1.xml").expect("slide").read_to_string(&mut xml).expect("read");+    let start = xml.find("<p:custDataLst>").expect("tags listed");+    let end = xml[start..].find("</p:custDataLst>").expect("list ends") + start + "</p:custDataLst>".len();+    let list = &xml[start..end];+    let inner = list.trim_start_matches("<p:custDataLst>").trim_end_matches("</p:custDataLst>");+    let many = format!("<p:custDataLst>{}</p:custDataLst>", inner.repeat(100));+    let xml = xml.replace(list, "").replace("<p:nvPr/>", "<p:nvPr></p:nvPr>").replace("<p:nvPr>", &format!("<p:nvPr>{many}"));+    assert!(xml.matches(&many).count() >= 5, "enough shapes to go over the budget");+    let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));+    for i in 0..zip.len() {+        let mut f = zip.by_index(i).expect("entry");+        let name = f.name().to_string();+        let mut data = vec![];+        f.read_to_end(&mut data).expect("read");+        out.start_file(&name, zip::write::SimpleFileOptions::default()).expect("start");+        std::io::Write::write_all(&mut out, if name == "ppt/slides/slide1.xml" { xml.as_bytes() } else { &data }).expect("write");+    }+    let bytes = out.finish().expect("finish").into_inner();++    let back = deckcraft_pptx::import(&bytes).expect("import");+    let tagged: Vec<_> = back.slides[0].shapes.iter().filter(|x| !x.tags.is_empty()).collect();+    assert_eq!(tagged.len(), 4, "four copies fit the budget");+    assert!(tagged.iter().all(|x| x.tags.len() == 1), "each shape keeps the part's tag once, not 100 times");+}++/// #1164: empty tags cost something too, or a part of 10,000 empty pairs listed by every shape+/// would be copied without ever reaching the budget.+#[test]+fn empty_tags_count_against_the_budget() {+    let pair = std::mem::size_of::<(String, String)>();+    let per_shape = 10_000 * pair;+    let shapes = deckcraft_pptx::MAX_TAG_BYTES / per_shape + 2;+    let mut s = Session::new();+    deckcraft_engine::sample::open_sample(&mut s).expect("sample");+    let mut p = s.doc().expect("doc").doc.as_ref().clone();+    let slide = Arc::make_mut(&mut p.slides[0]);+    let template = slide.shapes[0].clone();+    slide.shapes.clear();+    for i in 0..shapes {+        let mut sh = template.clone();+        sh.id = deckcraft_model::ShapeId(10_000 + i as u32);+        sh.ph = None;+        sh.tags = vec![t("", ""); 10_000];+        slide.shapes.push(sh);+    }+    let bytes = deckcraft_pptx::export(&p).expect("export");+    let back = deckcraft_pptx::import(&bytes).expect("import");+    let pairs: usize = back.slides[0].shapes.iter().map(|x| x.tags.len()).sum();+    assert!(pairs * pair <= deckcraft_pptx::MAX_TAG_BYTES, "{pairs} pairs copied");+    assert!(pairs < shapes * 10_000, "the budget stopped the copying");+}