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
2 changed files with 118 additions and 5 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

@@ -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");
}
});