Compare commits

...

3 Commits

Author SHA1 Message Date
robobun
a870e7b1ea fix(formdata): preserve binary data with null bytes in multipart parsing (#27483)
## Summary
- `Request.formData()` truncated small binary files (≤8 bytes) at the
first `0x00` (null) byte
- Root cause: `FormData.Field.value` used `bun.Semver.String`, whose
inline storage mode scans for null bytes to determine string length
(C-string semantics)
- Fix: Replace `Field.value` with a raw `[]const u8` slice into the
input buffer, bypassing `Semver.String` entirely

## Test plan
- [x] Added regression test `test/regression/issue/27478.test.ts` with 4
cases:
- Gzip header bytes (`[0x1f, 0x8b, 0x08, 0x00]`) - original issue repro
  - All-null-byte file (`[0x00, 0x00, 0x00, 0x00]`)
  - Single null byte file (`[0x00]`)
  - 8-byte file with interleaved nulls (`[0x01, 0x00, 0x02, 0x00, ...]`)
- [x] Tests pass with `bun bd test` and fail with `USE_SYSTEM_BUN=1 bun
test`
- [x] Existing FormData tests pass (342 pass in body.test.ts, 110 pass
in FormData.test.ts, 2 pass in form-data-set-append.test.js)

Closes #27478

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-02-28 02:42:57 -08:00
robobun
0309ae0c59 harden chunked encoding size parsing to use full 64-bit width (#27499)
## Summary

- Fix `chunkSize()` in `ChunkedEncoding.h` to return `uint64_t` instead
of `unsigned int`, preventing silent truncation of large chunk size
values
- Update `decChunkSize()` parameter and local variables to use
`uint64_t` consistently
- This ensures the existing `STATE_SIZE_OVERFLOW` check (bits 56-59)
actually works, as it was previously dead code due to the 32-bit
truncation

## Test plan

- [x] `bun bd test test/js/bun/http/request-smuggling.test.ts` — all 17
tests pass
- [x] New test "large chunk size exceeding 32 bits does not produce
empty body" verifies correct behavior for chunk sizes > 2^32
- [x] New test "rejects extremely large chunk size hex values" verifies
overflow detection works
- [x] New test "accepts valid chunk sizes within normal range" verifies
no regression for normal usage
- [x] Verified new test fails with `USE_SYSTEM_BUN=1` (old code) and
passes with `bun bd test` (fixed code)
- [x] Pre-existing tests unaffected

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
2026-02-28 02:42:06 -08:00
robobun
af0c90da55 fix(transpiler): treat emitDecoratorMetadata as implying legacy decorators (#27527)
## Summary

- When `emitDecoratorMetadata: true` is set in tsconfig.json without
`experimentalDecorators: true`, Bun now correctly uses legacy decorator
semantics instead of TC39 standard decorator lowering
- `emitDecoratorMetadata` only makes sense with TypeScript's legacy
decorator system (`reflect-metadata`), so its presence implies the user
expects legacy decorator behavior
- This fixes a regression from ce715b5a0f where NestJS, TypeORM,
Angular, and other legacy-decorator frameworks would crash with
`descriptor.value` undefined

Closes #27526

## Test plan

- [x] Added regression test `test/regression/issue/27526.test.ts` with
two cases:
- Legacy decorators work when `emitDecoratorMetadata: true` but
`experimentalDecorators` is absent
  - TC39 standard decorators still work when neither option is set
- [x] Verified test fails with system Bun (`USE_SYSTEM_BUN=1`) and
passes with debug build (`bun bd test`)


🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-28 02:39:57 -08:00
7 changed files with 377 additions and 10 deletions

View File

@@ -36,7 +36,7 @@ namespace uWS {
constexpr uint64_t STATE_IS_ERROR = ~0ull;//0xFFFFFFFFFFFFFFFF;
constexpr uint64_t STATE_SIZE_OVERFLOW = 0x0Full << (sizeof(uint64_t) * 8 - 8);//0x0F00000000000000;
inline unsigned int chunkSize(uint64_t state) {
inline uint64_t chunkSize(uint64_t state) {
return state & STATE_SIZE_MASK;
}
@@ -139,7 +139,7 @@ namespace uWS {
// short read
}
inline void decChunkSize(uint64_t &state, unsigned int by) {
inline void decChunkSize(uint64_t &state, uint64_t by) {
//unsigned int bits = state & STATE_IS_CHUNKED;
@@ -208,7 +208,7 @@ namespace uWS {
}
// do we have data to emit all?
unsigned int remaining = chunkSize(state);
uint64_t remaining = chunkSize(state);
if (data.length() >= remaining) {
// emit all but 2 bytes then reset state to 0 and goto beginning
// not fin
@@ -248,7 +248,7 @@ namespace uWS {
} else {
/* We will consume all our input data */
std::string_view emitSoon;
unsigned int size = chunkSize(state);
uint64_t size = chunkSize(state);
size_t len = data.length();
if (size > 2) {
uint64_t maximalAppEmit = size - 2;
@@ -284,7 +284,7 @@ namespace uWS {
return std::nullopt;
}
}
decChunkSize(state, (unsigned int) len);
decChunkSize(state, (uint64_t) len);
state |= STATE_IS_CHUNKED;
data.remove_prefix(len);
if (emitSoon.length()) {

View File

@@ -1219,7 +1219,10 @@ fn runWithSourceCode(
opts.features.minify_keep_names = transpiler.options.keep_names;
opts.features.minify_whitespace = transpiler.options.minify_whitespace;
opts.features.emit_decorator_metadata = task.emit_decorator_metadata;
opts.features.standard_decorators = !loader.isTypeScript() or !task.experimental_decorators;
// emitDecoratorMetadata implies legacy/experimental decorators, as it only
// makes sense with TypeScript's legacy decorator system (reflect-metadata).
// TC39 standard decorators have their own metadata mechanism.
opts.features.standard_decorators = !loader.isTypeScript() or !(task.experimental_decorators or task.emit_decorator_metadata);
opts.features.unwrap_commonjs_packages = transpiler.options.unwrap_commonjs_packages;
opts.features.bundler_feature_flags = transpiler.options.bundler_feature_flags;
opts.features.hot_module_reloading = output_format == .internal_bake_dev and !source.index.isRuntime();

View File

@@ -1103,7 +1103,10 @@ pub const Transpiler = struct {
var opts = js_parser.Parser.Options.init(jsx, loader);
opts.features.emit_decorator_metadata = this_parse.emit_decorator_metadata;
opts.features.standard_decorators = !loader.isTypeScript() or !this_parse.experimental_decorators;
// emitDecoratorMetadata implies legacy/experimental decorators, as it only
// makes sense with TypeScript's legacy decorator system (reflect-metadata).
// TC39 standard decorators have their own metadata mechanism.
opts.features.standard_decorators = !loader.isTypeScript() or !(this_parse.experimental_decorators or this_parse.emit_decorator_metadata);
opts.features.allow_runtime = transpiler.options.allow_runtime;
opts.features.set_breakpoint_on_first_line = this_parse.set_breakpoint_on_first_line;
opts.features.trim_unused_imports = transpiler.options.trim_unused_imports orelse loader.isTypeScript();

View File

@@ -976,7 +976,10 @@ pub const FormData = struct {
}
pub const Field = struct {
value: bun.Semver.String = .{},
/// 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.
value: []const u8 = "",
filename: bun.Semver.String = .{},
content_type: bun.Semver.String = .{},
is_file: bool = false,
@@ -1088,7 +1091,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.value;
var key = jsc.ZigString.initUTF8(name.slice(buf));
if (field.is_file) {
@@ -1278,7 +1281,7 @@ pub const FormData = struct {
if (strings.endsWithComptime(body, "\r\n")) {
body = body[0 .. body.len - 2];
}
field.value = subslicer.sub(body).value();
field.value = body;
field.filename = filename orelse .{};
field.is_file = is_file;

View File

@@ -561,6 +561,152 @@ describe("SPILL.TERM - invalid chunk terminators", () => {
});
});
describe("chunked encoding size hardening", () => {
test("rejects extremely large chunk size hex values", async () => {
// Chunk sizes with many hex digits should be rejected by the overflow check.
// 'FFFFFFFFFFFFFFFF' sets bits in the overflow-detection region (bits 56-59),
// so the parser must return an error.
let bodyReadSucceeded = false;
await using server = Bun.serve({
port: 0,
async fetch(req) {
try {
await req.text();
bodyReadSucceeded = true;
} catch {
// Expected to fail
}
return new Response("OK");
},
});
const client = net.connect(server.port, "127.0.0.1");
// 16 hex digits all 'F' — sets overflow bits and must be rejected
const maliciousRequest =
"POST / HTTP/1.1\r\n" +
"Host: localhost\r\n" +
"Transfer-Encoding: chunked\r\n" +
"\r\n" +
"FFFFFFFFFFFFFFFF\r\n" +
"data\r\n" +
"0\r\n" +
"\r\n";
await new Promise<void>(resolve => {
let responseData = "";
client.on("error", () => resolve());
client.on("data", data => {
responseData += data.toString();
});
client.on("close", () => {
expect(responseData).toContain("HTTP/1.1 400");
expect(bodyReadSucceeded).toBe(false);
resolve();
});
client.write(maliciousRequest);
});
});
test("large chunk size exceeding 32 bits does not produce empty body", async () => {
// '100000000' hex = 2^32 (4294967296). If the chunk size were truncated
// to 32 bits, this would become 0, and the +2 for CRLF would make it
// look like the end-of-chunks marker (size=2), producing an empty body.
// With correct 64-bit handling, the parser treats this as a large
// pending chunk — the body read should fail when we close the connection,
// because the server is still expecting ~4GB of data.
let receivedBody: string | null = null;
let bodyError = false;
const { promise: headersReceived, resolve: onHeadersReceived } = Promise.withResolvers<void>();
const { promise: bodyHandled, resolve: bodyDone } = Promise.withResolvers<void>();
await using server = Bun.serve({
port: 0,
async fetch(req) {
// Signal that headers have been parsed and the fetch handler entered
onHeadersReceived();
try {
receivedBody = await req.text();
} catch {
bodyError = true;
}
bodyDone();
return new Response("OK");
},
});
const client = net.connect(server.port, "127.0.0.1");
// Send the chunk header claiming 4GB of data, followed by a few bytes,
// then close the connection.
const maliciousRequest =
"POST / HTTP/1.1\r\n" +
"Host: localhost\r\n" +
"Transfer-Encoding: chunked\r\n" +
"\r\n" +
"100000000\r\n" +
"AAAA\r\n";
client.write(maliciousRequest);
// Wait until the server has parsed headers and entered the fetch handler,
// then close the connection to trigger the body error (since we won't send 4GB).
await headersReceived;
client.end();
await bodyHandled;
// With correct 64-bit handling, the body read must fail because we
// disconnected before sending 4GB of chunk data.
// With truncation to 32-bit zero, the body would be "" with no error.
expect(bodyError).toBe(true);
expect(receivedBody).toBeNull();
});
test("accepts valid chunk sizes within normal range", async () => {
// Normal-sized chunks should still work fine
let receivedBody = "";
await using server = Bun.serve({
port: 0,
async fetch(req) {
receivedBody = await req.text();
return new Response("Success");
},
});
const client = net.connect(server.port, "127.0.0.1");
// Use hex chunk sizes that are perfectly valid
const validRequest =
"POST / HTTP/1.1\r\n" +
"Host: localhost\r\n" +
"Transfer-Encoding: chunked\r\n" +
"\r\n" +
"a\r\n" + // 10 bytes
"0123456789\r\n" +
"FF\r\n" + // 255 bytes
Buffer.alloc(255, "A").toString() +
"\r\n" +
"0\r\n" +
"\r\n";
await new Promise<void>((resolve, reject) => {
client.on("error", reject);
client.on("data", data => {
const response = data.toString();
expect(response).toContain("HTTP/1.1 200");
expect(receivedBody).toBe("0123456789" + Buffer.alloc(255, "A").toString());
client.end();
resolve();
});
client.write(validRequest);
});
});
});
// Tests for strict RFC 7230 HEXDIG validation in chunk size parsing.
// Chunk sizes must only contain characters from the set [0-9a-fA-F].
// Non-HEXDIG characters must be rejected to ensure consistent parsing

View File

@@ -0,0 +1,120 @@
import { expect, test } from "bun:test";
// https://github.com/oven-sh/bun/issues/27478
// Request.formData() truncates small binary files at first null byte
test("multipart formdata preserves null bytes in small binary files", async () => {
const boundary = "----bun-null-byte-boundary";
const source = Buffer.from([0x1f, 0x8b, 0x08, 0x00]);
const payload = Buffer.concat([
Buffer.from(
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="file"; filename="test.bin"\r\n` +
`Content-Type: application/octet-stream\r\n\r\n`,
"utf8",
),
source,
Buffer.from(`\r\n--${boundary}--\r\n`, "utf8"),
]);
const request = new Request("http://localhost/", {
method: "POST",
headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
body: payload,
});
const form = await request.formData();
const file = form.get("file");
expect(file).toBeInstanceOf(File);
const parsed = new Uint8Array(await (file as File).arrayBuffer());
expect(Array.from(parsed)).toEqual(Array.from(source));
expect(parsed.byteLength).toBe(source.byteLength);
});
test("multipart formdata preserves files that are all null bytes", async () => {
const boundary = "----bun-test-boundary";
const source = Buffer.from([0x00, 0x00, 0x00, 0x00]);
const payload = Buffer.concat([
Buffer.from(
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="file"; filename="zeros.bin"\r\n` +
`Content-Type: application/octet-stream\r\n\r\n`,
"utf8",
),
source,
Buffer.from(`\r\n--${boundary}--\r\n`, "utf8"),
]);
const request = new Request("http://localhost/", {
method: "POST",
headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
body: payload,
});
const form = await request.formData();
const file = form.get("file");
expect(file).toBeInstanceOf(File);
const parsed = new Uint8Array(await (file as File).arrayBuffer());
expect(Array.from(parsed)).toEqual([0x00, 0x00, 0x00, 0x00]);
expect(parsed.byteLength).toBe(4);
});
test("multipart formdata preserves single null byte file", async () => {
const boundary = "----bun-test-boundary";
const source = Buffer.from([0x00]);
const payload = Buffer.concat([
Buffer.from(
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="file"; filename="null.bin"\r\n` +
`Content-Type: application/octet-stream\r\n\r\n`,
"utf8",
),
source,
Buffer.from(`\r\n--${boundary}--\r\n`, "utf8"),
]);
const request = new Request("http://localhost/", {
method: "POST",
headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
body: payload,
});
const form = await request.formData();
const file = form.get("file");
expect(file).toBeInstanceOf(File);
const parsed = new Uint8Array(await (file as File).arrayBuffer());
expect(Array.from(parsed)).toEqual([0x00]);
expect(parsed.byteLength).toBe(1);
});
test("multipart formdata preserves 8-byte binary with embedded nulls", async () => {
const boundary = "----bun-test-boundary";
// Exactly 8 bytes (max inline length of Semver.String) with nulls interspersed
const source = Buffer.from([0x01, 0x00, 0x02, 0x00, 0x03, 0x00, 0x04, 0x00]);
const payload = Buffer.concat([
Buffer.from(
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="file"; filename="mixed.bin"\r\n` +
`Content-Type: application/octet-stream\r\n\r\n`,
"utf8",
),
source,
Buffer.from(`\r\n--${boundary}--\r\n`, "utf8"),
]);
const request = new Request("http://localhost/", {
method: "POST",
headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
body: payload,
});
const form = await request.formData();
const file = form.get("file");
expect(file).toBeInstanceOf(File);
const parsed = new Uint8Array(await (file as File).arrayBuffer());
expect(Array.from(parsed)).toEqual([0x01, 0x00, 0x02, 0x00, 0x03, 0x00, 0x04, 0x00]);
expect(parsed.byteLength).toBe(8);
});

View File

@@ -0,0 +1,92 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
// When emitDecoratorMetadata is true in tsconfig but experimentalDecorators is
// absent, Bun should use legacy decorator semantics (not TC39 standard).
// emitDecoratorMetadata only makes sense with legacy decorators.
test("legacy decorators work when emitDecoratorMetadata is true without experimentalDecorators", async () => {
using dir = tempDir("issue-27526", {
"tsconfig.json": JSON.stringify({
compilerOptions: {
target: "ES2021",
module: "commonjs",
strict: true,
esModuleInterop: true,
emitDecoratorMetadata: true,
},
}),
"index.ts": `
function MyDecorator(target: any, key: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = function(...args: any[]) {
return "decorated:" + original.apply(this, args);
};
}
class Foo {
@MyDecorator
hello() {
return "world";
}
}
console.log(new Foo().hello());
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "index.ts"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout.trim()).toBe("decorated:world");
expect(exitCode).toBe(0);
});
// When neither emitDecoratorMetadata nor experimentalDecorators is set,
// TypeScript files should use TC39 standard decorators.
test("TC39 standard decorators work when neither emitDecoratorMetadata nor experimentalDecorators is set", async () => {
using dir = tempDir("issue-27526-standard", {
"tsconfig.json": JSON.stringify({
compilerOptions: {
target: "ES2021",
module: "commonjs",
strict: true,
},
}),
"index.ts": `
function MyDecorator(value: Function, context: ClassMethodDecoratorContext) {
return function(this: any, ...args: any[]) {
return "decorated:" + (value as any).apply(this, args);
};
}
class Foo {
@MyDecorator
hello() {
return "world";
}
}
console.log(new Foo().hello());
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "index.ts"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout.trim()).toBe("decorated:world");
expect(exitCode).toBe(0);
});