diff --git a/packages/bun-debug-adapter-protocol/src/debugger/adapter.ts b/packages/bun-debug-adapter-protocol/src/debugger/adapter.ts index a67ec21774e..3d233bfce69 100644 --- a/packages/bun-debug-adapter-protocol/src/debugger/adapter.ts +++ b/packages/bun-debug-adapter-protocol/src/debugger/adapter.ts @@ -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 @@ -278,7 +298,9 @@ export abstract class BaseDebugAdapter 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; diff --git a/packages/bun-debug-adapter-protocol/src/debugger/signal.ts b/packages/bun-debug-adapter-protocol/src/debugger/signal.ts index 6caaed5a2e3..021eeeb5a5e 100644 --- a/packages/bun-debug-adapter-protocol/src/debugger/signal.ts +++ b/packages/bun-debug-adapter-protocol/src/debugger/signal.ts @@ -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(); }); diff --git a/packages/bun-debug-adapter-protocol/src/debugger/sourcemap.test.ts b/packages/bun-debug-adapter-protocol/src/debugger/sourcemap.test.ts index b050fa4a681..e4f883b9cb9 100644 --- a/packages/bun-debug-adapter-protocol/src/debugger/sourcemap.test.ts +++ b/packages/bun-debug-adapter-protocol/src/debugger/sourcemap.test.ts @@ -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", () => { @@ -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(resolve => signal.once("Signal.received", resolve)); + await new Promise((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(resolve => { + const client = connect({ host: externalHost, port }, () => { + client.end(); + resolve(null); + }); + client.on("error", error => resolve(error)); + }); + expect(connectError).not.toBeNull(); + } + } finally { + signal.close(); + } +}); diff --git a/src/exe_format/macho.rs b/src/exe_format/macho.rs index 6496ff6fabf..5020f3a057e 100644 --- a/src/exe_format/macho.rs +++ b/src/exe_format/macho.rs @@ -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; @@ -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 { @@ -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); } diff --git a/src/install/extract_tarball.rs b/src/install/extract_tarball.rs index 2160d9dff76..1a45b3ad018 100644 --- a/src/install/extract_tarball.rs +++ b/src/install/extract_tarball.rs @@ -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()..], diff --git a/src/install/lockfile/bun.lockb.rs b/src/install/lockfile/bun.lockb.rs index 452ed7289f5..826bda09c35 100644 --- a/src/install/lockfile/bun.lockb.rs +++ b/src/install/lockfile/bun.lockb.rs @@ -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 { diff --git a/src/js/node/_http2_upgrade.ts b/src/js/node/_http2_upgrade.ts index 5064b2f0414..a40b18b326a 100644 --- a/src/js/node/_http2_upgrade.ts +++ b/src/js/node/_http2_upgrade.ts @@ -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, diff --git a/src/js/node/_http_client.ts b/src/js/node/_http_client.ts index 2ff0c0e0a27..eae63b0a56c 100644 --- a/src/js/node/_http_client.ts +++ b/src/js/node/_http_client.ts @@ -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; @@ -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; + } const [url, proxy] = getURL(host); go(url, proxy, false); return true; diff --git a/src/jsc/bindings/v8/V8String.cpp b/src/jsc/bindings/v8/V8String.cpp index 8441d145bc4..83154798225 100644 --- a/src/jsc/bindings/v8/V8String.cpp +++ b/src/jsc/bindings/v8/V8String.cpp @@ -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(span.data()), span.size()); - return static_cast(len); + return static_cast(std::min(len, static_cast(std::numeric_limits::max()))); } else { const auto span = str->span16(); size_t len = simdutf::utf8_length_from_utf16(span.data(), span.size()); - return static_cast(len); + return static_cast(std::min(len, static_cast(std::numeric_limits::max()))); } } @@ -162,7 +162,7 @@ int String::WriteUtf8(Isolate* isolate, char* buffer, int length, int* nchars_re auto jsString = localToObjectPointer(); WTF::String string = jsString->getString(isolate->globalObject()); - size_t unsigned_length = length < 0 ? SIZE_MAX : length; + size_t unsigned_length = length < 0 ? static_cast(std::numeric_limits::max()) : static_cast(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); @@ -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); diff --git a/src/jsc/node_path.rs b/src/jsc/node_path.rs index 46035b8d1cc..9ed250e6168 100644 --- a/src/jsc/node_path.rs +++ b/src/jsc/node_path.rs @@ -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(); } diff --git a/src/runtime/node/node_fs.rs b/src/runtime/node/node_fs.rs index f4e1625f790..eae2f5e2f0d 100644 --- a/src/runtime/node/node_fs.rs +++ b/src/runtime/node/node_fs.rs @@ -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) { @@ -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() { diff --git a/src/runtime/node/path.rs b/src/runtime/node/path.rs index 293cf95dd47..e8fe78b7cd5 100644 --- a/src/runtime/node/path.rs +++ b/src/runtime/node/path.rs @@ -1211,7 +1211,7 @@ pub(crate) fn format_js_t( }) + (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::()); let mut scratch = PathScratch::::new(pool, buf_len); diff --git a/src/runtime/node/types.rs b/src/runtime/node/types.rs index feb75079726..4745de0b441 100644 --- a/src/runtime/node/types.rs +++ b/src/runtime/node/types.rs @@ -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))) diff --git a/src/runtime/webcore/s3/multipart.rs b/src/runtime/webcore/s3/multipart.rs index 86c0d2b433d..0e9d14a160d 100644 --- a/src/runtime/webcore/s3/multipart.rs +++ b/src/runtime/webcore/s3/multipart.rs @@ -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!( diff --git a/src/runtime/webview/ChromeProcess.rs b/src/runtime/webview/ChromeProcess.rs index b4cca3acfef..d489f9c1ad2 100644 --- a/src/runtime/webview/ChromeProcess.rs +++ b/src/runtime/webview/ChromeProcess.rs @@ -451,13 +451,20 @@ fn spawn( v.extend_from_slice(d); ZBox::from_vec(v) } else { - // pid_t → u32 cast so {d} formats. Fresh dir per parent process; - // multiple Bun.WebView instances in one process share the Chrome. - // SAFETY: getpid is always safe. - let pid: u32 = unsafe { libc::getpid() } as u32; - let mut v = Vec::new(); - write!(&mut v, "--user-data-dir=/tmp/bun-chrome-{}", pid) - .expect("infallible: in-memory write"); + let mut name_buf = [0u8; 64]; + let name = bun_paths::fs::FileSystem::tmpname( + b"bun-chrome", + &mut name_buf, + bun_core::fast_random(), + )?; + let mut dir_buf = path_buffer_pool::get(); + let dir_parts: [&[u8]; 2] = [bun_resolver::fs::RealFS::tmpdir_path(), name.as_bytes()]; + let dir = + resolve_path::join_string_buf_z::(&mut dir_buf[..], &dir_parts); + bun_sys::mkdir(dir, 0o700)?; + let mut v = Vec::with_capacity(16 + dir.len()); + v.extend_from_slice(b"--user-data-dir="); + v.extend_from_slice(&dir[..]); ZBox::from_vec(v) }; diff --git a/test/bundler/bundler_compile.test.ts b/test/bundler/bundler_compile.test.ts index 786feac0b8d..5955895e456 100644 --- a/test/bundler/bundler_compile.test.ts +++ b/test/bundler/bundler_compile.test.ts @@ -1018,3 +1018,135 @@ const server = serve({ expect(result.stdout.toString().trim()).toBe("IT WORKS"); }, 30_000); }); + +test("compile --compile-executable-path rejects a Mach-O template whose __BUN segment offsets exceed the file bounds", async () => { + // `bun build --compile --target=bun-darwin-*` patches the application bundle into the + // __BUN,__bun section of the base executable named by --compile-executable-path. The + // segment/section offsets in that file's load commands must be validated against the + // actual file size before they are used as memmove destinations. + const MH_MAGIC_64 = 0xfeedfacf; + const CPU_TYPE_X86_64 = 0x01000007; + const MH_EXECUTE = 2; + const LC_SEGMENT_64 = 0x19; + + // Minimal Mach-O "base executable": a __BUN segment with one __bun section followed by a + // __LINKEDIT segment. `bunFileOff` is where the load commands claim the __BUN data lives. + function machoTemplate(bunFileOff: number): Buffer { + const fileSize = 0x8100; // 33 KB of actual bytes + const segCmdSize = 72; // sizeof(segment_command_64) + const sectSize = 80; // sizeof(section_64) + const sizeofcmds = segCmdSize + sectSize + segCmdSize; + const buf = Buffer.alloc(fileSize); + const writeName = (off: number, name: string) => buf.write(name, off, 16, "latin1"); + + // mach_header_64 + buf.writeUInt32LE(MH_MAGIC_64, 0); + buf.writeInt32LE(CPU_TYPE_X86_64, 4); + buf.writeInt32LE(3, 8); // cpusubtype + buf.writeUInt32LE(MH_EXECUTE, 12); + buf.writeUInt32LE(2, 16); // ncmds + buf.writeUInt32LE(sizeofcmds, 20); + + // LC_SEGMENT_64 __BUN with one section + let o = 32; + buf.writeUInt32LE(LC_SEGMENT_64, o); + buf.writeUInt32LE(segCmdSize + sectSize, o + 4); // cmdsize + writeName(o + 8, "__BUN"); + buf.writeBigUInt64LE(0x1_0000_4000n, o + 24); // vmaddr + buf.writeBigUInt64LE(0x4000n, o + 32); // vmsize + buf.writeBigUInt64LE(BigInt(bunFileOff), o + 40); // fileoff + buf.writeBigUInt64LE(0x4000n, o + 48); // filesize + buf.writeInt32LE(7, o + 56); // maxprot + buf.writeInt32LE(3, o + 60); // initprot + buf.writeUInt32LE(1, o + 64); // nsects + + // section_64 __bun + o += segCmdSize; + writeName(o, "__bun"); + writeName(o + 16, "__BUN"); + buf.writeBigUInt64LE(0x1_0000_4000n, o + 32); // addr + buf.writeBigUInt64LE(0x4000n, o + 40); // size + buf.writeUInt32LE(bunFileOff, o + 48); // offset + buf.writeUInt32LE(14, o + 52); // align = 2^14 + + // LC_SEGMENT_64 __LINKEDIT + o += sectSize; + buf.writeUInt32LE(LC_SEGMENT_64, o); + buf.writeUInt32LE(segCmdSize, o + 4); + writeName(o + 8, "__LINKEDIT"); + buf.writeBigUInt64LE(0x1_0000_8000n, o + 24); // vmaddr + buf.writeBigUInt64LE(0x1000n, o + 32); // vmsize + buf.writeBigUInt64LE(BigInt(bunFileOff + 0x4000), o + 40); // fileoff (right after __BUN) + buf.writeBigUInt64LE(0x100n, o + 48); // filesize + buf.writeInt32LE(1, o + 56); // maxprot + buf.writeInt32LE(1, o + 60); // initprot + + return buf; + } + + using dir = tempDir("compile-macho-template-bounds", { + "entry.js": `console.log("compiled-from-template");`, + }); + const cwd = String(dir); + + // Template whose __BUN offsets point 1 GiB past the end of the 33 KB file. + const badTemplate = join(cwd, "template-bad"); + await Bun.write(badTemplate, machoTemplate(0x40000000)); + const outBad = join(cwd, "out-bad"); + { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "build", + "--compile", + "--target=bun-darwin-x64", + "--compile-executable-path", + badTemplate, + join(cwd, "entry.js"), + "--outfile", + outBad, + ], + env: bunEnv, + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // The out-of-range offsets must be reported as a clean error... + expect(stderr).toContain("OffsetOutOfRange"); + // ...no output executable is produced... + expect(await Bun.file(outBad).exists()).toBe(false); + // ...and the build exits with a normal failure code instead of crashing. + expect(exitCode).toBe(1); + } + + // The same template with in-bounds offsets is still accepted. + const goodTemplate = join(cwd, "template-good"); + await Bun.write(goodTemplate, machoTemplate(0x4000)); + const outGood = join(cwd, "out-good"); + { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "build", + "--compile", + "--target=bun-darwin-x64", + "--compile-executable-path", + goodTemplate, + join(cwd, "entry.js"), + "--outfile", + outGood, + ], + env: bunEnv, + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("error:"); + expect(stderr).not.toContain("OffsetOutOfRange"); + const outBytes = Buffer.from(await Bun.file(outGood).arrayBuffer()); + expect(outBytes.includes("compiled-from-template")).toBe(true); + expect(exitCode).toBe(0); + } +}, 60_000); diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index 40322a6b1f1..818bea8e285 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -9437,3 +9437,80 @@ it("does not extract a local file: tarball outside the temp dir for a dependency expect(outOk).toContain("1 package installed"); expect(exitCodeOk).toBe(0); }); + +it("does not create a cache index entry outside the cache directory for a dependency alias of '..'", async () => { + // For git/github/tarball dependencies the dependency alias (the key in + // `dependencies`) is used as the folder name for the per-package cache + // index (`//` symlinks). The alias must be a + // single safe path segment; an alias of exactly ".." must not cause index + // entries to be created in the parent of the cache directory. + using dir = tempDir("cache-index-alias-dotdot", { + "cache-holder/cache/.keep": "", + "project/package.json": JSON.stringify({ + name: "cache-index-alias-app", + version: "1.0.0", + dependencies: { + "..": "file:./baz-a-0.0.3.tgz", + }, + }), + "project-ok/package.json": JSON.stringify({ + name: "cache-index-alias-ok-app", + version: "1.0.0", + dependencies: { + "baz-ok": "file:./baz-b-0.0.3.tgz", + }, + }), + }); + const root = String(dir); + const cacheHolder = join(root, "cache-holder"); + const cacheDir = join(cacheHolder, "cache"); + const testEnv = { ...env, BUN_INSTALL_CACHE_DIR: cacheDir }; + await cp(join(import.meta.dir, "baz-0.0.3.tgz"), join(root, "project", "baz-a-0.0.3.tgz")); + await cp(join(import.meta.dir, "baz-0.0.3.tgz"), join(root, "project-ok", "baz-b-0.0.3.tgz")); + + const { stdout, stderr, exited } = spawn({ + cmd: [bunExe(), "install"], + cwd: join(root, "project"), + stdout: "pipe", + stdin: "pipe", + stderr: "pipe", + env: testEnv, + }); + const err = await stderr.text(); + await stdout.text(); + const exitCode = await exited; + + // The parent of the cache directory must contain only the cache directory + // itself — no per-alias index entries (e.g. "@T@..." symlinks) may be + // planted next to it. + expect(await readdirSorted(cacheHolder)).toEqual(["cache"]); + // The unsafe alias is rejected as an install folder name. + expect(err).toContain('Invalid dependency name ".."'); + expect(exitCode).not.toBe(0); + + // A normal single-segment alias still gets its cache index entry, inside the + // cache directory, and installs fine. + const { + stdout: stdoutOk, + stderr: stderrOk, + exited: exitedOk, + } = spawn({ + cmd: [bunExe(), "install"], + cwd: join(root, "project-ok"), + stdout: "pipe", + stdin: "pipe", + stderr: "pipe", + env: testEnv, + }); + const errOk = await stderrOk.text(); + const outOk = await stdoutOk.text(); + const exitCodeOk = await exitedOk; + + expect(await exists(join(cacheDir, "baz-ok"))).toBe(true); + expect(await exists(join(root, "project-ok", "node_modules", "baz-ok", "package.json"))).toBe(true); + // The cache parent still only contains the cache directory after a normal install. + expect(await readdirSorted(cacheHolder)).toEqual(["cache"]); + expect(errOk).not.toContain("error:"); + expect(outOk).toContain("1 package installed"); + expect(exitCodeOk).toBe(0); +}); diff --git a/test/cli/install/bun-lockb.test.ts b/test/cli/install/bun-lockb.test.ts index 0ea87cc0c90..69dd9e6df1c 100644 --- a/test/cli/install/bun-lockb.test.ts +++ b/test/cli/install/bun-lockb.test.ts @@ -257,3 +257,117 @@ index d156130662798530e852e1afaec5b1c03d429cdc..b4ddf35975a952fdaed99f2b14236519 expect(code).toBe(0); expect(await exists(join(packageDir, "node_modules", "optional-peer-deps"))).toBe(true); }); + +it("rejects a binary lockfile whose git resolved tag contains path separators", async () => { + const { packageDir, packageJson } = await registry.createTestDir({ bunfigOpts: { saveTextLockfile: false } }); + + // A git dependency pointing at an unreachable loopback endpoint (port 0) so + // this test stays offline. The commit below never has to exist: migrating + // package-lock.json transcribes the resolved commit into the lockfile + // without contacting the git host, and `--lockfile-only` saves it as + // bun.lockb (saveTextLockfile = false) without installing anything. + const gitUrl = "git+ssh://git@127.0.0.1:0/example/repo.git"; + const sha = "aabbccddeeff00112233445566778899aabbccdd"; + const installEnv = { + ...env, + GIT_SSH_COMMAND: "ssh -oBatchMode=yes -oStrictHostKeyChecking=accept-new -oConnectTimeout=5", + GIT_TERMINAL_PROMPT: "0", + }; + + await write( + packageJson, + JSON.stringify({ + name: "lockb-git-tag", + version: "1.0.0", + dependencies: { dep: gitUrl }, + }), + ); + await write( + join(packageDir, "package-lock.json"), + JSON.stringify({ + name: "lockb-git-tag", + version: "1.0.0", + lockfileVersion: 3, + requires: true, + packages: { + "": { name: "lockb-git-tag", version: "1.0.0", dependencies: { dep: gitUrl } }, + "node_modules/dep": { version: "1.0.0", resolved: `${gitUrl}#${sha}` }, + }, + }), + ); + + // Generate bun.lockb from the npm lockfile without performing an install. + { + const { stdout, stderr, exited } = spawn({ + cmd: [bunExe(), "install", "--lockfile-only", "--no-progress"], + cwd: packageDir, + stdout: "pipe", + stderr: "pipe", + env: installEnv, + }); + const [out, rawErr, code] = await Promise.all([stdout.text(), stderr.text(), exited]); + const err = stderrForInstall(rawErr); + expect(err).toContain("Saved lockfile"); + expect(err).not.toContain("error:"); + expect(out).toBeDefined(); + expect(code).toBe(0); + } + + const lockbPath = join(packageDir, "bun.lockb"); + expect(await exists(lockbPath)).toBe(true); + expect(await exists(join(packageDir, "bun.lock"))).toBe(false); + + // The legitimate case: a binary lockfile whose git resolution carries a + // well-formed 40-hex commit loads cleanly. + { + const { stdout, stderr, exited } = spawn({ + cmd: [bunExe(), "install", "--lockfile-only", "--no-progress"], + cwd: packageDir, + stdout: "pipe", + stderr: "pipe", + env: installEnv, + }); + const [out, rawErr, code] = await Promise.all([stdout.text(), stderr.text(), exited]); + const err = stderrForInstall(rawErr); + expect(err).not.toContain("Invalid git dependency tag"); + expect(err).not.toContain("error:"); + expect(out).toBeDefined(); + expect(code).toBe(0); + } + + // Rewrite every stored copy of the resolved commit in place (same length) so + // it contains ".." and a path separator. The resolved value becomes a cache + // folder name and a `git checkout` argument downstream, so it must only ever + // be a single safe path component. + const lockb = Buffer.from(await file(lockbPath).arrayBuffer()); + let occurrences = 0; + for (let off = lockb.indexOf(sha); off !== -1; off = lockb.indexOf(sha, off + 1)) { + lockb.write("../", off, "latin1"); + occurrences++; + } + expect(occurrences).toBeGreaterThan(0); + await write(lockbPath, lockb); + + const { stdout, stderr, exited } = spawn({ + cmd: [bunExe(), "install", "--no-progress"], + cwd: packageDir, + stdout: "pipe", + stderr: "pipe", + env: installEnv, + }); + const [out, rawErr, code] = await Promise.all([stdout.text(), stderr.text(), exited]); + const err = stderrForInstall(rawErr); + + // The tampered resolved value must fail binary lockfile loading (the same + // fail-closed rule the text lockfile parser applies) instead of flowing into + // cache folder names and git commands. The install then falls back to a + // fresh resolve rather than consuming the tampered resolution. + expect(err).toContain("Invalid git dependency tag"); + expect(err).toContain("in bun.lockb"); + expect(err).toContain("Ignoring lockfile"); + expect(out).toBeDefined(); + // Nothing was installed from the tampered resolution (the git host is + // unreachable, so the fallback resolve cannot fetch it either). + expect(await exists(join(packageDir, "node_modules", "dep"))).toBe(false); + expect(code).not.toBe(0); +}); diff --git a/test/js/bun/s3/s3.test.ts b/test/js/bun/s3/s3.test.ts index 6ede69dddc6..7ee0b6ffa48 100644 --- a/test/js/bun/s3/s3.test.ts +++ b/test/js/bun/s3/s3.test.ts @@ -1713,3 +1713,91 @@ describe.skipIf(!minioCredentials)("Archive with S3", () => { await s3File.delete(); }); }); + +describe("s3 multipart upload id validation", () => { + it("rejects a CreateMultipartUpload response whose upload id contains non-ASCII bytes", async () => { + // The whole scenario runs in a subprocess so a misbehaving runtime cannot take down the test runner. + const fixture = ` + const goodUploadId = "valid-upload-id-1234567890"; + function initiateXml(uploadIdBytes) { + return Buffer.concat([ + Buffer.from("my_bucketobj"), + uploadIdBytes, + Buffer.from(""), + ]); + } + const server = Bun.serve({ + port: 0, + async fetch(req) { + const isCreateMultipartUpload = req.method === "POST" && req.url.includes("?uploads="); + if (isCreateMultipartUpload) { + // The "malformed-id-object" key gets an upload id made entirely of bytes >= 0x80, + // which no real S3 server returns. Everything else gets a normal ASCII upload id. + const uploadId = req.url.includes("malformed-id-object") + ? Buffer.alloc(1024, 0xff) + : Buffer.from(goodUploadId); + return new Response(initiateXml(uploadId), { + status: 200, + headers: { "Content-Type": "text/xml" }, + }); + } + const isCompleteMultipartUpload = req.method === "POST" && req.url.includes("uploadId="); + if (isCompleteMultipartUpload) { + return new Response( + 'my_bucketobj"etag"', + { status: 200, headers: { "Content-Type": "text/xml" } }, + ); + } + return new Response(undefined, { status: 200, headers: { "ETag": '"etag"' } }); + }, + }); + + const client = new Bun.S3Client({ + accessKeyId: "test", + secretAccessKey: "test", + region: "eu-west-3", + bucket: "my_bucket", + endpoint: server.url.href, + }); + + // One part size plus 1 MiB so the writer takes the multipart path instead of a single PUT. + const part = Buffer.alloc(6 * 1024 * 1024, "a"); + + { + const writer = client.file("malformed-id-object").writer({ partSize: 5 * 1024 * 1024 }); + writer.write(part); + try { + await writer.end(); + console.log("malformed-id: resolved"); + } catch (err) { + console.log("malformed-id: rejected", err?.code, "-", err?.message); + } + } + + { + const writer = client.file("valid-id-object").writer({ partSize: 5 * 1024 * 1024 }); + writer.write(part); + await writer.end(); + console.log("valid-id: resolved"); + } + + server.stop(true); + `; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // A server-supplied upload id containing non-ASCII bytes must surface as a normal S3 error + // on the writer promise instead of terminating the process. + expect(stdout).toContain("malformed-id: rejected UnknownError - Failed to initiate multipart upload"); + // A well-formed upload id still completes the multipart upload in the same process. + expect(stdout).toContain("valid-id: resolved"); + expect(exitCode).toBe(0); + }, 60_000); +}); diff --git a/test/js/node/fs/fs.test.ts b/test/js/node/fs/fs.test.ts index df0948c30de..355ab233a62 100644 --- a/test/js/node/fs/fs.test.ts +++ b/test/js/node/fs/fs.test.ts @@ -1224,6 +1224,44 @@ it.skipIf(isWindows)( ); describe("readSync", () => { + it("rejects the read when the length argument detaches the destination buffer during coercion", () => { + const fd = openSync(import.meta.dir + "/readFileSync.txt", "r"); + try { + // A plain numeric length still works. + const ok = new Uint8Array(4); + expect(readSync(fd, ok, 0, 4, 0)).toBe(4); + + // Coercing a non-numeric length argument re-enters JavaScript. If that + // re-entry detaches the destination buffer, the call must be rejected + // instead of reading into the previously captured backing store. + const ab = new ArrayBuffer(65536); + const buf = new Uint8Array(ab); + // Keep the transferred ArrayBuffer reachable so its memory stays alive + // for the duration of the call. + let moved: ArrayBuffer | undefined; + expect(() => + readSync( + fd, + buf, + 0, + { + valueOf() { + moved = ab.transfer(); + return 65536; + }, + } as any, + 0, + ), + ).toThrow(); + // The coercion side effect really ran: the destination view is detached + // and its bytes now live in the transferred ArrayBuffer. + expect(buf.byteLength).toBe(0); + expect(moved?.byteLength).toBe(65536); + } finally { + closeSync(fd); + } + }); + const firstFourBytes = new Uint32Array(new TextEncoder().encode("File").buffer)[0]; it("works on large files", () => { @@ -4289,3 +4327,28 @@ it("fs.writeFile (callback) keeps the source buffer attached while the write is expect(readFileSync(file, "latin1")).toBe("FFFFFFFF"); }); + +it("fs.promises.writeFile keeps a buffer path argument attached while options are read", async () => { + using dir = tempDir("fs-writefile-path-pin", {}); + const file = join(String(dir), "out.txt"); + const pathBytes = new TextEncoder().encode(file); + // Standalone ArrayBuffer (not the shared Buffer pool) so detaching it would + // only affect this path argument. + const pathBuf = new Uint8Array(new ArrayBuffer(pathBytes.byteLength)); + pathBuf.set(pathBytes); + + let detachedDuringOptions: boolean | undefined; + await fs.promises.writeFile(pathBuf as any, "hello world", { + // Reading the options object re-enters JavaScript after the native call + // captured a pointer into the path buffer; the backing store must not be + // detachable out from under it. + get flag() { + pathBuf.buffer.transfer(); + detachedDuringOptions = pathBuf.buffer.detached; + return "w"; + }, + }); + + expect(detachedDuringOptions).toBe(false); + expect(readFileSync(file, "utf8")).toBe("hello world"); +}); diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index c063be69911..a78115a5e00 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -913,6 +913,47 @@ describe("node:http", () => { }); describe("get", () => { + it("treats host option containing URL delimiter characters as an unresolvable hostname", async () => { + let requestCount = 0; + const server = createServer((req, res) => { + requestCount++; + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("ok"); + }); + try { + const url = await listen(server); + + // The literal host option string is the DNS/connect target (Node.js semantics). + // Characters like "/" and "?" must not allow the value to be re-interpreted as a + // URL whose host points at a different server; the only acceptable outcome is a + // lookup failure with no request ever being sent. + const confusedHost = `127.0.0.1:${url.port}/?.invalid.example`; + const { promise, resolve, reject } = Promise.withResolvers(); + const req = get({ host: confusedHost, path: "/info", auth: "svc:secret" }, res => { + res.resume(); + reject(new Error(`request unexpectedly completed with status ${res.statusCode}`)); + }); + req.on("error", resolve); + const err: any = await promise; + expect(err.code).toBe("ENOTFOUND"); + expect(err.hostname).toBe(confusedHost); + expect(requestCount).toBe(0); + + // A plain host + port still works. + const { promise: okPromise, resolve: resolveOk, reject: rejectOk } = Promise.withResolvers(); + get({ host: "127.0.0.1", port: url.port, path: "/info" }, res => { + let data = ""; + res.setEncoding("utf8"); + res.on("data", chunk => (data += chunk)); + res.on("end", () => resolveOk({ statusCode: res.statusCode, data })); + }).on("error", rejectOk); + expect(await okPromise).toEqual({ statusCode: 200, data: "ok" }); + expect(requestCount).toBe(1); + } finally { + server.close(); + } + }); + it("should make a standard GET request, like request", async done => { const server = createServer((req, res) => { res.writeHead(200, { "Content-Type": "text/plain" }); diff --git a/test/js/node/path/parse-format.test.js b/test/js/node/path/parse-format.test.js index 4e91eb7cabe..3b1bf08e412 100644 --- a/test/js/node/path/parse-format.test.js +++ b/test/js/node/path/parse-format.test.js @@ -1,4 +1,5 @@ -import { describe, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; import assert from "node:assert"; import path from "node:path"; @@ -221,3 +222,36 @@ describe("path.parse", () => { assert.strictEqual(path.format({ name: "x", ext: ".png" }), "x.png"); }); }); + +describe("path.format", () => { + test("formats { dir, name, ext } with a dot-less ext when the result exceeds the platform path limit", async () => { + // When `base` is absent and `ext` does not start with ".", format() inserts the dot itself. + // The formatted result must stay correct even when the combined length of dir/name/ext is far + // larger than the platform MAX_PATH, and the process must not crash. Run in a subprocess so a + // crash in the native formatter shows up as a bad exit code instead of taking down the runner. + const code = ` + const path = require("node:path"); + const dir = "/" + "d".repeat(70000); + const name = "n".repeat(70000); + const ext = "txt"; + + const posix = path.posix.format({ dir, name, ext }); + const win = path.win32.format({ dir, name, ext }); + + console.log(posix === dir + "/" + name + "." + ext); + console.log(win === dir + "\\\\" + name + "." + ext); + + // Legitimate small case keeps working too. + console.log(path.posix.format({ dir: "/tmp", name: "file", ext: "txt" })); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", code], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toBe("true\ntrue\n/tmp/file.txt\n"); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/v8/v8.test.ts b/test/v8/v8.test.ts index f8c5d55a94e..8363e2745e6 100644 --- a/test/v8/v8.test.ts +++ b/test/v8/v8.test.ts @@ -1,7 +1,7 @@ import { spawn } from "bun"; import { jscDescribe } from "bun:jsc"; import { beforeAll, describe, expect, it } from "bun:test"; -import { bunEnv, bunExe, isASAN, isBroken, isMusl, isWindows, nodeExe, tmpdirSync } from "harness"; +import { bunEnv, bunExe, isASAN, isBroken, isMusl, isWindows, nodeExe, tempDir, tmpdirSync } from "harness"; import assert from "node:assert"; import fs from "node:fs/promises"; import { basename, join } from "path"; @@ -396,3 +396,122 @@ async function runOn(runtime: Runtime, buildMode: BuildMode, testName: string, j expect(exitCode, crashMsg).toBe(0); return out.trim(); } + +describe.todoIf(isBroken && isMusl)("String::Utf8Length bounds", () => { + it( + "saturates at INT32_MAX for strings whose UTF-8 size exceeds it", + async () => { + // Build a tiny standalone V8-API addon that just reports String::Utf8Length of its + // argument, then feed it a Latin-1 string whose UTF-8 expansion is larger than INT32_MAX. + // The reported length must stay positive and saturate at INT32_MAX instead of wrapping. + using dir = tempDir("v8-utf8-length", { + "package.json": JSON.stringify({ + name: "v8-utf8-length-test", + version: "1.0.0", + devDependencies: { "node-gyp": "~11.2.0" }, + }), + "binding.gyp": JSON.stringify({ + targets: [ + { + target_name: "utf8len", + sources: ["addon.cpp"], + cflags: ["-Wno-deprecated-declarations"], + cflags_cc: ["-Wno-deprecated-declarations"], + xcode_settings: { + OTHER_CFLAGS: ["-Wno-deprecated-declarations"], + OTHER_CPLUSPLUSFLAGS: ["-Wno-deprecated-declarations"], + }, + }, + ], + }), + "addon.cpp": `#include +#include + +using namespace v8; + +namespace utf8len_test { + +void string_utf8_length(const FunctionCallbackInfo &info) { + Isolate *isolate = info.GetIsolate(); + Local s = info[0].As(); + printf("Utf8Length = %d\\n", s->Utf8Length(isolate)); + fflush(stdout); +} + +void initialize(Local exports, Local module, + Local context) { + NODE_SET_METHOD(exports, "string_utf8_length", string_utf8_length); +} + +NODE_MODULE_CONTEXT_AWARE(NODE_GYP_MODULE_NAME, initialize) + +} // namespace utf8len_test +`, + "run.js": `const addon = require("./build/Release/utf8len"); +// sanity check: 3 two-byte characters encode to 6 UTF-8 bytes +addon.string_utf8_length("\\u00e9".repeat(3)); +// 2**30 + 1 Latin-1 characters that each take 2 UTF-8 bytes encode to 2**31 + 2 UTF-8 bytes, +// which is larger than INT32_MAX +addon.string_utf8_length("\\u00ff".repeat(2 ** 30 + 1)); +`, + }); + const cwd = String(dir); + + { + const install = spawn({ + cmd: [bunExe(), "install", "--ignore-scripts"], + cwd, + env: bunEnv, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + expect(await install.exited).toBe(0); + } + + { + const build = spawn({ + cmd: [bunExe(), "--bun", "run", "node-gyp", "rebuild", "--release", "-j", "max"], + cwd, + env: bunEnv, + stdin: "inherit", + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, out, err] = await Promise.all([ + build.exited, + new Response(build.stdout).text(), + new Response(build.stderr).text(), + ]); + if (exitCode !== 0) { + throw new Error(`node-gyp rebuild failed with code ${exitCode}:\n${err}\n${out}`); + } + } + + const proc = spawn({ + cmd: [bunExe(), join(cwd, "run.js")], + cwd, + env: bunEnv, + stdin: "inherit", + stdout: "pipe", + stderr: "pipe", + }); + const [out, err, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + // strip debug-build scoped log lines, same as checkSameOutput does + const lines = out + .replaceAll(/^\[\w+\].+$/gm, "") + .trim() + .split(/\r?\n/) + .filter(Boolean); + // The small string reports its exact UTF-8 size; the oversized string saturates at + // INT32_MAX (2147483647) instead of wrapping to a negative or small value. + expect(lines, `stderr:\n${err}`).toEqual(["Utf8Length = 6", "Utf8Length = 2147483647"]); + expect(exitCode).toBe(0); + }, + 10 * 60 * 1000, + ); +});