Repository navigation
fix(mux): bound what an fMP4 moov or moof costs to parse and plan - #7687
Conversation
Quest-Claim: eb8ceb94362cb1b8
mp4-atom expands a parsed body up to tenfold, so a moov or moof body over fmp4::MAX_METADATA_BYTES (2 MiB) is now refused at its header, and MAX_HELD_BYTES (32 MiB) bounds only a text sample. MAX_FRAGMENT_SAMPLES drops to 2^17 and the sample plan is presized with a smaller per-sample queue, so a fragment at the bounds plans within MAX_HELD_BYTES. A moov declaring more than MAX_TRACKS (256) tracks is refused before any is created. Declaring a catalog rendition no longer clones and serializes the whole catalog, which made a moov of N tracks cost N^2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 1 minute. View limit details
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. CodeRabbit Verify
|
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed commit: 3917bd8
- P2 — Validate the resulting catalog for custom extensions (
rs/moq-mux/src/catalog/tracks.rs:390–397):writeserializes a catalog containing only the new entry before mutating the real catalog. PublicCatalogExtimplementations can serialize conditionally on the combined entries, so an insertion may return success yet leave the actual catalog unserializable; subsequent publication then aborts instead of rejecting the edit atomically as before. Preserve full aggregate validation for custom extensions or expose an equivalent validation contract, and add a cross-entry serializer regression test.
Bounding headers and sample plans is a useful direction. The disclosed upstream sgpd allocation remains a separate limit on the overall memory guarantee until the dependency fix lands. Static review only; I did not run tests or measure memory.
There was a problem hiding this comment.
ℹ️ Minor suggestions only. The new bounds, the text-staging rework, the plan presizing, and the catalog linearization all check out; one documentation caveat inline.
Reviewed changes
- Parse and sample bounds (
rs/moq-mux/src/container/fmp4/mod.rs):MAX_METADATA_BYTES(2 MiB) caps amoov/moofbody at its header,MAX_FRAGMENT_SAMPLESdrops to 2^17 and now presizes the plan, andMAX_TRACKS(256) refuses an over-largemoovwithError::TooManyTracksbefore any track is created.MAX_HELD_BYTESnow bounds only a text sample. - Sample plan (
fmp4/import.rs,fmp4/sample.rs,fmp4/text.rs): text samples move out of the per-sampleChunkinto oneMdat::textlist plus a single sharedMdat::stagedbuffer, shrinkingChunkto a range andChunkOutput;Claimed::takeextends an adjacent range rather than inserting;Vec/VecDequeare presized from the already-checked sample count viasample::count. - Catalog writes (
rs/moq-mux/src/catalog/tracks.rs): the sharedwritehelper validates a rendition's identity and serialization in a catalog holding only its own entry, replacing the whole-catalog clone-and-serialize that made declaring N renditions cost N². This applies toset,replace, andstageacross every importer. - Allocator accounting (
rs/moq-mux/src/allocation.rs):Usage::peakrecords peak live bytes, accounting for the both-blocks-live window duringrealloc. - Tests and docs:
fragment_plans_within_the_held_boundandmoov_tracks_are_boundedassert peak allocation and refusal; the CHANGELOG,doc/lib/rs/moq-mux.md, and the m1/m2 quest docs record the new limits.
I traced the two subtle behavioral changes: the shared staged buffer is safe because write_mdat only ever feeds the front chunk and drains staged on each completion, so no two text samples are staged at once; and Claimed::take's merge preserves cross-track disjointness. The write refactor is equivalent to the old whole-catalog check, since check_identity inspects only the named video/audio entry and insert touches exactly one category.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
The entry-alone check could not see an extension whose serializer depends on its combined entries. Seed the scratch catalog with the real extension, which built-in configs leave untouched, so they stay linear. Also name the sgpd exception to the tenfold parse bound. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Re the OpenAI review's P2 on The scratch catalog is now seeded with a clone of the real extension ( (Written by Claude Opus 5.5) |
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed commit: fb90bcf
- P2 — Validate custom insertions against the real catalog context (
rs/moq-mux/src/catalog/tracks.rs:395–400): Cloning the extension fixes the previously reported cross-entry serializer case, but the scratch catalog still has empty video, audio, text, and data sections.RenditionConfig::insertis public and receives the whole catalog. For example, a custom insertion can write only its own extension while reading whether a video rendition already exists; its extension serializer can reject the resulting value. The scratch insertion sees no video and passes, while the real insertion sees video and leaves a catalog that cannot serialize, so an edit that previously failed atomically can be accepted and later abort publication. Validate custom insertions against equivalent full context, or define and enforce a narrower insertion contract, and cover this cross-section dependency in a regression test.
The new extension clone addresses the earlier extension-only example. Static review only; I did not run tests or measure memory.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Extension validated whole (
rs/moq-mux/src/catalog/tracks.rs):writenow clonescatalog.extinto the scratch catalog, so a customCatalogExtserializer that depends on its combined entries is checked before the real catalog is mutated. Media/data entries are still excluded, keeping N renditions from re-serializing each other. This closes the OpenAI P2 finding. - Regression test:
an_entry_that_breaks_the_extension_is_refusedadds aSolosection that refuses more than one entry and asserts the second write fails while the first is kept. - Docs:
MAX_METADATA_BYTESnow names thesgpdcase that expands past the bound until the mp4-atom count fix lands. - Merge of
origin/main: no behavioral change to the touched files.
The extension clone is bounded by application-supplied catalog data, not by the untrusted moov/moof, so it does not reopen the attacker-controlled quadratic path this PR closes.
Merge main (#7687 and #7693 merged): drop the CMAF quest's Required edge and the abort-wake quest's link to the deleted moq-boy quest. Per the OpenAI review, the CMAF quest bounds every top-level box before mp4-atom's Any decoder and bounds counts too. Per Pullfrog, js/msf's decode already keeps a __proto__ extension, so only its exported schema remains. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Completes
quest/m1/fmp4-metadata-limits.md: an fMP4moovormoofwithin the importer's bounds now parses and plans withinMAX_HELD_BYTES(32 MiB), and track setup costs linear time.Measured
Peak live heap of one
Import::decodecall, from the counting allocator (release build; the tests assert the same numbers in debug):moofof field-lesstruns atMAX_FRAGMENT_SAMPLES, then anmdatheadermoofof 4-bytetrunentries atMAX_FRAGMENT_SAMPLESmoofof 4-byte entries filling the byte boundTooManySamplesTooManySamplesmoovof repeated audiotraksChanges
trunentry becomes a 40-byteTrunEntry,subsgrows about 8x, andtraks about 4.4x. So a 32 MiBmoovormoofcould cost about 320 MB to parse. A newfmp4::MAX_METADATA_BYTES(MAX_HELD_BYTES / 16, 2 MiB) bounds amoovormoofbody at its header, andMAX_HELD_BYTESnow bounds only a text sample.MAX_FRAGMENT_SAMPLESis nowMAX_METADATA_BYTES / 16(2^17, down from 2^21): amoofat the byte bound still holds that many full 16-byte entries. The plan is presized from the declared count instead of growing. Text samples moved out of the per-sampleChunkqueue into one list on theMdat, so aChunkshrank to a byte range and an index. Themdatbyte-ownership map extends a range that ends where the next sample starts, so samples that sit back to back take one entry per run.Rendition::setandstagechecked each new catalog entry by cloning and serializing the whole catalog, so amoovof N tracks cost N². They now check the entry in a catalog that holds only it and the extension, then write it in place. This applies to every importer, not just fMP4. A newfmp4::MAX_TRACKS(256) refuses amoovdeclaring more withError::TooManyTracksbefore any track is created.fragment_plans_within_the_held_boundcovers field-less runs, full entries, 4-byte entries filling the byte bound, and one-sample runs whose bytes are never adjacent, each unwrapped and passthrough.moov_tracks_are_boundedchecks linear allocation (it fails on the old catalog code, at a 3.3x ratio), the peak, and refusal both pastMAX_TRACKSand at the byte bound. Together they run in under a second in debug.allocation::Usagegainspeakfor them.doc/lib/rs/moq-mux.md, the CHANGELOG, and the m2 policy quest state the new bounds.Choices made without asking
trunexpansion: read the counts from the held body, or bound amoofbody separately. I chose the byte bound and applied it tomoovtoo. mp4-atom decodes a known box (atrun, atrak) wherever it appears, before rejecting it, so a pre-scan of the expected paths would leak. The byte bound caps every box's expansion at once, includingsubsandtrak. Trade-off: atruncount is still refused after a parse, but that parse is capped at about 20 MB.MAX_METADATA_BYTES= 2 MiB,MAX_FRAGMENT_SAMPLES= 2^17,MAX_TRACKS= 256 (the quest sets them). Amoovwith more than 2 MiB of metadata, such as large embedded cover art, is now refused. Real fMP4 metadata is kilobytes.CatalogExtserializer may depend on its combined entries, as the OpenAI review pointed out.Impact
fmp4::MAX_METADATA_BYTES,fmp4::MAX_TRACKS, andfmp4::Error::TooManyTracks(the enum is#[non_exhaustive]).MAX_FRAGMENT_SAMPLESchanges value, andMAX_HELD_BYTESkeeps its value but now covers only text samples. TheMetadataTooLargemessage names the new bound. No signatures change.moovormoofover 2 MiB, amoofof more than 131,072 samples, or amoovof more than 256 tracks.Checks
cargo test -p moq-mux: 1991 passed, 3 ignored, including the peak-memory testsfragment_plans_within_the_held_boundandmoov_tracks_are_bounded, and the newan_entry_that_breaks_the_extension_is_refused(fails without the fix).cargo clippy -p moq-mux --all-targets --all-features -- -D warnings,cargo fmt, andRUSTDOCFLAGS=-D warnings cargo doc -p moq-mux --no-deps: clean.quest check --base origin/main: ok.sh/markdown.sh), which needsbun installon this disk-constrained host.Not in this PR
sgpdv0 entries that cost no input (21 bytes → 251 MB at 4M entries) are already fixed upstream in kixelated/mp4-atom#264. Picking up that fix is tracked byquest/m1/fuzz-findings/mp4-atom-bump.mdand waits on a release.Wire::decodeparses amooffrom a frame of up to 32 MiB, so it has the same tenfold expansion. I'm suggesting it as a follow-up.Review
Codex
gpt-6.1-sol, medium effort (quest review), new session round 1 onfb90bcf87437826db0df112d6fb7d9ae45a2b164againstorigin/mainat151ed3820d: no findings.Online reviews on
3917bd8583:MAX_METADATA_BYTESdoc overstates the tenfold bound forsgpd: fixed in 5fc9276. The doc names the exception and links fix: reject sgpd entry counts past the box kixelated/mp4-atom#264.🤖 Generated with Claude Code
(Written by Claude Opus 5.5)