Compare commits

...

4 Commits

Author SHA1 Message Date
Claude Bot
38170d7493 fix: use correct stdio index for Blob redirect targets in shell
Fix copy-paste bug where stdout and stderr Blob redirects were
incorrectly indexing spawn_args.stdio[stdin_no] instead of the
correct stdout_no/stderr_no index, causing the redirect to modify
the stdin slot instead of the intended one.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-28 09:30:22 +00:00
Claude Bot
c7cfb0cf5d fix: propagate jsobjs_len to sublexers in shell
The make_sublexer function was not copying jsobjs_len from the parent
lexer, causing sublexers to default to 0 and trigger false out-of-bounds
errors for JS object references inside subshells.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-28 09:19:53 +00:00
Jarred Sumner
47cd6a2ca5 Merge branch 'main' into claude/harden-shell-interpolation 2026-02-27 22:07:56 -08:00
Claude Bot
0b62be630f harden shell interpolation and object reference validation
- Add the internal sentinel byte (0x08) to SPECIAL_CHARS so that
  user-supplied strings containing it are stored out-of-band rather
  than being appended raw to the pre-lex script buffer
- Fix validateJSObjRefIdx to bounds-check against the actual jsobjs
  array length instead of only checking maxInt(u32), matching the
  existing behavior of validateJSStringRefIdx
- Add defense-in-depth bounds checks in Cmd.zig and Builtin.zig
  before indexing into the jsobjs array

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-27 06:01:54 +00:00
5 changed files with 85 additions and 9 deletions

View File

@@ -510,6 +510,12 @@ fn initRedirections(
},
.jsbuf => |val| {
const globalObject = interpreter.event_loop.js.global;
if (file.jsbuf.idx >= interpreter.jsobjs.len) {
globalObject.throw("Invalid JS object reference in shell", .{}) catch {};
return .failed;
}
if (interpreter.jsobjs[file.jsbuf.idx].asArrayBuffer(globalObject)) |buf| {
const arraybuf: BuiltinIO.ArrayBuf = .{ .buf = jsc.ArrayBuffer.Strong{
.array_buffer = buf,

View File

@@ -792,13 +792,14 @@ pub const Interpreter = struct {
out_parser: *?bun.shell.Parser,
out_lex_result: *?shell.LexResult,
) !ast.Script {
const jsobjs_len: u32 = @intCast(jsobjs.len);
const lex_result = brk: {
if (bun.strings.isAllASCII(script)) {
var lexer = bun.shell.LexerAscii.new(arena_allocator, script, jsstrings_to_escape);
var lexer = bun.shell.LexerAscii.new(arena_allocator, script, jsstrings_to_escape, jsobjs_len);
try lexer.lex();
break :brk lexer.get_result();
}
var lexer = bun.shell.LexerUnicode.new(arena_allocator, script, jsstrings_to_escape);
var lexer = bun.shell.LexerUnicode.new(arena_allocator, script, jsstrings_to_escape, jsobjs_len);
try lexer.lex();
break :brk lexer.get_result();
};

View File

@@ -2334,6 +2334,9 @@ pub fn NewLexer(comptime encoding: StringEncoding) type {
/// Not owned by this struct
string_refs: []bun.String,
/// Number of JS object references expected (for bounds validation)
jsobjs_len: u32 = 0,
const SubShellKind = enum {
/// (echo hi; echo hello)
normal,
@@ -2363,13 +2366,14 @@ pub fn NewLexer(comptime encoding: StringEncoding) type {
delimit_quote: bool,
};
pub fn new(alloc: Allocator, src: []const u8, strings_to_escape: []bun.String) @This() {
pub fn new(alloc: Allocator, src: []const u8, strings_to_escape: []bun.String, jsobjs_len: u32) @This() {
return .{
.chars = Chars.init(src),
.tokens = ArrayList(Token).init(alloc),
.strpool = ArrayList(u8).init(alloc),
.errors = ArrayList(LexError).init(alloc),
.string_refs = strings_to_escape,
.jsobjs_len = jsobjs_len,
};
}
@@ -2400,6 +2404,7 @@ pub fn NewLexer(comptime encoding: StringEncoding) type {
.word_start = self.word_start,
.j = self.j,
.string_refs = self.string_refs,
.jsobjs_len = self.jsobjs_len,
};
sublexer.chars.state = .Normal;
return sublexer;
@@ -3358,7 +3363,7 @@ pub fn NewLexer(comptime encoding: StringEncoding) type {
}
fn validateJSObjRefIdx(self: *@This(), idx: usize) bool {
if (idx >= std.math.maxInt(u32)) {
if (idx >= self.jsobjs_len) {
self.add_error("Invalid JS object ref (out of bounds)");
return false;
}
@@ -4129,7 +4134,7 @@ pub const ShellSrcBuilder = struct {
};
/// Characters that need to escaped
const SPECIAL_CHARS = [_]u8{ '~', '[', ']', '#', ';', '\n', '*', '{', ',', '}', '`', '$', '=', '(', ')', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '|', '>', '<', '&', '\'', '"', ' ', '\\' };
const SPECIAL_CHARS = [_]u8{ '~', '[', ']', '#', ';', '\n', '*', '{', ',', '}', '`', '$', '=', '(', ')', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '|', '>', '<', '&', '\'', '"', ' ', '\\', SPECIAL_JS_CHAR };
const SPECIAL_CHARS_TABLE: bun.bit_set.IntegerBitSet(256) = brk: {
var table = bun.bit_set.IntegerBitSet(256).initEmpty();
for (SPECIAL_CHARS) |c| {
@@ -4554,15 +4559,16 @@ pub const TestingAPIs = struct {
var script = std.array_list.Managed(u8).init(arena.allocator());
try shellCmdFromJS(globalThis, string_args, &template_args, &jsobjs, &jsstrings, &script, marked_argument_buffer);
const jsobjs_len: u32 = @intCast(jsobjs.items.len);
const lex_result = brk: {
if (bun.strings.isAllASCII(script.items[0..])) {
var lexer = LexerAscii.new(arena.allocator(), script.items[0..], jsstrings.items[0..]);
var lexer = LexerAscii.new(arena.allocator(), script.items[0..], jsstrings.items[0..], jsobjs_len);
lexer.lex() catch |err| {
return globalThis.throwError(err, "failed to lex shell");
};
break :brk lexer.get_result();
}
var lexer = LexerUnicode.new(arena.allocator(), script.items[0..], jsstrings.items[0..]);
var lexer = LexerUnicode.new(arena.allocator(), script.items[0..], jsstrings.items[0..], jsobjs_len);
lexer.lex() catch |err| {
return globalThis.throwError(err, "failed to lex shell");
};

View File

@@ -556,6 +556,10 @@ fn initRedirections(this: *Cmd, spawn_args: *Subprocess.SpawnArgs) bun.JSError!?
if (this.base.eventLoop() != .js) @panic("JS values not allowed in this context");
const global = this.base.eventLoop().js.global;
if (val.idx >= this.base.interpreter.jsobjs.len) {
return global.throw("Invalid JS object reference in shell", .{});
}
if (this.base.interpreter.jsobjs[val.idx].asArrayBuffer(global)) |buf| {
const stdio: bun.shell.subproc.Stdio = .{ .array_buffer = jsc.ArrayBuffer.Strong{
.array_buffer = buf,
@@ -568,9 +572,9 @@ fn initRedirections(this: *Cmd, spawn_args: *Subprocess.SpawnArgs) bun.JSError!?
if (this.node.redirect.stdin) {
try spawn_args.stdio[stdin_no].extractBlob(global, .{ .Blob = blob }, stdin_no);
} else if (this.node.redirect.stdout) {
try spawn_args.stdio[stdin_no].extractBlob(global, .{ .Blob = blob }, stdout_no);
try spawn_args.stdio[stdout_no].extractBlob(global, .{ .Blob = blob }, stdout_no);
} else if (this.node.redirect.stderr) {
try spawn_args.stdio[stdin_no].extractBlob(global, .{ .Blob = blob }, stderr_no);
try spawn_args.stdio[stderr_no].extractBlob(global, .{ .Blob = blob }, stderr_no);
}
} else if (try jsc.WebCore.ReadableStream.fromJS(this.base.interpreter.jsobjs[val.idx], global)) |rstream| {
_ = rstream;

View File

@@ -0,0 +1,59 @@
import { $ } from "bun";
import { describe, expect, test } from "bun:test";
import { createTestBuilder } from "./util";
const TestBuilder = createTestBuilder(import.meta.path);
describe("shell sentinel character hardening", () => {
test("strings containing \\x08 are properly escaped in interpolation", async () => {
// The internal sentinel byte (0x08, backspace) should be treated as a
// special character so that user-supplied strings containing it are
// stored out-of-band rather than appended raw to the script buffer.
const str = "\x08__bun_0";
const { stdout } = await $`echo ${str}`;
// The string should round-trip through the shell correctly
expect(stdout.toString()).toEqual(`${str}\n`);
});
test("strings with sentinel byte followed by digits are escaped", async () => {
const str = "\x08__bun_2024";
const { stdout } = await $`echo ${str}`;
expect(stdout.toString()).toEqual(`${str}\n`);
});
test("sentinel byte in redirect position does not cause out-of-bounds", async () => {
// Ensure that a string containing the sentinel pattern in redirect
// position produces an error rather than an out-of-bounds access.
const malicious = "\x08__bun_9999";
try {
await $`echo hello > ${malicious}`;
} catch {
// An error is acceptable — the important thing is no crash / no OOB access
}
// If we get here without a crash, the hardening is working
expect(true).toBe(true);
});
test("$.escape handles sentinel byte", () => {
const str = "\x08__bun_42";
const escaped = $.escape(str);
// The escaped string should be safe to use and should be quoted/escaped
expect(escaped).toBeDefined();
expect(typeof escaped).toBe("string");
});
test("plain \\x08 in string is properly handled", async () => {
const str = "hello\x08world";
const { stdout } = await $`echo ${str}`;
expect(stdout.toString()).toEqual(`${str}\n`);
});
test("interpolated string with sentinel pattern echoes correctly", async () => {
// Ensure that even strings that look like internal object references
// are treated as literal strings when they come from interpolation.
const parts = ["\x08__bun_", "0", "\x08__bunstr_", "1"];
for (const part of parts) {
const { stdout } = await $`echo ${part}`;
expect(stdout.toString()).toEqual(`${part}\n`);
}
});
});