diff --git a/src/bun.js/api/server/RequestContext.zig b/src/bun.js/api/server/RequestContext.zig index 1b635b5144..979a99a54e 100644 --- a/src/bun.js/api/server/RequestContext.zig +++ b/src/bun.js/api/server/RequestContext.zig @@ -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; diff --git a/test/regression/issue/26959.test.ts b/test/regression/issue/26959.test.ts new file mode 100644 index 0000000000..a55cd76086 --- /dev/null +++ b/test/regression/issue/26959.test.ts @@ -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"); +});