Summary
Xlsx::read_shared_strings (src/xlsx/mod.rs) reserves Vec<String> capacity directly from the attacker-controlled uniqueCount attribute in sharedStrings.xml, with no upper bound.
Affected version observed locally: calamine 0.36.0. I have not verified whether this is already fixed on main; this report is based on local source inspection of calamine-0.36.0 only (no CI/test-suite check was performed against a newer revision).
Affected code path
In src/xlsx/mod.rs, read_shared_strings parses the <sst uniqueCount="N"> root attribute and calls self.strings.reserve(n):
if let Some(count) = e.raw_attr(b"uniqueCount")? {
if let Ok(n) = atoi_simd::parse::<usize, true, false>(count) {
self.strings.reserve(n);
}
}
This runs unconditionally inside Xlsx::new()'s constructor, before the caller regains control — there is no point between calling open_workbook/open_workbook_auto and getting a Result back where the caller's own code executes.
Contrast with worksheet_range_ref's structurally identical <dimension>-driven reservation, which the same file already guards:
let len = cell_reader.dimensions().len();
let mut cells = Vec::new();
if len < 100_000 {
cells.reserve(len as usize);
}
read_shared_strings's uniqueCount-driven reservation has no equivalent guard.
Impact
A ~1 KB, otherwise perfectly ordinary .xlsx (normal ZIP central directory, no unusual compression ratio, no oversized declared/actual size anywhere) can declare uniqueCount="4000000000" in sharedStrings.xml with zero <si> children, driving a single Vec::reserve request for ~96 GB (4,000,000,000 × 24 bytes/String header on a 64-bit target) before the caller ever regains control.
Because this uses the non-fallible .reserve() (not try_reserve), an allocation failure invokes handle_alloc_error, whose default behavior is to abort the process — not a catchable Result. A calling application's ordinary Result-based error handling around open_workbook/open_workbook_auto cannot intercept this; the effect is a crash of the entire host process.
Minimal repro shape
A ZIP archive containing a minimal valid [Content_Types].xml, _rels/.rels, xl/workbook.xml (+ relationships) sufficient for open_workbook_auto/Xlsx::new() to proceed as far as read_shared_strings, plus xl/sharedStrings.xml:
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="0" uniqueCount="4000000000"></sst>
No <si> elements are required for the reserve call to fire — it happens while parsing the <sst> start tag itself, before the second parsing loop (which reads <si> children) even begins.
Suggested fix
Apply the same guard pattern already used for the dimension-driven reservation, symmetrically, to uniqueCount: either skip the reserve call above some sane maximum (mirroring if len < 100_000), or switch to try_reserve(n) (which returns a Result/TryReserveError instead of aborting) combined with a sane maximum, so a legitimate large-but-plausible uniqueCount still gets a useful capacity hint while an implausible one fails gracefully rather than aborting the host process.
Downstream mitigation note
We've since implemented and tested a preflight validation layer in our own application (a live ZIP-entry probe measuring each entry's actual decompressed byte count via a capped reader — never trusting the declared uncompressed_size — plus a byte-level scan of sharedStrings.xml's root tag validating uniqueCount against a hard ceiling and the entry's own measured length) that closes this on our side regardless of upstream's response. We're filing this so the underlying library can close the gap at the source too — this is not a request to invent new defensive machinery, only to apply a pattern this codebase's own authors have already used elsewhere in the same file, symmetrically — and not a substitute for our own downstream hardening, nor something we're waiting on before shipping.
Summary
Xlsx::read_shared_strings(src/xlsx/mod.rs) reservesVec<String>capacity directly from the attacker-controlleduniqueCountattribute insharedStrings.xml, with no upper bound.Affected version observed locally:
calamine 0.36.0. I have not verified whether this is already fixed onmain; this report is based on local source inspection ofcalamine-0.36.0only (no CI/test-suite check was performed against a newer revision).Affected code path
In
src/xlsx/mod.rs,read_shared_stringsparses the<sst uniqueCount="N">root attribute and callsself.strings.reserve(n):This runs unconditionally inside
Xlsx::new()'s constructor, before the caller regains control — there is no point between callingopen_workbook/open_workbook_autoand getting aResultback where the caller's own code executes.Contrast with
worksheet_range_ref's structurally identical<dimension>-driven reservation, which the same file already guards:read_shared_strings'suniqueCount-driven reservation has no equivalent guard.Impact
A ~1 KB, otherwise perfectly ordinary
.xlsx(normal ZIP central directory, no unusual compression ratio, no oversized declared/actual size anywhere) can declareuniqueCount="4000000000"insharedStrings.xmlwith zero<si>children, driving a singleVec::reserverequest for ~96 GB (4,000,000,000 × 24 bytes/Stringheader on a 64-bit target) before the caller ever regains control.Because this uses the non-fallible
.reserve()(nottry_reserve), an allocation failure invokeshandle_alloc_error, whose default behavior is to abort the process — not a catchableResult. A calling application's ordinaryResult-based error handling aroundopen_workbook/open_workbook_autocannot intercept this; the effect is a crash of the entire host process.Minimal repro shape
A ZIP archive containing a minimal valid
[Content_Types].xml,_rels/.rels,xl/workbook.xml(+ relationships) sufficient foropen_workbook_auto/Xlsx::new()to proceed as far asread_shared_strings, plusxl/sharedStrings.xml:No
<si>elements are required for thereservecall to fire — it happens while parsing the<sst>start tag itself, before the second parsing loop (which reads<si>children) even begins.Suggested fix
Apply the same guard pattern already used for the
dimension-driven reservation, symmetrically, touniqueCount: either skip thereservecall above some sane maximum (mirroringif len < 100_000), or switch totry_reserve(n)(which returns aResult/TryReserveErrorinstead of aborting) combined with a sane maximum, so a legitimate large-but-plausibleuniqueCountstill gets a useful capacity hint while an implausible one fails gracefully rather than aborting the host process.Downstream mitigation note
We've since implemented and tested a preflight validation layer in our own application (a live ZIP-entry probe measuring each entry's actual decompressed byte count via a capped reader — never trusting the declared
uncompressed_size— plus a byte-level scan ofsharedStrings.xml's root tag validatinguniqueCountagainst a hard ceiling and the entry's own measured length) that closes this on our side regardless of upstream's response. We're filing this so the underlying library can close the gap at the source too — this is not a request to invent new defensive machinery, only to apply a pattern this codebase's own authors have already used elsewhere in the same file, symmetrically — and not a substitute for our own downstream hardening, nor something we're waiting on before shipping.