mirror of
https://github.com/oven-sh/bun
synced 2026-02-17 22:32:06 +00:00
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:
@@ -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;
|
||||
|
||||
|
||||
76
test/regression/issue/26959.test.ts
Normal file
76
test/regression/issue/26959.test.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user