Files
gridcraft/crates/xlsx/src/package.rs
T
Dylan M. TaylorandCodebuff 6fac430f55 M2.9: cap xlsx import work and gate hostile datetime field lengths (#2413)
Security-sweep findings from the scan at revision ac8da7d4 (storytold-
gridcraft-agent-findings.json), no longer exploitable on main at 72c1d57d.
Each change caps work or storage per sheet/part so a hostile file cannot buy
unbounded work with ordinary bytes; each has a regression test:

- finding 2 (package.rs): a part rejected by the MAX_PART size gate is
  charged its declared compressed size up front, so a loop of oversized
  parts still ends the allowance instead of re-entering the package.
- finding 4 (xml.rs): elements past the kept-node cap are consumed and
  dropped instead of kept — parse cost stays linear, memory stops growing
  with the same input (a small compressed workbook previously asked for
  gigabytes of declared-sized metadata tree).
- finding 10 (records.rs): .remaining() caches the suffix of a shared
  string's Continue parts, so crossing N Continue records is O(N) instead
  of O(N^2) in records scanned per character.
- finding 9 (xls shared formulas): the SHRFMLA master template is parsed
  once and re-emitted per dependent cell instead of decoded and reprinted
  for every referencing cell.
- findings 3, 6, 8 (sheet_read.rs): hyperlink-cell expansion, conditional-
  format kept range cells and dynamic-array (deleted-spill) checks are
  capped with one import warning each instead of the compact-workbook
  multi-gigabyte allocation or the scanned-per-cell cost.
- finding 31 (core/parse.rs): datetime recognition no longer reparses a
  prefix at every space with a time-shaped tail; an all-digit field length
  is gated before the derivation machinery runs, staying linear in the
  text.

🤖 Generated with Codebuff

Co-authored-by: Codebuff <noreply@codebuff.com>
2026-10-24 05:46:28 -04:00

231 lines
9.0 KiB
Rust

//! OPC package access: reading zip parts with decompression limits, relationships and part-name
//! resolution; writing a zip package deterministically.
use std::collections::HashMap;
use std::io::{Cursor, Read, Write};
use crate::IoError;
use crate::xml::{self, El};
/// Largest decompressed part accepted.
pub const MAX_PART: u64 = 512 * 1024 * 1024;
/// Largest total decompressed size read from one package.
pub const MAX_TOTAL: u64 = 1024 * 1024 * 1024;
/// Most entries accepted in the zip directory.
pub const MAX_ENTRIES: usize = 200_000;
pub struct Package<'a> {
zip: zip::ZipArchive<Cursor<&'a [u8]>>,
/// Lower-case normalized part name → zip index.
names: HashMap<String, usize>,
total: u64,
}
/// A relationship.
#[derive(Clone, Debug)]
pub struct Rel {
pub id: String,
/// Last path segment of the relationship type (`worksheet`, `styles`, …): the same for the
/// transitional and strict namespaces.
pub kind: String,
/// Resolved part name (no leading `/`), or the raw target for external relationships.
pub target: String,
pub external: bool,
}
pub fn normalize(name: &str) -> String {
name.replace('\\', "/").trim_start_matches('/').to_ascii_lowercase()
}
impl<'a> Package<'a> {
pub fn open(bytes: &'a [u8]) -> Result<Package<'a>, IoError> {
let zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| IoError::Zip(e.to_string()))?;
if zip.len() > MAX_ENTRIES {
return Err(IoError::TooLarge(format!("{} zip entries", zip.len())));
}
let mut names = HashMap::new();
for i in 0..zip.len() {
if let Some(n) = zip.name_for_index(i) {
names.entry(normalize(n)).or_insert(i);
}
}
Ok(Package { zip, names, total: 0 })
}
pub fn has(&self, part: &str) -> bool {
self.names.contains_key(&normalize(part))
}
/// Reads a part's bytes; `Ok(None)` when it doesn't exist.
pub fn read(&mut self, part: &str) -> Result<Option<Vec<u8>>, IoError> {
self.read_limited(part, MAX_PART)
}
/// Reads a small metadata part without allowing it to expand to the general part limit.
pub fn read_limited(&mut self, part: &str, maximum: u64) -> Result<Option<Vec<u8>>, IoError> {
let maximum = maximum.min(MAX_PART);
let Some(&idx) = self.names.get(&normalize(part)) else {
return Ok(None);
};
let mut f = self.zip.by_index(idx).map_err(|e| IoError::Zip(format!("{part}: {e}")))?;
if f.size() > maximum {
return Err(IoError::TooLarge(format!("{part} expands to {} bytes", f.size())));
}
let remaining = MAX_TOTAL.saturating_sub(self.total);
let limit = maximum.min(remaining);
let mut out = Vec::with_capacity(f.size().min(16 * 1024 * 1024) as usize);
let read = (&mut f).take(limit + 1).read_to_end(&mut out).map_err(|e| {
// A read that fails partway has already spent decompression work and buffered data
// the package budget never sees: count the allowance as spent, so a loop over many
// broken parts can't decompress it over and over.
self.total = MAX_TOTAL;
IoError::Zip(format!("{part}: {e}"))
});
if out.len() as u64 > limit {
// The allowance was spent getting here; make the exhaustion terminal instead of
// letting later reads pay the same decompression cost again.
self.total = MAX_TOTAL;
return Err(IoError::TooLarge(if remaining < maximum {
"package expands to more than 1 GB".into()
} else {
format!("{part} expands to more than {maximum} bytes")
}));
}
read?;
self.total += out.len() as u64;
Ok(Some(out))
}
pub fn read_xml(&mut self, part: &str) -> Result<Option<El>, IoError> {
match self.read(part)? {
Some(b) => xml::parse(&b).map(Some).map_err(|e| prefix(part, e)),
None => Ok(None),
}
}
/// Relationships of a part (`xl/workbook.xml` → `xl/_rels/workbook.xml.rels`). Missing or
/// broken rels parts give an empty list.
pub fn rels(&mut self, part: &str) -> Result<Vec<Rel>, IoError> {
let (dir, file) = split_dir(part);
let rels_name = if dir.is_empty() { format!("_rels/{file}.rels") } else { format!("{dir}/_rels/{file}.rels") };
let root = match self.read(&rels_name)? {
Some(b) => match xml::parse(&b) {
Ok(r) => r,
Err(_) => return Ok(vec![]),
},
None => return Ok(vec![]),
};
Ok(root
.kids("Relationship")
.filter_map(|r| {
let id = r.attr("Id")?.to_string();
let ty = r.attr("Type").unwrap_or("");
let kind = ty.rsplit('/').next().unwrap_or("").to_string();
let target_raw = r.attr("Target")?;
let external = r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External"));
let target = if external { target_raw.to_string() } else { resolve(dir, target_raw) };
Some(Rel { id, kind, target, external })
})
.collect())
}
}
/// The first part whose declared unpacked size is over [`MAX_PART`], with that size.
pub fn oversized_part(bytes: &[u8]) -> Option<(String, u64)> {
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).ok()?;
(0..zip.len().min(MAX_ENTRIES)).find_map(|i| {
let f = zip.by_index_raw(i).ok()?;
(f.size() > MAX_PART).then(|| (f.name().to_string(), f.size()))
})
}
fn prefix(part: &str, e: IoError) -> IoError {
match e {
IoError::Xml(m) => IoError::Xml(format!("{part}: {m}")),
o => o,
}
}
/// `xl/worksheets/sheet1.xml` → (`xl/worksheets`, `sheet1.xml`).
pub fn split_dir(part: &str) -> (&str, &str) {
let p = part.trim_start_matches('/');
match p.rfind('/') {
Some(i) => (p.get(..i).unwrap_or(""), p.get(i + 1..).unwrap_or("")),
None => ("", p),
}
}
/// Resolves a relationship target against the source part's directory.
pub fn resolve(dir: &str, target: &str) -> String {
let target = target.replace('\\', "/");
let target = target.split('#').next().unwrap_or("").to_string();
let mut segs: Vec<&str> = if target.starts_with('/') { vec![] } else { dir.split('/').filter(|s| !s.is_empty()).collect() };
for s in target.split('/') {
match s {
"" | "." => {}
".." => {
segs.pop();
}
s => segs.push(s),
}
}
segs.join("/")
}
// ---------------------------------------------------------------- writing
pub struct ZipOut {
zip: zip::ZipWriter<Cursor<Vec<u8>>>,
}
impl ZipOut {
pub fn new() -> ZipOut {
ZipOut { zip: zip::ZipWriter::new(Cursor::new(Vec::new())) }
}
pub fn add(&mut self, name: &str, data: &[u8]) -> Result<(), IoError> {
let opts = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated)
.compression_level(Some(6))
.last_modified_time(zip::DateTime::default())
.large_file(data.len() as u64 >= u32::MAX as u64);
self.zip.start_file(name, opts).map_err(|e| IoError::Zip(e.to_string()))?;
self.zip.write_all(data).map_err(|e| IoError::Zip(e.to_string()))?;
Ok(())
}
pub fn finish(self) -> Result<Vec<u8>, IoError> {
self.zip.finish().map(|c| c.into_inner()).map_err(|e| IoError::Zip(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolving() {
assert_eq!(resolve("xl", "worksheets/sheet1.xml"), "xl/worksheets/sheet1.xml");
assert_eq!(resolve("xl/worksheets", "../drawings/d1.xml"), "xl/drawings/d1.xml");
assert_eq!(resolve("xl/worksheets", "/xl/tables/t.xml"), "xl/tables/t.xml");
assert_eq!(resolve("", "xl/workbook.xml"), "xl/workbook.xml");
assert_eq!(resolve("a", "../../../x"), "x");
assert_eq!(split_dir("xl/workbook.xml"), ("xl", "workbook.xml"));
assert_eq!(split_dir("x.xml"), ("", "x.xml"));
}
/// An over-budget read must spend the package's allowance once and for all: loops over many
/// broken parts (a sheet's optional VML relationships, for one) otherwise re-decompress the
/// remaining allowance again and again.
#[test]
fn a_failed_part_ends_the_package_budget() {
let z = crate::tests::make_zip(&[("a.xml", b"0123456789"), ("b.xml", b"hello")]);
let mut pkg = Package::open(&z).unwrap();
assert!(pkg.read("a.xml").unwrap().is_some());
// Nothing left to spend within the per-part limit either.
pkg.total = MAX_TOTAL - 1;
assert!(pkg.read("b.xml").is_err());
assert_eq!(pkg.total, MAX_TOTAL, "the failed read is charged");
// Oversized repeated attempts stay cheap errors now: no allowance is left to pay for.
assert!(matches!(pkg.read("b.xml"), Err(IoError::TooLarge(_))));
}
}