Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 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
2 changes: 1 addition & 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,7 @@ 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", () => {
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 { getAvailablePort, 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 port = await getAvailablePort();
const signal = new TCPSocketSignal(port);
await signal.ready;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

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
23 changes: 23 additions & 0 deletions src/install/lockfile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,29 @@ impl Lockfile {
}
};

for res in self.packages.items_resolution() {
Comment thread
Jarred-Sumner marked this conversation as resolved.
Outdated
if res.tag != ResolutionTag::Git && res.tag != ResolutionTag::Github {
continue;
}
let resolved = self.str(&res.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 LoadResult::Err(LoadResultErr {
step: LoadStep::ParseFile,
value: err!("InvalidLockfile"),
lockfile_path: zstr!("bun.lockb"),
format: LockfileFormat::Binary,
});
}
}

if cfg!(debug_assertions) {
self.verify_data().expect("lockfile data is corrupt");
}
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
9 changes: 9 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,14 @@ 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";
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
10 changes: 9 additions & 1 deletion src/runtime/node/node_fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3995,11 +3995,19 @@ pub mod args {
};

// length |= 0;
let length_float: f64 = if let Some(arg) = arguments.next_eat() {
let length_value = arguments.next_eat();
let length_float: f64 = if let Some(arg) = length_value {
arg.to_number(ctx)?
} else {
0.0
};
let buffer = if length_value.is_some_and(|arg| !arg.is_number()) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not make it slower. One pass. Not two.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reordered to a single pass — the re-fetch is gone. The buffer pointer/length is now captured exactly once, immediately after the offset/length arguments are processed (length's ToNumber is the only step there that can re-enter JS), so nothing captured before that point is used after it. The all-numbers fast path does exactly the same operations as before this change set: fd, offset validation, length ToNumber, then one Buffer::from_js — no added conversions or branches, just a different order. The readSync detach test still fails on the released build and passes here, test-fs-read* parallel tests pass, and a readSync loop microbench is at parity with main.

Buffer::from_js(ctx, buffer_value).ok_or_else(|| {
ctx.throw_invalid_argument_type_value(b"buffer", b"TypedArray", buffer_value)
})?
} else {
buffer
};

// 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
12 changes: 12 additions & 0 deletions src/runtime/node/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1220,6 +1220,12 @@ impl PathLikeExt for PathLike {
Valid::path_buffer(&buffer, ctx)?;
Valid::path_null_bytes(buffer.slice(), ctx)?;

let buffer = if arguments.will_be_async {
Buffer::from_js_pinned(ctx, arg).unwrap_or(buffer)
} else {
buffer
};

arguments.protect_eat();
Ok(Some(Self::Buffer(buffer)))
}
Expand All @@ -1229,6 +1235,12 @@ impl PathLikeExt for PathLike {
Valid::path_buffer(&buffer, ctx)?;
Valid::path_null_bytes(buffer.slice(), ctx)?;

let buffer = if arguments.will_be_async {
Buffer::from_js_pinned(ctx, arg).unwrap_or(buffer)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes it slower. One pass. Not 2.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restructured to one pass. When the call will be async, the single conversion is now Buffer::from_js_pinned (pin + one extraction), with the plain conversion as the fallback — same shape as the existing async StringOrBuffer path earlier in this file — and the sync path is a single from_typed_array/from_array_buffer exactly as on main. The second walk over the argument is gone. Validation runs on the pinned buffer and the throw paths release the pin before propagating, so the Drop balance in PathLike stays correct. String path and sync path do the same work as main; the async buffer path only adds the pin itself. The fs.promises.writeFile buffer-path test on the branch still passes and still fails on released Bun.

} else {
buffer
};

arguments.protect_eat();
Ok(Some(Self::Buffer(buffer)))
}
Expand Down
5 changes: 4 additions & 1 deletion src/runtime/webcore/s3/multipart.rs
Original file line number Diff line number Diff line change
Expand Up @@ -702,7 +702,10 @@ impl MultiPartUpload {
this.uploadid_buffer = response.body;
if this.upload_id.is_empty()
|| this.upload_id.len() > Self::MAX_UPLOAD_ID_LEN
|| this.upload_id.iter().any(|b| b.is_ascii_control())
|| this
.upload_id
.iter()
.any(|b| !b.is_ascii() || b.is_ascii_control())
{
// Unknown type of response error from AWS
scoped_log!(
Expand Down
Loading
Loading