fix: bound parser paths reachable from a crafted document - #148
Conversation
Five resource-exhaustion paths were reachable from untrusted input, one of
them a hard crash. Each is bounded by a limit in package::limits now, so it
returns ResourceLimit like every other attack shape.
ppt: Extractor::walk documents itself as an iterative walk and enforces
MAX_RECORD_DEPTH against stack.len(), but the recovery-mode NotesContainer
branch called walk() again. The re-entrant call started a fresh stack, so the
bound reset to 1 at every level and nesting was capped only by MAX_RECORDS
(16M) - orders of magnitude past a stack overflow. A ~1.6 MB .ppt aborts the
process with SIGSEGV. The descent is a stack push carrying an end-of-segment
flag now, which keeps the original ordering.
rtf: math zones build an Element tree through MathState rather than
parse_xml, so MAX_XML_DEPTH never applied to it; the OMML serializer recurses
per level, and so does dropping the tree. Cap it at construction, since a
guard in the serializer would not save the recursive Drop. Separately, the
group stack retained a CharState per '{' with no cap (10 MB -> ~1 GB RSS),
now MAX_RTF_GROUP_DEPTH.
doc: FKP page numbers in the PLC may all alias one page, so the entry count
did not bound the run list - a few MB of table stream expanded into tens of
GB. Bounded by MAX_DOC_RUNS. parse_plf_lfo also sized a Vec from a raw u32
header field, so 0xFFFFFFFF asked the allocator for ~68 GB and aborted; it is
clamped to what the buffer can hold.
sheet: formatCode has no length bound anywhere and parse_section materializes
several vectors per character, so a compressed entry amplified into GBs
without a worksheet cell being involved. Capped by MAX_NUMBER_FORMAT_BYTES,
falling back to General.
Also forbid unsafe_code at the crate root: the crate already had none, this
makes it enforced rather than incidental.
Regression tests for the two stack overflows fail on the parent commit
(the .ppt one aborts the test binary).
There was a problem hiding this comment.
3 issues found across 7 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/package/limits.rs">
<violation number="1" location="src/package/limits.rs:47">
P3: The module header states "crossing one returns ConvertError::ResourceLimit, always." The three new caps violate that invariant: MAX_DOC_RUNS degrades formatting by ignoring later runs, and MAX_NUMBER_FORMAT_BYTES falls back to a General format, and neither returns ResourceLimit (only MAX_RTF_GROUP_DEPTH does). Update the header (or the new comments) so the documented contract accurately reflects that some of these boundaries degrade gracefully rather than returning ResourceLimit, so callers don't rely on ResourceLimit for the two that don't raise it.</violation>
</file>
<file name="src/formats/ppt/mod.rs">
<violation number="1" location="src/formats/ppt/mod.rs:663">
P2: The 200,000-level regression test copies the entire fixture on every iteration, writing about 160 GB for a final 1.6 MB input. Construct the final buffer once and write each header in place so this test does not make CI unnecessarily slow.</violation>
</file>
<file name="src/formats/rtf/mod.rs">
<violation number="1" location="src/formats/rtf/mod.rs:1369">
P2: Because `\*` suppresses the following destination, this test never creates a `MathState` or exercises the new XML-depth bound. Use the unstarred `\mmath` destination so the regression test actually covers `open_group`.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
| let mut buf: Vec<u8> = Vec::new(); | ||
| for _ in 0..depth { | ||
| let mut outer = Vec::with_capacity(buf.len() + 8); | ||
| outer.extend_from_slice(&0x000Fu16.to_le_bytes()); // container | ||
| outer.extend_from_slice(&0x03F0u16.to_le_bytes()); // NotesContainer | ||
| outer.extend_from_slice(&(buf.len() as u32).to_le_bytes()); | ||
| outer.extend_from_slice(&buf); | ||
| buf = outer; | ||
| } |
There was a problem hiding this comment.
P2: The 200,000-level regression test copies the entire fixture on every iteration, writing about 160 GB for a final 1.6 MB input. Construct the final buffer once and write each header in place so this test does not make CI unnecessarily slow.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/formats/ppt/mod.rs, line 663:
<comment>The 200,000-level regression test copies the entire fixture on every iteration, writing about 160 GB for a final 1.6 MB input. Construct the final buffer once and write each header in place so this test does not make CI unnecessarily slow.</comment>
<file context>
@@ -634,3 +651,55 @@ impl Extractor {
+ /// `depth` NotesContainers (0x03F0), each holding the next. No NotesAtom
+ /// child, so none of them reads as the notes master.
+ fn nested_notes(depth: usize) -> Vec<u8> {
+ let mut buf: Vec<u8> = Vec::new();
+ for _ in 0..depth {
+ let mut outer = Vec::with_capacity(buf.len() + 8);
</file context>
| let mut buf: Vec<u8> = Vec::new(); | |
| for _ in 0..depth { | |
| let mut outer = Vec::with_capacity(buf.len() + 8); | |
| outer.extend_from_slice(&0x000Fu16.to_le_bytes()); // container | |
| outer.extend_from_slice(&0x03F0u16.to_le_bytes()); // NotesContainer | |
| outer.extend_from_slice(&(buf.len() as u32).to_le_bytes()); | |
| outer.extend_from_slice(&buf); | |
| buf = outer; | |
| } | |
| let mut buf = vec![0u8; depth * 8]; | |
| for i in 0..depth { | |
| let off = i * 8; | |
| buf[off..off + 2].copy_from_slice(&0x000Fu16.to_le_bytes()); | |
| buf[off + 2..off + 4].copy_from_slice(&0x03F0u16.to_le_bytes()); | |
| let len = ((depth - i - 1) * 8) as u32; | |
| buf[off + 4..off + 8].copy_from_slice(&len.to_le_bytes()); | |
| } |
| /// dropping the tree. | ||
| #[test] | ||
| fn deep_math_nesting_stays_within_the_xml_depth_bound() { | ||
| let mut src = String::from(r"{\rtf1\ansi {\*\mmath "); |
There was a problem hiding this comment.
P2: Because \* suppresses the following destination, this test never creates a MathState or exercises the new XML-depth bound. Use the unstarred \mmath destination so the regression test actually covers open_group.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/formats/rtf/mod.rs, line 1369:
<comment>Because `\*` suppresses the following destination, this test never creates a `MathState` or exercises the new XML-depth bound. Use the unstarred `\mmath` destination so the regression test actually covers `open_group`.</comment>
<file context>
@@ -1329,6 +1347,36 @@ impl<'a> Parser<'a> {
+ /// dropping the tree.
+ #[test]
+ fn deep_math_nesting_stays_within_the_xml_depth_bound() {
+ let mut src = String::from(r"{\rtf1\ansi {\*\mmath ");
+ // Stay under the group bound so this exercises the math cap alone.
+ let groups = limits::MAX_XML_DEPTH * 2;
</file context>
| let mut src = String::from(r"{\rtf1\ansi {\*\mmath "); | |
| let mut src = String::from(r"{\rtf1\ansi {\mmath "); |
| /// page numbers in the PLC are attacker-controlled and may all alias a single | ||
| /// page, so the entry count on its own does not bound how many runs they | ||
| /// yield. Past the cap formatting degrades; text extraction is unaffected. | ||
| pub const MAX_DOC_RUNS: usize = 1_000_000; |
There was a problem hiding this comment.
P3: The module header states "crossing one returns ConvertError::ResourceLimit, always." The three new caps violate that invariant: MAX_DOC_RUNS degrades formatting by ignoring later runs, and MAX_NUMBER_FORMAT_BYTES falls back to a General format, and neither returns ResourceLimit (only MAX_RTF_GROUP_DEPTH does). Update the header (or the new comments) so the documented contract accurately reflects that some of these boundaries degrade gracefully rather than returning ResourceLimit, so callers don't rely on ResourceLimit for the two that don't raise it.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/package/limits.rs, line 47:
<comment>The module header states "crossing one returns ConvertError::ResourceLimit, always." The three new caps violate that invariant: MAX_DOC_RUNS degrades formatting by ignoring later runs, and MAX_NUMBER_FORMAT_BYTES falls back to a General format, and neither returns ResourceLimit (only MAX_RTF_GROUP_DEPTH does). Update the header (or the new comments) so the documented contract accurately reflects that some of these boundaries degrade gracefully rather than returning ResourceLimit, so callers don't rely on ResourceLimit for the two that don't raise it.</comment>
<file context>
@@ -40,5 +40,23 @@ pub const MAX_ASSET_TOTAL_BYTES: usize = 128 * 1024 * 1024;
+/// page numbers in the PLC are attacker-controlled and may all alias a single
+/// page, so the entry count on its own does not bound how many runs they
+/// yield. Past the cap formatting degrades; text extraction is unaffected.
+pub const MAX_DOC_RUNS: usize = 1_000_000;
+
+/// Maximum RTF group nesting depth. Real documents nest a few dozen groups;
</file context>
Five resource-exhaustion paths are reachable from untrusted input. One is a hard crash: a ~1.6 MB
.pptaborts the process with a stack overflow.Each is bounded by a constant in
package::limitsnow, so it returnsResourceLimitlike every other attack shape.ppt— stack overflow (SIGSEGV)Extractor::walkdocuments itself as "Iterative container walk over an explicit stack with fixed depth and record-count bounds" and enforcesMAX_RECORD_DEPTHagainststack.len(). But the recovery-modeNotesContainerbranch calledself.walk(body)again. The re-entrant call starts a fresh localstack, so the depth bound resets to 1 at every level and never fires — nesting ends up capped only byMAX_RECORDS(16M), orders of magnitude past what the real stack survives.recoveringis attacker-controlled: any unusable persist directory sets it.Reproduced on
261fc25:The descent is a stack push carrying an end-of-segment flag now, so the
end_segment/current_is_notesordering is byte-for-byte what the recursive version did.rtf— stack overflowMath zones build their
Elementtree throughMathState::open_group, notparse_xml, soMAX_XML_DEPTHnever applied to it.close_groupsnests each group into its parent, making tree depth equal brace depth, and the OMML serializer (walk_children/walk_elem) recurses once per level — as does dropping the tree. Capped at construction, since a guard in the serializer would not save the recursiveDrop.Separately the group stack retained a
CharStateper{with no cap: ~10 MB of{→ ~1 GB RSS. NowMAX_RTF_GROUP_DEPTH.doc— OOM and allocator abortFKP page numbers in the PLC are masked to 22 bits and nothing stops every entry pointing at the same page, so the entry count did not bound the run list — a few MB of table stream expanded into tens of GB. Bounded by
MAX_DOC_RUNS; formatting degrades past it, text extraction is unaffected.parse_plf_lfoalso sized aVecstraight from a rawu32header field, so0xFFFFFFFFasked the allocator for ~68 GB and hithandle_alloc_error→abort(). Clamped to what the buffer can hold; the existing loop already bounded the real count.sheet— OOMformatCodehas no length bound anywhere andparse_sectionmaterializes several vectors per character, so a compressed archive entry amplified into GBs without a worksheet cell being involved (Styles::readparses everycellXfs/xfunconditionally). Capped byMAX_NUMBER_FORMAT_BYTES, falling back to General.Also
#![forbid(unsafe_code)]at the crate root. The crate already had zerounsafe— this makes the property enforced rather than incidental. (Scoped to this crate; the parsing dependencies are unaffected.)Tests
Four regression tests, two of which fail on the parent commit — the
.pptone by aborting the test binary.cargo fmt --checkclean ·cargo clippy --all-targets -- -D warningsexit 0 · 300 tests pass · workspace incl. Python and WASM bindings compiles.Happy to split this into per-format PRs, or to move the crash to a private advisory instead if you'd rather it not sit in a public diff — there is no
SECURITY.md, and since this is a DoS in safe Rust rather than memory corruption I defaulted to the normal PR flow.Summary by cubic
Fixes five resource-exhaustion paths reachable from crafted documents, including a
~1.6 MB.pptthat aborts the process with a stack overflow. Each path now returnsResourceLimitinstead of crashing or exhausting memory, matching how other attack shapes are handled.Bug Fixes
pptnow descends via the shared bounded stack instead of re-enteringwalk, which reset the depth bound to 1 at every level and left nesting capped only by the 16M record limit.Dropwould defeat a serializer-side guard; the group stack also gets its own cap to bound retained character-state snapshots..docFKP formatting runs are bounded, andparse_plf_lfoclamps a raw header count to the buffer length instead of reserving tens of gigabytes from a value like0xFFFFFFFF.formatCodelonger than 4096 bytes falls back to General rather than amplifying a compressed archive entry into gigabytes.#![forbid(unsafe_code)]makes the crate's existing safe-only property enforced rather than incidental.Written for commit f8c87cc. Summary will update on new commits.