Compare commits

..

4 Commits

Author SHA1 Message Date
Claude Bot
79ebfc8a24 test: rename to issue number and improve filename regex
- Rename test file to 26959.test.ts per regression test convention
- Fix Content-Disposition regex to match filename parameter anywhere
  in the header value, not just as the entire value
- Assert the regex match is found before checking captured group

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-12 07:17:17 +00:00
Claude Bot
0177c433ec test: use module-scope imports and tempDir from harness
Replace dynamic `await import("fs")` / `await import("os")` with
`tempDir` from harness for temporary directory creation.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-12 07:05:30 +00:00
Claude
647ce82494 refactor: extract Content-Disposition sanitization into separate function
Extracts the inline filename sanitization logic into a dedicated
`sanitizeForContentDisposition` function for better readability.

https://claude.ai/code/session_01HV11VchHZ9PKfFFtZsFhKu
2026-02-12 06:52:26 +00:00
Claude Bot
8366772da6 fix(server): sanitize Content-Disposition filename to prevent header injection
The auto-generated Content-Disposition header for Bun.file() and File
responses used the filename basename verbatim via Zig's {s} format
specifier, which outputs bytes without escaping. On Linux, filenames
can contain \r\n bytes, enabling CRLF header injection. The filename
could also contain double quotes to break out of the filename="" value.

Sanitize the basename by stripping \r and \n characters and replacing
" with ' before interpolation into the header value.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-12 04:44:17 +00:00
4 changed files with 149 additions and 140 deletions

View File

