diff --git a/src/bun.js/api/bun/js_bun_spawn_bindings.zig b/src/bun.js/api/bun/js_bun_spawn_bindings.zig index 7010c9f7e2..45c33a5a19 100644 --- a/src/bun.js/api/bun/js_bun_spawn_bindings.zig +++ b/src/bun.js/api/bun/js_bun_spawn_bindings.zig @@ -6,6 +6,11 @@ fn getArgv0(globalThis: *jsc.JSGlobalObject, PATH: []const u8, cwd: []const u8, } { var arg0 = try first_cmd.toSliceOrNullWithAllocator(globalThis, allocator); defer arg0.deinit(); + + // Check for null bytes in command (security: prevent null byte injection) + if (strings.indexOfChar(arg0.slice(), 0) != null) { + return globalThis.ERR(.INVALID_ARG_VALUE, "The argument 'args[0]' must be a string without null bytes. Received {f}", .{bun.fmt.quote(arg0.slice())}).throw(); + } // Heap allocate it to ensure we don't run out of stack space. const path_buf: *bun.PathBuffer = try bun.default_allocator.create(bun.PathBuffer); defer bun.default_allocator.destroy(path_buf); @@ -63,11 +68,18 @@ fn getArgv(globalThis: *jsc.JSGlobalObject, args: JSValue, PATH: []const u8, cwd argv0.* = argv0_result.argv0.ptr; argv.appendAssumeCapacity(argv0_result.arg0.ptr); + var arg_index: usize = 1; while (try cmds_array.next()) |value| { const arg = try value.toBunString(globalThis); defer arg.deref(); + // Check for null bytes in argument (security: prevent null byte injection) + if (arg.indexOfAsciiChar(0) != null) { + return globalThis.ERR(.INVALID_ARG_VALUE, "The argument 'args[{d}]' must be a string without null bytes. Received \"{f}\"", .{ arg_index, arg.toZigString() }).throw(); + } + argv.appendAssumeCapacity(try arg.toOwnedSliceZ(allocator)); + arg_index += 1; } if (argv.items.len == 0) { @@ -1063,7 +1075,18 @@ pub fn appendEnvpFromJS(globalThis: *jsc.JSGlobalObject, object: *jsc.JSObject, var value = object_iter.value; if (value.isUndefined()) continue; - const line = try std.fmt.allocPrintSentinel(envp.allocator, "{f}={f}", .{ key, try value.getZigString(globalThis) }, 0); + const value_bunstr = try value.toBunString(globalThis); + defer value_bunstr.deref(); + + // Check for null bytes in env key and value (security: prevent null byte injection) + if (key.indexOfAsciiChar(0) != null) { + return globalThis.ERR(.INVALID_ARG_VALUE, "The property 'options.env['{f}']' must be a string without null bytes. Received \"{f}\"", .{ key.toZigString(), key.toZigString() }).throw(); + } + if (value_bunstr.indexOfAsciiChar(0) != null) { + return globalThis.ERR(.INVALID_ARG_VALUE, "The property 'options.env['{f}']' must be a string without null bytes. Received \"{f}\"", .{ key.toZigString(), value_bunstr.toZigString() }).throw(); + } + + const line = try std.fmt.allocPrintSentinel(envp.allocator, "{f}={f}", .{ key, value_bunstr.toZigString() }, 0); if (key.eqlComptime("PATH")) { PATH.* = bun.asByteSlice(line["PATH=".len..]); diff --git a/src/shell/shell.zig b/src/shell/shell.zig index 8243f63bb6..92bf54c9cb 100644 --- a/src/shell/shell.zig +++ b/src/shell/shell.zig @@ -3918,6 +3918,12 @@ pub fn handleTemplateValue( if (store.data == .file) { if (store.data.file.pathlike == .path) { const path = store.data.file.pathlike.path.slice(); + + // Check for null bytes in path (security: prevent null byte injection) + if (bun.strings.indexOfChar(path, 0) != null) { + return globalThis.ERR(.INVALID_ARG_VALUE, "The shell argument must be a string without null bytes. Received {f}", .{bun.fmt.quote(path)}).throw(); + } + if (!try builder.appendUTF8(path, true)) { return globalThis.throw("Shell script string contains invalid UTF-16", .{}); } @@ -3983,6 +3989,12 @@ pub fn handleTemplateValue( if (try template_value.getOwnTruthy(globalThis, "raw")) |maybe_str| { const bunstr = try maybe_str.toBunString(globalThis); defer bunstr.deref(); + + // Check for null bytes in shell argument (security: prevent null byte injection) + if (bunstr.indexOfAsciiChar(0) != null) { + return globalThis.ERR(.INVALID_ARG_VALUE, "The shell argument must be a string without null bytes. Received \"{f}\"", .{bunstr.toZigString()}).throw(); + } + if (!try builder.appendBunStr(bunstr, false)) { return globalThis.throw("Shell script string contains invalid UTF-16", .{}); } @@ -4032,6 +4044,11 @@ pub const ShellSrcBuilder = struct { const bunstr = try jsval.toBunString(this.globalThis); defer bunstr.deref(); + // Check for null bytes in shell argument (security: prevent null byte injection) + if (bunstr.indexOfAsciiChar(0) != null) { + return this.globalThis.ERR(.INVALID_ARG_VALUE, "The shell argument must be a string without null bytes. Received \"{f}\"", .{bunstr.toZigString()}).throw(); + } + return try this.appendBunStr(bunstr, allow_escape); } diff --git a/test/js/bun/spawn/null-byte-injection.test.ts b/test/js/bun/spawn/null-byte-injection.test.ts new file mode 100644 index 0000000000..e5a58ac92a --- /dev/null +++ b/test/js/bun/spawn/null-byte-injection.test.ts @@ -0,0 +1,125 @@ +import { $ } from "bun"; +import { describe, expect, test } from "bun:test"; + +describe("null byte injection protection", () => { + describe("Bun.spawn", () => { + test("throws error when command contains null byte", async () => { + const command = "echo\0evil"; + expect(() => { + Bun.spawn([command]); + }).toThrow(/must be a string without null bytes/); + }); + + test("throws error when argument contains null byte", async () => { + const arg = "x.html\0.txt"; + expect(() => { + Bun.spawn(["echo", arg]); + }).toThrow(/must be a string without null bytes/); + }); + + test("throws error with ERR_INVALID_ARG_VALUE code for args with null byte", async () => { + const arg = "test\0value"; + try { + Bun.spawn(["echo", arg]); + expect.unreachable(); + } catch (e: any) { + expect(e.code).toBe("ERR_INVALID_ARG_VALUE"); + expect(e.message).toMatch(/args\[1\]/); + expect(e.message).toMatch(/must be a string without null bytes/); + } + }); + + test("throws error for null byte in env key", async () => { + expect(() => { + Bun.spawn(["echo", "hello"], { + env: { + "MY\0VAR": "value", + }, + }); + }).toThrow(/must be a string without null bytes/); + }); + + test("throws error for null byte in env value", async () => { + expect(() => { + Bun.spawn(["echo", "hello"], { + env: { + MY_VAR: "val\0ue", + }, + }); + }).toThrow(/must be a string without null bytes/); + }); + + test("works normally with valid arguments", async () => { + await using proc = Bun.spawn(["echo", "hello"], { stdout: "pipe" }); + const stdout = await new Response(proc.stdout).text(); + expect(stdout.trim()).toBe("hello"); + expect(await proc.exited).toBe(0); + }); + + test("works with spread process.env", async () => { + await using proc = Bun.spawn(["echo", "hello"], { + env: { ...process.env }, + stdout: "pipe", + }); + const stdout = await new Response(proc.stdout).text(); + expect(stdout.trim()).toBe("hello"); + expect(await proc.exited).toBe(0); + }); + }); + + describe("Bun.spawnSync", () => { + test("throws error when command contains null byte", () => { + const command = "echo\0evil"; + expect(() => { + Bun.spawnSync([command]); + }).toThrow(/must be a string without null bytes/); + }); + + test("throws error when argument contains null byte", () => { + const arg = "x.html\0.txt"; + expect(() => { + Bun.spawnSync(["echo", arg]); + }).toThrow(/must be a string without null bytes/); + }); + + test("works normally with valid arguments", () => { + const result = Bun.spawnSync(["echo", "hello"]); + expect(result.stdout.toString().trim()).toBe("hello"); + expect(result.exitCode).toBe(0); + }); + }); + + describe("Shell ($)", () => { + test("throws error when interpolated string contains null byte", () => { + const name = "x.html\0.txt"; + expect(() => $`echo ${name}`).toThrow(/must be a string without null bytes/); + }); + + test("throws error with ERR_INVALID_ARG_VALUE code for shell args with null byte", () => { + const arg = "test\0value"; + try { + $`echo ${arg}`; + expect.unreachable(); + } catch (e: any) { + expect(e.code).toBe("ERR_INVALID_ARG_VALUE"); + expect(e.message).toMatch(/must be a string without null bytes/); + } + }); + + test("throws error when array element contains null byte", () => { + const args = ["valid", "x\0y", "also valid"]; + expect(() => $`echo ${args}`).toThrow(/must be a string without null bytes/); + }); + + test("throws error when object with raw property contains null byte", () => { + const raw = { raw: "test\0value" }; + expect(() => $`echo ${raw}`).toThrow(/must be a string without null bytes/); + }); + + test("works normally with valid arguments", async () => { + const name = "hello.txt"; + const result = await $`echo ${name}`.text(); + expect(result.trim()).toBe("hello.txt"); + }); + }); +});