diff --git a/src/jsc/array_buffer.rs b/src/jsc/array_buffer.rs index 69947bfbd5a1..3bb8793f6eb4 100644 --- a/src/jsc/array_buffer.rs +++ b/src/jsc/array_buffer.rs @@ -389,8 +389,8 @@ impl ArrayBuffer { pub fn from_bytes(bytes: &mut [u8], typed_array_type: JSType) -> ArrayBuffer { ArrayBuffer { - len: u32::try_from(bytes.len()).expect("int cast") as usize, - byte_len: u32::try_from(bytes.len()).expect("int cast") as usize, + len: bytes.len(), + byte_len: bytes.len(), typed_array_type, ptr: bytes.as_mut_ptr(), ..Default::default() @@ -411,8 +411,8 @@ impl ArrayBuffer { // this is an FFI hand-off, not a leak. let ptr = bun_core::heap::into_raw(bytes).cast::(); ArrayBuffer { - len: u32::try_from(len).expect("int cast") as usize, - byte_len: u32::try_from(len).expect("int cast") as usize, + len, + byte_len: len, typed_array_type, ptr, ..Default::default() diff --git a/src/runtime/webcore/FormData.rs b/src/runtime/webcore/FormData.rs index 2255d6fcc4f1..d6a2dfa228ee 100644 --- a/src/runtime/webcore/FormData.rs +++ b/src/runtime/webcore/FormData.rs @@ -6,7 +6,6 @@ use bun_jsc::{ AnyPromise, CallFrame, DOMFormData, JSGlobalObject, JSValue, JsError, JsResult, ZigStringJsc as _, }; -use bun_semver::{self, SlicedString}; use core::ffi::c_void; use crate::webcore::Blob; @@ -61,14 +60,12 @@ impl AsyncFormDataExt for AsyncFormData { } } -/// Raw slice into the input buffer. Not using `bun.Semver.String` because -/// file bodies are binary data that can contain null bytes, which -/// Semver.String's inline storage treats as terminators. +/// One multipart part; every slice borrows from the caller-owned input buffer. pub struct Field<'a> { /// Borrows into the caller-owned input buffer (binary body slice). pub value: &'a [u8], - pub(crate) filename: bun_semver::String, - pub(crate) content_type: bun_semver::String, + pub(crate) filename: &'a [u8], + pub(crate) content_type: &'a [u8], pub(crate) is_file: bool, pub(crate) zero_count: u8, } @@ -77,8 +74,8 @@ impl Default for Field<'_> { fn default() -> Self { Field { value: b"", - filename: bun_semver::String::default(), - content_type: bun_semver::String::default(), + filename: b"", + content_type: b"", is_file: false, zero_count: 0, } @@ -173,20 +170,21 @@ pub(crate) fn to_js_from_multipart_data( } impl<'a> Wrapper<'a> { - fn on_entry(wrap: &mut Self, name: bun_semver::String, field: &Field<'_>, buf: &[u8]) { + fn on_entry(wrap: &mut Self, name: &[u8], field: &Field<'_>) { let value_str: &[u8] = field.value; - let key = ZigString::init_utf8(name.slice(buf)); + let key = ZigString::init_utf8(name); if field.is_file { - let filename_str = field.filename.slice(buf); + let filename_str: &[u8] = field.filename; let mut blob = Blob::create(value_str, wrap.global, false); let filename = ZigString::init_utf8(filename_str); if !field.content_type.is_empty() { - let ct = field.content_type.slice(buf); blob.content_type - .set(crate::webcore::blob::BlobContentType::Owned(ct.into())); + .set(crate::webcore::blob::BlobContentType::Owned( + field.content_type.into(), + )); blob.content_type_was_set.set(true); } else { let mime = 'brk: { @@ -249,10 +247,9 @@ pub(crate) fn for_each_multipart_entry( input: &[u8], boundary: &[u8], ctx: &mut C, - mut iterator: impl FnMut(&mut C, bun_semver::String, &Field<'_>, &[u8]), + mut iterator: impl FnMut(&mut C, &[u8], &Field<'_>), ) -> crate::Result<()> { let mut slice = input; - let subslicer = SlicedString::init(input, input); let mut buf = [0u8; 76]; { @@ -292,11 +289,11 @@ pub(crate) fn for_each_multipart_entry( remain = &remain[header_end + 4..]; let mut field = Field::default(); - let mut name = bun_semver::String::default(); - let mut filename: Option = None; + let mut name: &[u8] = b""; + let mut filename: Option<&[u8]> = None; let mut header_chunk = header; let mut is_file = false; - while !header_chunk.is_empty() && (filename.is_none() || name.len() == 0) { + while !header_chunk.is_empty() && (filename.is_none() || name.is_empty()) { let line_end = strings::index_of(header_chunk, b"\r\n") .ok_or(crate::Error::IsMissingHeaderLineEnd)?; let line = &header_chunk[..line_end]; @@ -348,9 +345,9 @@ pub(crate) fn for_each_multipart_entry( } if strings::eql_case_insensitive_ascii(eql_key, b"name", true) { - name = subslicer.sub(field_value).value(); + name = field_value; } else if strings::eql_case_insensitive_ascii(eql_key, b"filename", true) { - filename = Some(subslicer.sub(field_value).value()); + filename = Some(field_value); is_file = true; } @@ -379,7 +376,7 @@ pub(crate) fn for_each_multipart_entry( .iter() .all(|&b| b == b'\t' || (0x20..=0x7E).contains(&b)) { - field.content_type = subslicer.sub(trimmed).value(); + field.content_type = trimmed; } } } @@ -393,10 +390,10 @@ pub(crate) fn for_each_multipart_entry( body = &body[..body.len() - 2]; } field.value = body; - field.filename = filename.unwrap_or_default(); + field.filename = filename.unwrap_or(b""); field.is_file = is_file; - iterator(ctx, name, &field, input); + iterator(ctx, name, &field); } Ok(()) diff --git a/test/js/web/html/FormData.test.ts b/test/js/web/html/FormData.test.ts index 311852c31eaa..aa852397df24 100644 --- a/test/js/web/html/FormData.test.ts +++ b/test/js/web/html/FormData.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, test } from "bun:test"; -import { bunEnv, bunExe, isASAN, isDebug } from "harness"; +import { bunEnv, bunExe, isASAN, isDebug, isWindows } from "harness"; +import { totalmem } from "os"; import { join } from "path"; describe("FormData", () => { @@ -1004,3 +1005,91 @@ describe("USVString conversion of lone surrogates", () => { expect(formData.get("\uFFFD")).toBeNull(); }); }); + +// https://github.com/oven-sh/bun/issues/21490 +// +// The multipart parser stored part metadata (name, filename, content-type) as +// `bun.Semver.String`, which packs offset/length into 32-bit fields. For a +// part whose header sits past 4 GiB in the body, the offset wrapped and the +// parser read garbage. +// +// Needs ~10 GiB of working set in a subprocess, so skip on small machines and +// on Windows (where ArrayBuffer backing commits eagerly). Use the +// cgroup-aware limit when available so containerized CI runners aren't fooled +// by the host's physical RAM. +const effectiveMemory = process.constrainedMemory?.() || totalmem(); +it.skipIf(isWindows || effectiveMemory < 16 * 1024 * 1024 * 1024)( + "multipart parser handles parts at offsets > 4 GiB", + async () => { + const fixture = ` + const boundary = "----bun-issue-21490"; + const GiB = 1024 * 1024 * 1024; + + const head = Buffer.from( + "--" + boundary + "\\r\\n" + + 'Content-Disposition: form-data; name="big_upload"; filename="big.bin"\\r\\n' + + "Content-Type: application/octet-stream\\r\\n\\r\\n", + "utf8", + ); + const mid = Buffer.from( + "\\r\\n--" + boundary + "\\r\\n" + + 'Content-Disposition: form-data; name="description_field"\\r\\n\\r\\n' + + "this part lives past the 4 GiB mark\\r\\n", + "utf8", + ); + const mid2 = Buffer.from( + "--" + boundary + "\\r\\n" + + 'Content-Disposition: form-data; name="second_attachment"; filename="also_past_4gb.txt"\\r\\n\\r\\n' + + "file contents\\r\\n", + "utf8", + ); + const tail = Buffer.from("--" + boundary + "--\\r\\n", "utf8"); + + // 4 GiB + 256 bytes so the trailing parts' headers sit above 2**32. + let chunk = new Uint8Array(GiB); + const fileBody = new Blob([chunk, chunk, chunk, chunk, new Uint8Array(256)]); + chunk = null; + const fileSize = fileBody.size; + + const body = new Blob([head, fileBody, mid, mid2, tail]); + + const request = new Request("http://localhost/", { + method: "POST", + headers: { "content-type": "multipart/form-data; boundary=" + boundary }, + body, + }); + + const form = await request.formData(); + const big = form.get("big_upload"); + const second = form.get("second_attachment"); + const result = { + keys: [...form.keys()], + big: { name: big?.name, size: big?.size, type: big?.type }, + description_field: form.get("description_field"), + second: { name: second?.name, size: second?.size, text: second ? await second.text() : null }, + expectedFileSize: fileSize, + }; + console.log(JSON.stringify(result)); + `; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + const result = JSON.parse(stdout.trim()); + expect(result).toEqual({ + keys: ["big_upload", "description_field", "second_attachment"], + big: { name: "big.bin", size: result.expectedFileSize, type: "application/octet-stream" }, + description_field: "this part lives past the 4 GiB mark", + second: { name: "also_past_4gb.txt", size: "file contents".length, text: "file contents" }, + expectedFileSize: 4 * 1024 * 1024 * 1024 + 256, + }); + expect(exitCode).toBe(0); + }, + 120_000, +);