mirror of
https://github.com/oven-sh/bun
synced 2026-02-09 10:28:47 +00:00
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:
17
src/url.zig
17
src/url.zig
@@ -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;
|
||||
|
||||
|
||||
88
test/regression/issue/26740.test.ts
Normal file
88
test/regression/issue/26740.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user