fix(http): preserve binary data with null bytes in FormData multipart parsing

FormData multipart parsing was truncating binary file content at null
bytes for files 8 bytes or smaller. The root cause was using
Semver.String's inline storage optimization which scans for null bytes
to determine string length - appropriate for version strings but not
for binary data.

Replaced Field.value (Semver.String) with explicit value_off/value_len
fields that store offset and length directly, preserving binary data
with null bytes.

Fixes #26740

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Claude Bot
2026-02-04 22:51:34 +00:00
parent ddefa11070
commit 40eb697cf8
2 changed files with 102 additions and 3 deletions

View File

@@ -976,12 +976,21 @@ pub const FormData = struct {
}
pub const Field = struct {
value: bun.Semver.String = .{},
/// Offset into the input buffer for the value (binary-safe, no null-termination).
value_off: u32 = 0,
/// Length of the value (binary-safe, no null-termination).
value_len: u32 = 0,
filename: bun.Semver.String = .{},
content_type: bun.Semver.String = .{},
is_file: bool = false,
zero_count: u8 = 0,
/// Get the value slice from the buffer.
pub fn valueSlice(self: Field, buf: []const u8) []const u8 {
if (self.value_len == 0) return "";
return buf[self.value_off..][0..self.value_len];
}
pub const Entry = union(enum) {
field: Field,
list: bun.BabyList(Field),
@@ -1088,7 +1097,7 @@ pub const FormData = struct {
form: *jsc.DOMFormData,
pub fn onEntry(wrap: *@This(), name: bun.Semver.String, field: Field, buf: []const u8) void {
const value_str = field.value.slice(buf);
const value_str = field.valueSlice(buf);
var key = jsc.ZigString.initUTF8(name.slice(buf));
if (field.is_file) {
@@ -1278,7 +1287,9 @@ pub const FormData = struct {
if (strings.endsWithComptime(body, "\r\n")) {
body = body[0 .. body.len - 2];
}
field.value = subslicer.sub(body).value();
// Store offset and length directly to preserve binary data with null bytes
field.value_off = @truncate(@intFromPtr(body.ptr) - @intFromPtr(input.ptr));
field.value_len = @truncate(body.len);
field.filename = filename orelse .{};
field.is_file = is_file;

View File

@@ -0,0 +1,88 @@
import { expect, test } from "bun:test";
// Regression test for https://github.com/oven-sh/bun/issues/26740
// FormData multipart parsing was truncating binary file content at null bytes
// for files 8 bytes or smaller due to Semver.String inline storage optimization.
test("FormData preserves binary data with null bytes in small files", async () => {
const testCases = [
{ name: "8 bytes with null at position 3", data: [0x01, 0x02, 0x03, 0x00, 0x05, 0x06, 0x07, 0x08] },
{ name: "4 bytes with null at end", data: [0x1f, 0x8b, 0x08, 0x00] },
{ name: "1 byte null", data: [0x00] },
{ name: "all nulls (8 bytes)", data: [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] },
{ name: "7 bytes ending with null", data: [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00] },
{ name: "6 bytes starting with null", data: [0x00, 0x02, 0x03, 0x04, 0x05, 0x06] },
];
await using server = Bun.serve({
port: 0,
async fetch(req) {
const formData = await req.formData();
const file = formData.get("file") as File;
const bytes = new Uint8Array(await file.arrayBuffer());
return Response.json({
expectedSize: parseInt(req.headers.get("x-expected-size") || "0"),
actualSize: bytes.byteLength,
content: Array.from(bytes),
});
},
});
for (const tc of testCases) {
const content = new Uint8Array(tc.data);
const file = new File([content], "test.bin", { type: "application/octet-stream" });
const formData = new FormData();
formData.append("file", file);
const res = await fetch(`http://localhost:${server.port}`, {
method: "POST",
body: formData,
headers: { "x-expected-size": String(tc.data.length) },
});
const result = (await res.json()) as { expectedSize: number; actualSize: number; content: number[] };
expect(result.actualSize).toBe(result.expectedSize);
expect(result.content).toEqual(tc.data);
}
});
test("FormData preserves binary data in larger files (> 8 bytes)", async () => {
// This should have worked before the fix, but let's verify it still works
const testCases = [
{ name: "16 bytes with nulls", data: Array.from({ length: 16 }, (_, i) => (i % 3 === 0 ? 0x00 : i)) },
{ name: "9 bytes with null at start", data: [0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08] },
];
await using server = Bun.serve({
port: 0,
async fetch(req) {
const formData = await req.formData();
const file = formData.get("file") as File;
const bytes = new Uint8Array(await file.arrayBuffer());
return Response.json({
expectedSize: parseInt(req.headers.get("x-expected-size") || "0"),
actualSize: bytes.byteLength,
content: Array.from(bytes),
});
},
});
for (const tc of testCases) {
const content = new Uint8Array(tc.data);
const file = new File([content], "test.bin", { type: "application/octet-stream" });
const formData = new FormData();
formData.append("file", file);
const res = await fetch(`http://localhost:${server.port}`, {
method: "POST",
body: formData,
headers: { "x-expected-size": String(tc.data.length) },
});
const result = (await res.json()) as { expectedSize: number; actualSize: number; content: number[] };
expect(result.actualSize).toBe(result.expectedSize);
expect(result.content).toEqual(tc.data);
}
});