@@ -2294,11 +2294,9 @@ pub fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool,
const basename = std.fs.path.basename(filename);
if (basename.len > 0) {
var filename_buf: [1024]u8 = undefined;
resp.writeHeader(
"content-disposition",
std.fmt.bufPrint(&filename_buf, "filename=\"{s}\"", .{basename[0..@min(basename.len, 1024 - 32)]}) catch "",
);
if (sanitizeForContentDisposition(basename, &filename_buf)) |value| {
resp.writeHeader("content-disposition", value);
}
}
}
}
@@ -2337,6 +2335,25 @@ pub fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool,
writeHeaders(headers, ssl_enabled, this.resp);
}
/// Sanitize a filename for use in a Content-Disposition header value.
/// Strips \r and \n to prevent CRLF header injection, and replaces
/// double quotes with single quotes to prevent breaking out of the
/// filename="..." parameter. Returns the formatted header value, or
/// null if the sanitized name is empty.
fn sanitizeForContentDisposition(basename: []const u8, out: *[1024]u8) ?[]const u8 {
var sanitized_buf: [1024 - 32]u8 = undefined;
const max_len = @min(basename.len, sanitized_buf.len);
var sanitized_len: usize = 0;
for (basename[0..max_len]) |c| {
if (c != '\r' and c != '\n') {
sanitized_buf[sanitized_len] = if (c == '"') '\'' else c;
sanitized_len += 1;
}
}
if (sanitized_len == 0) return null;
return std.fmt.bufPrint(out, "filename=\"{s}\"", .{sanitized_buf[0..sanitized_len]}) catch null;
}
pub fn renderBytes(this: *RequestContext) void {
// copy it to stack memory to prevent aliasing issues in release builds
const blob = this.blob;

View File

@@ -23,7 +23,6 @@ var print_every_i: usize = 0;
// we always rewrite the entire HTTP request when write() returns EAGAIN
// so we can reuse this buffer
var shared_request_headers_buf: [256]picohttp.Header = undefined;
var shared_request_headers_overflow: ?[]picohttp.Header = null;
// this doesn't need to be stack memory because it is immediately cloned after use
var shared_response_headers_buf: [256]picohttp.Header = undefined;
@@ -606,32 +605,7 @@ pub fn buildRequest(this: *HTTPClient, body_len: usize) picohttp.Request {
var header_entries = this.header_entries.slice();
const header_names = header_entries.items(.name);
const header_values = header_entries.items(.value);
// The maximum number of headers is the user-provided headers plus up to
// 6 extra headers that may be added below (Connection, User-Agent,
// Accept, Host, Accept-Encoding, Content-Length/Transfer-Encoding).
const max_headers = header_names.len + 6;
const static_buf_len = shared_request_headers_buf.len;
// Use the static buffer for the common case, dynamically allocate for overflow.
// The overflow buffer is kept around for reuse to avoid repeated allocations.
var request_headers_buf: []picohttp.Header = if (max_headers <= static_buf_len)
&shared_request_headers_buf
else blk: {
if (shared_request_headers_overflow) |overflow| {
if (overflow.len >= max_headers) {
break :blk overflow;
}
bun.default_allocator.free(overflow);
shared_request_headers_overflow = null;
}
const buf = bun.default_allocator.alloc(picohttp.Header, max_headers) catch
// On allocation failure, fall back to the static buffer and
// truncate headers rather than writing out of bounds.
break :blk @as([]picohttp.Header, &shared_request_headers_buf);
shared_request_headers_overflow = buf;
break :blk buf;
};
var request_headers_buf = &shared_request_headers_buf;
var override_accept_encoding = false;
var override_accept_header = false;
@@ -693,32 +667,43 @@ pub fn buildRequest(this: *HTTPClient, body_len: usize) picohttp.Request {
else => {},
}
if (header_count >= request_headers_buf.len) break;
request_headers_buf[header_count] = .{
.name = name,
.value = this.headerStr(header_values[i]),
};
// header_name_hashes[header_count] = hash;
// // ensure duplicate headers come after each other
// if (header_count > 2) {
// var head_i: usize = header_count - 1;
// while (head_i > 0) : (head_i -= 1) {
// if (header_name_hashes[head_i] == header_name_hashes[header_count]) {
// std.mem.swap(picohttp.Header, &header_name_hashes[header_count], &header_name_hashes[head_i + 1]);
// std.mem.swap(u64, &request_headers_buf[header_count], &request_headers_buf[head_i + 1]);
// break;
// }
// }
// }
header_count += 1;
}
if (!override_connection_header and !this.flags.disable_keepalive and header_count < request_headers_buf.len) {
if (!override_connection_header and !this.flags.disable_keepalive) {
request_headers_buf[header_count] = connection_header;
header_count += 1;
}
if (!override_user_agent and header_count < request_headers_buf.len) {
if (!override_user_agent) {
request_headers_buf[header_count] = getUserAgentHeader();
header_count += 1;
}
if (!override_accept_header and header_count < request_headers_buf.len) {
if (!override_accept_header) {
request_headers_buf[header_count] = accept_header;
header_count += 1;
}
if (!override_host_header and header_count < request_headers_buf.len) {
if (!override_host_header) {
request_headers_buf[header_count] = .{
.name = host_header_name,
.value = this.url.host,
@@ -726,33 +711,31 @@ pub fn buildRequest(this: *HTTPClient, body_len: usize) picohttp.Request {
header_count += 1;
}
if (!override_accept_encoding and !this.flags.disable_decompression and header_count < request_headers_buf.len) {
if (!override_accept_encoding and !this.flags.disable_decompression) {
request_headers_buf[header_count] = accept_encoding_header;
header_count += 1;
}
if (header_count < request_headers_buf.len) {
if (body_len > 0 or this.method.hasRequestBody()) {
if (this.flags.is_streaming_request_body) {
if (add_transfer_encoding and this.flags.upgrade_state == .none) {
request_headers_buf[header_count] = chunked_encoded_header;
header_count += 1;
}
} else {
request_headers_buf[header_count] = .{
.name = content_length_header_name,
.value = std.fmt.bufPrint(&this.request_content_len_buf, "{d}", .{body_len}) catch "0",
};
if (body_len > 0 or this.method.hasRequestBody()) {
if (this.flags.is_streaming_request_body) {
if (add_transfer_encoding and this.flags.upgrade_state == .none) {
request_headers_buf[header_count] = chunked_encoded_header;
header_count += 1;
}
} else if (original_content_length) |content_length| {
} else {
request_headers_buf[header_count] = .{
.name = content_length_header_name,
.value = content_length,
.value = std.fmt.bufPrint(&this.request_content_len_buf, "{d}", .{body_len}) catch "0",
};
header_count += 1;
}
} else if (original_content_length) |content_length| {
request_headers_buf[header_count] = .{
.name = content_length_header_name,
.value = content_length,
};
header_count += 1;
}
return picohttp.Request{

View File

@@ -1,87 +0,0 @@
import { describe, expect, test } from "bun:test";
import { once } from "node:events";
import { createServer } from "node:net";
describe("fetch with many headers", () => {
test("should not crash or corrupt memory with more than 256 headers", async () => {
// Use a raw TCP server to avoid uws header count limits on the server side.
// We just need to verify that the client sends the request without crashing.
await using server = createServer(socket => {
let data = "";
socket.on("data", (chunk: Buffer) => {
data += chunk.toString();
// Wait for the end of HTTP headers (double CRLF)
if (data.includes("\r\n\r\n")) {
// Count headers (lines between the request line and the blank line)
const headerSection = data.split("\r\n\r\n")[0];
const lines = headerSection.split("\r\n");
// First line is the request line (GET / HTTP/1.1), rest are headers
const headerCount = lines.length - 1;
const body = String(headerCount);
const response = ["HTTP/1.1 200 OK", `Content-Length: ${body.length}`, "Connection: close", "", body].join(
"\r\n",
);
socket.write(response);
socket.end();
}
});
}).listen(0);
await once(server, "listening");
const port = (server.address() as any).port;
// Build 300 unique custom headers (exceeds the 256-entry static buffer)
const headers = new Headers();
const headerCount = 300;
for (let i = 0; i < headerCount; i++) {
headers.set(`x-custom-${i}`, `value-${i}`);
}
const res = await fetch(`http://localhost:${port}/`, { headers });
const receivedCount = parseInt(await res.text(), 10);
expect(res.status).toBe(200);
// The server should receive our custom headers plus default ones
// (host, connection, user-agent, accept, accept-encoding = 5 extra)
expect(receivedCount).toBeGreaterThanOrEqual(headerCount);
});
test("should handle exactly 256 user headers without issues", async () => {
await using server = createServer(socket => {
let data = "";
socket.on("data", (chunk: Buffer) => {
data += chunk.toString();
if (data.includes("\r\n\r\n")) {
const headerSection = data.split("\r\n\r\n")[0];
const lines = headerSection.split("\r\n");
const headerCount = lines.length - 1;
const body = String(headerCount);
const response = ["HTTP/1.1 200 OK", `Content-Length: ${body.length}`, "Connection: close", "", body].join(
"\r\n",
);
socket.write(response);
socket.end();
}
});
}).listen(0);
await once(server, "listening");
const port = (server.address() as any).port;
const headers = new Headers();
const headerCount = 256;
for (let i = 0; i < headerCount; i++) {
headers.set(`x-custom-${i}`, `value-${i}`);
}
const res = await fetch(`http://localhost:${port}/`, { headers });
const receivedCount = parseInt(await res.text(), 10);
expect(res.status).toBe(200);
expect(receivedCount).toBeGreaterThanOrEqual(headerCount);
});
});

View File

@@ -0,0 +1,96 @@
import { expect, test } from "bun:test";
import { tempDir } from "harness";
test("Content-Disposition header injection via CRLF in File name", async () => {
await using server = Bun.serve({
port: 0,
fetch() {
// The File name contains a CRLF sequence that could inject a header.
// Use application/octet-stream so autosetFilename() returns true.
const maliciousName = "evil.bin\r\nX-Injected: true";
const file = new File(["hello"], maliciousName, { type: "application/octet-stream" });
return new Response(file);
},
});
const response = await fetch(server.url);
const body = await response.text();
// The injected header must NOT appear
expect(response.headers.get("X-Injected")).toBeNull();
// The Content-Disposition header must not contain CRLF
const contentDisposition = response.headers.get("content-disposition");
if (contentDisposition) {
expect(contentDisposition).not.toContain("\r");
expect(contentDisposition).not.toContain("\n");
}
expect(body).toBe("hello");
});
test("Content-Disposition header injection via quotes in File name", async () => {
await using server = Bun.serve({
port: 0,
fetch() {
// The File name contains quotes that could break out of filename=""
const maliciousName = 'evil.bin" ; malicious="true';
const file = new File(["hello"], maliciousName, { type: "application/octet-stream" });
return new Response(file);
},
});
const response = await fetch(server.url);
const body = await response.text();
const contentDisposition = response.headers.get("content-disposition");
if (contentDisposition) {
expect(contentDisposition).not.toContain("\r");
expect(contentDisposition).not.toContain("\n");
// The filename parameter value should not contain unescaped double quotes
const match = contentDisposition.match(/filename="([^"]*)"/);
expect(match).not.toBeNull();
expect(match![1]).not.toContain('"');
}
expect(body).toBe("hello");
});
test("Content-Disposition header injection via Bun.file with crafted path", async () => {
// Create a temp dir, then add a file with CRLF in its name (Linux allows this)
using dir = tempDir("crlf-filename", {});
const maliciousFilename = "evil.bin\r\nX-Injected: true";
const filePath = `${dir}/${maliciousFilename}`;
let fileCreated = false;
try {
await Bun.write(filePath, "hello from file");
fileCreated = true;
} catch {
// Some filesystems may not support CRLF in filenames
console.log("Skipping Bun.file test - filesystem does not support CRLF in filenames");
}
if (fileCreated) {
await using server = Bun.serve({
port: 0,
fetch() {
return new Response(Bun.file(filePath));
},
});
const response = await fetch(server.url);
const body = await response.text();
// The injected header must NOT appear
expect(response.headers.get("X-Injected")).toBeNull();
const contentDisposition = response.headers.get("content-disposition");
if (contentDisposition) {
expect(contentDisposition).not.toContain("\r");
expect(contentDisposition).not.toContain("\n");
}
expect(body).toBe("hello from file");
}
});