fix(server): sanitize Content-Disposition filename to prevent header injection

Strip \r, \n, ", \, and null bytes from filenames used in auto-generated
Content-Disposition headers to prevent CRLF injection / HTTP response
splitting attacks.

Closes #26959

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Claude Bot
2026-02-12 07:22:30 +00:00
parent 50e478dcdc
commit 2c8d016c76
2 changed files with 102 additions and 5 deletions

View File

@@ -2294,11 +2294,15 @@ 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 "",
);
// Sanitize the basename to prevent header injection via
// CRLF sequences or unescaped quotes in the filename.
const sanitized_basename = sanitizeFilenameForContentDisposition(basename[0..@min(basename.len, 1024 - 32)]);
if (sanitized_basename.len > 0) {
resp.writeHeader(
"content-disposition",
std.fmt.bufPrint(&filename_buf, "filename=\"{s}\"", .{sanitized_basename}) catch "",
);
}
}
}
}
@@ -2673,6 +2677,23 @@ const ctxLog = Output.scoped(.RequestContext, .visible);
const string = []const u8;
const std = @import("std");
/// Sanitize a filename for use in a Content-Disposition header value.
/// Strips characters that could enable HTTP header injection (CRLF) or
/// break out of the quoted filename value (double quotes, backslashes).
fn sanitizeFilenameForContentDisposition(input: []const u8) []const u8 {
const T = struct {
threadlocal var buf: [1024]u8 = undefined;
};
var len: usize = 0;
for (input) |c| {
if (c == '\r' or c == '\n' or c == '"' or c == '\\' or c == 0) continue;
if (len >= T.buf.len) break;
T.buf[len] = c;
len += 1;
}
return T.buf[0..len];
}
const Fallback = @import("../../../runtime.zig").Fallback;
const linux = std.os.linux;

View File

@@ -0,0 +1,76 @@
import { expect, test } from "bun:test";
// Regression test for https://github.com/oven-sh/bun/issues/26959
// Content-Disposition header injection via unsanitized filename
test("Content-Disposition filename sanitizes CRLF characters", async () => {
using server = Bun.serve({
port: 0,
fetch() {
const file = new File(["hello"], "evil.bin\r\nX-Injected: true", {
type: "application/octet-stream",
});
return new Response(file);
},
});
const resp = await fetch(`http://localhost:${server.port}/`);
expect(resp.headers.get("X-Injected")).toBeNull();
const disposition = resp.headers.get("content-disposition");
expect(disposition).toBe('filename="evil.binX-Injected: true"');
expect(await resp.text()).toBe("hello");
});
test("Content-Disposition filename sanitizes double quotes", async () => {
using server = Bun.serve({
port: 0,
fetch() {
const file = new File(["hello"], 'file"name.bin', {
type: "application/octet-stream",
});
return new Response(file);
},
});
const resp = await fetch(`http://localhost:${server.port}/`);
const disposition = resp.headers.get("content-disposition");
expect(disposition).toBe('filename="filename.bin"');
expect(await resp.text()).toBe("hello");
});
test("Content-Disposition filename sanitizes backslashes", async () => {
using server = Bun.serve({
port: 0,
fetch() {
const file = new File(["hello"], "file\\name.bin", {
type: "application/octet-stream",
});
return new Response(file);
},
});
const resp = await fetch(`http://localhost:${server.port}/`);
const disposition = resp.headers.get("content-disposition");
expect(disposition).toBe('filename="filename.bin"');
expect(await resp.text()).toBe("hello");
});
test("Content-Disposition with clean filename is unchanged", async () => {
using server = Bun.serve({
port: 0,
fetch() {
const file = new File(["hello"], "normal-file.bin", {
type: "application/octet-stream",
});
return new Response(file);
},
});
const resp = await fetch(`http://localhost:${server.port}/`);
const disposition = resp.headers.get("content-disposition");
expect(disposition).toBe('filename="normal-file.bin"');
expect(await resp.text()).toBe("hello");
});