fix(formdata): multipart parser corrupts parts located past 4 GiB - #30609
fix(formdata): multipart parser corrupts parts located past 4 GiB#30609robobun wants to merge 1 commit into
Conversation
|
Updated 12:04 PM PT - Aug 15th, 2026
🔄 @robobun, the build for your commit |
|
Warning Review limit reached
Next review available in: 31 seconds Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (3)
WalkthroughFormData multipart parsing switches from buffer-offset ChangesFormData multipart parsing with raw slices and large-offset support
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/runtime/webcore/FormData.zig`:
- Line 332: The parsing loop in FormData.zig currently exits early when both
filename and name are found (condition using header_chunk.len > 0 and (filename
== null or name.len == 0)), which skips subsequent headers like Content-Type and
leaves field.content_type empty; update the loop(s) (the header parsing while
using header_chunk, filename, name and the similar blocks around the other
occurrences) to continue scanning all header lines until header_chunk.len == 0
(i.e., remove the early-exit dependency on filename/name), and ensure that when
a "Content-Type" header is encountered you set field.content_type appropriately
so file parts retain their content type (apply same change to the other similar
blocks referenced).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 605edc8f-a6a8-4e88-9564-83e94b6b122e
📒 Files selected for processing (3)
src/jsc/array_buffer.zigsrc/runtime/webcore/FormData.zigtest/js/web/html/FormData.test.ts
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@test/js/web/html/FormData.test.ts`:
- Around line 2-3: The test currently uses os.totalmem() to decide skipping
which checks installed RAM; change the skip gating to use the effective memory
ceiling instead (e.g., use v8.getHeapStatistics().heap_size_limit or process
resource limits or a new harness helper) so the FormData.test.ts skip is
deterministic under cgroup/job memory caps; locate the check that imports
totalmem and replace the logic that references totalmem with a call to
v8.getHeapStatistics().heap_size_limit (or the harness helper) and base the
skip/skipIf on that value (update any references to totalmem in the test file
accordingly).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 1c8fdad6-7d2c-4275-b642-19cc32e4e870
📒 Files selected for processing (1)
test/js/web/html/FormData.test.ts
There was a problem hiding this comment.
LGTM — mechanical bun.Semver.String → []const u8 widening that mirrors what Field.value already went through, with a gated regression test.
Extended reasoning...
Overview
This PR touches three files: src/runtime/webcore/FormData.zig (multipart parser), src/jsc/array_buffer.zig (one-line cast removal), and test/js/web/html/FormData.test.ts (new regression test). The core change converts Field.filename, Field.content_type, and the local name from bun.Semver.String (which packs offset/length into u32 with a tag bit) to raw []const u8 slices, matching what was already done for Field.value in #27483. The subslicer, the buf callback parameter, and the unused string type alias are dropped accordingly. The ArrayBuffer.fromBytes change removes a vestigial @as(u32, @intCast(...)) whose targets (.len, .byte_len) are already usize.
Security risks
The multipart parser handles untrusted request bodies, but this change is strictly a type widening — slices that previously round-tripped through a lossy u32 offset/length encoding now stay as native fat pointers into the same input buffer. No new parsing logic, no new indexing arithmetic, no allocation changes. The fromBytes change removes a debug-build panic on >4 GiB inputs without altering release behavior. I see no injection, OOB, or auth surface introduced.
Level of scrutiny
Low–medium. The transformation is mechanical (.isEmpty() → .len == 0, .len() → .len, .slice(buf) → direct slice, subslicer.sub(x).value() → x) and follows the exact pattern already applied to Field.value. Grep confirms forEachMultipartEntry and FormData.Field are only referenced within FormData.zig, so the callback signature change is self-contained.
Other factors
The bug-hunting system found no issues. All inline review threads are resolved: the os.totalmem() gating concern was addressed in 8de77d5 with process.constrainedMemory(), and both CodeRabbit and my own note about the pre-existing Content-Type early-exit on line 332 were acknowledged as out of scope (the diff there is purely .len() → .len). The new test is appropriately gated (skipped on Windows / <16 GiB) and runs in a subprocess. The musl build failures in CI appear to be infrastructure-related rather than caused by this diff. No CODEOWNERS apply.
CI statusThe diff is green. Build #72460 (4c84809, current HEAD)72 lanes pass, cargo clippy passes. 1 failed lane (
Neither intersects the multipart parser. The new test in this PR ("multipart parser handles parts at offsets > 4 GiB") does not appear in any failure output and has passed on every lane where it runs. The PR touches only Ready for merge. |
|
Rebased onto main (post-Rust-rewrite) and ported:
Gate verified: without the (Unrelated: |
1dc0c33 to
31d5a69
Compare
There was a problem hiding this comment.
No issues found and all prior feedback addressed — the fix is a clean mechanical swap from bun.Semver.String to native slices following the pattern Field.value already uses. Deferring to a maintainer only because this touches the multipart body parser (untrusted-input path) across both the Zig reference and the freshly-ported Rust implementation, and the latter adds new unsafe raw-pointer derefs (same contract as the pre-existing field.value, but worth a human glance).
Extended reasoning...
Overview
This PR fixes #21490: req.formData() corrupts part names/filenames for any multipart part whose header sits past byte 2³² in the body. Root cause is that forEachMultipartEntry stored name/filename/content_type as bun.Semver.String, which packs offset/length into a u32/u31 pair and silently wraps. The fix swaps these to raw []const u8 (Zig) / *const [u8] (Rust) slices into the input buffer — exactly the pattern Field.value already uses since #27483. It also drops a leftover u32 round-trip cast in ArrayBuffer.fromBytes/from_bytes whose target fields are already usize. A new memory-gated, Windows-skipped regression test in FormData.test.ts constructs a 4 GiB + 256 B body and asserts the trailing parts round-trip.
Files: src/runtime/webcore/FormData.{zig,rs}, src/jsc/array_buffer.{zig,rs}, test/js/web/html/FormData.test.ts.
Security risks
The multipart parser handles untrusted HTTP request bodies, so this is a security-adjacent code path. However, the change does not alter parsing logic, boundary handling, or header tokenization — it only changes how already-parsed subslices are stored (native fat pointer instead of u32-packed offset). The fix strictly removes a silent integer truncation; it cannot introduce new OOB reads because the slices are direct subslices of input produced by the same indexing as before. The Rust side's new unsafe { &*field.content_type } / &*field.filename derefs follow the identical SAFETY contract already in place for field.value (always either the b"" static or a subslice of the caller-owned input, valid for the iterator call). No auth/crypto/permissions code is touched.
Level of scrutiny
Moderate. The transformation is mechanical and the diff is small, but it spans (a) a parser on the untrusted-input path and (b) two parallel implementations — the Zig reference and the recently-landed Rust port. The Rust change adds three unsafe blocks. While each mirrors the pre-existing field.value: *const [u8] pattern and the TODO(port): lifetime comment explains why &'a [u8] isn't used yet, I'd prefer a maintainer confirm the Rust lifetime story rather than rubber-stamp it.
Other factors
forEachMultipartEntry/for_each_multipart_entryhave no external callers (grep confirms both are file-local), so the iterator signature change is self-contained.- No CODEOWNERS apply to the touched paths.
- All prior review feedback (CodeRabbit's cgroup-memory gate, my
require("os")→ static import nit, the pre-existing Content-Type early-exit) has been addressed or explicitly scoped out, and all inline threads are resolved. - CI was green on the previous push except for an unrelated fleet-wide Windows
test-http-should-emit-close-when-connection-is-abortedflake; build #54342 for the latest commit is in progress. - The bug-hunting system found no issues on the current revision.
d11a1e1 to
fdb922c
Compare
There was a problem hiding this comment.
LGTM — mechanical Semver.String → raw-slice swap that mirrors what #27483 already did for Field.value, plus a dead u32 cast removal; all prior nits addressed.
Extended reasoning...
Overview
Touches src/runtime/webcore/FormData.{zig,rs} (multipart parser), src/jsc/array_buffer.{zig,rs}, and test/js/web/html/FormData.test.ts. The core change converts Field.{filename,content_type} and the per-part name from bun.Semver.String / bun_semver::String (which packs offset/len as u32 with bit 63 stolen as a tag) to raw []const u8 / *const [u8] slices into the input buffer. The iterator callback signature drops the now-unused buf back-reference and SlicedString subslicer. The array_buffer change removes a vestigial @as(u32, @intCast(...)) / u32::try_from().expect() round-trip on fields that were already usize. A memory-gated, subprocess-isolated regression test exercises a 4 GiB+ multipart body.
Security risks
None introduced. The multipart parser does process untrusted HTTP bodies, but this change doesn't alter parsing logic or bounds computation — it only changes how already-computed subslices are stored (native fat pointer instead of a packed u32 offset/len that silently wrapped at 4 GiB). The slices point into the same caller-owned buffer with identical bounds; the only behavioral delta is that offsets past 2³² no longer truncate. The Rust *const [u8] + unsafe { &* } pattern is the same idiom Field.value already uses (ported in #27483), so no new unsafe surface is being introduced — two more fields adopt an existing, documented invariant.
Level of scrutiny
Low–medium. The transformation is mechanical and follows an established precedent (Field.value underwent the identical conversion in #27483). I verified via grep that forEachMultipartEntry / for_each_multipart_entry and the Field struct have no consumers outside these two files, so the signature change is fully self-contained. The array_buffer hunk is a strict simplification (the target fields are usize; the cast was a debug-build panic waiting to happen and a no-op in release). No CODEOWNERS cover these paths.
Other factors
All review threads are resolved: my pre-existing Content-Type early-exit note was acknowledged as out of scope; CodeRabbit's cgroup-memory gate suggestion was applied (process.constrainedMemory?.() || totalmem()); my require("os") → static-import nit was fixed in fdb922c. The bug-hunting system found nothing this round. CI on the prior HEAD was green modulo a fleet-wide fetch-tcp-keepalive flake unrelated to this diff, and the new regression test passed on every lane where it ran.
fdb922c to
28f675b
Compare
There was a problem hiding this comment.
LGTM — mechanical Semver.String → raw slice swap matching the existing Field.value pattern; all prior feedback addressed.
Extended reasoning...
Overview
This PR fixes #21490 by changing how the multipart FormData parser stores part metadata. forEachMultipartEntry previously stored name, filename, and content_type as bun.Semver.String (which packs offset/length into u32 fields, wrapping at 4 GiB), and now stores them as raw []const u8 / *const [u8] slices into the input buffer — exactly the same representation Field.value already uses since #27483. The buf callback parameter and SlicedString subslicer become unnecessary and are dropped. A separate one-line change in array_buffer.{zig,rs} removes a vestigial u32 round-trip on fields that are already usize. A new regression test in FormData.test.ts constructs a >4 GiB multipart body in a subprocess and asserts trailing parts round-trip correctly; it's gated on process.constrainedMemory()/totalmem() ≥ 16 GiB and skipped on Windows.
Security risks
None introduced. The multipart parser handles untrusted HTTP bodies, but this change strictly removes a lossy u32 packing step — the same subslices of the same caller-owned input buffer flow through the same logic, just without offset truncation. No new dereferences of attacker-controlled offsets; the Rust unsafe { &*field.filename } pattern is identical to the pre-existing unsafe { &*field.value } pattern with the same SAFETY invariant (points into the caller-owned input or b"").
Level of scrutiny
Low-to-medium. The diff is a mechanical type substitution (.slice(buf) → direct use, .isEmpty() → .len > 0, .len() → .len) following an already-landed pattern from #27483. forEachMultipartEntry / for_each_multipart_entry and FormData.Field have no callers outside the two modified files, so the signature change is fully self-contained. The array_buffer change is a strict simplification (removing @as(u32, @intCast(x)) / u32::try_from(x).expect() as usize where x is already usize and the target field is usize).
Other factors
All four review threads are resolved: the CodeRabbit loop-condition comment and my own pre-existing-bug note were correctly scoped out (the .len() → .len change is purely mechanical, the early-exit behavior is unchanged from main); the cgroup-memory gate was addressed with process.constrainedMemory?.(); and my require("os") nit was fixed with a static import. The author's CI summary reports the diff green across 65+ lanes with only a fleet-wide fetch-tcp-keepalive flake unrelated to this change. No CODEOWNERS cover these paths. The bug-hunting system found no issues.
28f675b to
65dce7d
Compare
There was a problem hiding this comment.
No issues found and both earlier nits are addressed — deferring only because this touches the multipart body parser (untrusted input) across both the Zig and Rust implementations, which I'd rather a human sign off on.
Extended reasoning...
Overview
This PR fixes #21490 by changing how forEachMultipartEntry stores part metadata (name, filename, content_type): from bun.Semver.String / bun_semver::String — which packs offset/length into 32-bit fields and silently wraps past 4 GiB — to native []const u8 / *const [u8] slices. It applies the same change in parallel to src/runtime/webcore/FormData.zig and src/runtime/webcore/FormData.rs, drops the now-unused subslicer/buf plumbing, removes a redundant u32 round-trip in ArrayBuffer.fromBytes / ArrayBuffer::from_bytes (target fields are already usize), and adds a memory-gated regression test in test/js/web/html/FormData.test.ts.
The transformation is mechanical and follows the exact pattern already applied to Field.value in #27483: subslicer.sub(x).value() → x, .len() → .len, .isEmpty() → .len == 0 / .is_empty(), .slice(buf) → direct use. The Rust side adds the required per-block SAFETY: comments for the *const [u8] derefs, and the Default impl keeps them initialised to b"" so they are always valid fat pointers. The control-character Content-Type guard from the recent hardening PR is preserved.
Security risks
Multipart parsing operates on untrusted HTTP request bodies, so it is security-adjacent. However, this PR does not introduce or alter any parsing logic, bounds checks, or allocations — it only widens the storage representation of already-computed subslices from u32-packed offsets to native fat pointers. The array_buffer change removes a debug-build panic on >4 GiB buffers without changing release behaviour. I see no new injection, OOB, or lifetime exposure; the Rust *const [u8] fields borrow into the caller-owned input for exactly the duration of the iterator callback, matching the existing Field.value contract.
Level of scrutiny
Medium-high. The change itself is locally verifiable and semantically equivalent modulo the bug being fixed, but it spans the production multipart parser in two coordinated language implementations and changes the public forEachMultipartEntry iterator signature. That combination is past my threshold for auto-approval of code on an untrusted-input path.
Other factors
The bug-hunting system found nothing. Both of my earlier inline comments (the pre-existing Content-Type early-exit, scoped out as orthogonal; and the require("os") → static import nit) are resolved, as is CodeRabbit's cgroup-aware memory-gate suggestion. CI is reported green across 71 lanes with only expired darwin agents outstanding. The new test is sensibly gated (process.constrainedMemory() with totalmem() fallback, skip on Windows) and runs in a subprocess. No CODEOWNERS match the touched paths.
65dce7d to
8500af0
Compare
There was a problem hiding this comment.
LGTM — mechanical type swap (Semver.String → raw slice) mirroring the existing Field.value pattern, plus a gated regression test; all prior review threads resolved and CI green.
Extended reasoning...
Overview
This PR fixes #21490 by changing FormData.Field.filename/content_type and the multipart iterator's name parameter from bun.Semver.String / bun_semver::String (which packs offset/length as u32 and silently wraps past 4 GiB) to native []const u8 / &[u8] slices. The same fix was already applied to Field.value in #27483; this completes it for the remaining three fields. It also drops a vestigial u32 round-trip in ArrayBuffer.fromBytes / ArrayBuffer::from_bytes where the target fields are already usize. A memory-gated subprocess regression test in FormData.test.ts verifies parts past the 4 GiB mark round-trip correctly.
Security risks
The multipart parser handles untrusted HTTP bodies, but this change adds no new parsing logic — it only changes how subslices of the input buffer are represented (native fat pointer instead of u32 offset+len packed into a u64). All slices still point into the same caller-owned input buffer with the same bounds; in the Rust port the borrow checker enforces this via the 'a lifetime on Field. The pre-existing printable-ASCII guard on content_type (header-injection hardening) is preserved verbatim. Net effect is a correctness improvement: the old code could hand back bytes from the wrong offset within the buffer.
Level of scrutiny
Moderate. The transformation is mechanical and follows the exact pattern already established for Field.value in the same struct: subslicer.sub(x).value() → x, .slice(buf) → direct use, .len()/.isEmpty() → .len/.is_empty(). forEachMultipartEntry/for_each_multipart_entry is only called from within the same file (verified via grep), so the iterator signature change has no external callers to break. The array_buffer change is a no-op simplification (removing u32::try_from(x).expect() as usize where x: usize).
Other factors
All prior review threads (CodeRabbit's Content-Type early-exit note, the cgroup-aware memory gate, and the static-import nit) are resolved — the early-exit is correctly identified as pre-existing and out of scope, and the test gate now uses process.constrainedMemory() with a totalmem() fallback. The bug-hunting pass found nothing. The robobun CI summary reports zero test failures on the current HEAD with only infra-expired darwin agents red. No CODEOWNERS cover these paths.
8500af0 to
2c59d42
Compare
|
Rebased onto current main. Two things changed shape:
Verified post-rebase: |
There was a problem hiding this comment.
LGTM — clean mechanical fix replacing the u32-packed bun_semver::String with lifetime-checked &'a [u8] borrows, plus a trivially-correct cast removal in array_buffer.rs.
Extended reasoning...
Overview
This PR touches three files: src/runtime/webcore/FormData.rs (switches Field.filename/content_type and the name parameter from bun_semver::String — which packs offset/length into 32 bits — to plain &'a [u8] borrows, matching Field.value which already had that type), src/jsc/array_buffer.rs (drops a vestigial u32::try_from(len).expect(...) as usize round-trip whose target fields are already usize), and test/js/web/html/FormData.test.ts (adds a memory-gated subprocess regression test for parts past 4 GiB). The Zig half of the fix was dropped after the porting-reference sources were removed from the tree, so the diff is now Rust-only with no unsafe.
Security risks
The multipart parser ingests untrusted request bodies, but this change strictly narrows the attack surface: it replaces a u32 offset that silently wrapped at 4 GiB (causing slice(buf) to read garbage from elsewhere in the buffer) with proper lifetime-checked Rust slices. No new parsing logic, header handling, or allocation paths are introduced. The adjacent control-character Content-Type rejection test is reported still passing. The array_buffer.rs change is a pure no-op for <4 GiB inputs and only removes a debug-build panic for larger ones. I see no auth/crypto/permissions surface here.
Level of scrutiny
Low-to-moderate. The fix is mechanical and follows the pattern already established for Field.value in #27483/#31783 — the struct was already lifetime-parameterized as Field<'a>, so the new fields slot in without any borrow-checker gymnastics. for_each_multipart_entry has exactly one caller (to_js_from_multipart_data::Wrapper::on_entry), updated in the same diff, and the dropped buf/subslicer were only ever used to reconstitute the now-direct slices. The array_buffer.rs hunk is a four-line identity simplification.
Other factors
All prior review threads (CodeRabbit's cgroup-memory gate, my static-import nit, the pre-existing Content-Type early-exit) are resolved; the early-exit was correctly scoped out as orthogonal and pre-existing. The new test is well-isolated (subprocess, process.constrainedMemory()-aware skip, 120s timeout) and the bug-hunting system found nothing on this revision. No CODEOWNERS cover these paths. The author's CI summary shows green with only fleet-wide flakes unrelated to FormData.
2c59d42 to
a554586
Compare
|
Rebased onto current main (a554586). The only conflict was in Verified post-rebase: |
There was a problem hiding this comment.
LGTM — mechanical type change (bun_semver::String → &'a [u8]) matching the existing value field, plus a dead u32 round-trip removal; all prior feedback addressed.
Extended reasoning...
Overview
This PR touches three files:
src/runtime/webcore/FormData.rs: switchesField.filename/content_typeand thenameparameter offor_each_multipart_entryfrombun_semver::String(u32-packed offset/length) to plain&'a [u8]borrows, matching the shapeField.valuealready had. Drops the now-unusedSlicedStringsubslicer and thebuf: &[u8]back-reference on the iterator callback. The one caller (Wrapper::on_entry) is updated in the same file; grep confirms no other callers.src/jsc/array_buffer.rs: removesu32::try_from(bytes.len()).expect("int cast") as usizeround-trips infrom_bytes/from_owned_bytes— the target fields are alreadyusize, so the cast just panicked on >4 GiB buffers in debug builds.test/js/web/html/FormData.test.ts: adds a memory-gated (process.constrainedMemory() || totalmem() >= 16 GiB, non-Windows) subprocess test that builds a 4 GiB+256 B multipart body and asserts the trailing parts' names/filenames/contents round-trip.
Security risks
None introduced. The multipart parser handles untrusted network input, but this change adds no parsing logic — it only changes how already-computed subslices are stored and passed to the callback. The old bun_semver::String encoding silently truncated offsets at 2³² and lengths at 2³¹; the new &'a [u8] is a direct lifetime-checked borrow with native-width bounds, so the change strictly removes a corruption vector. No unsafe is introduced; borrowck enforces that the slices don't outlive the input buffer.
Level of scrutiny
Low-medium. The Rust change is a mechanical type substitution following the pattern already established for Field.value (which was moved to &'a [u8] in #27483/#31783). The array_buffer.rs change is a trivial dead-cast removal. The test is well-structured (subprocess-isolated, cgroup-aware skip gate, drains pipes concurrently, asserts stdout before exit code) and follows repo conventions.
Other factors
- No bugs found by the bug hunting system.
- All four prior review threads (CodeRabbit ×2, my own ×2) are resolved: the memory gate now uses
constrainedMemory(), therequire("os")is a static import, and the pre-existing Content-Type early-exit was correctly scoped out as orthogonal. - The author's CI status comment reports the diff green with only fleet-wide unrelated TLS failures.
- The public
for_each_multipart_entrysignature changes, but grep shows the only caller is in the same file.
a554586 to
860984c
Compare
|
Rebased onto current main (860984c) and squashed to a single commit to stop the import-line churn conflicting on every round. One semantic change to absorb:
The other conflict was just the test-file import line (#33622 added Verified post-rebase: |
There was a problem hiding this comment.
LGTM — mechanical bun_semver::String → &'a [u8] swap matching the existing value field, plus a no-op cast removal.
Extended reasoning...
Overview
Three files touched:
src/runtime/webcore/FormData.rs:Field.filename/Field.content_typechange frombun_semver::String(u32-packed offset/len) to plain&'a [u8]borrows, matching the existingvalue: &'a [u8]field. Thefor_each_multipart_entryiterator signature drops thebuf: &[u8]back-reference and passesnameas a raw slice; theSlicedStringsubslicer is deleted. Net: fewer lines, nounsafe, one indirection removed.src/jsc/array_buffer.rs: removesu32::try_from(len).expect(...) as usizeround-trips infrom_bytes/from_owned_bytes. The target fields were alreadyusize, so this only ever added a debug-build panic on >4 GiB buffers.test/js/web/html/FormData.test.ts: adds a memory-gated (constrainedMemory() || totalmem()≥ 16 GiB, non-Windows) subprocess regression test that constructs a 4 GiB + 256 B multipart body and asserts parts past 2³² bytes round-trip.
Security risks
None. This widens an internal offset representation from u32 to native slice pointers, fixing silent wrap/corruption on large bodies. No new parsing logic, no new attack surface; the control-character Content-Type guard and boundary length check are unchanged.
Level of scrutiny
Medium — production request-body parsing code — but the change is a mechanical type substitution that follows the exact pattern already established for Field.value in #27483/#31783, and strictly simplifies (drops subslicer.sub(...).value() indirection and the buf callback param). The array_buffer.rs hunk is trivially correct. The remaining use bun_semver; is still live via Map<'a> = ArrayHashMap<bun_semver::String, ...>.
Other factors
- Bug hunter found nothing on the current revision.
- All prior review threads (CodeRabbit's cgroup-memory gate, my
require("os")nit, the pre-existing Content-Type early-exit note) are resolved; the author addressed each with a commit or a scoping justification. - CI green on the latest rebase per the robobun status comment (only red lane was a darwin artifact-download infra timeout).
- PR has been rebased four times since May with no semantic drift; the Rust diff is now smaller than the original Zig one because #31783 already lifetime-parameterized
Field<'a>.
860984c to
4c84809
Compare
|
Rebased onto current main (4c84809). The only conflict was |
There was a problem hiding this comment.
LGTM — mechanical type widening that follows the existing value: &'a [u8] pattern in the same struct.
Reviewed: Field<'a> now stores filename/content_type as native &'a [u8] borrows instead of bun_semver::String (u32-packed offset/len) — the borrow checker enforces they don't outlive input, and the only caller of for_each_multipart_entry is in the same file. The array_buffer.rs change drops a u32::try_from().expect() as usize round-trip on fields that are already usize — no release-build behavior change, just removes a debug panic on >4 GiB. Test is subprocess-isolated, memory-gated via process.constrainedMemory(), and drains pipes concurrently. All prior review threads (Content-Type early-exit is pre-existing, cgroup-aware skip, static os import) are resolved.
Extended reasoning...
Overview
This PR fixes silent data corruption in req.formData() when a multipart body exceeds 4 GiB: part metadata (name, filename, content_type) was stored as bun_semver::String, which packs a slice as {off: u32, len: u32} with bit 63 as a tag — so offsets past 2³² wrapped and slice(buf) returned garbage from the middle of the preceding file body.
Three files touched:
src/runtime/webcore/FormData.rs(~30 lines):Field<'a>.filename/content_typechange frombun_semver::Stringto&'a [u8], matching the existingvalue: &'a [u8]field. Thefor_each_multipart_entryiterator drops thebufback-reference andsubslicerhelper since callers now receive slices directly. Verified this function has exactly one caller (Wrapper::on_entry, same file).src/jsc/array_buffer.rs(4 lines):from_bytes/from_owned_bytesdropu32::try_from(len).expect("int cast") as usizewherelen/byte_lenare alreadyusize. Pure dead-cast removal.test/js/web/html/FormData.test.ts(~90 lines): new subprocess test constructing a 4 GiB + 256 byte multipart body with trailing parts past the 2³² mark.
Security risks
The multipart parser handles untrusted request bodies, so this is security-adjacent. However, the change does not touch any bounds-checking, allocation, or parsing logic — it only changes how already-computed subslices of the input buffer are stored (native &'a [u8] borrow vs. a lossy packed offset). The Rust borrow checker now statically enforces that these slices cannot outlive input, which is strictly stronger than the previous SlicedString indirection. No unsafe is introduced. The array_buffer.rs change only removes a debug-build panic path; release builds already truncated silently via as usize after the (successful in release) u32::try_from, so behavior is unchanged there — actually, no: u32::try_from(x).expect() panics in both debug and release when x > u32::MAX. So this removes a reachable panic on >4 GiB buffers, which is a correctness improvement (the fields were already usize-wide, the cast was vestigial from the Zig port).
Level of scrutiny
Medium-low. The fix is the canonical Rust idiom (borrow with a lifetime parameter) applied to two fields whose sibling value already uses that exact shape on the same struct. There is no design decision here — the u32 packing was simply wrong for this use case, and native slices are the obvious replacement. The single caller is updated in lockstep. cargo check/clippy/fmt are reported clean across four rebases.
Other factors
- No bugs found by the bug hunting system on the current revision.
- All prior review threads resolved: my earlier inline comments (pre-existing Content-Type early-exit,
require("os")→ static import) were addressed or acknowledged as out-of-scope; CodeRabbit's cgroup-memory-gate concern was fixed viaprocess.constrainedMemory(). - Test follows repo conventions:
bunExe()/bunEnv,await using proc, concurrent pipe drain, stderr/stdout asserted before exitCode, memory/platform skip gate with a stated reason. use bun_semver;remains becauseMap<'a>(unrelated to this fix) still namesbun_semver::String.- The PR has been open two months with four clean rebases and no human reviewer has raised concerns; the diff has only gotten smaller (Zig half deleted when the porting references were removed, raw-pointer + SAFETY comments dropped when
Fieldgained a lifetime parameter upstream).
|
Triage note: #21490 was closed as fixed by #27483, which covers the single large file case. The variant this PR fixes is still present on current main (165dc9f): (took about 20 seconds under a debug build here.) Staying open; the branch currently conflicts with main and needs a rebase. |
4c84809 to
317c345
Compare
|
Rebased onto current main (317c345). One conflict in #34362's case-insensitive |
for_each_multipart_entry stored part name, filename, and content-type as bun_semver::String, which packs offset and length into 32-bit fields (with bit 31 of length stolen as a tag). For any part whose header sat past 4 GiB in the request body, the offset wrapped and the parser read garbage, so field names came back as bytes from the middle of the preceding file body and the part was unreachable by name. This is the remaining half of the bug behind #21490. The file body itself (Field.value) was already switched to a raw slice in #27483, which fixed the 2 GiB length truncation, but name/filename/content_type were still u32-indexed. Switch all Field slices to plain &'a [u8] borrows on the existing lifetime-parameterized Field<'a> and drop the subslicer. Also remove a leftover u32::try_from(len).expect() as usize round-trip in ArrayBuffer::from_bytes/from_boxed_slice whose target fields are usize; it panicked on >4 GiB buffers in debug builds on the client-side FormData serialization path. Fixes #21490
317c345 to
cc060d9
Compare
There was a problem hiding this comment.
LGTM — mechanical type change from u32-packed bun_semver::String to &'a [u8] borrows.
What was reviewed:
Field<'a>andfor_each_multipart_entrycallback signature: only caller isWrapper::on_entryin the same file;.slice(buf)sites map 1:1 to direct slice use.array_buffer.rsfrom_bytes/from_owned_bytes: target fields are alreadyusize, so dropping theu32::try_from().expect()round-trip is a pure widening.- Test: subprocess-isolated, drains stdout/stderr/exited concurrently, cgroup-aware skip via
process.constrainedMemory(), exit code asserted last. - Prior inline feedback (static
osimport, pre-existing Content-Type early-exit) confirmed addressed/out-of-scope.
Extended reasoning...
Overview
Three files: src/runtime/webcore/FormData.rs (~30 lines) switches Field.filename / Field.content_type and the local name in for_each_multipart_entry from bun_semver::String (u32 offset+len packed into a u64) to plain &'a [u8] borrows on the existing lifetime parameter, matching how Field.value already works. The SlicedString subslicer and the buf: &[u8] callback back-reference become dead and are removed along with the use bun_semver import. src/jsc/array_buffer.rs drops a leftover u32::try_from(len).expect() as usize round-trip in from_bytes/from_owned_bytes where the target fields were already usize. test/js/web/html/FormData.test.ts adds a memory-gated subprocess test that builds a 4 GiB + 256 B multipart body and asserts parts past the 4 GiB mark round-trip.
Security risks
None. The parser already operated on the full input via native &[u8] slices for boundary scanning and body extraction; this change only moves three metadata fields off a lossy u32-offset representation onto the same native slice type. No new unsafe, no new allocation, no user-controlled arithmetic introduced.
Level of scrutiny
Low-to-medium. The Rust change is a type substitution whose correctness follows from the fact that &[u8] carries usize pointer+len — every .slice(buf) call site is replaced by the slice itself, and .len() == 0 / .is_empty() map directly. I grep-verified for_each_multipart_entry and Field have no callers outside this file, so the callback signature change is fully self-contained. The array_buffer.rs hunk is a dead-cast removal (target type was already usize).
Other factors
All prior review threads are resolved: CodeRabbit's cgroup-memory gating suggestion was applied (process.constrainedMemory?.() || totalmem()), my earlier require("os") nit was addressed with a static import, and the pre-existing Content-Type early-exit was correctly scoped out (unchanged loop condition, only .len() == 0 → .is_empty() here). robobun reports the branch green on CI with only fleet-wide flakes unrelated to the multipart parser. The test follows harness conventions (bunExe/bunEnv, await using, concurrent pipe drain, stderr/content asserted before exit code) and is skip-gated on Windows and <16 GiB effective memory.
Problem
req.formData()on a multipart body larger than 4 GiB returns corrupted field names/filenames for any part whose header sits past the 4 GiB mark. The part is then unreachable by name:This is the remaining half of #21490. The original symptom there — a 3.5 GB file coming through as 1.5 GB (exactly 2³¹ bytes lost) — was caused by
Field.valuebeing stored as aSemver.String, and was fixed as a side effect of #27483 (landed in 1.3.11) whenvaluewas switched to a plain slice.Root cause
for_each_multipart_entrystill storedname,filename, andcontent_typeasbun_semver::String, which packs a slice asPointer { off: u32, len: u32 }bitcast into au64with bit 63 used as a tag. That gives:len: effectivelyu31(bit 31 is the tag) → silent truncation at 2 GiBoff:u32truncation → silent wrap at 4 GiBSo once a part's
Content-Dispositionheader sat past byte 2³², the offset wrapped andslice(buf)returned bytes from the middle of the preceding file body.Fix
In
src/runtime/webcore/FormData.rs, storename,filename, andcontent_typeas plain&'a [u8]borrows on the existing lifetime-parameterizedField<'a>, the same wayvaluealready is. Thesubslicerand thebufcallback parameter are no longer needed, and nounsafeis introduced.Also drop a leftover
u32round-trip inArrayBuffer::from_bytes/from_boxed_slice(src/jsc/array_buffer.rs) — the target fields are alreadyusize, so the cast just panicked on >4 GiB buffers in debug builds (hit on the client side when serializing a FormData containing a >4 GiBBun.file()).Earlier revisions of this PR carried the identical fix in
FormData.zig/array_buffer.zig; those files were removed from the tree in #32621 (the Zig porting references), so the PR is now Rust-only.Verification
New test in
test/js/web/html/FormData.test.tsconstructs a 4 GiB + 256 byte multipart body with a text field and a second file part after the big file, and asserts the trailing parts round-trip correctly. Spawned in a subprocess and skipped on Windows / machines with < 16 GiB effective memory (process.constrainedMemory()aware).["big_upload", "", ""]["big_upload", "description_field", "second_attachment"]get("description_field")null"this part lives past the 4 GiB mark"undefined"also_past_4gb.txt"Gate: test fails with
src/stashed, passes with the fix.cargo check/cargo clippy/cargo fmt --checkclean; the adjacent FormData tests (control-character Content-Type, duplicate numeric field names) still pass.Fixes #21490
Related: #27483, #27443
no test proof · iteration 23 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/web/html/FormData.test.ts