Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/jsc/array_buffer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -411,8 +411,8 @@ impl ArrayBuffer {
// this is an FFI hand-off, not a leak.
let ptr = bun_core::heap::into_raw(bytes).cast::<u8>();
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()
Expand Down
43 changes: 20 additions & 23 deletions src/runtime/webcore/FormData.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
}
Expand All @@ -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,
}
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -249,10 +247,9 @@ pub(crate) fn for_each_multipart_entry<C>(
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];
{
Expand Down Expand Up @@ -292,11 +289,11 @@ pub(crate) fn for_each_multipart_entry<C>(
remain = &remain[header_end + 4..];

let mut field = Field::default();
let mut name = bun_semver::String::default();
let mut filename: Option<bun_semver::String> = 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];
Expand Down Expand Up @@ -348,9 +345,9 @@ pub(crate) fn for_each_multipart_entry<C>(
}

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;
}

Expand Down Expand Up @@ -379,7 +376,7 @@ pub(crate) fn for_each_multipart_entry<C>(
.iter()
.all(|&b| b == b'\t' || (0x20..=0x7E).contains(&b))
{
field.content_type = subslicer.sub(trimmed).value();
field.content_type = trimmed;
}
}
}
Expand All @@ -393,10 +390,10 @@ pub(crate) fn for_each_multipart_entry<C>(
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(())
Expand Down
91 changes: 90 additions & 1 deletion test/js/web/html/FormData.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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,
);
Loading