Skip to content

read_shared_strings reserves Vec<String> capacity from unbounded attacker-controlled uniqueCount #684

Description

@devsamhan

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.

Metadata

Metadata

Assignees

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions