Skip to content
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
74a3b43
node:http: tighten host option validation
Jarred-Sumner May 29, 2026
fd804a3
debugger: tighten inspector event validation
Jarred-Sumner May 29, 2026
0c327b7
v8: bound string length handling
Jarred-Sumner May 29, 2026
bc04040
debug-adapter: bound signal listener handling
Jarred-Sumner May 29, 2026
0cda3fd
node:http2: tighten upgrade option handling
Jarred-Sumner May 29, 2026
9f11fe1
test: add regression coverage for input validation changes
Jarred-Sumner May 29, 2026
2a18007
install: gate cache index creation on safe folder names
Jarred-Sumner May 29, 2026
c313159
install: validate git dependency tags when loading bun.lockb
Jarred-Sumner May 29, 2026
5daffe4
webview: create the default chrome profile dir privately
Jarred-Sumner May 29, 2026
9786c09
compile: bounds-check macho segment offsets before patching
Jarred-Sumner May 29, 2026
860577b
fs: tighten read argument handling
Jarred-Sumner May 29, 2026
97adcbf
fs: hold buffer path arguments across async ops
Jarred-Sumner May 29, 2026
f5409ee
s3: tighten multipart upload id validation
Jarred-Sumner May 29, 2026
8db23c4
path: size format scratch buffer correctly
Jarred-Sumner May 29, 2026
6c1ffb2
test: add regression coverage for input validation changes
Jarred-Sumner May 29, 2026
99b6a0a
test: add regression coverage for input validation changes
Jarred-Sumner May 29, 2026
c545eb6
Merge branch 'claude/security-round-8-shard-1' into claude/security-r…
Jarred-Sumner May 29, 2026
e79d0e0
Merge branch 'claude/security-round-8-shard-2' into claude/security-r…
Jarred-Sumner May 29, 2026
342d39d
Merge branch 'claude/security-round-8-shard-3' into claude/security-r…
Jarred-Sumner May 29, 2026
ae77ef3
[autofix.ci] apply automated fixes
autofix-ci[bot] May 29, 2026
7bee3c7
node:fs: capture the read destination buffer once after argument coer…
Jarred-Sumner May 29, 2026
d342992
node:fs: convert buffer path arguments in a single pass
Jarred-Sumner May 29, 2026
07eabcc
install: move resolved tag validation into the lockfile deserializer …
Jarred-Sumner May 29, 2026
4e2b03a
test: split addon output on CRLF as well as LF
Jarred-Sumner May 29, 2026
f082d95
http: include hostname on synthetic lookup errors
Jarred-Sumner May 29, 2026
002e8f0
debug-adapter: let TCPSocketSignal report the OS-assigned port
Jarred-Sumner May 29, 2026
077ee84
test: tidy lockb git fixture port and assert exit code
Jarred-Sumner May 29, 2026
7a35ffa
install tests: make offline git fixtures fail fast over ssh
Jarred-Sumner May 29, 2026
bd18e22
Merge branch 'main' into claude/security-round-8
Jarred-Sumner May 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion packages/bun-debug-adapter-protocol/src/debugger/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,26 @@ export type DebugAdapterEventMap = InspectorEventMap & {
const isDebug = process.env.NODE_ENV === "development";
const debugSilentEvents = new Set(["Adapter.event", "Inspector.event"]);

const inspectorEventDomains = new Set([
"Audit",
"Console",
"Debugger",
"Heap",
"Inspector",
"LifecycleReporter",
"Runtime",
"ScriptProfiler",
"TestReporter",
]);

function isInspectorEvent(event: unknown): boolean {
if (typeof event !== "string") {
return false;
}
const dot = event.indexOf(".");
return dot !== -1 && inspectorEventDomains.has(event.slice(0, dot));
}

let threadId = 1;

// Add these helper functions at the top level
Expand Down Expand Up @@ -278,7 +298,9 @@ export abstract class BaseDebugAdapter<T extends Inspector = Inspector>
this.inspector.emit = (event, ...args) => {
let sent = false;
sent ||= emit(event, ...args);
sent ||= this.emit(event as keyof JSC.EventMap, ...(args as any));
if (isInspectorEvent(event)) {
sent ||= this.emit(event as keyof JSC.EventMap, ...(args as any));
}
return sent;
};
this.#sourceId = 1;
Expand Down
6 changes: 5 additions & 1 deletion packages/bun-debug-adapter-protocol/src/debugger/signal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,11 @@ export class TCPSocketSignal extends EventEmitter {
});

this.#ready = new Promise((resolve, reject) => {
this.#server.listen(this.#port, () => {
this.#server.listen(this.#port, "127.0.0.1", () => {
const address = this.#server.address();
if (address && typeof address === "object") {
this.#port = address.port;
}
this.emit("Signal.listening");
resolve();
});
Expand Down
83 changes: 83 additions & 0 deletions packages/bun-debug-adapter-protocol/src/debugger/sourcemap.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { connect } from "node:net";
import { networkInterfaces } from "node:os";
import { WebSocketDebugAdapter } from "./adapter.js";
import { TCPSocketSignal } from "./signal.js";
import { SourceMap } from "./sourcemap.js";

test("works without source map", () => {
Expand Down Expand Up @@ -29,3 +33,82 @@ function getSourceMap(filename: string): SourceMap {
}
return SourceMap();
}

test("only forwards inspector events from known protocol domains to the adapter", () => {
const adapter = new WebSocketDebugAdapter();

// Replace the launch request handler so a (wrongly) forwarded event is observable
// without spawning any process.
const launchCalls: unknown[][] = [];
(adapter as any).launch = (...args: unknown[]) => {
launchCalls.push(args);
};

const inspector = adapter.getInspector();

// The WebSocket inspector re-emits any message without an "id" using the method name
// chosen by the remote peer. An event named after a DAP request must not be forwarded
// to the adapter, where it would be dispatched to the matching request handler.
(inspector as any).emit("launch", {
runtime: "/bin/sh",
runtimeArgs: ["-c", "echo unexpected"],
program: "example.js",
});
expect(launchCalls).toHaveLength(0);

// A genuine inspector-domain event still reaches listeners registered on the adapter.
const heapEvents: unknown[] = [];
adapter.on("Heap.garbageCollected", event => {
heapEvents.push(event);
});
(inspector as any).emit("Heap.garbageCollected", {
collection: { type: "full", startTime: 0, endTime: 1 },
});
expect(heapEvents).toEqual([{ collection: { type: "full", startTime: 0, endTime: 1 } }]);
});

test("TCPSocketSignal accepts connections only on the loopback interface", async () => {
// Same construction the VS Code extension uses (diagnostics.ts createSignal).
const signal = new TCPSocketSignal(0);
await signal.ready;
const port = signal.port;

try {
// The legitimate local client connects over loopback and its payload is delivered.
const received = new Promise<string>(resolve => signal.once("Signal.received", resolve));
await new Promise<void>((resolve, reject) => {
const client = connect({ host: "127.0.0.1", port }, () => {
client.end("hello");
resolve();
});
client.on("error", reject);
});
expect(await received).toBe("hello");

// The same port is not reachable through a non-loopback interface address.
let external: string | undefined;
for (const addresses of Object.values(networkInterfaces())) {
for (const { family, internal, address } of addresses ?? []) {
if (family === "IPv4" && !internal) {
external = address;
break;
}
}
if (external) break;
}

if (external) {
const externalHost = external;
const connectError = await new Promise<Error | null>(resolve => {
const client = connect({ host: externalHost, port }, () => {
client.end();
resolve(null);
});
client.on("error", error => resolve(error));
});
expect(connectError).not.toBeNull();
}
} finally {
signal.close();
}
});
30 changes: 20 additions & 10 deletions src/exe_format/macho.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,10 @@ impl MachoFile {
found_bun = true;
original_fileoff = sect.offset as u64;
let original_vmaddr = sect.addr;
original_data_end = command.fileoff + command.filesize;
original_data_end = command
.fileoff
.checked_add(command.filesize)
.ok_or(MachoError::OffsetOverflow)?;
original_segsize = command.filesize;
self.segment = command;
self.section = sect;
Expand Down Expand Up @@ -207,6 +210,15 @@ impl MachoFile {
let mut sig_size: usize = 0;

let prev_len = self.data.len();
let original_bun_end = usize::try_from(original_fileoff)
.ok()
.and_then(|off| off.checked_add(usize::try_from(original_segsize).ok()?))
.ok_or(MachoError::OffsetOverflow)?;
let original_data_end =
usize::try_from(original_data_end).map_err(|_| MachoError::OffsetOverflow)?;
if original_bun_end > prev_len || original_data_end > original_bun_end {
return Err(MachoError::OffsetOutOfRange);
}
// SAFETY: we just reserved `size_diff` bytes; new_len <= capacity. The newly-exposed bytes
// are written below before being read (memmove + memset cover the whole range).
unsafe {
Expand All @@ -217,19 +229,17 @@ impl MachoFile {
// Binary is:
// [header][...data before __BUN][__BUN][...data after __BUN]
// We need to shift [...data after __BUN] forward by size_diff bytes.
// SAFETY: source and destination overlap; ptr::copy (memmove) handles this. Ranges are
// within self.data per the offset arithmetic above.
// SAFETY: source and destination overlap; ptr::copy (memmove) handles this.
// `original_bun_end <= prev_len` and `original_data_end <= original_bun_end` were
// checked above, so the source range stays within the previously-initialized bytes
// and the destination range stays within the new length `prev_len + size_diff`.
unsafe {
let after_bun_dst = self
.data
.as_mut_ptr()
.add((original_data_end as usize) + usize::try_from(size_diff).expect("int cast"));
let prev_after_bun_src = self
.data
.as_ptr()
.add(original_fileoff as usize + original_segsize as usize);
let prev_after_bun_len =
prev_len - (original_fileoff as usize + original_segsize as usize);
.add(original_data_end + usize::try_from(size_diff).expect("int cast"));
let prev_after_bun_src = self.data.as_ptr().add(original_bun_end);
let prev_after_bun_len = prev_len - original_bun_end;
core::ptr::copy(prev_after_bun_src, after_bun_dst, prev_after_bun_len);
}

Expand Down
4 changes: 3 additions & 1 deletion src/install/extract_tarball.rs
Original file line number Diff line number Diff line change
Expand Up @@ -849,7 +849,9 @@ impl ExtractTarball {
.unwrap_or(false)
{
// create an index storing each version of a package installed
if strings::index_of_char(basename, b'/').is_none() {
if strings::index_of_char(basename, b'/').is_none()
&& bun_install::dependency::is_safe_install_folder_name(name)
{
'create_index: {
let dest_name: &[u8] = match self.resolution.tag {
ResolutionTag::Github => &folder_name[b"@GH@".len()..],
Expand Down
15 changes: 15 additions & 0 deletions src/install/lockfile/bun.lockb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -786,6 +786,21 @@ pub(crate) fn load(
let resolution = lockfile.packages.items_resolution()[id];
lockfile.get_or_put_id(id as PackageID, name_hash)?;

if matches!(resolution.tag, ResolutionTag::Git | ResolutionTag::Github) {
let resolved = lockfile.str(&resolution.repository().resolved);
if !resolved.is_empty() && !crate::repository::is_safe_resolved_tag(resolved) {
log.add_error_fmt(
None,
bun_ast::Loc::EMPTY,
format_args!(
"Invalid git dependency tag \"{}\" in bun.lockb",
bstr::BStr::new(resolved)
),
);
return Err(bun_core::err!("InvalidLockfile"));
}
}

// compatibility with < Bun v1.0.4
#[allow(clippy::single_match)]
match resolution.tag {
Expand Down
2 changes: 2 additions & 0 deletions src/js/node/_http2_upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,8 @@ function upgradeRawSocketToH2(
cert: server.cert,
ca: server.ca,
passphrase: server.passphrase,
requestCert: server._requestCert,
rejectUnauthorized: server._rejectUnauthorized,
ALPNProtocols: server.ALPNProtocols
? server.ALPNProtocols.buffer.slice(
server.ALPNProtocols.byteOffset,
Expand Down
10 changes: 10 additions & 0 deletions src/js/node/_http_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const { OutgoingMessage } = require("node:_http_outgoing");
const globalReportError = globalThis.reportError;
const setTimeout = globalThis.setTimeout;
const INVALID_PATH_REGEX = /[^\u0021-\u00ff]/;
const INVALID_HOST_CHAR_REGEX = /[/\\?#@\t\n\r]/;

const { URL } = globalThis;

Expand Down Expand Up @@ -527,6 +528,15 @@ function ClientRequest(input, options, cb) {

if (isIP(host) || !options.lookup) {
// Don't need to bother with lookup if it's already an IP address or no lookup function is provided.
if (RegExpPrototypeExec.$call(INVALID_HOST_CHAR_REGEX, host) !== null) {
const error = new Error(`getaddrinfo ENOTFOUND ${host}`);
error.name = "DNSException";
error.code = "ENOTFOUND";
error.syscall = "getaddrinfo";
error.hostname = host;
process.nextTick((self, err) => self.emit("error", err), this, error);
return false;
}
Comment thread
Jarred-Sumner marked this conversation as resolved.
const [url, proxy] = getURL(host);
go(url, proxy, false);
return true;
Expand Down
8 changes: 4 additions & 4 deletions src/jsc/bindings/v8/V8String.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,11 @@ int String::Utf8Length(Isolate* isolate) const
if (str->is8Bit()) {
const auto span = str->span8();
size_t len = simdutf::utf8_length_from_latin1(reinterpret_cast<const char*>(span.data()), span.size());
return static_cast<int>(len);
return static_cast<int>(std::min(len, static_cast<size_t>(std::numeric_limits<int>::max())));
} else {
const auto span = str->span16();
size_t len = simdutf::utf8_length_from_utf16(span.data(), span.size());
return static_cast<int>(len);
return static_cast<int>(std::min(len, static_cast<size_t>(std::numeric_limits<int>::max())));
}
}

Expand Down Expand Up @@ -162,7 +162,7 @@ int String::WriteUtf8(Isolate* isolate, char* buffer, int length, int* nchars_re
auto jsString = localToObjectPointer<JSString>();
WTF::String string = jsString->getString(isolate->globalObject());

size_t unsigned_length = length < 0 ? SIZE_MAX : length;
size_t unsigned_length = length < 0 ? static_cast<size_t>(std::numeric_limits<int>::max()) : static_cast<size_t>(length);

uint64_t result = string.is8Bit() ? TextEncoder__encodeInto8(string.span8().data(), string.span8().size(), buffer, unsigned_length)
: TextEncoder__encodeInto16(string.span16().data(), string.span16().size(), buffer, unsigned_length);
Expand All @@ -173,7 +173,7 @@ int String::WriteUtf8(Isolate* isolate, char* buffer, int length, int* nchars_re
buffer[written] = 0;
written++;
}
if (read < string.length() && U16_IS_SURROGATE(string[read]) && written + 3 <= length) {
if (read < string.length() && U16_IS_SURROGATE(string[read]) && written + 3 <= unsigned_length) {
// encode unpaired surrogate
char16_t surrogate = string[read];
buffer[written + 0] = 0xe0 | (surrogate >> 12);
Expand Down
10 changes: 7 additions & 3 deletions src/jsc/node_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,9 +141,13 @@ impl Clone for PathLike {
impl Drop for PathLike {
fn drop(&mut self) {
match self {
// `PathString` is a borrowed (ptr,len) pair; `MarkedArrayBuffer`
// is JS-GC-owned. Neither needs an explicit release here.
Self::String(_) | Self::Buffer(_) => {}
Self::String(_) => {}
Self::Buffer(b) => {
if b.pinned {
b.pinned = false;
b.buffer.unpin();
}
}
Self::SliceWithUnderlyingString(s) | Self::ThreadsafeString(s) => {
core::mem::take(s).deinit();
}
Expand Down
6 changes: 3 additions & 3 deletions src/runtime/node/node_fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3971,9 +3971,6 @@ pub mod args {
let buffer_value = arguments.next_eat().ok_or_else(||
// theoretically impossible, argument has been passed already
ctx.throw_invalid_arguments(format_args!("buffer is required")))?;
let buffer = Buffer::from_js(ctx, buffer_value).ok_or_else(|| {
ctx.throw_invalid_argument_type_value(b"buffer", b"TypedArray", buffer_value)
})?;

let offset_value = arguments.next_eat().unwrap_or(JSValue::NULL);
// if (offset == null) {
Expand All @@ -4000,6 +3997,9 @@ pub mod args {
} else {
0.0
};
let buffer = Buffer::from_js(ctx, buffer_value).ok_or_else(|| {
ctx.throw_invalid_argument_type_value(b"buffer", b"TypedArray", buffer_value)
})?;

// if (length === 0) {
// return process.nextTick(function tick() {
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/node/path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1211,7 +1211,7 @@ pub(crate) fn format_js_t<T: PathCharCwd>(
}) + (if base_len > 0 {
base_len
} else {
path_object.name.len() + path_object.ext.len()
path_object.name.len() + path_object.ext.len() + 1
}))
.max(path_size::<T>());
let mut scratch = PathScratch::<T>::new(pool, buf_len);
Expand Down
36 changes: 30 additions & 6 deletions src/runtime/node/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1216,18 +1216,42 @@ impl PathLikeExt for PathLike {
use jsc::JSType;
match arg.js_type() {
JSType::Uint8Array | JSType::DataView => {
let buffer = Buffer::from_typed_array(ctx, arg);
Valid::path_buffer(&buffer, ctx)?;
Valid::path_null_bytes(buffer.slice(), ctx)?;
let mut buffer = if arguments.will_be_async {
Buffer::from_js_pinned(ctx, arg)
.unwrap_or_else(|| Buffer::from_typed_array(ctx, arg))
} else {
Buffer::from_typed_array(ctx, arg)
};
if let Err(err) = Valid::path_buffer(&buffer, ctx)
.and_then(|_| Valid::path_null_bytes(buffer.slice(), ctx))
{
if buffer.pinned {
buffer.pinned = false;
buffer.buffer.unpin();
}
return Err(err);
}

arguments.protect_eat();
Ok(Some(Self::Buffer(buffer)))
}

JSType::ArrayBuffer => {
let buffer = Buffer::from_array_buffer(ctx, arg);
Valid::path_buffer(&buffer, ctx)?;
Valid::path_null_bytes(buffer.slice(), ctx)?;
let mut buffer = if arguments.will_be_async {
Buffer::from_js_pinned(ctx, arg)
.unwrap_or_else(|| Buffer::from_array_buffer(ctx, arg))
} else {
Buffer::from_array_buffer(ctx, arg)
};
if let Err(err) = Valid::path_buffer(&buffer, ctx)
.and_then(|_| Valid::path_null_bytes(buffer.slice(), ctx))
{
if buffer.pinned {
buffer.pinned = false;
buffer.buffer.unpin();
}
return Err(err);
}

arguments.protect_eat();
Ok(Some(Self::Buffer(buffer)))
Expand Down
Loading
Loading