Compare commits

..

1 Commits

Author SHA1 Message Date
Claude Bot
a4f5036df4 fix(valkey): enforce max nesting depth in RESP parser to prevent stack overflow
A malicious Redis/Valkey server could send deeply nested RESP structures
(e.g., arrays nested 100,000+ levels deep) causing unbounded recursion in
readValue() and crashing the process via stack overflow (SIGSEGV). This
adds a maximum nesting depth of 64 levels, which is far beyond any
legitimate use case while preventing stack exhaustion.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-12 04:45:02 +00:00
20 changed files with 157 additions and 990 deletions

View File

@@ -954,7 +954,6 @@ BUN_DEFINE_HOST_FUNCTION(jsFunctionBunPluginClear, (JSC::JSGlobalObject * global
global->onResolvePlugins.namespaces.clear();
delete global->onLoadPlugins.virtualModules;
global->onLoadPlugins.virtualModules = nullptr;
return JSC::JSValue::encode(JSC::jsUndefined());
}

View File

@@ -948,7 +948,6 @@ pub const CommandLineReporter = struct {
this.printSummary();
Output.prettyError("\nBailed out after {d} failure{s}<r>\n", .{ this.jest.bail, if (this.jest.bail == 1) "" else "s" });
Output.flush();
this.writeJUnitReportIfNeeded();
Global.exit(1);
}
},
@@ -971,20 +970,6 @@ pub const CommandLineReporter = struct {
Output.printStartEnd(bun.start_time, std.time.nanoTimestamp());
}
/// Writes the JUnit reporter output file if a JUnit reporter is active and
/// an outfile path was configured. This must be called before any early exit
/// (e.g. bail) so that the report is not lost.
pub fn writeJUnitReportIfNeeded(this: *CommandLineReporter) void {
if (this.reporters.junit) |junit| {
if (this.jest.test_options.reporter_outfile) |outfile| {
if (junit.current_file.len > 0) {
junit.endTestSuite() catch {};
}
junit.writeToFile(outfile) catch {};
}
}
}
pub fn generateCodeCoverage(this: *CommandLineReporter, vm: *jsc.VirtualMachine, opts: *TestCommand.CodeCoverageOptions, comptime reporters: TestCommand.Reporters, comptime enable_ansi_colors: bool) !void {
if (comptime !reporters.text and !reporters.lcov) {
return;
@@ -1787,7 +1772,12 @@ pub const TestCommand = struct {
Output.prettyError("\n", .{});
Output.flush();
reporter.writeJUnitReportIfNeeded();
if (reporter.reporters.junit) |junit| {
if (junit.current_file.len > 0) {
junit.endTestSuite() catch {};
}
junit.writeToFile(ctx.test_options.reporter_outfile.?) catch {};
}
if (vm.hot_reload == .watch) {
vm.runWithAPILock(jsc.VirtualMachine, vm, runEventLoopForWatch);
@@ -1930,7 +1920,6 @@ pub const TestCommand = struct {
if (reporter.jest.bail == reporter.summary().fail) {
reporter.printSummary();
Output.prettyError("\nBailed out after {d} failure{s}<r>\n", .{ reporter.jest.bail, if (reporter.jest.bail == 1) "" else "s" });
reporter.writeJUnitReportIfNeeded();
vm.exit_handler.exit_code = 1;
vm.is_shutting_down = true;

View File

@@ -27,7 +27,6 @@ pub fn NewWebSocketClient(comptime ssl: bool) type {
ping_frame_bytes: [128 + 6]u8 = [_]u8{0} ** (128 + 6),
ping_len: u8 = 0,
ping_received: bool = false,
pong_received: bool = false,
close_received: bool = false,
close_frame_buffering: bool = false,
@@ -121,7 +120,6 @@ pub fn NewWebSocketClient(comptime ssl: bool) type {
this.clearReceiveBuffers(true);
this.clearSendBuffers(true);
this.ping_received = false;
this.pong_received = false;
this.ping_len = 0;
this.close_frame_buffering = false;
this.receive_pending_chunk_len = 0;
@@ -652,38 +650,14 @@ pub fn NewWebSocketClient(comptime ssl: bool) type {
if (data.len == 0) break;
},
.pong => {
if (!this.pong_received) {
if (receive_body_remain > 125) {
this.terminate(ErrorCode.invalid_control_frame);
terminated = true;
break;
}
this.ping_len = @truncate(receive_body_remain);
receive_body_remain = 0;
this.pong_received = true;
}
const pong_len = this.ping_len;
const pong_len = @min(data.len, @min(receive_body_remain, this.ping_frame_bytes.len));
if (data.len > 0) {
const total_received = @min(pong_len, receive_body_remain + data.len);
const slice = this.ping_frame_bytes[6..][receive_body_remain..total_received];
@memcpy(slice, data[0..slice.len]);
receive_body_remain = total_received;
data = data[slice.len..];
}
const pending_body = pong_len - receive_body_remain;
if (pending_body > 0) {
// wait for more data - pong payload is fragmented across TCP segments
break;
}
const pong_data = this.ping_frame_bytes[6..][0..pong_len];
this.dispatchData(pong_data, .Pong);
this.dispatchData(data[0..pong_len], .Pong);
data = data[pong_len..];
receive_state = .need_header;
receive_body_remain = 0;
receiving_type = last_receive_data_type;
this.pong_received = false;
if (data.len == 0) break;
},

View File

@@ -291,32 +291,25 @@ pub const Parser = struct {
}
},
else => {
switch (bun.strings.utf8ByteSequenceLength(c)) {
0, 1 => try unesc.appendSlice(&[_]u8{ '\\', c }),
2 => if (val.len - i >= 2) {
try unesc.appendSlice(&[_]u8{ '\\', c, val[i + 1] });
i += 1;
} else {
try unesc.appendSlice(&[_]u8{ '\\', c });
try unesc.appendSlice(switch (bun.strings.utf8ByteSequenceLength(c)) {
1 => brk: {
break :brk &[_]u8{ '\\', c };
},
3 => if (val.len - i >= 3) {
try unesc.appendSlice(&[_]u8{ '\\', c, val[i + 1], val[i + 2] });
i += 2;
} else {
try unesc.append('\\');
try unesc.appendSlice(val[i..val.len]);
i = val.len - 1;
2 => brk: {
defer i += 1;
break :brk &[_]u8{ '\\', c, val[i + 1] };
},
4 => if (val.len - i >= 4) {
try unesc.appendSlice(&[_]u8{ '\\', c, val[i + 1], val[i + 2], val[i + 3] });
i += 3;
} else {
try unesc.append('\\');
try unesc.appendSlice(val[i..val.len]);
i = val.len - 1;
3 => brk: {
defer i += 2;
break :brk &[_]u8{ '\\', c, val[i + 1], val[i + 2] };
},
4 => brk: {
defer i += 3;
break :brk &[_]u8{ '\\', c, val[i + 1], val[i + 2], val[i + 3] };
},
// this means invalid utf8
else => unreachable,
}
});
},
}
@@ -349,30 +342,25 @@ pub const Parser = struct {
try unesc.append('.');
}
},
else => switch (bun.strings.utf8ByteSequenceLength(c)) {
0, 1 => try unesc.append(c),
2 => if (val.len - i >= 2) {
try unesc.appendSlice(&[_]u8{ c, val[i + 1] });
i += 1;
} else {
try unesc.append(c);
else => try unesc.appendSlice(switch (bun.strings.utf8ByteSequenceLength(c)) {
1 => brk: {
break :brk &[_]u8{c};
},
3 => if (val.len - i >= 3) {
try unesc.appendSlice(&[_]u8{ c, val[i + 1], val[i + 2] });
i += 2;
} else {
try unesc.appendSlice(val[i..val.len]);
i = val.len - 1;
2 => brk: {
defer i += 1;
break :brk &[_]u8{ c, val[i + 1] };
},
4 => if (val.len - i >= 4) {
try unesc.appendSlice(&[_]u8{ c, val[i + 1], val[i + 2], val[i + 3] });
i += 3;
} else {
try unesc.appendSlice(val[i..val.len]);
i = val.len - 1;
3 => brk: {
defer i += 2;
break :brk &[_]u8{ c, val[i + 1], val[i + 2] };
},
4 => brk: {
defer i += 3;
break :brk &[_]u8{ c, val[i + 1], val[i + 2], val[i + 3] };
},
// this means invalid utf8
else => unreachable,
},
}),
}
}

View File

@@ -460,13 +460,13 @@ pub const Archiver = struct {
if (comptime Environment.isWindows) {
try bun.MakePath.makePath(u16, dir, path);
} else {
std.posix.mkdiratZ(dir_fd, path, @intCast(mode)) catch |err| {
std.posix.mkdiratZ(dir_fd, pathname, @intCast(mode)) catch |err| {
// It's possible for some tarballs to return a directory twice, with and
// without `./` in the beginning. So if it already exists, continue to the
// next entry.
if (err == error.PathAlreadyExists or err == error.NotDir) continue;
bun.makePath(dir, std.fs.path.dirname(path_slice) orelse return err) catch {};
std.posix.mkdiratZ(dir_fd, path, 0o777) catch {};
std.posix.mkdiratZ(dir_fd, pathname, 0o777) catch {};
};
}
},

View File

@@ -221,11 +221,7 @@ pub const S3Credentials = struct {
defer str.deref();
if (str.tag != .Empty and str.tag != .Dead) {
new_credentials._contentDispositionSlice = str.toUTF8(bun.default_allocator);
const slice = new_credentials._contentDispositionSlice.?.slice();
if (containsNewlineOrCR(slice)) {
return globalObject.throwInvalidArguments("contentDisposition must not contain newline characters (CR/LF)", .{});
}
new_credentials.content_disposition = slice;
new_credentials.content_disposition = new_credentials._contentDispositionSlice.?.slice();
}
} else {
return globalObject.throwInvalidArgumentTypeValue("contentDisposition", "string", js_value);
@@ -240,11 +236,7 @@ pub const S3Credentials = struct {
defer str.deref();
if (str.tag != .Empty and str.tag != .Dead) {
new_credentials._contentTypeSlice = str.toUTF8(bun.default_allocator);
const slice = new_credentials._contentTypeSlice.?.slice();
if (containsNewlineOrCR(slice)) {
return globalObject.throwInvalidArguments("type must not contain newline characters (CR/LF)", .{});
}
new_credentials.content_type = slice;
new_credentials.content_type = new_credentials._contentTypeSlice.?.slice();
}
} else {
return globalObject.throwInvalidArgumentTypeValue("type", "string", js_value);
@@ -259,11 +251,7 @@ pub const S3Credentials = struct {
defer str.deref();
if (str.tag != .Empty and str.tag != .Dead) {
new_credentials._contentEncodingSlice = str.toUTF8(bun.default_allocator);
const slice = new_credentials._contentEncodingSlice.?.slice();
if (containsNewlineOrCR(slice)) {
return globalObject.throwInvalidArguments("contentEncoding must not contain newline characters (CR/LF)", .{});
}
new_credentials.content_encoding = slice;
new_credentials.content_encoding = new_credentials._contentEncodingSlice.?.slice();
}
} else {
return globalObject.throwInvalidArgumentTypeValue("contentEncoding", "string", js_value);
@@ -1162,12 +1150,6 @@ const CanonicalRequest = struct {
}
};
/// Returns true if the given slice contains any CR (\r) or LF (\n) characters,
/// which would allow HTTP header injection if used in a header value.
fn containsNewlineOrCR(value: []const u8) bool {
return std.mem.indexOfAny(u8, value, "\r\n") != null;
}
const std = @import("std");
const ACL = @import("./acl.zig").ACL;
const MultiPartUploadOptions = @import("./multipart_options.zig").MultiPartUploadOptions;

View File

@@ -87,16 +87,7 @@ fn handleChangeCwdErr(this: *Cd, err: Syscall.Error, new_cwd_: []const u8) Yield
return this.writeStderrNonBlocking("not a directory: {s}\n", .{new_cwd_});
},
else => {
const message: []const u8 = brk: {
if (err.getErrorCodeTagName()) |entry| {
_, const sys_errno = entry;
if (Syscall.coreutils_error_map.get(sys_errno)) |msg| break :brk msg;
}
break :brk "unknown error";
};
return this.writeStderrNonBlocking("{s}: {s}\n", .{ new_cwd_, message });
},
else => return .failed,
}
}

View File

@@ -1154,7 +1154,7 @@ pub const Interpreter = struct {
_ = callframe; // autofix
if (this.setupIOBeforeRun().asErr()) |e| {
defer this.#derefRootShellAndIOIfNeeded(true);
defer this.#deinitFromExec();
const shellerr = bun.shell.ShellErr.newSys(e);
return try throwShellErr(&shellerr, .{ .js = globalThis.bunVM().event_loop });
}

View File

@@ -422,19 +422,6 @@ pub fn createInstance(globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFra
break :brk b.allocatedSlice();
};
// Reject null bytes in connection parameters to prevent protocol injection
// (null bytes act as field terminators in the MySQL wire protocol).
inline for (.{ .{ username, "username" }, .{ password, "password" }, .{ database, "database" }, .{ path, "path" } }) |entry| {
if (entry[0].len > 0 and std.mem.indexOfScalar(u8, entry[0], 0) != null) {
bun.default_allocator.free(options_buf);
tls_config.deinit();
if (tls_ctx) |tls| {
tls.deinit(true);
}
return globalObject.throwInvalidArguments(entry[1] ++ " must not contain null bytes", .{});
}
}
const on_connect = arguments[9];
const on_close = arguments[10];
const idle_timeout = arguments[11].toInt32();

View File

@@ -680,20 +680,6 @@ pub fn call(globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JS
break :brk b.allocatedSlice();
};
// Reject null bytes in connection parameters to prevent Postgres startup
// message parameter injection (null bytes act as field terminators in the
// wire protocol's key\0value\0 format).
inline for (.{ .{ username, "username" }, .{ password, "password" }, .{ database, "database" }, .{ path, "path" } }) |entry| {
if (entry[0].len > 0 and std.mem.indexOfScalar(u8, entry[0], 0) != null) {
bun.default_allocator.free(options_buf);
tls_config.deinit();
if (tls_ctx) |tls| {
tls.deinit(true);
}
return globalObject.throwInvalidArguments(entry[1] ++ " must not contain null bytes", .{});
}
}
const on_connect = arguments[9];
const on_close = arguments[10];
const idle_timeout = arguments[11].toInt32();
@@ -1640,10 +1626,7 @@ pub fn on(this: *PostgresSQLConnection, comptime MessageType: @Type(.enum_litera
// This will usually start with "v="
const comparison_signature = final.data.slice();
if (comparison_signature.len < 2 or
server_signature.len != comparison_signature.len - 2 or
BoringSSL.c.CRYPTO_memcmp(server_signature.ptr, comparison_signature[2..].ptr, server_signature.len) != 0)
{
if (comparison_signature.len < 2 or !bun.strings.eqlLong(server_signature, comparison_signature[2..], true)) {
debug("SASLFinal - SASL Server signature mismatch\nExpected: {s}\nActual: {s}", .{ server_signature, comparison_signature[2..] });
this.fail("The server did not return the correct signature", error.SASL_SIGNATURE_MISMATCH);
} else {

View File

@@ -26,6 +26,7 @@ pub const RedisError = error{
UnsupportedProtocol,
ConnectionTimeout,
IdleTimeout,
NestingTooDeep,
};
pub fn valkeyErrorToJS(globalObject: *jsc.JSGlobalObject, message: ?[]const u8, err: RedisError) jsc.JSValue {
@@ -52,6 +53,7 @@ pub fn valkeyErrorToJS(globalObject: *jsc.JSGlobalObject, message: ?[]const u8,
error.InvalidCommand => .REDIS_INVALID_COMMAND,
error.InvalidArgument => .REDIS_INVALID_ARGUMENT,
error.UnsupportedProtocol => .REDIS_INVALID_RESPONSE,
error.NestingTooDeep => .REDIS_INVALID_RESPONSE,
error.InvalidResponseType => .REDIS_INVALID_RESPONSE_TYPE,
error.ConnectionTimeout => .REDIS_CONNECTION_TIMEOUT,
error.IdleTimeout => .REDIS_IDLE_TIMEOUT,
@@ -340,6 +342,10 @@ pub const ValkeyReader = struct {
buffer: []const u8,
pos: usize = 0,
/// Maximum allowed nesting depth for recursive RESP types (Array, Map, Set, etc.)
/// to prevent stack overflow from malicious deeply-nested responses.
const max_nesting_depth = 64;
pub fn init(buffer: []const u8) ValkeyReader {
return .{
.buffer = buffer,
@@ -421,6 +427,10 @@ pub const ValkeyReader = struct {
}
pub fn readValue(self: *ValkeyReader, allocator: std.mem.Allocator) RedisError!RESPValue {
return self.readValueDepth(allocator, 0);
}
fn readValueDepth(self: *ValkeyReader, allocator: std.mem.Allocator, depth: usize) RedisError!RESPValue {
const type_byte = try self.readByte();
return switch (RESPType.fromByte(type_byte) orelse return error.InvalidResponseType) {
@@ -451,6 +461,7 @@ pub const ValkeyReader = struct {
return RESPValue{ .BulkString = owned };
},
.Array => {
if (depth >= max_nesting_depth) return error.NestingTooDeep;
const len = try self.readInteger();
if (len < 0) return RESPValue{ .Array = &[_]RESPValue{} };
const array = try allocator.alloc(RESPValue, @as(usize, @intCast(len)));
@@ -462,7 +473,7 @@ pub const ValkeyReader = struct {
}
}
while (i < len) : (i += 1) {
array[i] = try self.readValue(allocator);
array[i] = try self.readValueDepth(allocator, depth + 1);
}
return RESPValue{ .Array = array };
},
@@ -495,6 +506,7 @@ pub const ValkeyReader = struct {
return RESPValue{ .VerbatimString = try self.readVerbatimString(allocator) };
},
.Map => {
if (depth >= max_nesting_depth) return error.NestingTooDeep;
const len = try self.readInteger();
if (len < 0) return error.InvalidMap;
@@ -508,11 +520,12 @@ pub const ValkeyReader = struct {
}
while (i < len) : (i += 1) {
entries[i] = .{ .key = try self.readValue(allocator), .value = try self.readValue(allocator) };
entries[i] = .{ .key = try self.readValueDepth(allocator, depth + 1), .value = try self.readValueDepth(allocator, depth + 1) };
}
return RESPValue{ .Map = entries };
},
.Set => {
if (depth >= max_nesting_depth) return error.NestingTooDeep;
const len = try self.readInteger();
if (len < 0) return error.InvalidSet;
@@ -525,11 +538,12 @@ pub const ValkeyReader = struct {
}
}
while (i < len) : (i += 1) {
set[i] = try self.readValue(allocator);
set[i] = try self.readValueDepth(allocator, depth + 1);
}
return RESPValue{ .Set = set };
},
.Attribute => {
if (depth >= max_nesting_depth) return error.NestingTooDeep;
const len = try self.readInteger();
if (len < 0) return error.InvalidAttribute;
@@ -542,9 +556,9 @@ pub const ValkeyReader = struct {
}
}
while (i < len) : (i += 1) {
var key = try self.readValue(allocator);
var key = try self.readValueDepth(allocator, depth + 1);
errdefer key.deinit(allocator);
const value = try self.readValue(allocator);
const value = try self.readValueDepth(allocator, depth + 1);
attrs[i] = .{ .key = key, .value = value };
}
@@ -553,7 +567,7 @@ pub const ValkeyReader = struct {
errdefer {
allocator.destroy(value_ptr);
}
value_ptr.* = try self.readValue(allocator);
value_ptr.* = try self.readValueDepth(allocator, depth + 1);
return RESPValue{ .Attribute = .{
.attributes = attrs,
@@ -561,11 +575,12 @@ pub const ValkeyReader = struct {
} };
},
.Push => {
if (depth >= max_nesting_depth) return error.NestingTooDeep;
const len = try self.readInteger();
if (len < 0 or len == 0) return error.InvalidPush;
// First element is the push type
const push_type = try self.readValue(allocator);
const push_type = try self.readValueDepth(allocator, depth + 1);
var push_type_str: []const u8 = "";
switch (push_type) {
@@ -594,7 +609,7 @@ pub const ValkeyReader = struct {
}
}
while (i < len - 1) : (i += 1) {
data[i] = try self.readValue(allocator);
data[i] = try self.readValueDepth(allocator, depth + 1);
}
return RESPValue{ .Push = .{

View File

@@ -260,35 +260,14 @@ devTest("hmr handles rapid consecutive edits", {
await Bun.sleep(1);
}
// Wait event-driven for "render 10" to appear. Intermediate renders may
// be skipped (watcher coalescing) and the final render may fire multiple
// times (duplicate reloads), so we just listen for any occurrence.
const finalRender = "render 10";
await new Promise<void>((resolve, reject) => {
const check = () => {
for (const msg of client.messages) {
if (typeof msg === "string" && msg.includes("HMR_ERROR")) {
cleanup();
reject(new Error("Unexpected HMR error message: " + msg));
return;
}
if (msg === finalRender) {
cleanup();
resolve();
return;
}
}
};
const cleanup = () => {
client.off("message", check);
};
client.on("message", check);
// Check messages already buffered.
check();
});
// Drain all buffered messages — intermediate renders and possible
// duplicates of the final render are expected and harmless.
client.messages.length = 0;
while (true) {
const message = await client.getStringMessage();
if (message === finalRender) break;
if (typeof message === "string" && message.includes("HMR_ERROR")) {
throw new Error("Unexpected HMR error message: " + message);
}
}
const hmrErrors = await client.js`return globalThis.__hmrErrors ? [...globalThis.__hmrErrors] : [];`;
if (hmrErrors.length > 0) {

View File

@@ -611,82 +611,6 @@ describe("Bun.Archive", () => {
// Very deep paths might fail on some systems - that's acceptable
}
});
test("directory entries with path traversal components cannot escape extraction root", async () => {
// Manually craft a tar archive containing directory entries with "../" traversal
// components in their pathnames. This tests that the extraction code uses the
// normalized path (which strips "..") rather than the raw pathname from the tarball.
function createTarHeader(
name: string,
size: number,
type: "0" | "5", // 0=file, 5=directory
): Uint8Array {
const header = new Uint8Array(512);
const enc = new TextEncoder();
header.set(enc.encode(name).slice(0, 100), 0);
header.set(enc.encode(type === "5" ? "0000755 " : "0000644 "), 100);
header.set(enc.encode("0000000 "), 108);
header.set(enc.encode("0000000 "), 116);
header.set(enc.encode(size.toString(8).padStart(11, "0") + " "), 124);
const mtime = Math.floor(Date.now() / 1000)
.toString(8)
.padStart(11, "0");
header.set(enc.encode(mtime + " "), 136);
header.set(enc.encode(" "), 148); // checksum placeholder
header[156] = type.charCodeAt(0);
header.set(enc.encode("ustar"), 257);
header[262] = 0;
header.set(enc.encode("00"), 263);
let checksum = 0;
for (let i = 0; i < 512; i++) checksum += header[i];
header.set(enc.encode(checksum.toString(8).padStart(6, "0") + "\0 "), 148);
return header;
}
const blocks: Uint8Array[] = [];
const enc = new TextEncoder();
// A legitimate directory
blocks.push(createTarHeader("safe_dir/", 0, "5"));
// A directory entry with traversal: "safe_dir/../../escaped_dir/"
// After normalization this becomes "escaped_dir" (safe),
// but the raw pathname resolves ".." via the kernel in mkdirat.
blocks.push(createTarHeader("safe_dir/../../escaped_dir/", 0, "5"));
// A normal file
const content = enc.encode("hello");
blocks.push(createTarHeader("safe_dir/file.txt", content.length, "0"));
blocks.push(content);
const pad = 512 - (content.length % 512);
if (pad < 512) blocks.push(new Uint8Array(pad));
// End-of-archive markers
blocks.push(new Uint8Array(1024));
const totalLen = blocks.reduce((s, b) => s + b.length, 0);
const tarball = new Uint8Array(totalLen);
let offset = 0;
for (const b of blocks) {
tarball.set(b, offset);
offset += b.length;
}
// Create a parent directory so we can check if "escaped_dir" appears outside extractDir
using parentDir = tempDir("archive-traversal-parent", {});
const extractPath = join(String(parentDir), "extract");
const { mkdirSync, existsSync } = require("fs");
mkdirSync(extractPath, { recursive: true });
const archive = new Bun.Archive(tarball);
await archive.extract(extractPath);
// The "escaped_dir" should NOT exist in the parent directory (outside extraction root)
const escapedOutside = join(String(parentDir), "escaped_dir");
expect(existsSync(escapedOutside)).toBe(false);
// The "safe_dir" should exist inside the extraction directory
expect(existsSync(join(extractPath, "safe_dir"))).toBe(true);
// The normalized "escaped_dir" may or may not exist inside extractPath
// (depending on whether normalization keeps it), but it must NOT be outside
});
});
describe("Archive.write()", () => {

View File

@@ -489,61 +489,6 @@ brr = 3
"zr": ["deedee"],
});
});
describe("truncated/invalid utf-8", () => {
test("bare continuation byte (0x80) should not crash", () => {
// 0x80 is a continuation byte without a leading byte
// utf8ByteSequenceLength returns 0, which must not hit unreachable
const ini = Buffer.concat([Buffer.from("key = "), Buffer.from([0x80])]).toString("latin1");
// Should not crash - just parse gracefully
expect(() => parse(ini)).not.toThrow();
});
test("truncated 2-byte sequence at end of value", () => {
// 0xC0 is a 2-byte lead byte, but there's no continuation byte following
const ini = Buffer.concat([Buffer.from("key = "), Buffer.from([0xc0])]).toString("latin1");
expect(() => parse(ini)).not.toThrow();
});
test("truncated 3-byte sequence at end of value", () => {
// 0xE0 is a 3-byte lead byte, but only 0 continuation bytes follow
const ini = Buffer.concat([Buffer.from("key = "), Buffer.from([0xe0])]).toString("latin1");
expect(() => parse(ini)).not.toThrow();
});
test("truncated 3-byte sequence with 1 continuation byte at end", () => {
// 0xE0 is a 3-byte lead byte, but only 1 continuation byte follows
const ini = Buffer.concat([Buffer.from("key = "), Buffer.from([0xe0, 0x80])]).toString("latin1");
expect(() => parse(ini)).not.toThrow();
});
test("truncated 4-byte sequence at end of value", () => {
// 0xF0 is a 4-byte lead byte, but only 0 continuation bytes follow
const ini = Buffer.concat([Buffer.from("key = "), Buffer.from([0xf0])]).toString("latin1");
expect(() => parse(ini)).not.toThrow();
});
test("truncated 4-byte sequence with 1 continuation byte at end", () => {
const ini = Buffer.concat([Buffer.from("key = "), Buffer.from([0xf0, 0x80])]).toString("latin1");
expect(() => parse(ini)).not.toThrow();
});
test("truncated 4-byte sequence with 2 continuation bytes at end", () => {
const ini = Buffer.concat([Buffer.from("key = "), Buffer.from([0xf0, 0x80, 0x80])]).toString("latin1");
expect(() => parse(ini)).not.toThrow();
});
test("truncated 2-byte sequence in escaped context", () => {
// Backslash followed by a 2-byte lead byte at end of value
const ini = Buffer.concat([Buffer.from("key = \\"), Buffer.from([0xc0])]).toString("latin1");
expect(() => parse(ini)).not.toThrow();
});
test("bare continuation byte in escaped context", () => {
const ini = Buffer.concat([Buffer.from("key = \\"), Buffer.from([0x80])]).toString("latin1");
expect(() => parse(ini)).not.toThrow();
});
});
});
const wtf = {

View File

@@ -1,35 +0,0 @@
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, isPosix } from "harness";
// Regression test: cd with a path component exceeding NAME_MAX (255 bytes)
// triggers ENAMETOOLONG from openat(). The handleChangeCwdErr function
// had `else => return .failed` which means "JS error was thrown" but
// no error was actually thrown, causing the shell to hang indefinitely.
describe.if(isPosix)("cd with path exceeding NAME_MAX should not hang", () => {
test("cd returns error for path component > 255 chars", async () => {
const longComponent = Buffer.alloc(256, "A").toString();
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
import { $ } from "bun";
$.throws(false);
const r = await $\`cd ${longComponent}\`;
console.log("exitCode:" + r.exitCode);
`,
],
env: { ...bunEnv, longComponent },
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stdout).toContain("exitCode:1");
expect(stderr).toContain("File name too long");
expect(exitCode).toBe(0);
});
});

View File

@@ -1,222 +0,0 @@
import { TCPSocketListener } from "bun";
import { describe, expect, test } from "bun:test";
const hostname = "127.0.0.1";
const MAX_HEADER_SIZE = 16 * 1024;
function doHandshake(
socket: any,
handshakeBuffer: Uint8Array,
data: Uint8Array,
): { buffer: Uint8Array; done: boolean } {
const newBuffer = new Uint8Array(handshakeBuffer.length + data.length);
newBuffer.set(handshakeBuffer);
newBuffer.set(data, handshakeBuffer.length);
if (newBuffer.length > MAX_HEADER_SIZE) {
socket.end();
throw new Error("Handshake headers too large");
}
const dataStr = new TextDecoder("utf-8").decode(newBuffer);
const endOfHeaders = dataStr.indexOf("\r\n\r\n");
if (endOfHeaders === -1) {
return { buffer: newBuffer, done: false };
}
if (!dataStr.startsWith("GET")) {
throw new Error("Invalid handshake");
}
const magic = /Sec-WebSocket-Key:\s*(.*)\r\n/i.exec(dataStr);
if (!magic) {
throw new Error("Missing Sec-WebSocket-Key");
}
const hasher = new Bun.CryptoHasher("sha1");
hasher.update(magic[1].trim());
hasher.update("258EAFA5-E914-47DA-95CA-C5AB0DC85B11");
const accept = hasher.digest("base64");
socket.write(
"HTTP/1.1 101 Switching Protocols\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
`Sec-WebSocket-Accept: ${accept}\r\n` +
"\r\n",
);
socket.flush();
return { buffer: newBuffer, done: true };
}
function makeTextFrame(text: string): Uint8Array {
const payload = new TextEncoder().encode(text);
const len = payload.length;
let header: Uint8Array;
if (len < 126) {
header = new Uint8Array([0x81, len]);
} else if (len < 65536) {
header = new Uint8Array([0x81, 126, (len >> 8) & 0xff, len & 0xff]);
} else {
throw new Error("Message too large for this test");
}
const frame = new Uint8Array(header.length + len);
frame.set(header);
frame.set(payload, header.length);
return frame;
}
describe("WebSocket", () => {
test("fragmented pong frame does not cause frame desync", async () => {
let server: TCPSocketListener | undefined;
let client: WebSocket | undefined;
let handshakeBuffer = new Uint8Array(0);
let handshakeComplete = false;
try {
const { promise, resolve, reject } = Promise.withResolvers<void>();
server = Bun.listen({
socket: {
data(socket, data) {
if (handshakeComplete) {
// After handshake, we just receive client frames (like close) - ignore them
return;
}
const result = doHandshake(socket, handshakeBuffer, new Uint8Array(data));
handshakeBuffer = result.buffer;
if (!result.done) return;
handshakeComplete = true;
// Build a pong frame with a 50-byte payload, but deliver it in two parts.
// Pong opcode = 0x8A, FIN=1
const pongPayload = new Uint8Array(50);
for (let i = 0; i < 50; i++) pongPayload[i] = 0x41 + (i % 26); // 'A'-'Z' repeated
const pongFrame = new Uint8Array(2 + 50);
pongFrame[0] = 0x8a; // FIN + Pong opcode
pongFrame[1] = 50; // payload length
pongFrame.set(pongPayload, 2);
// Part 1 of pong: header (2 bytes) + first 2 bytes of payload = 4 bytes
// This leaves 48 bytes of pong payload undelivered.
const pongPart1 = pongFrame.slice(0, 4);
// Part 2: remaining 48 bytes of pong payload
const pongPart2 = pongFrame.slice(4);
// A text message to send after the pong completes.
const textFrame = makeTextFrame("hello after pong");
// Send part 1 of pong
socket.write(pongPart1);
socket.flush();
// After a delay, send part 2 of pong + the follow-up text message
setTimeout(() => {
// Concatenate part2 + text frame to simulate them arriving together
const combined = new Uint8Array(pongPart2.length + textFrame.length);
combined.set(pongPart2);
combined.set(textFrame, pongPart2.length);
socket.write(combined);
socket.flush();
}, 50);
},
},
hostname,
port: 0,
});
const messages: string[] = [];
client = new WebSocket(`ws://${server.hostname}:${server.port}`);
client.addEventListener("error", event => {
reject(new Error("WebSocket error"));
});
client.addEventListener("close", event => {
// If the connection closes unexpectedly due to frame desync, the test should fail
reject(new Error(`WebSocket closed unexpectedly: code=${event.code} reason=${event.reason}`));
});
client.addEventListener("message", event => {
messages.push(event.data as string);
if (messages.length === 1) {
// We got the text message after the fragmented pong
try {
expect(messages[0]).toBe("hello after pong");
resolve();
} catch (err) {
reject(err);
}
}
});
await promise;
} finally {
client?.close();
server?.stop(true);
}
});
test("pong frame with payload > 125 bytes is rejected", async () => {
let server: TCPSocketListener | undefined;
let client: WebSocket | undefined;
let handshakeBuffer = new Uint8Array(0);
let handshakeComplete = false;
try {
const { promise, resolve, reject } = Promise.withResolvers<void>();
server = Bun.listen({
socket: {
data(socket, data) {
if (handshakeComplete) return;
const result = doHandshake(socket, handshakeBuffer, new Uint8Array(data));
handshakeBuffer = result.buffer;
if (!result.done) return;
handshakeComplete = true;
// Send a pong frame with a 126-byte payload (invalid per RFC 6455 Section 5.5)
// Control frames MUST have a payload length of 125 bytes or less.
// Use 2-byte extended length encoding since 126 > 125.
// But actually, the 7-bit length field in byte[1] can encode 0-125 directly.
// For 126, the server must use the extended 16-bit length.
// However, control frames with >125 payload are invalid regardless of encoding.
const pongFrame = new Uint8Array(4 + 126);
pongFrame[0] = 0x8a; // FIN + Pong
pongFrame[1] = 126; // Signals 16-bit extended length follows
pongFrame[2] = 0; // High byte of length
pongFrame[3] = 126; // Low byte of length = 126
// Fill payload with arbitrary data
for (let i = 0; i < 126; i++) pongFrame[4 + i] = 0x42;
socket.write(pongFrame);
socket.flush();
},
},
hostname,
port: 0,
});
client = new WebSocket(`ws://${server.hostname}:${server.port}`);
client.addEventListener("error", () => {
// Expected - the connection should error due to invalid control frame
resolve();
});
client.addEventListener("close", () => {
// Also acceptable - connection closes due to protocol error
resolve();
});
client.addEventListener("message", () => {
reject(new Error("Should not receive a message from an invalid pong frame"));
});
await promise;
} finally {
client?.close();
server?.stop(true);
}
});
});

View File

@@ -1,77 +0,0 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
import { join } from "path";
test("--bail writes JUnit reporter outfile", async () => {
using dir = tempDir("bail-junit", {
"fail.test.ts": `
import { test, expect } from "bun:test";
test("failing test", () => { expect(1).toBe(2); });
`,
});
const outfile = join(String(dir), "results.xml");
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "--bail", "--reporter=junit", `--reporter-outfile=${outfile}`, "fail.test.ts"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
// The test should fail and bail
expect(exitCode).not.toBe(0);
// The JUnit report file should still be written despite bail
const file = Bun.file(outfile);
expect(await file.exists()).toBe(true);
const xml = await file.text();
expect(xml).toContain("<?xml");
expect(xml).toContain("<testsuites");
expect(xml).toContain("</testsuites>");
expect(xml).toContain("failing test");
});
test("--bail writes JUnit reporter outfile with multiple files", async () => {
using dir = tempDir("bail-junit-multi", {
"a_pass.test.ts": `
import { test, expect } from "bun:test";
test("passing test", () => { expect(1).toBe(1); });
`,
"b_fail.test.ts": `
import { test, expect } from "bun:test";
test("another failing test", () => { expect(1).toBe(2); });
`,
});
const outfile = join(String(dir), "results.xml");
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "--bail", "--reporter=junit", `--reporter-outfile=${outfile}`],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const exitCode = await proc.exited;
// The test should fail and bail
expect(exitCode).not.toBe(0);
// The JUnit report file should still be written despite bail
const file = Bun.file(outfile);
expect(await file.exists()).toBe(true);
const xml = await file.text();
expect(xml).toContain("<?xml");
expect(xml).toContain("<testsuites");
expect(xml).toContain("</testsuites>");
// Both the passing and failing tests should be recorded
expect(xml).toContain("passing test");
expect(xml).toContain("another failing test");
});

View File

@@ -0,0 +1,80 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
// Test for security issue: ValkeyReader.readValue() unbounded recursion
// A malicious Redis/Valkey server can send deeply nested RESP structures
// that exhaust the call stack and crash the Bun process via stack overflow.
test("valkey RESP parser should reject deeply nested responses", async () => {
// Create a malicious server that sends deeply nested RESP arrays
// Each "*1\r\n" is a RESP array of length 1, nesting into the next level
const nestingDepth = 100_000;
using server = Bun.listen({
hostname: "127.0.0.1",
port: 0,
socket: {
open(socket) {
// Do nothing on open - wait for data
},
data(socket, data) {
const request = Buffer.from(data).toString();
// Respond to HELLO (RESP3 protocol negotiation) with a simple OK
if (request.includes("HELLO")) {
socket.write("+OK\r\n");
return;
}
// For any other command (e.g., GET), send a deeply nested RESP array
// *1\r\n repeated nestingDepth times, then a leaf value
let response = "";
for (let i = 0; i < nestingDepth; i++) {
response += "*1\r\n";
}
response += "$3\r\nfoo\r\n";
socket.write(response);
},
close() {},
error(socket, err) {},
},
});
const port = server.port;
// Use a subprocess so a crash doesn't take down the test runner
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const client = new Bun.RedisClient("redis://127.0.0.1:${port}");
try {
const result = await client.send("GET", ["test"]);
// If we get here without crashing, the parser handled it
console.log("RESULT:" + JSON.stringify(result));
} catch (e) {
console.log("ERROR:" + e.message);
} finally {
client.close();
}
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// The process should NOT crash (exit code 0 or a handled error)
// Before the fix: process crashes with stack overflow (signal 11/SIGSEGV or similar)
// After the fix: parser returns an error about nesting depth exceeded
if (exitCode !== 0) {
console.log("stdout:", stdout);
console.log("stderr:", stderr);
console.log("exitCode:", exitCode);
}
expect(stdout).toContain("ERROR:");
expect(exitCode).toBe(0);
});

View File

@@ -1,187 +0,0 @@
import { SQL } from "bun";
import { expect, test } from "bun:test";
import net from "net";
test("postgres connection rejects null bytes in username", async () => {
let serverReceivedData = false;
const server = net.createServer(socket => {
serverReceivedData = true;
socket.destroy();
});
await new Promise<void>(r => server.listen(0, "127.0.0.1", () => r()));
const port = (server.address() as net.AddressInfo).port;
try {
const sql = new SQL({
hostname: "127.0.0.1",
port,
username: "alice\x00search_path\x00evil_schema,public",
database: "testdb",
max: 1,
idleTimeout: 1,
connectionTimeout: 2,
});
await sql`SELECT 1`;
expect.unreachable();
} catch (e: any) {
expect(e.message).toContain("null bytes");
} finally {
server.close();
}
// The server should never have received any data because the null byte
// should be rejected before the connection is established.
expect(serverReceivedData).toBe(false);
});
test("postgres connection rejects null bytes in database", async () => {
let serverReceivedData = false;
const server = net.createServer(socket => {
serverReceivedData = true;
socket.destroy();
});
await new Promise<void>(r => server.listen(0, "127.0.0.1", () => r()));
const port = (server.address() as net.AddressInfo).port;
try {
const sql = new SQL({
hostname: "127.0.0.1",
port,
username: "alice",
database: "testdb\x00search_path\x00evil_schema,public",
max: 1,
idleTimeout: 1,
connectionTimeout: 2,
});
await sql`SELECT 1`;
expect.unreachable();
} catch (e: any) {
expect(e.message).toContain("null bytes");
} finally {
server.close();
}
expect(serverReceivedData).toBe(false);
});
test("postgres connection rejects null bytes in password", async () => {
let serverReceivedData = false;
const server = net.createServer(socket => {
serverReceivedData = true;
socket.destroy();
});
await new Promise<void>(r => server.listen(0, "127.0.0.1", () => r()));
const port = (server.address() as net.AddressInfo).port;
try {
const sql = new SQL({
hostname: "127.0.0.1",
port,
username: "alice",
password: "pass\x00search_path\x00evil_schema",
database: "testdb",
max: 1,
idleTimeout: 1,
connectionTimeout: 2,
});
await sql`SELECT 1`;
expect.unreachable();
} catch (e: any) {
expect(e.message).toContain("null bytes");
} finally {
server.close();
}
expect(serverReceivedData).toBe(false);
});
test("postgres connection does not use truncated path with null bytes", async () => {
// The JS layer's fs.existsSync() rejects paths containing null bytes,
// so the path is dropped before reaching the native layer. Verify that a
// path with null bytes doesn't silently connect via a truncated path.
let serverReceivedData = false;
const server = net.createServer(socket => {
serverReceivedData = true;
socket.destroy();
});
await new Promise<void>(r => server.listen(0, "127.0.0.1", () => r()));
const port = (server.address() as net.AddressInfo).port;
try {
const sql = new SQL({
hostname: "127.0.0.1",
port,
username: "alice",
database: "testdb",
path: "/tmp\x00injected",
max: 1,
idleTimeout: 1,
connectionTimeout: 2,
});
await sql`SELECT 1`;
} catch {
// Expected to fail
} finally {
server.close();
}
// The path had null bytes so it should have been dropped by the JS layer,
// falling back to TCP where it hits our mock server (not a truncated Unix socket).
expect(serverReceivedData).toBe(true);
});
test("postgres connection works with normal parameters (no null bytes)", async () => {
// Verify that normal connections without null bytes still work.
// Use a mock server that sends an auth error so we can verify the
// startup message is sent correctly.
let receivedData = false;
const server = net.createServer(socket => {
socket.once("data", () => {
receivedData = true;
const errMsg = Buffer.from("SFATAL\0VFATAL\0C28000\0Mauthentication failed\0\0");
const len = errMsg.length + 4;
const header = Buffer.alloc(5);
header.write("E", 0);
header.writeInt32BE(len, 1);
socket.write(Buffer.concat([header, errMsg]));
socket.destroy();
});
});
await new Promise<void>(r => server.listen(0, "127.0.0.1", () => r()));
const port = (server.address() as net.AddressInfo).port;
try {
const sql = new SQL({
hostname: "127.0.0.1",
port,
username: "alice",
database: "testdb",
max: 1,
idleTimeout: 1,
connectionTimeout: 2,
});
await sql`SELECT 1`;
} catch {
// Expected - mock server sends auth error
} finally {
server.close();
}
// Normal parameters should connect fine - the server should receive data
expect(receivedData).toBe(true);
});

View File

@@ -1,148 +0,0 @@
import { S3Client } from "bun";
import { describe, expect, test } from "bun:test";
// Test that CRLF characters in S3 options are rejected to prevent header injection.
// See: HTTP Header Injection via S3 Content-Disposition Value
describe("S3 header injection prevention", () => {
test("contentDisposition with CRLF should throw", () => {
using server = Bun.serve({
port: 0,
fetch() {
return new Response("OK", { status: 200 });
},
});
const client = new S3Client({
accessKeyId: "test-key",
secretAccessKey: "test-secret",
endpoint: server.url.href,
bucket: "test-bucket",
});
expect(() =>
client.write("test-file.txt", "Hello", {
contentDisposition: 'attachment; filename="evil"\r\nX-Injected: value',
}),
).toThrow(/CR\/LF/);
});
test("contentEncoding with CRLF should throw", () => {
using server = Bun.serve({
port: 0,
fetch() {
return new Response("OK", { status: 200 });
},
});
const client = new S3Client({
accessKeyId: "test-key",
secretAccessKey: "test-secret",
endpoint: server.url.href,
bucket: "test-bucket",
});
expect(() =>
client.write("test-file.txt", "Hello", {
contentEncoding: "gzip\r\nX-Injected: value",
}),
).toThrow(/CR\/LF/);
});
test("type (content-type) with CRLF should throw", () => {
using server = Bun.serve({
port: 0,
fetch() {
return new Response("OK", { status: 200 });
},
});
const client = new S3Client({
accessKeyId: "test-key",
secretAccessKey: "test-secret",
endpoint: server.url.href,
bucket: "test-bucket",
});
expect(() =>
client.write("test-file.txt", "Hello", {
type: "text/plain\r\nX-Injected: value",
}),
).toThrow(/CR\/LF/);
});
test("contentDisposition with only CR should throw", () => {
using server = Bun.serve({
port: 0,
fetch() {
return new Response("OK", { status: 200 });
},
});
const client = new S3Client({
accessKeyId: "test-key",
secretAccessKey: "test-secret",
endpoint: server.url.href,
bucket: "test-bucket",
});
expect(() =>
client.write("test-file.txt", "Hello", {
contentDisposition: "attachment\rinjected",
}),
).toThrow(/CR\/LF/);
});
test("contentDisposition with only LF should throw", () => {
using server = Bun.serve({
port: 0,
fetch() {
return new Response("OK", { status: 200 });
},
});
const client = new S3Client({
accessKeyId: "test-key",
secretAccessKey: "test-secret",
endpoint: server.url.href,
bucket: "test-bucket",
});
expect(() =>
client.write("test-file.txt", "Hello", {
contentDisposition: "attachment\ninjected",
}),
).toThrow(/CR\/LF/);
});
test("valid contentDisposition without CRLF should not throw", async () => {
const { promise: requestReceived, resolve: onRequestReceived } = Promise.withResolvers<Headers>();
using server = Bun.serve({
port: 0,
async fetch(req) {
onRequestReceived(req.headers);
return new Response("OK", { status: 200 });
},
});
const client = new S3Client({
accessKeyId: "test-key",
secretAccessKey: "test-secret",
endpoint: server.url.href,
bucket: "test-bucket",
});
// Valid content-disposition values should not throw synchronously.
// The write may eventually fail because the mock server doesn't speak S3 protocol,
// but the option parsing should succeed and a request should be initiated.
expect(() =>
client.write("test-file.txt", "Hello", {
contentDisposition: 'attachment; filename="report.pdf"',
}),
).not.toThrow();
const receivedHeaders = await requestReceived;
expect(receivedHeaders.get("content-disposition")).toBe('attachment; filename="report.pdf"');
});
});