From dc889407cf982795d973e981bb8a94442f7bdfa8 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:24:12 +0000 Subject: [PATCH 01/75] install: tighten dependency name validation before install --- src/install/PackageInstaller.rs | 45 +++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/install/PackageInstaller.rs b/src/install/PackageInstaller.rs index 9ecf887a8bd..7d7673cfed8 100644 --- a/src/install/PackageInstaller.rs +++ b/src/install/PackageInstaller.rs @@ -357,6 +357,32 @@ impl<'a> LazyPackageDestinationDir<'a> { } } +/// A dependency alias becomes the install destination inside `node_modules` +/// (the existing entry is renamed aside, deleted, and re-created). Reject +/// anything that could escape `node_modules`: empty names, `.`/`..` +/// components, absolute paths, drive letters, backslashes, NUL bytes, and any +/// separator other than the single `/` in a scoped name (`@scope/name`). +fn alias_is_safe_install_target(alias: &[u8]) -> bool { + if alias.is_empty() + || alias.len() >= MAX_PATH_BYTES + || alias.contains(&b'\\') + || alias.contains(&b':') + || alias.contains(&0) + { + return false; + } + + let mut component_count = 0usize; + for component in alias.split(|&c| c == b'/') { + component_count += 1; + if component.is_empty() || component == b"." || component == b".." { + return false; + } + } + + component_count == 1 || (component_count == 2 && alias[0] == b'@') +} + impl<'a> PackageInstaller<'a> { // ────────────────────────────────────────────────────────────────────── // BACKREF accessors @@ -1164,6 +1190,25 @@ impl<'a> PackageInstaller<'a> { } let alias = self.lockfile().buffers.dependencies.as_slice()[dependency_id as usize].name; + + // The alias is used as a path relative to `node_modules` for delete, + // rename, and create operations. Refuse anything that could escape it. + if !alias_is_safe_install_target(alias.slice(string_buf!())) { + if log_level != Options::LogLevel::Silent { + Output::pretty_errorln(format_args!( + "error: refusing to install dependency with unsafe name {}", + bstr::BStr::new(alias.slice(string_buf!())), + )); + } + self.summary.fail += 1; + self.increment_tree_install_count( + !IS_PENDING_PACKAGE_INSTALL, + self.current_tree_id, + log_level, + ); + return; + } + // PORT NOTE: `PackageInstall` stores both `destination_dir_subpath: &mut ZStr` // and `destination_dir_subpath_buf: &mut [u8]` aliasing the same bytes (Zig // slices don't enforce noalias). Derive BOTH from a single `*mut PathBuffer` From b8dbf3fcb46454b9bbd3528ffb0c69015fc683dc Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:25:07 +0000 Subject: [PATCH 02/75] install: tighten trusted dependency resolution checks --- src/install/PackageInstaller.rs | 9 +++++---- src/install/isolated_install.rs | 6 +++++- src/install/isolated_install/Installer.rs | 6 +++++- src/install/lockfile.rs | 16 ++++++++++++---- src/install/lockfile/Package/Scripts.rs | 2 +- src/runtime/cli/pm_trusted_command.rs | 12 +++++++++--- 6 files changed, 37 insertions(+), 14 deletions(-) diff --git a/src/install/PackageInstaller.rs b/src/install/PackageInstaller.rs index 7d7673cfed8..a0be3a71913 100644 --- a/src/install/PackageInstaller.rs +++ b/src/install/PackageInstaller.rs @@ -1776,10 +1776,11 @@ impl<'a> PackageInstaller<'a> { { break 'brk (true, true); } - if self - .lockfile() - .has_trusted_dependency(alias.slice(string_buf!()), resolution) - { + if self.lockfile().has_trusted_dependency( + alias.slice(string_buf!()), + pkg_name.slice(string_buf!()), + resolution, + ) { break 'brk (true, false); } break 'brk (false, false); diff --git a/src/install/isolated_install.rs b/src/install/isolated_install.rs index 2c3b7c8b885..e2909a27da5 100644 --- a/src/install/isolated_install.rs +++ b/src/install/isolated_install.rs @@ -1305,7 +1305,11 @@ pub fn install_isolated_packages( pkg_name_hashes[pkg_id as usize], ) }; - if lockfile.has_trusted_dependency(dep_name, pkg_res) + if lockfile.has_trusted_dependency( + dep_name, + pkg_names[pkg_id as usize].slice(string_buf), + pkg_res, + ) || trusted_from_update.contains( &(dep_name_hash as crate::TruncatedPackageNameHash), ) diff --git a/src/install/isolated_install/Installer.rs b/src/install/isolated_install/Installer.rs index f9a3736653c..982e975a2c1 100644 --- a/src/install/isolated_install/Installer.rs +++ b/src/install/isolated_install/Installer.rs @@ -1632,7 +1632,11 @@ impl Task { { break 'brk (true, true); } - if lockfile.has_trusted_dependency(dep.name.slice(string_buf), &pkg_res) { + if lockfile.has_trusted_dependency( + dep.name.slice(string_buf), + pkg_name.slice(string_buf), + &pkg_res, + ) { break 'brk (true, false); } break 'brk (false, false); diff --git a/src/install/lockfile.rs b/src/install/lockfile.rs index c2a94c4b404..f3dd6fabbd2 100644 --- a/src/install/lockfile.rs +++ b/src/install/lockfile.rs @@ -3349,14 +3349,22 @@ pub mod default_trusted_dependencies { } impl Lockfile { - pub fn has_trusted_dependency(&self, name: &[u8], resolution: &Resolution) -> bool { + pub fn has_trusted_dependency( + &self, + alias: &[u8], + pkg_name: &[u8], + resolution: &Resolution, + ) -> bool { if let Some(trusted_dependencies) = &self.trusted_dependencies { - let hash = SemverStringBuilder::string_hash(name) as u32; + let hash = SemverStringBuilder::string_hash(alias) as u32; return trusted_dependencies.contains(&hash); } - // Only allow default trusted dependencies for npm packages - resolution.tag == ResolutionTag::Npm && default_trusted_dependencies::has(name) + // Only allow default trusted dependencies for npm packages. Check the + // resolved package's real name, not the dependency alias, so an + // `npm:`-aliased package can't inherit trust from a default-trusted + // name. + resolution.tag == ResolutionTag::Npm && default_trusted_dependencies::has(pkg_name) } } diff --git a/src/install/lockfile/Package/Scripts.rs b/src/install/lockfile/Package/Scripts.rs index 165c2b3ff36..39befcfce17 100644 --- a/src/install/lockfile/Package/Scripts.rs +++ b/src/install/lockfile/Package/Scripts.rs @@ -310,7 +310,7 @@ impl Scripts { // TODO(port): narrow error set if self.has_any() { let add_node_gyp_rebuild_script = if lockfile - .has_trusted_dependency(folder_name, resolution) + .has_trusted_dependency(folder_name, folder_name, resolution) && self.install.is_empty() && self.preinstall.is_empty() { diff --git a/src/runtime/cli/pm_trusted_command.rs b/src/runtime/cli/pm_trusted_command.rs index 02597fd3043..f80827aa60e 100644 --- a/src/runtime/cli/pm_trusted_command.rs +++ b/src/runtime/cli/pm_trusted_command.rs @@ -95,8 +95,9 @@ impl UntrustedCommand { // called alias because a dependency name is not always the package name let alias = dep.name.slice(buf); + let pkg_name = packages.items_name()[package_id as usize].slice(buf); let resolution = &resolutions[package_id as usize]; - if !lockfile.has_trusted_dependency(alias, resolution) { + if !lockfile.has_trusted_dependency(alias, pkg_name, resolution) { untrusted_dep_ids.put(dep_id, ())?; } } @@ -325,8 +326,9 @@ impl TrustCommand { } let alias = dep.name.slice(buf); + let pkg_name = packages.items_name()[package_id as usize].slice(buf); let resolution = &resolutions[package_id as usize]; - if !lockfile.has_trusted_dependency(alias, resolution) { + if !lockfile.has_trusted_dependency(alias, pkg_name, resolution) { untrusted_dep_ids.put(dep_id, ())?; } } @@ -407,7 +409,11 @@ impl TrustCommand { for package_name_from_cli in &packages_to_trust { if strings::eql_long(package_name_from_cli, alias, true) - && !lockfile.has_trusted_dependency(alias, resolution) + && !lockfile.has_trusted_dependency( + alias, + packages.items_name()[package_id as usize].slice(buf), + resolution, + ) { break 'brk false; } From a450aabde1a30820006cc8b32fc22c0e5db1f4e5 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:25:24 +0000 Subject: [PATCH 03/75] http3: guard stream header callback state --- packages/bun-uws/src/Http3Context.h | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/bun-uws/src/Http3Context.h b/packages/bun-uws/src/Http3Context.h index 4c8865290dd..2b4a4086997 100644 --- a/packages/bun-uws/src/Http3Context.h +++ b/packages/bun-uws/src/Http3Context.h @@ -26,6 +26,12 @@ struct Http3Context { Http3ContextData *cd = (Http3ContextData *) us_quic_socket_context_ext(us_quic_stream_context(s)); Http3Response *res = (Http3Response *) s; Http3ResponseData *rd = res->getHttpResponseData(); + /* lsquic re-fires on_stream_headers for every HEADERS block on + * the stream; only the first one is the request. Re-running + * reset()/route() for trailers would wipe the live response's + * onAborted/userData and dispatch the handler a second time. + * state is 0 only before the first reset() on this stream. */ + if (rd->state != 0) return; rd->reset(); Http3Request req(s); From d17368a4c1d7e48e48e7c7f99b76ebd740ca1e16 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:25:41 +0000 Subject: [PATCH 04/75] install: tighten lockfile field validation on load --- src/install/lockfile/Package.rs | 34 +++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/install/lockfile/Package.rs b/src/install/lockfile/Package.rs index dec11f6a1f1..901bab9889a 100644 --- a/src/install/lockfile/Package.rs +++ b/src/install/lockfile/Package.rs @@ -3484,6 +3484,40 @@ pub mod serializer { } } } + if matches!(field, PackageField::Meta) { + // Same hardening as `Resolution` above: `Meta` embeds two + // `#[repr(u8)]` enums (`Origin` = 0..=2 and + // `HasInstallScript` = 0..=2). Copying an out-of-range byte + // into either field and reading it back as the enum would + // be immediate UB, so check the raw stream bytes first. + let stride = mem::size_of::(); + let origin_at = mem::offset_of!(Meta, origin); + let install_script_at = mem::offset_of!(Meta, has_install_script); + debug_assert!(stride != 0 && src.len().is_multiple_of(stride)); + for raw in src.chunks_exact(stride) { + if !matches!(raw[origin_at], 0 | 1 | 2) + || !matches!(raw[install_script_at], 0 | 1 | 2) + { + return Err(bun_core::err!( + "Lockfile validation failed: invalid package meta" + )); + } + } + } + if matches!(field, PackageField::Bin) { + // `Bin.tag` is a `#[repr(u8)]` enum with discriminants + // 0..=4; validate it the same way before the copy. + let stride = mem::size_of::(); + let tag_at = mem::offset_of!(Bin, tag); + debug_assert!(stride != 0 && src.len().is_multiple_of(stride)); + for raw in src.chunks_exact(stride) { + if !matches!(raw[tag_at], 0 | 1 | 2 | 3 | 4) { + return Err(bun_core::err!( + "Lockfile validation failed: invalid bin tag" + )); + } + } + } bytes.copy_from_slice(src); stream.pos = end_pos; if matches!(field, PackageField::Meta) { From 5a01667b2de5c71d7a62af0c466686481c937842 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:26:14 +0000 Subject: [PATCH 05/75] install: track created symlinks during streaming extraction --- src/install/TarballStream.rs | 45 +++++++++++++++++++++++++++++------- src/libarchive/lib.rs | 2 +- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/install/TarballStream.rs b/src/install/TarballStream.rs index 6be4de6a529..069b05d4c63 100644 --- a/src/install/TarballStream.rs +++ b/src/install/TarballStream.rs @@ -144,6 +144,13 @@ pub struct TarballStream { want_first_dirname: bool, npm_mode: bool, + /// Symlinks created so far during this extraction. Later entries whose + /// path traverses one of these are skipped: the per-target check in + /// `make_symlink` is purely lexical, so once a link is on disk the kernel + /// would follow it and a chained link could escape the extraction root. + #[cfg(unix)] + created_symlinks: Vec>, + bytes_received: usize, entry_count: u32, fail: Option, @@ -237,6 +244,8 @@ impl TarballStream { resolved_github_dirname: b"", want_first_dirname, npm_mode, + #[cfg(unix)] + created_symlinks: Vec::new(), bytes_received: 0, entry_count: 0, fail: None, @@ -800,6 +809,17 @@ impl TarballStream { let path_slice: &[OSPathChar] = &path[..]; let dest = self.dest.unwrap(); + // Reject any entry whose path traverses a symlink created earlier in + // this extraction; the kernel would follow it and the entry could land + // outside the extraction root. Same defense as the buffered extractor + // in `Archiver::extract_to_dir`. + #[cfg(unix)] + if bun_libarchive::path_traverses_created_symlink(path_slice, &self.created_symlinks) { + self.phase = Phase::WantData; + self.out_fd = None; + return Ok(()); + } + match kind { FileKind::Directory => { make_directory(entry, dest, path, path_slice); @@ -808,7 +828,9 @@ impl TarballStream { } FileKind::SymLink => { #[cfg(unix)] - make_symlink(entry, dest, path, path_slice); + if make_symlink(entry, dest, path, path_slice) { + self.created_symlinks.push(path_slice.to_vec()); + } self.phase = Phase::WantData; self.out_fd = None; } @@ -1339,13 +1361,20 @@ fn make_directory(entry: &mut lib::Entry, dest_fd: Fd, path: OSPathZ, path_slice } } +/// Returns `true` only when a symlink was actually created on disk, so the +/// caller can record it in `created_symlinks`. #[cfg(unix)] -fn make_symlink(entry: &mut lib::Entry, dest_fd: Fd, path: OSPathZ, path_slice: &[OSPathChar]) { +fn make_symlink( + entry: &mut lib::Entry, + dest_fd: Fd, + path: OSPathZ, + path_slice: &[OSPathChar], +) -> bool { let target = entry.symlink(); // Same safety rule as `isSymlinkTargetSafe` in the buffered path: // reject absolute targets and anything that escapes via `..`. if target.is_empty() || target[0] == b'/' { - return; + return false; } { let symlink_dir = bun_paths::dirname(path_slice).unwrap_or(b""); @@ -1356,19 +1385,19 @@ fn make_symlink(entry: &mut lib::Entry, dest_fd: Fd, path: OSPathZ, path_slice: &[symlink_dir, target.as_bytes()], ); if !resolved.starts_with(b"/packages/") { - return; + return false; } } match bun_sys::symlinkat(target, dest_fd, path) { - Ok(()) => {} + Ok(()) => true, Err(e) if matches!(e.get_errno(), bun_sys::E::EPERM | bun_sys::E::ENOENT) => { let Some(dir) = bun_paths::dirname(path_slice) else { - return; + return false; }; let _ = dest_fd.make_path(dir); - let _ = bun_sys::symlinkat(target, dest_fd, path); + bun_sys::symlinkat(target, dest_fd, path).is_ok() } - Err(_) => {} + Err(_) => false, } } diff --git a/src/libarchive/lib.rs b/src/libarchive/lib.rs index 3dbe9655f45..88e47c6354b 100644 --- a/src/libarchive/lib.rs +++ b/src/libarchive/lib.rs @@ -1364,7 +1364,7 @@ fn is_symlink_target_safe( /// during later `mkdirat`/`openat`/`symlinkat` calls — such entries must be /// rejected rather than resolved. #[cfg(unix)] -fn path_traverses_created_symlink(path: &[u8], created_symlinks: &[Vec]) -> bool { +pub fn path_traverses_created_symlink(path: &[u8], created_symlinks: &[Vec]) -> bool { if created_symlinks.is_empty() { return false; } From 6c02ed5fecf6f9b6fe88ad7430d3ead5b376ab6f Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:26:38 +0000 Subject: [PATCH 06/75] libarchive: tighten file open flags during extraction --- src/libarchive/lib.rs | 17 +++++++++++++++++ src/sys/lib.rs | 6 ++++++ 2 files changed, 23 insertions(+) diff --git a/src/libarchive/lib.rs b/src/libarchive/lib.rs index 88e47c6354b..b7120acf9aa 100644 --- a/src/libarchive/lib.rs +++ b/src/libarchive/lib.rs @@ -1997,6 +1997,23 @@ impl Archiver { ) .unwrap(); + // `path_traverses_created_symlink` is a lexical check: on + // filesystems that alias differently-encoded names (Unicode + // NFC/NFD normalization on APFS/HFS+), a path component can + // reach a created symlink without byte-matching its recorded + // path. Once this extraction has created any symlink, ask the + // kernel to refuse to follow symlinks while opening file + // entries. `NOFOLLOW_ANY` is 0 on non-Darwin targets. + #[cfg(unix)] + let flags = { + let mut flags = + bun_sys::O::WRONLY | bun_sys::O::CREAT | bun_sys::O::TRUNC; + if !created_symlinks.is_empty() { + flags |= bun_sys::O::NOFOLLOW_ANY; + } + flags + }; + #[cfg(not(unix))] let flags = bun_sys::O::WRONLY | bun_sys::O::CREAT | bun_sys::O::TRUNC; #[cfg(windows)] diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 053313cafd0..f71adefb9b3 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -1289,6 +1289,12 @@ pub mod O { pub const EVTONLY: i32 = libc::O_EVTONLY; #[cfg(not(target_os = "macos"))] pub const EVTONLY: i32 = 0; + // Darwin-only: fail with ELOOP if *any* path component is a symlink, not + // just the final one like O_NOFOLLOW. 0 elsewhere so the bit-or is a no-op. + #[cfg(target_os = "macos")] + pub const NOFOLLOW_ANY: i32 = libc::O_NOFOLLOW_ANY; + #[cfg(not(target_os = "macos"))] + pub const NOFOLLOW_ANY: i32 = 0; } // ────────────────────────────────────────────────────────────────────────── // `File` / `Dir` — high-level handles. Extracted to file.rs / dir.rs From 696ce1a837aa564f0b5cca548105343678787077 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:27:08 +0000 Subject: [PATCH 07/75] install: validate bin entry targets before linking --- src/install/bin.rs | 39 +++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/src/install/bin.rs b/src/install/bin.rs index 3ee27a7d7e1..c705efbb779 100644 --- a/src/install/bin.rs +++ b/src/install/bin.rs @@ -757,6 +757,31 @@ pub fn normalized_bin_name(name: &[u8]) -> &[u8] { name } +/// True when a `bin` entry's target value would resolve outside the package +/// directory (absolute path or `..` traversal). The bin *value* is taken +/// verbatim from package.json, so without this check a malicious package could +/// point a bin link at (and chmod) an arbitrary file on disk (the bug class +/// npm fixed as CVE-2019-16775). +pub fn bin_target_escapes_package_dir(target: &[u8]) -> bool { + if path::is_absolute(target) { + return true; + } + let mut depth: isize = 0; + for component in target.split(|&b| b == b'/' || b == b'\\') { + match component { + b"" | b"." => {} + b".." => { + depth -= 1; + if depth < 0 { + return true; + } + } + _ => depth += 1, + } + } + false +} + pub struct Linker<'a> { pub bin: Bin, @@ -1436,7 +1461,7 @@ impl<'a> Linker<'a> { Tag::File => { let file = self.bin.value.file; let target = file.slice(self.string_buf); - if target.is_empty() { + if target.is_empty() || bin_target_escapes_package_dir(target) { return; } @@ -1481,7 +1506,10 @@ impl<'a> Linker<'a> { let name = named[0].slice(self.string_buf); let normalized_name = normalized_bin_name(name); let target = named[1].slice(self.string_buf); - if normalized_name.is_empty() || target.is_empty() { + if normalized_name.is_empty() + || target.is_empty() + || bin_target_escapes_package_dir(target) + { return; } if normalized_name.len() >= self.abs_dest_buf.len().saturating_sub(dest_off) { @@ -1524,7 +1552,10 @@ impl<'a> Linker<'a> { let normalized_bin_dest = normalized_bin_name(bin_dest); let bin_target = self.extern_string_buf[(i + 1) as usize].slice(self.string_buf); - if bin_target.is_empty() || normalized_bin_dest.is_empty() { + if bin_target.is_empty() + || normalized_bin_dest.is_empty() + || bin_target_escapes_package_dir(bin_target) + { i += 2; continue; } @@ -1564,7 +1595,7 @@ impl<'a> Linker<'a> { Tag::Dir => { let dir = self.bin.value.dir; let target = dir.slice(self.string_buf); - if target.is_empty() { + if target.is_empty() || bin_target_escapes_package_dir(target) { return; } From 0edb723df93fa3ad2e625167ced4e1f46fe146fe Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:28:00 +0000 Subject: [PATCH 08/75] spawn: tighten argument handling for script interpreters --- src/runtime/api/bun/js_bun_spawn_bindings.rs | 19 +++++++++++++ src/runtime/shell/states/Cmd.rs | 24 ++++++++++++++++ src/which/lib.rs | 30 ++++++++++++++++++++ 3 files changed, 73 insertions(+) diff --git a/src/runtime/api/bun/js_bun_spawn_bindings.rs b/src/runtime/api/bun/js_bun_spawn_bindings.rs index 4c066e428f5..f693381ee15 100644 --- a/src/runtime/api/bun/js_bun_spawn_bindings.rs +++ b/src/runtime/api/bun/js_bun_spawn_bindings.rs @@ -257,6 +257,13 @@ fn get_argv( cmds_array.next()?.unwrap(), )?; + // CreateProcessW runs `.bat`/`.cmd` files through `cmd.exe`, which + // re-tokenizes the command line with shell metacharacter rules + // (BatBadBut, CVE-2024-24576 / CVE-2024-27980). libuv's MSVCRT-style + // quoting cannot make that safe, so reject arguments that cmd.exe would + // reinterpret. + let is_batch_file = cfg!(windows) && bun_which::is_batch_file(argv0_result.argv0.as_bytes()); + *argv0 = Some(argv0_result.argv0.as_ptr()); argv.push(argv0_result.arg0.as_ptr()); // Transfer ownership to the caller's backing store so the pointers above @@ -283,6 +290,18 @@ fn get_argv( } let owned = arg.to_owned_slice_z(); + if is_batch_file && bun_which::batch_arg_has_cmd_metachars(owned.as_bytes()) { + return Err(global_this + .err( + jsc::ErrorCode::INVALID_ARG_VALUE, + format_args!( + "The argument 'args[{}]' contains a cmd.exe special character and cannot be safely passed to a .bat/.cmd file. Received {}", + arg_index, + bun_fmt::quote(owned.as_bytes()) + ), + ) + .throw()); + } argv.push(owned.as_ptr()); storage.push(owned); arg_index += 1; diff --git a/src/runtime/shell/states/Cmd.rs b/src/runtime/shell/states/Cmd.rs index 528c9000cd6..fa01e49d5ac 100644 --- a/src/runtime/shell/states/Cmd.rs +++ b/src/runtime/shell/states/Cmd.rs @@ -523,6 +523,30 @@ impl Cmd { format_args!("bun: command not found: {}\n", bstr::BStr::new(&first_arg)), ); }; + // CreateProcessW runs `.bat`/`.cmd` files through `cmd.exe`, which + // re-tokenizes the command line with shell metacharacter rules + // (BatBadBut). libuv's MSVCRT-style quoting cannot make that safe, so + // reject arguments that cmd.exe would reinterpret. + if cfg!(windows) && bun_which::is_batch_file(&resolved) { + let unsafe_arg: Option> = interp + .as_cmd(this) + .args + .iter() + .skip(1) + .find(|a| bun_which::batch_arg_has_cmd_metachars(&a[..a.len() - 1])) + .map(|a| a[..a.len() - 1].to_vec()); + if let Some(arg) = unsafe_arg { + drop(spawn_args); + return Builtin::cmd_write_failing_error( + interp, + this, + format_args!( + "bun: refusing to pass argument with cmd.exe special characters to a batch file: {}\n", + bstr::BStr::new(&arg) + ), + ); + } + } // Replace argv[0] with the resolved absolute path (NUL-terminated for // `execve`). resolved.push(0); diff --git a/src/which/lib.rs b/src/which/lib.rs index 524eac570a3..12d4c240edf 100644 --- a/src/which/lib.rs +++ b/src/which/lib.rs @@ -139,6 +139,36 @@ pub fn ends_with_extension(str: &[u8]) -> bool { false } +/// Returns true when `path` names a Windows batch script (`.cmd` / `.bat`). +/// +/// `CreateProcessW` runs these through `cmd.exe`, which re-tokenizes the +/// command line with shell metacharacter rules ("BatBadBut", +/// CVE-2024-24576 / CVE-2024-27980). Spawn paths must not pass untrusted +/// arguments to one without checking [`batch_arg_has_cmd_metachars`]. +pub fn is_batch_file(path: &[u8]) -> bool { + if path.len() < 4 || path[path.len() - 4] != b'.' { + return false; + } + let file_ext = &path[path.len() - 3..]; + strings::eql_case_insensitive_asciii_check_length(file_ext, b"cmd") + || strings::eql_case_insensitive_asciii_check_length(file_ext, b"bat") +} + +/// Returns true when `arg` contains a byte `cmd.exe` would reinterpret while +/// re-tokenizing the command line of a `.bat`/`.cmd` invocation: `"` breaks +/// out of libuv's MSVCRT-style quoting, `%` expands environment variables +/// even inside quotes, and the rest are command separators / redirection / +/// escape characters in unquoted positions. None of these can be escaped for +/// `cmd.exe`, so callers must reject the spawn instead. +pub fn batch_arg_has_cmd_metachars(arg: &[u8]) -> bool { + arg.iter().any(|&c| { + matches!( + c, + b'"' | b'%' | b'&' | b'|' | b'<' | b'>' | b'^' | b'\r' | b'\n' + ) + }) +} + /// Check if the WPathBuffer holds a existing file path, checking also for windows extensions variants like .exe, .cmd and .bat (internally used by which_win) fn search_bin( buf: &mut WPathBuffer, From 5bafdf896727c7d4f952686703fd1a2cd9f5b2eb Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:28:12 +0000 Subject: [PATCH 09/75] mysql: align ssl mode fallback behavior with postgres driver --- src/sql_jsc/mysql/MySQLConnection.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/sql_jsc/mysql/MySQLConnection.rs b/src/sql_jsc/mysql/MySQLConnection.rs index 53a44a3d49a..45f8b142062 100644 --- a/src/sql_jsc/mysql/MySQLConnection.rs +++ b/src/sql_jsc/mysql/MySQLConnection.rs @@ -706,13 +706,14 @@ impl MySQLConnection { self.tls_status = TLSStatus::SslNotAvailable; match self.ssl_mode { - SSLMode::VerifyCa | SSLMode::VerifyFull => { + // The server did not advertise CLIENT_SSL. `require` and + // stricter must fail rather than silently continue in + // plaintext (matches the Postgres driver's TLSNotAvailable + // handling). + SSLMode::Require | SSLMode::VerifyCa | SSLMode::VerifyFull => { return Err(AnyMySQLError::AuthenticationFailed); } - // require behaves like prefer for postgres.js compatibility, - // allowing graceful fallback to non-SSL when the server - // doesn't support it. - SSLMode::Require | SSLMode::Prefer | SSLMode::Disable => {} + SSLMode::Prefer | SSLMode::Disable => {} } } // Send auth response From 23afa361ca65a6292b173837bd11b0a4b9c07b2c Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:28:31 +0000 Subject: [PATCH 10/75] bunx: tighten bin name extraction from package manifests --- src/runtime/cli/bunx_command.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/runtime/cli/bunx_command.rs b/src/runtime/cli/bunx_command.rs index f2d7c11906a..bbbaa2746d7 100644 --- a/src/runtime/cli/bunx_command.rs +++ b/src/runtime/cli/bunx_command.rs @@ -279,6 +279,21 @@ impl BunxCommand { #[cfg(windows)] const NANOSECONDS_CACHE_VALID: i128 = (Self::SECONDS_CACHE_VALID as i128) * 1_000_000_000; + /// `bin` keys (and the `name` fallback) in package.json are command + /// names, not paths. The bunx cache lives in a world-writable temp dir, + /// so a crafted package.json there could yield a key like + /// `../../../../tmp/x` or `/tmp/x`; `bun_which::which` resolves + /// slash-containing names against the cwd, escaping `node_modules/.bin` + /// and skipping the cache-ownership check before execution. Reject + /// anything that isn't a plain file name. + fn is_safe_bin_name(name: &[u8]) -> bool { + !name.is_empty() + && name != b"." + && name != b".." + && strings::index_of_char(name, b'/').is_none() + && strings::index_of_char(name, b'\\').is_none() + } + fn get_bin_name_from_subpath( transpiler: &mut Transpiler, dir_fd: Fd, @@ -308,7 +323,7 @@ impl BunxCommand { for prop in object.properties.slice() { if let Some(key) = &prop.key { if let Some(bin_name) = key.as_string(&bump) { - if bin_name.is_empty() { + if !Self::is_safe_bin_name(bin_name) { continue; } return Ok(Box::<[u8]>::from(bin_name)); @@ -319,7 +334,9 @@ impl BunxCommand { ExprData::EString(_) => { if let Some(name_expr) = expr.get(b"name") { if let Some(name) = name_expr.as_string(&bump) { - return Ok(Box::<[u8]>::from(name)); + if Self::is_safe_bin_name(name) { + return Ok(Box::<[u8]>::from(name)); + } } } } From 41d69dc83740a801b6bf85f9b2dd0704fcfe92b8 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:28:52 +0000 Subject: [PATCH 11/75] bake: normalize error report strings before terminal output --- .../bake/DevServer/ErrorReportRequest.rs | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/src/runtime/bake/DevServer/ErrorReportRequest.rs b/src/runtime/bake/DevServer/ErrorReportRequest.rs index db780cde5b8..20ae72de2fa 100644 --- a/src/runtime/bake/DevServer/ErrorReportRequest.rs +++ b/src/runtime/bake/DevServer/ErrorReportRequest.rs @@ -135,16 +135,16 @@ impl ErrorReportRequest { let dev: &DevServer = unsafe { &*ctx }.dev.get(); // Read payload, assemble ZigException - let name = read_string32(&mut reader)?; - let message = read_string32(&mut reader)?; - let browser_url = read_string32(&mut reader)?; + let name = sanitize_for_terminal(read_string32(&mut reader)?, &arena); + let message = sanitize_for_terminal(read_string32(&mut reader)?, &arena); + let browser_url = sanitize_for_terminal(read_string32(&mut reader)?, &arena); let stack_count = reader.read_int_le::()?.min(255); // does not support more than 255 let mut frames: Vec = Vec::with_capacity(stack_count as usize); for _ in 0..stack_count { let line = reader.read_int_le::()?; let column = reader.read_int_le::()?; - let function_name = read_string32(&mut reader)?; - let file_name = read_string32(&mut reader)?; + let function_name = sanitize_for_terminal(read_string32(&mut reader)?, &arena); + let file_name = sanitize_for_terminal(read_string32(&mut reader)?, &arena); frames.push(ZigStackFrame { function_name: BunString::init(function_name), source_url: BunString::init(file_name), @@ -577,4 +577,25 @@ fn read_string32<'a>( Ok(s) } +/// The report body is attacker-controlled: `/_bun/report_error` accepts a +/// CORS "simple request" POST from any origin, and these strings are printed +/// to the developer's terminal. Replace C0 control bytes (except `\t`/`\n`) +/// and DEL so the payload cannot inject ANSI/OSC escape sequences (cursor +/// movement, OSC 52 clipboard writes, hyperlinks). +fn sanitize_for_terminal<'a>(s: &'a [u8], arena: &'a Arena) -> &'a [u8] { + fn is_disallowed(b: u8) -> bool { + (b < 0x20 && b != b'\t' && b != b'\n') || b == 0x7f + } + if !s.iter().any(|&b| is_disallowed(b)) { + return s; + } + let copy = arena.alloc_slice_copy(s); + for b in copy.iter_mut() { + if is_disallowed(*b) { + *b = b' '; + } + } + copy +} + // ported from: src/bake/DevServer/ErrorReportRequest.zig From cacf7313c1efb1def25a5a4186a4fd10e7e5f1cc Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:30:02 +0000 Subject: [PATCH 12/75] valkey: scan for complete replies before parsing buffered data --- src/runtime/valkey_jsc/js_valkey.rs | 2 + src/runtime/valkey_jsc/valkey.rs | 46 ++++++--- src/valkey/valkey_protocol.rs | 155 ++++++++++++++++++++++++++++ 3 files changed, 190 insertions(+), 13 deletions(-) diff --git a/src/runtime/valkey_jsc/js_valkey.rs b/src/runtime/valkey_jsc/js_valkey.rs index 98890974bd7..0c0dee561cc 100644 --- a/src/runtime/valkey_jsc/js_valkey.rs +++ b/src/runtime/valkey_jsc/js_valkey.rs @@ -727,6 +727,7 @@ impl JSValkeyClient { idle_timeout_interval_ms: options.idle_timeout_ms, write_buffer: Default::default(), read_buffer: Default::default(), + reply_scanner: Default::default(), retry_attempts: 0, auto_flusher: Default::default(), }), @@ -851,6 +852,7 @@ impl JSValkeyClient { idle_timeout_interval_ms: client.idle_timeout_interval_ms, write_buffer: Default::default(), read_buffer: Default::default(), + reply_scanner: Default::default(), retry_attempts: 0, auto_flusher: Default::default(), }), diff --git a/src/runtime/valkey_jsc/valkey.rs b/src/runtime/valkey_jsc/valkey.rs index 6246d0b1b80..e096e0986f1 100644 --- a/src/runtime/valkey_jsc/valkey.rs +++ b/src/runtime/valkey_jsc/valkey.rs @@ -280,6 +280,8 @@ pub struct ValkeyClient { // Buffer management pub write_buffer: OffsetByteList, pub read_buffer: OffsetByteList, + /// Resumable end-of-reply scanner over `read_buffer.remaining()`. + pub reply_scanner: protocol::ReplyScanner, /// In-flight commands, after the data has been written to the network socket // TODO(port): `Queue` is `std.fifo.LinearFifo(PromisePair, .Dynamic)` in Zig — assume @@ -777,25 +779,40 @@ impl ValkeyClient { break; // Buffer processed completely } + // Incrementally check whether a complete reply is buffered + // before running the allocating tree parser. The scanner + // resumes from its saved position, so the elements of a + // partially-received aggregate are not re-parsed on every + // socket callback (which is quadratic in the element count). + match self.reply_scanner.scan(remaining_buffer) { + Ok(protocol::ScanResult::Complete) => {} + Ok(protocol::ScanResult::NeedMoreData) => { + // Need more data in the buffer, wait for next onData call + if cfg!(debug_assertions) { + debug!( + "read_buffer: needs more data ({} bytes available)", + remaining_buffer.len() + ); + } + return Ok(()); + } + Err(err) => { + self.fail(b"Failed to read data (buffer path)", err)?; + return Ok(()); + } + } + let mut reader = protocol::ValkeyReader::init(remaining_buffer); let before_read_pos = reader_pos(&reader); let value = match reader.read_value() { Ok(v) => v, Err(err) => { - if err == RedisError::InvalidResponse { - // Need more data in the buffer, wait for next onData call - if cfg!(debug_assertions) { - debug!( - "read_buffer: needs more data ({} bytes available)", - remaining_buffer.len() - ); - } - return Ok(()); - } else { - self.fail(b"Failed to read data (buffer path)", err)?; - return Ok(()); - } + // The scanner verified a complete reply is buffered, so + // a parse failure here (including `InvalidResponse`) is + // a protocol error, not a short read. + self.fail(b"Failed to read data (buffer path)", err)?; + return Ok(()); } }; // PORT NOTE: `defer value.deinit(allocator)` — RESPValue should impl Drop. @@ -810,6 +827,7 @@ impl ValkeyClient { } self.read_buffer.consume(bytes_consumed as u32); + self.reply_scanner.reset(); let mut value_to_handle = value; // Use temp var for defer // TODO(port): narrow error set — Zig caller passes err to fail() which takes RedisError; @@ -843,6 +861,7 @@ impl ValkeyClient { current_data_slice.len() - before_read_pos ); } + self.reply_scanner.reset(); self.read_buffer .write(¤t_data_slice[before_read_pos..]) .expect("failed to write remaining stack data to buffer"); @@ -1265,6 +1284,7 @@ impl ValkeyClient { self.socket = socket; self.write_buffer.clear_and_free(); self.read_buffer.clear_and_free(); + self.reply_scanner.reset(); // A fresh socket has opened, so reset per-connection state. Without // this, `send()` would permanently reject with "Connection has failed" // after a previous connection exhausted retries (#29925), and the diff --git a/src/valkey/valkey_protocol.rs b/src/valkey/valkey_protocol.rs index fc218bcf7b2..0cd66aa297b 100644 --- a/src/valkey/valkey_protocol.rs +++ b/src/valkey/valkey_protocol.rs @@ -548,6 +548,161 @@ impl<'a> ValkeyReader<'a> { } } +/// Outcome of an incremental [`ReplyScanner::scan`] pass. +pub enum ScanResult { + /// A complete top-level reply is buffered and safe to hand to + /// [`ValkeyReader::read_value`]. + Complete, + /// The buffer does not yet contain a complete reply. + NeedMoreData, +} + +/// Incrementally locates the end of the next complete RESP reply without +/// materializing any values. +/// +/// `on_data` re-runs the tree parser over the accumulated read buffer on every +/// socket callback. Without this scanner, an aggregate reply (`*N`, `%N`, `~N`, +/// `>N`, `|N`) whose elements arrive in separate TCP segments is re-parsed from +/// its header each time — O(N^2) element parses for an N-element reply, which a +/// hostile server can use to pin the JS thread. The scanner persists its byte +/// offset and the stack of in-progress aggregates across calls so each buffered +/// byte is examined a bounded number of times; the allocating parser only runs +/// once a full reply is known to be present. +#[derive(Default)] +pub struct ReplyScanner { + /// Byte offset of the next unscanned element, relative to the start of the + /// buffer passed to [`ReplyScanner::scan`]. + pos: usize, + /// Remaining child-value count for each in-progress aggregate, outermost + /// first. + stack: Vec, +} + +impl ReplyScanner { + /// Discard all progress. Must be called whenever the underlying buffer is + /// consumed or replaced. + pub fn reset(&mut self) { + self.pos = 0; + self.stack.clear(); + } + + /// Resume scanning `buffer` (the connection's accumulated, unconsumed read + /// buffer) for the end of the next complete reply. `buffer` must be the + /// same byte stream as the previous call with zero or more bytes appended. + pub fn scan(&mut self, buffer: &[u8]) -> Result { + loop { + let mut reader = ValkeyReader { + buffer, + pos: self.pos, + }; + let children = match Self::scan_one(&mut reader, self.stack.len()) { + Ok(children) => children, + // `InvalidResponse` is the parser's "ran out of bytes" sentinel. + Err(RedisError::InvalidResponse) => return Ok(ScanResult::NeedMoreData), + Err(err) => return Err(err), + }; + self.pos = reader.pos; + if let Some(children) = children + && children > 0 + { + self.stack.push(children); + continue; + } + // A scalar or empty aggregate finished; unwind every aggregate it + // completes. + while let Some(remaining) = self.stack.last_mut() { + *remaining -= 1; + if *remaining > 0 { + break; + } + self.stack.pop(); + } + if self.stack.is_empty() { + return Ok(ScanResult::Complete); + } + } + } + + /// Skip a single element starting at `reader.pos`. Returns `Some(n)` for an + /// aggregate expecting `n` further child values, or `None` for a + /// fully-skipped scalar. `InvalidResponse` means the element is not yet + /// fully buffered. + fn scan_one(reader: &mut ValkeyReader<'_>, depth: usize) -> Result, RedisError> { + let type_byte = reader.read_byte()?; + let ty = RESPType::from_byte(type_byte).ok_or(RedisError::InvalidResponseType)?; + match ty { + RESPType::SimpleString + | RESPType::Error + | RESPType::Integer + | RESPType::Null + | RESPType::Double + | RESPType::Boolean + | RESPType::BigNumber => { + let _ = reader.read_until_crlf()?; + Ok(None) + } + RESPType::BulkString | RESPType::BlobError | RESPType::VerbatimString => { + let invalid = match ty { + RESPType::BlobError => RedisError::InvalidBlobError, + RESPType::VerbatimString => RedisError::InvalidVerbatimString, + _ => RedisError::InvalidBulkString, + }; + let len = reader.read_integer()?; + if len < 0 { + // Only `$-1` (RESP2 null bulk string) is legal; the tree + // parser rejects negative `!`/`=` lengths. + return if ty == RESPType::BulkString { + Ok(None) + } else { + Err(invalid) + }; + } + if len > ValkeyReader::MAX_BULK_LEN { + return Err(invalid); + } + let len = usize::try_from(len).expect("int cast"); + // The payload plus its trailing CRLF must be fully buffered. + if reader.buffer.len() - reader.pos < len + 2 { + return Err(RedisError::InvalidResponse); + } + if reader.buffer[reader.pos + len] != b'\r' + || reader.buffer[reader.pos + len + 1] != b'\n' + { + return Err(invalid); + } + reader.pos += len + 2; + Ok(None) + } + RESPType::Array | RESPType::Set | RESPType::Push => { + if depth >= ValkeyReader::MAX_NESTING_DEPTH { + return Err(RedisError::NestingDepthExceeded); + } + let len = reader.read_integer()?; + Ok(Some(u64::try_from(len).unwrap_or(0))) + } + RESPType::Map => { + if depth >= ValkeyReader::MAX_NESTING_DEPTH { + return Err(RedisError::NestingDepthExceeded); + } + let len = reader.read_integer()?; + Ok(Some(u64::try_from(len).unwrap_or(0).saturating_mul(2))) + } + RESPType::Attribute => { + if depth >= ValkeyReader::MAX_NESTING_DEPTH { + return Err(RedisError::NestingDepthExceeded); + } + let len = reader.read_integer()?; + Ok(Some( + u64::try_from(len) + .unwrap_or(0) + .saturating_mul(2) + .saturating_add(1), + )) + } + } + } +} + pub struct MapEntry { pub key: RESPValue, pub value: RESPValue, From 703b3b689de438a5e84567fa88a0f48edb8526cf Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:30:56 +0000 Subject: [PATCH 13/75] http: bound decompressed response body growth --- src/brotli/lib.rs | 8 ++++++++ src/http/Decompressor.rs | 14 +++++++++++--- src/zlib/lib.rs | 8 ++++++++ src/zstd/lib.rs | 9 +++++++++ 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/brotli/lib.rs b/src/brotli/lib.rs index 7b0d0feb77a..7ff0f78035c 100644 --- a/src/brotli/lib.rs +++ b/src/brotli/lib.rs @@ -59,6 +59,9 @@ pub struct BrotliReaderArrayList<'a> { pub state: ReaderState, pub total_out: usize, pub total_in: usize, + /// Decompression-bomb guard: `read_all` errors instead of growing the + /// output past this many bytes. Defaults to unbounded. + pub max_output_size: usize, pub flush_op: c::BrotliEncoderOperation, pub finish_flush_op: c::BrotliEncoderOperation, pub full_flush_op: c::BrotliEncoderOperation, @@ -152,6 +155,7 @@ impl<'a> BrotliReaderArrayList<'a> { state: ReaderState::Uninitialized, total_out: 0, total_in: 0, + max_output_size: usize::MAX, flush_op, finish_flush_op, full_flush_op, @@ -237,6 +241,10 @@ impl<'a> BrotliReaderArrayList<'a> { return Err(err!("ShortRead")); } c::BrotliDecoderResult::needs_more_output => { + if self.list_ptr.len() >= self.max_output_size { + self.state = ReaderState::Error; + return Err(err!("BrotliDecompressionError")); + } let target = self.list_ptr.capacity() + 4096; self.list_ptr .reserve(target.saturating_sub(self.list_ptr.len())); diff --git a/src/http/Decompressor.rs b/src/http/Decompressor.rs index e706db24449..6c550c9c20e 100644 --- a/src/http/Decompressor.rs +++ b/src/http/Decompressor.rs @@ -54,6 +54,11 @@ unsafe fn seat<'a>(input: &'a [u8], out: &'a mut Vec) -> (&'static [u8], &'s } } +/// Decompression-bomb guard for response bodies inflated on the HTTP thread: +/// a hostile server must not be able to expand a tiny compressed payload into +/// an unbounded allocation. +const MAX_DECOMPRESSED_BODY_SIZE: usize = 1024 * 1024 * 1024; + impl Decompressor { // PORT NOTE: Zig `deinit` called `that.deinit()` on the active reader and // reset to `.none`. The boxed readers' `Drop` impls call `end()`, so an @@ -77,7 +82,7 @@ impl Decompressor { let (input, out) = unsafe { seat(buffer, &mut body_out_str.list) }; match encoding { Encoding::Gzip | Encoding::Deflate => { - let reader = ZlibReaderArrayList::init_with_options_and_list_allocator( + let mut reader = ZlibReaderArrayList::init_with_options_and_list_allocator( input, out, // PORT NOTE: Zig passed `body_out_str.allocator` and @@ -97,26 +102,29 @@ impl Decompressor { ..Default::default() }, )?; + reader.max_output_size = MAX_DECOMPRESSED_BODY_SIZE; *self = Decompressor::Zlib(reader); return Ok(()); } Encoding::Brotli => { - let reader = BrotliReaderArrayList::new_with_options( + let mut reader = BrotliReaderArrayList::new_with_options( input, out, // PORT NOTE: Zig passed `body_out_str.allocator`; dropped per §Allocators. &Default::default(), )?; + reader.max_output_size = MAX_DECOMPRESSED_BODY_SIZE; *self = Decompressor::Brotli(reader); return Ok(()); } Encoding::Zstd => { - let reader = ZstdReaderArrayList::init_with_list_allocator( + let mut reader = ZstdReaderArrayList::init_with_list_allocator( input, out, // PORT NOTE: Zig passed `body_out_str.allocator` and // `bun.http.default_allocator`; dropped per §Allocators. )?; + reader.max_output_size = MAX_DECOMPRESSED_BODY_SIZE; *self = Decompressor::Zstd(reader); return Ok(()); } diff --git a/src/zlib/lib.rs b/src/zlib/lib.rs index ea4e463fa42..b2b9651514e 100644 --- a/src/zlib/lib.rs +++ b/src/zlib/lib.rs @@ -367,6 +367,9 @@ pub struct ZlibReaderArrayList<'a> { pub zlib: zStream_struct, // PORT NOTE: allocator field dropped (global mimalloc) pub state: ZlibReaderArrayListState, + /// Decompression-bomb guard: `read_all` errors instead of growing the + /// output past this many bytes. Defaults to unbounded. + pub max_output_size: usize, } impl<'a> Drop for ZlibReaderArrayList<'a> { @@ -414,6 +417,7 @@ impl<'a> ZlibReaderArrayList<'a> { list_ptr: list, zlib: bun_core::ffi::zeroed(), state: ZlibReaderArrayListState::Uninitialized, + max_output_size: usize::MAX, }); let list_len = zlib_reader.list_ptr.len(); @@ -510,6 +514,10 @@ impl<'a> ZlibReaderArrayList<'a> { // flush parameter). if self.zlib.avail_out == 0 { + if self.zlib.total_out as usize >= self.max_output_size { + self.state = ZlibReaderArrayListState::Error; + return Err(ZlibError::ZlibError); + } // SAFETY: zlib writes the tail; len is truncated to `total_out` before any read. let (next_out, avail_out) = unsafe { self.list_ptr.reserve_expand_tail(4096) }; self.zlib.next_out = next_out; diff --git a/src/zstd/lib.rs b/src/zstd/lib.rs index a0a1c76b960..767141c9f9f 100644 --- a/src/zstd/lib.rs +++ b/src/zstd/lib.rs @@ -312,6 +312,9 @@ pub struct ZstdReaderArrayList<'a> { pub state: State, pub total_out: usize, pub total_in: usize, + /// Decompression-bomb guard: `read_all` errors instead of growing the + /// output past this many bytes. Defaults to unbounded. + pub max_output_size: usize, } impl<'a> ZstdReaderArrayList<'a> { @@ -343,6 +346,7 @@ impl<'a> ZstdReaderArrayList<'a> { state: State::Uninitialized, total_out: 0, total_in: 0, + max_output_size: usize::MAX, })) } @@ -380,6 +384,11 @@ impl<'a> ZstdReaderArrayList<'a> { return Ok(()); } + if self.list_ptr.len() >= self.max_output_size { + self.state = State::Error; + return Err(ZstdError::ZstdDecompressionError); + } + // SAFETY: write-only spare; ZSTD_decompressStream initializes the // first `out_buf.pos` bytes. let spare = unsafe { bun_core::vec::reserve_spare_bytes(self.list_ptr, 4096) }; From e805175feb250dfe1411f2e7d4574b06146a549e Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:31:14 +0000 Subject: [PATCH 14/75] http2: account for stream table size in session memory usage --- src/runtime/api/bun/h2_frame_parser.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/runtime/api/bun/h2_frame_parser.rs b/src/runtime/api/bun/h2_frame_parser.rs index 3166f2891b1..3c4f2f0a2e6 100644 --- a/src/runtime/api/bun/h2_frame_parser.rs +++ b/src/runtime/api/bun/h2_frame_parser.rs @@ -4442,6 +4442,20 @@ impl H2FrameParser { if stream_identifier & 1 == 0 { return None; } + // Bound per-connection stream state before allocating: a peer flooding + // tiny HEADERS frames with fresh stream ids would otherwise grow + // `streams` (and the JS objects pinned by `streamStart`) without limit. + // Mirrors the maxSessionMemory check on the PING and request() paths. + if self.get_session_memory_usage() > self.max_session_memory.get() as usize { + self.send_go_away( + stream_identifier, + ErrorCode::ENHANCE_YOUR_CALM, + b"ENHANCE_YOUR_CALM", + self.last_stream_id.get(), + true, + ); + return None; + } self.handle_received_stream_id(stream_identifier) } @@ -5518,7 +5532,9 @@ impl H2FrameParser { impl H2FrameParser { // get memory usage in MB fn get_session_memory_usage(&self) -> usize { - (self.write_buffer.get().len_u32() as usize + self.queued_data_size.get() as usize) + (self.write_buffer.get().len_u32() as usize + + self.queued_data_size.get() as usize + + self.streams.get().len() * core::mem::size_of::()) / 1024 / 1024 } From 089d047af482f3ae818200fc51792171bed85f79 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:31:27 +0000 Subject: [PATCH 15/75] mysql: bound result set column count --- src/sql_jsc/mysql/MySQLConnection.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/sql_jsc/mysql/MySQLConnection.rs b/src/sql_jsc/mysql/MySQLConnection.rs index 45f8b142062..0f28e938a8f 100644 --- a/src/sql_jsc/mysql/MySQLConnection.rs +++ b/src/sql_jsc/mysql/MySQLConnection.rs @@ -1416,6 +1416,14 @@ impl MySQLConnection { // Can't be 0 return Err(AnyMySQLError::UnexpectedPacket); } + // field_count is a server-controlled lenenc int (up to 2^64-1) and is + // used below to size a Vec (~256 bytes each). MySQL + // hard-caps a result set at 4096 columns (MAX_FIELDS), so anything + // larger is a malformed/hostile packet trying to make us commit + // gigabytes from a ~13-byte response. + if header.field_count > 4096 { + return Err(AnyMySQLError::UnexpectedPacket); + } if statement.columns.len() as u64 != header.field_count { bun_core::scoped_log!( MySQLConnection, From aae8a444687c5383b2ed356eb05257514ca39e61 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:31:54 +0000 Subject: [PATCH 16/75] bundler: escape source path comments in css output --- .../linker_context/postProcessCSSChunk.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/bundler/linker_context/postProcessCSSChunk.rs b/src/bundler/linker_context/postProcessCSSChunk.rs index db469638061..54c558b0bfd 100644 --- a/src/bundler/linker_context/postProcessCSSChunk.rs +++ b/src/bundler/linker_context/postProcessCSSChunk.rs @@ -73,8 +73,22 @@ pub fn post_process_css_chunk( j.push_static(b"/* "); line_offset.advance(b"/* "); - j.push_static(pretty); - line_offset.advance(pretty); + // A `*/` in the path would terminate the comment early and let the + // rest of the path be parsed as CSS in the bundled output. + if bun_core::strings::contains(pretty, b"*/") { + let mut escaped = Vec::with_capacity(pretty.len() + 1); + for &byte in pretty { + if byte == b'/' && escaped.last() == Some(&b'*') { + escaped.push(b'\\'); + } + escaped.push(byte); + } + line_offset.advance(&escaped); + j.push_owned(escaped.into_boxed_slice()); + } else { + j.push_static(pretty); + line_offset.advance(pretty); + } j.push_static(b" */\n"); line_offset.advance(b" */\n"); From f2f2179cafdcfdc1372caa3774ddde88dbc9af09 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:32:18 +0000 Subject: [PATCH 17/75] valkey: bound aggregate preallocation while parsing replies --- src/valkey/valkey_protocol.rs | 39 +++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/src/valkey/valkey_protocol.rs b/src/valkey/valkey_protocol.rs index 0cd66aa297b..c3dacef0af5 100644 --- a/src/valkey/valkey_protocol.rs +++ b/src/valkey/valkey_protocol.rs @@ -215,11 +215,18 @@ impl fmt::Display for RESPValue { pub struct ValkeyReader<'a> { buffer: &'a [u8], pos: usize, + /// Bytes of aggregate `Vec` preallocation still allowed for the current + /// `read_value` call. See `take_prealloc_budget`. + prealloc_budget: usize, } impl<'a> ValkeyReader<'a> { pub fn init(buffer: &'a [u8]) -> ValkeyReader<'a> { - ValkeyReader { buffer, pos: 0 } + ValkeyReader { + buffer, + pos: 0, + prealloc_budget: buffer.len(), + } } /// Current read offset into the underlying buffer. @@ -331,7 +338,20 @@ impl<'a> ValkeyReader<'a> { /// attacker-chosen size. const MAX_BULK_LEN: i64 = 512 * 1024 * 1024; + /// Caps an aggregate's `Vec::with_capacity` so the total bytes reserved + /// across the whole parse — every nesting level combined — never exceed + /// the input buffer's size. Re-applying a per-level "remaining buffer" + /// cap at each of up to `MAX_NESTING_DEPTH` levels would let a hostile + /// server amplify a few KB of nested aggregate headers carrying huge + /// declared lengths into gigabytes of reserved capacity. + fn take_prealloc_budget(&mut self, len: usize, element_size: usize) -> usize { + let cap = len.min(self.prealloc_budget / element_size.max(1)); + self.prealloc_budget -= cap * element_size; + cap + } + pub fn read_value(&mut self) -> Result { + self.prealloc_budget = self.buffer.len() - self.pos; self.read_value_with_depth(0) } @@ -384,7 +404,8 @@ impl<'a> ValkeyReader<'a> { return Ok(RESPValue::Array(Vec::new())); } let len = usize::try_from(len).expect("int cast"); - let mut array = Vec::with_capacity(len.min(self.buffer.len() - self.pos)); + let mut array = + Vec::with_capacity(self.take_prealloc_budget(len, size_of::())); // errdefer cleanup handled by Vec Drop on `?` let mut i: usize = 0; while i < len { @@ -436,7 +457,8 @@ impl<'a> ValkeyReader<'a> { } let len = usize::try_from(len).expect("int cast"); - let mut entries = Vec::with_capacity(len.min(self.buffer.len() - self.pos)); + let mut entries = + Vec::with_capacity(self.take_prealloc_budget(len, size_of::())); // errdefer cleanup handled by Vec Drop on `?` let mut i: usize = 0; while i < len { @@ -458,7 +480,8 @@ impl<'a> ValkeyReader<'a> { } let len = usize::try_from(len).expect("int cast"); - let mut set = Vec::with_capacity(len.min(self.buffer.len() - self.pos)); + let mut set = + Vec::with_capacity(self.take_prealloc_budget(len, size_of::())); // errdefer cleanup handled by Vec Drop on `?` let mut i: usize = 0; while i < len { @@ -477,7 +500,8 @@ impl<'a> ValkeyReader<'a> { } let len = usize::try_from(len).expect("int cast"); - let mut attrs = Vec::with_capacity(len.min(self.buffer.len() - self.pos)); + let mut attrs = + Vec::with_capacity(self.take_prealloc_budget(len, size_of::())); // errdefer cleanup handled by Vec Drop on `?` let mut i: usize = 0; while i < len { @@ -526,7 +550,9 @@ impl<'a> ValkeyReader<'a> { // Read the rest of the data let data_len = usize::try_from(len - 1).expect("int cast"); - let mut data = Vec::with_capacity(data_len.min(self.buffer.len() - self.pos)); + let mut data = Vec::with_capacity( + self.take_prealloc_budget(data_len, size_of::()), + ); // errdefer cleanup handled by Vec Drop on `?` let mut i: usize = 0; while i < data_len { @@ -594,6 +620,7 @@ impl ReplyScanner { let mut reader = ValkeyReader { buffer, pos: self.pos, + prealloc_budget: 0, }; let children = match Self::scan_one(&mut reader, self.stack.len()) { Ok(children) => children, From 1c0bbfbe6e2defd5eaabfd16a6daa26ce5273346 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:32:39 +0000 Subject: [PATCH 18/75] blob: tighten content type validation --- src/runtime/webcore/Blob.rs | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 607ce8df5f4..37287a4ba3c 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -78,6 +78,15 @@ pub extern "C" fn blob_store_array_buffer_deallocator(_bytes: *mut c_void, ctx: } } +/// WHATWG File API §3.1: a Blob/File `type` is only used when every character +/// is in the range U+0020 to U+007E; otherwise it is treated as the empty +/// string. Stricter than `is_all_ascii`: also rejects control characters such +/// as CR/LF, which would otherwise be stored in `content_type` and written +/// verbatim into outgoing HTTP headers. +fn is_valid_blob_type(slice: &[u8]) -> bool { + slice.iter().all(|&c| matches!(c, 0x20..=0x7E)) +} + /// Result delivered to `ReadBytesHandler::on_read_bytes`. pub enum ReadBytesResult { /// global-allocator-owned by the callback. @@ -1336,7 +1345,7 @@ impl BlobExt for Blob { } let content_type_str = content_type.to_slice(global_this)?; let slice = content_type_str.slice(); - if strings::is_all_ascii(slice) { + if is_valid_blob_type(slice) { self.free_content_type(); self.content_type_was_set.set(true); @@ -1775,7 +1784,7 @@ impl BlobExt for Blob { } let content_type_str = content_type.to_slice(global_this)?; let slice = content_type_str.slice(); - if strings::is_all_ascii(slice) { + if is_valid_blob_type(slice) { self.free_content_type(); self.content_type_was_set.set(true); // SAFETY: see other `mime_type` call sites. @@ -2107,7 +2116,7 @@ impl BlobExt for Blob { let zig_str = content_type_.get_zig_string(global_this)?; let slicer = zig_str.to_slice(); let slice = slicer.slice(); - if !strings::is_all_ascii(slice) { + if !is_valid_blob_type(slice) { break 'inner; } @@ -2463,7 +2472,7 @@ impl BlobExt for Blob { if content_type.is_string() { let content_type_str = content_type.to_slice(global_this)?; let slice = content_type_str.slice(); - if !strings::is_all_ascii(slice) { + if !is_valid_blob_type(slice) { break 'inner; } blob.content_type_was_set.set(true); @@ -5584,7 +5593,7 @@ pub fn jsdom_file_construct_( if content_type.is_string() { let content_type_str = content_type.to_slice(global_this)?; let slice = content_type_str.slice(); - if !strings::is_all_ascii(slice) { + if !is_valid_blob_type(slice) { break 'inner; } blob.content_type_was_set.set(true); @@ -5684,7 +5693,7 @@ pub fn construct_bun_file( if file_type.is_string() { let str = file_type.to_slice(global_object)?; let slice = str.slice(); - if !strings::is_all_ascii(slice) { + if !is_valid_blob_type(slice) { break 'inner; } blob.content_type_was_set.set(true); From ff45183c209409e487728e449a4b7804e45bab92 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:33:00 +0000 Subject: [PATCH 19/75] node:http: refine link header value pattern --- src/js/internal/validators.ts | 6 ++- .../http/early-hints-crlf-injection.test.ts | 43 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/js/internal/validators.ts b/src/js/internal/validators.ts index f10b6c30522..11f26a3eea3 100644 --- a/src/js/internal/validators.ts +++ b/src/js/internal/validators.ts @@ -21,8 +21,12 @@ function checkIsHttpToken(val) { This regex validates any string surrounded by angle brackets (not necessarily a valid URI reference) followed by zero or more link-params separated by semicolons. + + The parameter name excludes "=" so it cannot overlap with the optional + "=value" suffix; otherwise inputs like "<>;a=b;a=b;...;a=b " trigger + catastrophic backtracking. */ -const linkValueRegExp = /^(?:<[^>]*>)(?:\s*;\s*[^;"\s]+(?:=(")?[^;"\s]*\1)?)*$/; +const linkValueRegExp = /^(?:<[^>]*>)(?:\s*;\s*[^;"\s=]+(?:=(")?[^;"\s]*\1)?)*$/; function validateLinkHeaderFormat(value, name) { if (typeof value === "undefined" || !RegExpPrototypeExec.$call(linkValueRegExp, value)) { throw $ERR_INVALID_ARG_VALUE( diff --git a/test/js/node/http/early-hints-crlf-injection.test.ts b/test/js/node/http/early-hints-crlf-injection.test.ts index ad24729659a..38678d6d3e2 100644 --- a/test/js/node/http/early-hints-crlf-injection.test.ts +++ b/test/js/node/http/early-hints-crlf-injection.test.ts @@ -134,4 +134,47 @@ describe.concurrent("writeEarlyHints", () => { expect(stdout).toContain("body:ok"); expect(exitCode).toBe(0); }); + + test("rejects pathological link value without catastrophic backtracking", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const http = require("node:http"); + const server = http.createServer((req, res) => { + try { + res.writeEarlyHints({ + link: "" + ";a=b".repeat(32) + " ", + }); + console.log("FAIL: no error thrown"); + process.exit(1); + } catch (e) { + console.log("error_code:" + e.code); + res.writeHead(200); + res.end("ok"); + } + }); + server.listen(0, () => { + http.get({ port: server.address().port }, (res) => { + let data = ""; + res.on("data", (c) => data += c); + res.on("end", () => { + console.log("body:" + data); + server.close(); + }); + }); + }); + `, + ], + env: bunEnv, + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("error_code:ERR_INVALID_ARG_VALUE"); + expect(stdout).toContain("body:ok"); + expect(exitCode).toBe(0); + }); }); From e017fce3469a214b79e5a5632fb9595419d46f38 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:33:07 +0000 Subject: [PATCH 20/75] node:http: tighten link header value validation --- src/js/internal/validators.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/js/internal/validators.ts b/src/js/internal/validators.ts index 11f26a3eea3..038b1b1f7eb 100644 --- a/src/js/internal/validators.ts +++ b/src/js/internal/validators.ts @@ -27,8 +27,13 @@ function checkIsHttpToken(val) { catastrophic backtracking. */ const linkValueRegExp = /^(?:<[^>]*>)(?:\s*;\s*[^;"\s=]+(?:=(")?[^;"\s]*\1)?)*$/; +const linkValueForbiddenCharsRegExp = /[\r\n]/; function validateLinkHeaderFormat(value, name) { - if (typeof value === "undefined" || !RegExpPrototypeExec.$call(linkValueRegExp, value)) { + if ( + typeof value === "undefined" || + !RegExpPrototypeExec.$call(linkValueRegExp, value) || + RegExpPrototypeExec.$call(linkValueForbiddenCharsRegExp, value) !== null + ) { throw $ERR_INVALID_ARG_VALUE( name, value, From 1d80d2cbfedaedeb62bf6431a9e84266f8d06656 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:34:03 +0000 Subject: [PATCH 21/75] yaml: bound merge key expansion work --- src/parsers/yaml.rs | 50 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/src/parsers/yaml.rs b/src/parsers/yaml.rs index 73863d95286..1675a6b5f84 100644 --- a/src/parsers/yaml.rs +++ b/src/parsers/yaml.rs @@ -791,6 +791,8 @@ pub enum ParseError { MultipleYamlDirectives, #[error("InvalidIndentation")] InvalidIndentation, + #[error("MergeKeyLimitExceeded")] + MergeKeyLimitExceeded, #[error("StackOverflow")] StackOverflow, } @@ -2158,6 +2160,7 @@ pub enum ParseResultError { UnexpectedDocumentEnd { pos: Pos }, MultipleYamlDirectives { pos: Pos }, InvalidIndentation { pos: Pos }, + MergeKeyLimitExceeded { pos: Pos }, } impl ParseResultError { @@ -2208,6 +2211,9 @@ impl ParseResultError { ParseResultError::InvalidIndentation { pos } => { log.add_error(Some(source), pos.loc(), b"Invalid indentation"); } + ParseResultError::MergeKeyLimitExceeded { pos } => { + log.add_error(Some(source), pos.loc(), b"Merge key expansion is too large"); + } } Ok(()) } @@ -2265,6 +2271,9 @@ impl ParseResult { ParseError::InvalidIndentation => { ParseResultError::InvalidIndentation { pos: parser.pos } } + ParseError::MergeKeyLimitExceeded => ParseResultError::MergeKeyLimitExceeded { + pos: parser.token.start, + }, }; ParseResult::Err(e) } @@ -2310,6 +2319,11 @@ pub struct Parser<'i, Enc: Encoding> { pub whitespace_buf: Vec>, pub stack_check: StackCheck, + + /// Total key-equality comparisons performed while deduplicating `<<` merge + /// keys across the whole document. Bounded so that aliased anchors cannot + /// turn a small input into quadratic work. + pub merge_key_comparisons: u64, } impl<'i, Enc: Encoding> Parser<'i, Enc> { @@ -2332,6 +2346,7 @@ impl<'i, Enc: Encoding> Parser<'i, Enc> { tag_handles: StringHashMap::default(), whitespace_buf: Vec::new(), stack_check: StackCheck::init(), + merge_key_comparisons: 0, } } @@ -2690,7 +2705,7 @@ impl<'i, Enc: Encoding> Parser<'i, Enc> { })?; } else { let value = self.parse_node(ParseNodeOptions::default())?; - props.append_maybe_merge(key, value)?; + props.append_maybe_merge(key, value, &mut self.merge_key_comparisons)?; } if matches!(self.token.data, TokenData::CollectEntry) { @@ -2945,7 +2960,7 @@ impl<'i, Enc: Encoding> Parser<'i, Enc> { } }; - props.append_maybe_merge(first_key, value)?; + props.append_maybe_merge(first_key, value, &mut self.merge_key_comparisons)?; } if self.context.get() == Context::FlowIn { @@ -3052,7 +3067,7 @@ impl<'i, Enc: Encoding> Parser<'i, Enc> { } }; - props.append_maybe_merge(key, value)?; + props.append_maybe_merge(key, value, &mut self.merge_key_comparisons)?; } Ok(Expr::init( @@ -3082,6 +3097,12 @@ pub struct MappingProps { } impl MappingProps { + /// Upper bound on the total number of key-equality comparisons performed + /// while deduplicating `<<` merge keys across a single document. Aliases + /// make re-merging a large anchor nearly free in input bytes, so without + /// a cap a small document can force quadratic work. + pub const MAX_MERGE_KEY_COMPARISONS: u64 = 1 << 24; + pub fn init() -> Self { Self { list: bun_alloc::AstAlloc::vec(), @@ -3093,11 +3114,19 @@ impl MappingProps { Ok(()) } - pub fn merge(&mut self, merge_props: &[G::Property]) -> Result<(), AllocError> { + pub fn merge( + &mut self, + merge_props: &[G::Property], + comparisons: &mut u64, + ) -> Result<(), ParseError> { self.list.reserve(merge_props.len()); // PERF(port): was ensureUnusedCapacity 'next_merge_prop: for merge_prop in merge_props.iter().rev() { let merge_key = merge_prop.key.as_ref().unwrap(); + *comparisons = comparisons.saturating_add(self.list.len() as u64); + if *comparisons > Self::MAX_MERGE_KEY_COMPARISONS { + return Err(ParseError::MergeKeyLimitExceeded); + } for existing_prop in self.list.iter() { let existing_key = existing_prop.key.as_ref().unwrap(); if Parser::::yaml_merge_key_expr_eql(existing_key, merge_key) { @@ -3120,7 +3149,12 @@ impl MappingProps { Ok(()) } - pub fn append_maybe_merge(&mut self, key: Expr, value: Expr) -> Result<(), AllocError> { + pub fn append_maybe_merge( + &mut self, + key: Expr, + value: Expr, + comparisons: &mut u64, + ) -> Result<(), ParseError> { let is_merge_key = match &key.data { ast::ExprData::EString(key_str) => key_str.eql_comptime(b"<<"), _ => false, @@ -3137,14 +3171,16 @@ impl MappingProps { } match &value.data { - ast::ExprData::EObject(value_obj) => self.merge(value_obj.properties.slice()), + ast::ExprData::EObject(value_obj) => { + self.merge(value_obj.properties.slice(), comparisons) + } ast::ExprData::EArray(value_arr) => { for item in value_arr.items.slice() { let item_obj = match &item.data { ast::ExprData::EObject(obj) => obj, _ => continue, }; - self.merge(item_obj.properties.slice())?; + self.merge(item_obj.properties.slice(), comparisons)?; } Ok(()) } From ef2fb4fa675771b2e1dcbc656dcb38d1fee68ce3 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:34:44 +0000 Subject: [PATCH 22/75] shell: track literal brace metacharacters during expansion --- src/runtime/shell/states/Expansion.rs | 56 +++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/src/runtime/shell/states/Expansion.rs b/src/runtime/shell/states/Expansion.rs index 6a4367626bd..503503236c2 100644 --- a/src/runtime/shell/states/Expansion.rs +++ b/src/runtime/shell/states/Expansion.rs @@ -31,6 +31,12 @@ pub struct Expansion { /// boundary is hit (IFS split / glob result), it is flushed into `out` /// via `push_current_out`. Spec: Expansion.zig `current_out`. pub current_out: Vec, + /// Byte offsets in `current_out` written by literal `BraceBegin`/`Comma`/ + /// `BraceEnd` atoms. Only these positions may act as brace-expansion + /// metacharacters in `do_brace_expand`; `{`/`,`/`}` bytes from any other + /// source (JS interpolation, quoted text, `$var`, command substitution) + /// are data and must not change the expansion structure. + pub brace_meta_offsets: Vec, pub child_script: Option, /// Whether the in-flight command substitution was `"$(...)"` (no IFS /// splitting on its result). Only meaningful while `state == CmdSubst`. @@ -104,6 +110,7 @@ impl Expansion { word_idx: 0, out: ExpansionOut::default(), current_out: Vec::new(), + brace_meta_offsets: Vec::new(), child_script: None, cmd_subst_quoted: false, has_quoted_empty: false, @@ -179,6 +186,7 @@ impl Expansion { shell, simple, &mut me.current_out, + &mut me.brace_meta_offsets, &mut me.has_quoted_empty, true, event_loop, @@ -224,6 +232,7 @@ impl Expansion { // All sub-atoms expanded — post-process leading tilde then finish. if leading_tilde { let home = me.base.shell().get_homedir(); + let len_before = me.current_out.len(); match me.current_out.first() { Some(b'/') | Some(b'\\') => { me.current_out.splice(0..0, home.slice().iter().copied()); @@ -236,6 +245,17 @@ impl Expansion { } None => {} } + // The first two arms prepend; shift the recorded brace + // metacharacter offsets so they keep pointing at the same + // bytes. The `extend_from_slice` arm only runs when + // `current_out` (and therefore `brace_meta_offsets`) is + // empty, so the shift is a no-op there. + let prepended = (me.current_out.len() - len_before) as u32; + if prepended != 0 { + for off in &mut me.brace_meta_offsets { + *off += prepended; + } + } home.deref(); } // Spec (Expansion.zig next() lines 209-221): brace expansion @@ -264,7 +284,24 @@ impl Expansion { /// `expand_simple_no_io`) and push each variant as a separate argv word. fn do_brace_expand(me: &mut Expansion) { use bun_shell_parser::braces; - let brace_str = &me.current_out[..]; + // Only the `{`/`,`/`}` bytes recorded in `brace_meta_offsets` (written + // by literal BraceBegin/Comma/BraceEnd atoms) are brace-expansion + // metacharacters. Bytes from Text/Var/cmd-subst expansion — notably JS + // `${...}` interpolations — are data: backslash-escape them so the + // brace lexer cannot be steered into emitting extra argv words. + let mut escaped: Vec = Vec::with_capacity(me.current_out.len()); + let mut next_meta = 0usize; + for (i, &b) in me.current_out.iter().enumerate() { + if next_meta < me.brace_meta_offsets.len() + && me.brace_meta_offsets[next_meta] as usize == i + { + next_meta += 1; + } else if matches!(b, b'{' | b'}' | b',' | b'\\') { + escaped.push(b'\\'); + } + escaped.push(b); + } + let brace_str = &escaped[..]; let mut lexer_output = if bun_core::is_all_ascii(brace_str) { bun_core::handle_oom(braces::Lexer::tokenize(brace_str)) } else { @@ -359,6 +396,7 @@ impl Expansion { shell: &ShellExecEnv, atom: &ast::SimpleAtom, out: &mut Vec, + brace_meta_offsets: &mut Vec, has_quoted_empty: &mut bool, expand_tilde: bool, event_loop: EventLoopHandle, @@ -393,9 +431,18 @@ impl Expansion { } ast::SimpleAtom::Asterisk => out.push(b'*'), ast::SimpleAtom::DoubleAsterisk => out.extend_from_slice(b"**"), - ast::SimpleAtom::BraceBegin => out.push(b'{'), - ast::SimpleAtom::BraceEnd => out.push(b'}'), - ast::SimpleAtom::Comma => out.push(b','), + ast::SimpleAtom::BraceBegin => { + brace_meta_offsets.push(out.len() as u32); + out.push(b'{'); + } + ast::SimpleAtom::BraceEnd => { + brace_meta_offsets.push(out.len() as u32); + out.push(b'}'); + } + ast::SimpleAtom::Comma => { + brace_meta_offsets.push(out.len() as u32); + out.push(b','); + } ast::SimpleAtom::Tilde => { if expand_tilde { let home = shell.get_homedir(); @@ -419,6 +466,7 @@ impl Expansion { me.out.bounds.push(me.out.buf.len() as u32); } me.out.buf.append(&mut me.current_out); + me.brace_meta_offsets.clear(); } /// Spec: Expansion.zig `postSubshellExpansion` + `convertNewlinesToSpaces`. From c9076cdf712ad8290766fa951a8bf53555e3181e Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:35:14 +0000 Subject: [PATCH 23/75] bake: validate websocket upgrade origin on dev server --- src/runtime/bake/DevServer.rs | 76 ++++++++++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 11 deletions(-) diff --git a/src/runtime/bake/DevServer.rs b/src/runtime/bake/DevServer.rs index 689678d70e3..7bca4e63ebb 100644 --- a/src/runtime/bake/DevServer.rs +++ b/src/runtime/bake/DevServer.rs @@ -1423,17 +1423,7 @@ fn is_allowed_dev_host(dev: &DevServer, req: &Request) -> bool { let Some(host) = req.header(b"host") else { return false; }; - let host = if host.first() == Some(&b'[') { - match strings::index_of_scalar(host, b']') { - Some(end) => &host[..=end], - None => host, - } - } else { - match strings::last_index_of_char(host, b':') { - Some(colon) => &host[..colon], - None => host, - } - }; + let host = host_without_port(host); if strings::eql_case_insensitive_ascii(host, b"localhost", true) { return true; } @@ -1466,6 +1456,60 @@ fn is_allowed_dev_host(dev: &DevServer, req: &Request) -> bool { false } +/// `host[":" port]` / `"[" v6 "]" [":" port]` → host (brackets retained for IPv6). +fn host_without_port(host: &[u8]) -> &[u8] { + if host.first() == Some(&b'[') { + match strings::index_of_scalar(host, b']') { + Some(end) => &host[..=end], + None => host, + } + } else { + match strings::last_index_of_char(host, b':') { + Some(colon) => &host[..colon], + None => host, + } + } +} + +/// Cross-origin guard for the HMR WebSocket. WebSocket handshakes are exempt +/// from the same-origin policy, so any page the developer visits could open +/// `ws://localhost:/_bun/hmr` and subscribe to hot-update payloads (the +/// bundled source) — the browser still sends `Host: localhost`, so +/// `is_allowed_dev_host` alone does not stop it. Browsers always include an +/// `Origin` header on WebSocket handshakes; require its host to be the +/// request's own host or a localhost name. Requests without an `Origin` +/// header (non-browser clients) are allowed. +fn is_allowed_dev_origin(req: &Request) -> bool { + let Some(origin) = req.header(b"origin") else { + return true; + }; + // An origin is `scheme "://" host [":" port]`; opaque origins serialize + // to `null` and are rejected here. + let Some(scheme_end) = strings::index_of(origin, b"://") else { + return false; + }; + let origin_host = host_without_port(&origin[scheme_end + 3..]); + if strings::eql_case_insensitive_ascii(origin_host, b"localhost", true) { + return true; + } + const DOT_LOCALHOST: &[u8] = b".localhost"; + if origin_host.len() > DOT_LOCALHOST.len() + && strings::eql_case_insensitive_ascii( + &origin_host[origin_host.len() - DOT_LOCALHOST.len()..], + DOT_LOCALHOST, + true, + ) + { + return true; + } + match req.header(b"host") { + Some(host) => { + strings::eql_case_insensitive_ascii(origin_host, host_without_port(host), true) + } + None => false, + } +} + fn host_forbidden(resp: AnyResponse) { resp.corked(move || { resp.write_status(b"403 Forbidden"); @@ -1473,6 +1517,13 @@ fn host_forbidden(resp: AnyResponse) { }); } +fn origin_forbidden(resp: AnyResponse) { + resp.corked(move || { + resp.write_status(b"403 Forbidden"); + resp.end(b"Blocked: Origin header does not match the dev server", false); + }); +} + /// `extern "C"` trampoline: recovers `&mut DevServer` from user-data and wraps /// the raw `uws_res` as `AnyResponse`, then calls the handler for `ID`. extern "C" fn dev_route_tramp( @@ -1600,6 +1651,9 @@ impl bun_uws_sys::web_socket::WebSocketUpgradeServer for D if !is_allowed_dev_host(this, req) { return host_forbidden(res.as_any_response()); } + if !is_allowed_dev_origin(req) { + return origin_forbidden(res.as_any_response()); + } let dw = bun_core::heap::into_raw(HmrSocket::new(this, res)); let _ = this.active_websocket_connections.insert(dw, ()); let _ = res.upgrade( From e98cb6291bbb0daf21a0658dc1d96d55b922a24b Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:35:49 +0000 Subject: [PATCH 24/75] node:wasi: resolve guest paths against the preopen directory --- src/js/node/wasi.ts | 44 ++++++++++++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/src/js/node/wasi.ts b/src/js/node/wasi.ts index a0ae57df5c5..712ae7365b9 100644 --- a/src/js/node/wasi.ts +++ b/src/js/node/wasi.ts @@ -915,6 +915,22 @@ var require_wasi = __commonJS({ } return stats; }; + // Resolve a guest-supplied path against the directory backing `stats` and + // reject anything that escapes it (absolute paths, ".." traversal). + const RESOLVE_PATH = (stats, p) => { + if (!stats.path) { + throw new types_1.WASIError(constants_1.WASI_EINVAL); + } + const base = path.resolve(stats.path); + const resolved = path.resolve(base, p); + if (resolved !== base) { + const rel = path.relative(base, resolved); + if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) { + throw new types_1.WASIError(constants_1.WASI_ENOTCAPABLE); + } + } + return resolved; + }; const CPUTIME_START = Bun.nanoseconds(); const timeOrigin = Math.trunc(performance.timeOrigin * 1e6); const now = clockId => { @@ -1357,7 +1373,7 @@ var require_wasi = __commonJS({ } this.refreshMemory(); const p = Buffer.from(this.memory.buffer, pathPtr, pathLen).toString(); - fs.mkdirSync(path.resolve(stats.path, p)); + fs.mkdirSync(RESOLVE_PATH(stats, p)); return constants_1.WASI_ESUCCESS; }), path_filestat_get: wrap((fd, flags, pathPtr, pathLen, bufPtr) => { @@ -1367,11 +1383,12 @@ var require_wasi = __commonJS({ } this.refreshMemory(); const p = Buffer.from(this.memory.buffer, pathPtr, pathLen).toString(); + const resolved = RESOLVE_PATH(stats, p); let rstats; if (flags) { - rstats = fs.statSync(path.resolve(stats.path, p)); + rstats = fs.statSync(resolved); } else { - rstats = fs.lstatSync(path.resolve(stats.path, p)); + rstats = fs.lstatSync(resolved); } this.view.setBigUint64(bufPtr, BigInt(rstats.dev), true); bufPtr += 8; @@ -1419,7 +1436,7 @@ var require_wasi = __commonJS({ mtim = n; } const p = Buffer.from(this.memory.buffer, pathPtr, pathLen).toString(); - fs.utimesSync(path.resolve(stats.path, p), new Date(atim), new Date(mtim)); + fs.utimesSync(RESOLVE_PATH(stats, p), new Date(atim), new Date(mtim)); return constants_1.WASI_ESUCCESS; }), path_link: wrap((oldFd, _oldFlags, oldPath, oldPathLen, newFd, newPath, newPathLen) => { @@ -1431,13 +1448,13 @@ var require_wasi = __commonJS({ this.refreshMemory(); const op = Buffer.from(this.memory.buffer, oldPath, oldPathLen).toString(); const np = Buffer.from(this.memory.buffer, newPath, newPathLen).toString(); - fs.linkSync(path.resolve(ostats.path, op), path.resolve(nstats.path, np)); + fs.linkSync(RESOLVE_PATH(ostats, op), RESOLVE_PATH(nstats, np)); return constants_1.WASI_ESUCCESS; }), path_open: wrap( (dirfd, _dirflags, pathPtr, pathLen, oflags, fsRightsBase, fsRightsInheriting, fsFlags, fdPtr) => { try { - CHECK_FD(dirfd, constants_1.WASI_RIGHT_PATH_OPEN); + const stats = CHECK_FD(dirfd, constants_1.WASI_RIGHT_PATH_OPEN); fsRightsBase = BigInt(fsRightsBase); fsRightsInheriting = BigInt(fsRightsInheriting); const read = @@ -1512,7 +1529,7 @@ var require_wasi = __commonJS({ if (p.startsWith("proc/")) { throw new types_1.WASIError(constants_1.WASI_EBADF); } - const fullUnresolved = path.resolve(p); + const fullUnresolved = RESOLVE_PATH(stats, p); let full; try { full = fs.realpathSync(fullUnresolved); @@ -1548,6 +1565,9 @@ var require_wasi = __commonJS({ stat(this, newfd); this.view.setUint32(fdPtr, newfd, true); } catch (e) { + if (e instanceof types_1.WASIError) { + return e.errno; + } console.error(e); } return constants_1.WASI_ESUCCESS; @@ -1560,7 +1580,7 @@ var require_wasi = __commonJS({ } this.refreshMemory(); const p = Buffer.from(this.memory.buffer, pathPtr, pathLen).toString(); - const full = path.resolve(stats.path, p); + const full = RESOLVE_PATH(stats, p); const r = fs.readlinkSync(full); const used = Buffer.from(this.memory.buffer).write(r, buf, bufLen); this.view.setUint32(bufused, used, true); @@ -1573,7 +1593,7 @@ var require_wasi = __commonJS({ } this.refreshMemory(); const p = Buffer.from(this.memory.buffer, pathPtr, pathLen).toString(); - fs.rmdirSync(path.resolve(stats.path, p)); + fs.rmdirSync(RESOLVE_PATH(stats, p)); return constants_1.WASI_ESUCCESS; }), path_rename: wrap((oldFd, oldPath, oldPathLen, newFd, newPath, newPathLen) => { @@ -1585,7 +1605,7 @@ var require_wasi = __commonJS({ this.refreshMemory(); const op = Buffer.from(this.memory.buffer, oldPath, oldPathLen).toString(); const np = Buffer.from(this.memory.buffer, newPath, newPathLen).toString(); - fs.renameSync(path.resolve(ostats.path, op), path.resolve(nstats.path, np)); + fs.renameSync(RESOLVE_PATH(ostats, op), RESOLVE_PATH(nstats, np)); return constants_1.WASI_ESUCCESS; }), path_symlink: wrap((oldPath, oldPathLen, fd, newPath, newPathLen) => { @@ -1596,7 +1616,7 @@ var require_wasi = __commonJS({ this.refreshMemory(); const op = Buffer.from(this.memory.buffer, oldPath, oldPathLen).toString(); const np = Buffer.from(this.memory.buffer, newPath, newPathLen).toString(); - fs.symlinkSync(op, path.resolve(stats.path, np)); + fs.symlinkSync(op, RESOLVE_PATH(stats, np)); return constants_1.WASI_ESUCCESS; }), path_unlink_file: wrap((fd, pathPtr, pathLen) => { @@ -1606,7 +1626,7 @@ var require_wasi = __commonJS({ } this.refreshMemory(); const p = Buffer.from(this.memory.buffer, pathPtr, pathLen).toString(); - fs.unlinkSync(path.resolve(stats.path, p)); + fs.unlinkSync(RESOLVE_PATH(stats, p)); return constants_1.WASI_ESUCCESS; }), poll_oneoff: (sin, sout, nsubscriptions, neventsPtr) => { From cc3ef596f6de2acf7ee28b9a5f79aba4bdd663ce Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:36:04 +0000 Subject: [PATCH 25/75] postgres: tighten authentication state transitions --- src/sql_jsc/postgres/PostgresSQLConnection.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/sql_jsc/postgres/PostgresSQLConnection.rs b/src/sql_jsc/postgres/PostgresSQLConnection.rs index 129a983e73b..3baefd0281e 100644 --- a/src/sql_jsc/postgres/PostgresSQLConnection.rs +++ b/src/sql_jsc/postgres/PostgresSQLConnection.rs @@ -2863,6 +2863,19 @@ impl PostgresSQLConnection { } } protocol::Authentication::Ok => { + // RFC 5802 §5: once a SCRAM exchange has begun, the + // server must prove itself via SASLFinal (whose + // signature check above resets the state to `None`) + // before AuthenticationOk is acceptable. Accepting Ok + // mid-exchange would let a MITM skip the + // server-signature verification. + if matches!( + self.authentication_state.get(), + AuthenticationState::Sasl(_) + ) { + debug!("AuthenticationOk before SASL exchange completed"); + return Err(AnyPostgresError::UnexpectedMessage); + } debug!("Authentication OK"); self.authentication_state.with_mut(|s| s.zero()); self.authentication_state.set(AuthenticationState::Ok); From 95068a0f4ad8dbb465bbc2226d541d9da7c26dd2 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:36:17 +0000 Subject: [PATCH 26/75] mysql: harden reader capacity check --- src/sql/mysql/protocol/StackReader.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/sql/mysql/protocol/StackReader.rs b/src/sql/mysql/protocol/StackReader.rs index b0b7670fcd3..8c88244fefe 100644 --- a/src/sql/mysql/protocol/StackReader.rs +++ b/src/sql/mysql/protocol/StackReader.rs @@ -27,7 +27,10 @@ impl<'a> StackReader<'a> { } pub fn ensure_capacity(&self, length: usize) -> bool { - self.buffer.len() >= (self.offset.get() + length) + self.offset + .get() + .checked_add(length) + .is_some_and(|end| self.buffer.len() >= end) } pub fn init( From 9553f615e31f23f05422a6b5cd0df9342b590d47 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:36:33 +0000 Subject: [PATCH 27/75] crypto: normalize password hash comparison --- src/runtime/crypto/pwhash.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/runtime/crypto/pwhash.rs b/src/runtime/crypto/pwhash.rs index 2b5a49193ae..fdff648fde3 100644 --- a/src/runtime/crypto/pwhash.rs +++ b/src/runtime/crypto/pwhash.rs @@ -435,7 +435,8 @@ pub mod bcrypt { let computed = vendor::bcrypt(u32::from(rounds_log), salt, &buf[..used]); // Zig: `if (!mem.eql(u8, &hash, expected_hash)) return PasswordVerificationFailed`. - if computed[..DK_LENGTH] == expected { + // Compare in constant time like the `$2b$` path (BoringSSL `CRYPTO_memcmp`). + if bun_boringssl_sys::constant_time_eq(&computed[..DK_LENGTH], &expected) { Ok(()) } else { Err(bun_core::err!("PasswordVerificationFailed")) From ae403f7bf189feebab55cab7b9da63891d63fba6 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:37:22 +0000 Subject: [PATCH 28/75] formdata: anchor boundary parameter parsing --- src/bun_core/util.rs | 37 +++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/src/bun_core/util.rs b/src/bun_core/util.rs index 05a6967d529..211b00076e3 100644 --- a/src/bun_core/util.rs +++ b/src/bun_core/util.rs @@ -5786,21 +5786,34 @@ pub mod form_data { /// `FormData.getBoundary` — borrow the `boundary=` value out of a /// `Content-Type` header. Returns `None` on malformed quoting. + /// + /// Parameters are `;`-delimited per RFC 7231 and the parameter *name* must + /// be exactly `boundary`, so a different parameter (`xboundary=FAKE`) or a + /// `boundary=` substring inside another parameter's value is not picked up + /// by an unanchored substring search. pub fn get_boundary(content_type: &[u8]) -> Option<&[u8]> { - let idx = ::bstr::ByteSlice::find(content_type, b"boundary=")?; - let begin = &content_type[idx + b"boundary=".len()..]; - if begin.is_empty() { - return None; - } - let end = crate::strings_impl::index_of_char(begin, b';').unwrap_or(begin.len()); - if begin[0] == b'"' { - if end > 1 && begin[end - 1] == b'"' { - return Some(&begin[1..end - 1]); + let mut rest = content_type; + loop { + let semi = crate::strings_impl::index_of_char(rest, b';')?; + rest = &rest[semi + 1..]; + let Some(begin) = + crate::strings_impl::trim_left(rest, b" \t").strip_prefix(b"boundary=") + else { + continue; + }; + if begin.is_empty() { + return None; } - // Opening quote with no matching closing quote — malformed. - return None; + let end = crate::strings_impl::index_of_char(begin, b';').unwrap_or(begin.len()); + if begin[0] == b'"' { + if end > 1 && begin[end - 1] == b'"' { + return Some(&begin[1..end - 1]); + } + // Opening quote with no matching closing quote — malformed. + return None; + } + return Some(&begin[..end]); } - Some(&begin[..end]) } /// `FormData.AsyncFormData` — heap-allocated, owns its `Encoding`. From b33f506563a124b79b1d8c9d233ca4426e6f806e Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:37:43 +0000 Subject: [PATCH 29/75] shell: treat interpolated values as literal text --- src/shell_parser/parse.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/shell_parser/parse.rs b/src/shell_parser/parse.rs index 0961042fb71..b7259b427b0 100644 --- a/src/shell_parser/parse.rs +++ b/src/shell_parser/parse.rs @@ -3485,7 +3485,19 @@ impl<'bump, const ENCODING: StringEncoding> Lexer<'bump, ENCODING> { })); return Ok(()); } - self.append_string_to_str_pool(bunstr) + let start = self.j; + self.append_string_to_str_pool(bunstr)?; + // Interpolated values are data, not shell syntax. In the unquoted state, + // flush them as a quoted-text token so the parser does not re-interpret + // a leading `~` as tilde expansion. + if self.chars.state == CharState::Normal { + self.tokens.push(Token::DoubleQuotedText(TextRange { + start, + end: self.j, + })); + self.word_start = self.j; + } + Ok(()) } fn looks_like_js_obj_ref(&mut self) -> bool { From 91c2433ffc88f93133aec50f7ecf5a246a6fc165 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:38:35 +0000 Subject: [PATCH 30/75] server: apply host allowlist to devtools metadata route --- src/runtime/bake/DevServer.rs | 5 +++-- src/runtime/bake/mod.rs | 1 + src/runtime/server/server_body.rs | 10 +++++++++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/runtime/bake/DevServer.rs b/src/runtime/bake/DevServer.rs index 7bca4e63ebb..d5155566076 100644 --- a/src/runtime/bake/DevServer.rs +++ b/src/runtime/bake/DevServer.rs @@ -1415,11 +1415,12 @@ pub enum DevHandlerId { MemoryVisualizer, } -/// DNS-rebinding guard for `/_bun/...` internal routes. A rebound origin +/// DNS-rebinding guard for `/_bun/...` internal routes and the Chrome +/// DevTools `/.well-known/...` route. A rebound origin /// (`attacker.com` → 127.0.0.1) presents `Host: attacker.com`; rejecting /// non-loopback / non-IP / non-configured hostnames prevents the attacker's /// page from reading bundled source via same-origin fetch. -fn is_allowed_dev_host(dev: &DevServer, req: &Request) -> bool { +pub(crate) fn is_allowed_dev_host(dev: &DevServer, req: &Request) -> bool { let Some(host) = req.header(b"host") else { return false; }; diff --git a/src/runtime/bake/mod.rs b/src/runtime/bake/mod.rs index be41b115f2d..e39b7b63bf7 100644 --- a/src/runtime/bake/mod.rs +++ b/src/runtime/bake/mod.rs @@ -20,6 +20,7 @@ pub(crate) mod bake_body; #[path = "DevServer.rs"] mod dev_server_body; pub(crate) use dev_server_body::get_deinit_count_for_testing; +pub(crate) use dev_server_body::is_allowed_dev_host; #[path = "FrameworkRouter.rs"] pub(crate) mod framework_router_body; diff --git a/src/runtime/server/server_body.rs b/src/runtime/server/server_body.rs index 749440a6640..45949392b25 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -3402,7 +3402,15 @@ where } let authorized = 'brk: { - if self.dev_server.is_none() { + let Some(dev_server) = self.dev_server.as_deref() else { + break 'brk false; + }; + + // The loopback source-IP check below is not enough on its own: a + // DNS-rebound origin connects from 127.0.0.1 but presents the + // attacker's hostname in `Host`. Apply the same Host allowlist as + // the `/_bun/*` routes before disclosing the project root path. + if !bake::is_allowed_dev_host(dev_server, req) { break 'brk false; } From 5dfb8099bac4f6fbf08f01d81c6fed28a1f91bbe Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:38:49 +0000 Subject: [PATCH 31/75] create: align postinstall task gating with preinstall --- src/runtime/cli/create_command.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/runtime/cli/create_command.rs b/src/runtime/cli/create_command.rs index 21fc332968e..3cae3911f52 100644 --- a/src/runtime/cli/create_command.rs +++ b/src/runtime/cli/create_command.rs @@ -1511,7 +1511,7 @@ impl CreateCommand { let _ = process?; } - if !postinstall_tasks.is_empty() { + if npm_client_.is_some() && !postinstall_tasks.is_empty() { for task in &postinstall_tasks { exec_task(task, destination, path_env, npm_client_); } From 5185d97700cd35c6ad955ae82cd1b940d8125868 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:38:58 +0000 Subject: [PATCH 32/75] libarchive: normalize extracted file permissions --- src/libarchive/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libarchive/lib.rs b/src/libarchive/lib.rs index b7120acf9aa..ed9c6f002dc 100644 --- a/src/libarchive/lib.rs +++ b/src/libarchive/lib.rs @@ -1993,7 +1993,7 @@ impl Archiver { #[cfg(not(windows))] let mode: bun_sys::Mode = bun_sys::Mode::try_from( // SAFETY: entry valid - lib::Entry::opaque_ref(entry).perm() | 0o666, + (lib::Entry::opaque_ref(entry).perm() & 0o777) | 0o666, ) .unwrap(); From 4ad9a296f9332148b33506363daae9b4ededdcf7 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:39:11 +0000 Subject: [PATCH 33/75] structured-clone: validate regexp flags during deserialization --- src/jsc/bindings/webcore/SerializedScriptValue.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/jsc/bindings/webcore/SerializedScriptValue.cpp b/src/jsc/bindings/webcore/SerializedScriptValue.cpp index 631226598d7..0cac7b71f96 100644 --- a/src/jsc/bindings/webcore/SerializedScriptValue.cpp +++ b/src/jsc/bindings/webcore/SerializedScriptValue.cpp @@ -4962,7 +4962,10 @@ class CloneDeserializer : public CloneBase { if (!readStringData(flags)) return JSValue(); auto reFlags = Yarr::parseFlags(flags->string()); - ASSERT(reFlags.has_value()); + if (!reFlags.has_value()) { + fail(); + return JSValue(); + } VM& vm = m_lexicalGlobalObject->vm(); RegExp* regExp = RegExp::create(vm, pattern->string(), reFlags.value()); return RegExpObject::create(vm, m_globalObject->regExpStructure(), regExp); From a4945ad2a341b6980f868322a99379582253aaff Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:39:31 +0000 Subject: [PATCH 34/75] node:http: propagate parser callback errors when header buffer flushes --- src/jsc/bindings/node/http/NodeHTTPParser.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/jsc/bindings/node/http/NodeHTTPParser.cpp b/src/jsc/bindings/node/http/NodeHTTPParser.cpp index 487210cdd51..75e5ebd8462 100644 --- a/src/jsc/bindings/node/http/NodeHTTPParser.cpp +++ b/src/jsc/bindings/node/http/NodeHTTPParser.cpp @@ -392,7 +392,10 @@ int HTTPParser::onHeaderField(const char* at, size_t length) if (m_numFields == kMaxHeaderFieldsCount) { // ran out of space - flush to javascript land flush(); - RETURN_IF_EXCEPTION(scope, 0); + if (scope.exception()) [[unlikely]] { + llhttp_set_error_reason(&m_parserData, "HPE_USER:JS Exception"); + return HPE_USER; + } m_numFields = 1; m_numValues = 0; } From 383e5ed4a164401f0d1dee56bd435475b38d32a4 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:40:04 +0000 Subject: [PATCH 35/75] node:tls: validate checkServerIdentity option type --- src/js/node/net.ts | 3 +++ src/js/node/tls.ts | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index a4c9a777e59..30906ac0c8c 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -936,6 +936,9 @@ Socket.prototype.connect = function connect(...args) { tls.requestCert = true; tls.session = session || tls.session; this.servername = tls.servername; + if (checkServerIdentity !== undefined) { + validateFunction(checkServerIdentity, "options.checkServerIdentity"); + } tls.checkServerIdentity = checkServerIdentity || tls.checkServerIdentity; this[bunTLSConnectOptions] = tls; if (!connection && tls.socket) { diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index c4ef991ef83..c684f4f00a7 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -5,7 +5,7 @@ const Duplex = require("internal/streams/duplex"); const addServerName = $newZigFunction("Listener.zig", "jsAddServerName", 3); const { throwNotImplemented } = require("internal/shared"); const { throwOnInvalidTLSArray } = require("internal/tls"); -const { validateString } = require("internal/validators"); +const { validateString, validateFunction } = require("internal/validators"); const { Server: NetServer, Socket: NetSocket } = net; @@ -532,6 +532,9 @@ function TLSSocket(socket?, options?) { this.secureConnecting = true; this._secureEstablished = false; this._securePending = true; + if (options.checkServerIdentity !== undefined) { + validateFunction(options.checkServerIdentity, "options.checkServerIdentity"); + } this[kcheckServerIdentity] = options.checkServerIdentity || checkServerIdentity; this[ksession] = options.session || null; } From 5be79a3d80eccff4d81ad562c02047f4d64ce795 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:40:18 +0000 Subject: [PATCH 36/75] sourcemap: fix vlq decode table size --- src/base64/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/base64/lib.rs b/src/base64/lib.rs index e73c2c3abd6..dfa9ac5c89f 100644 --- a/src/base64/lib.rs +++ b/src/base64/lib.rs @@ -303,8 +303,8 @@ pub mod vlq { const U7_MAX: u8 = 127; // base64 stores values up to 7 bits - const BASE64_LUT: [u8; U7_MAX as usize] = { - let mut bytes = [U7_MAX; U7_MAX as usize]; + const BASE64_LUT: [u8; U7_MAX as usize + 1] = { + let mut bytes = [U7_MAX; U7_MAX as usize + 1]; let mut i = 0; while i < BASE64.len() { bytes[BASE64[i] as usize] = i as u8; From 0125b7868c1796982b16bc1fb33fb5e82680b2aa Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:40:30 +0000 Subject: [PATCH 37/75] http: tighten request line separator handling --- packages/bun-uws/src/HttpParser.h | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/bun-uws/src/HttpParser.h b/packages/bun-uws/src/HttpParser.h index f90f74d9f18..dc08aa896ef 100644 --- a/packages/bun-uws/src/HttpParser.h +++ b/packages/bun-uws/src/HttpParser.h @@ -570,8 +570,9 @@ namespace uWS } - bool isHTTPMethod = (__builtin_expect(data[1] == '/', 1)); - bool isConnect = !isHTTPMethod && ((data - start) == 7 && memcmp(start, "CONNECT", 7) == 0); + /* RFC 9112 3: exactly one SP separates method and request-target */ + bool isHTTPMethod = (__builtin_expect(data[0] == 32 && data[1] == '/', 1)); + bool isConnect = !isHTTPMethod && ((data - start) == 7 && data[0] == 32 && memcmp(start, "CONNECT", 7) == 0); /* Also accept proxy-style absolute URLs (http://... or https://...) as valid request targets */ bool isProxyStyleURL = !isHTTPMethod && !isConnect && data[0] == 32 && isHTTPorHTTPSPrefixForProxies(data + 1, end) == 1; if (isHTTPMethod || isConnect || isProxyStyleURL) [[likely]] { From ef745900aede1bff4dfabddba2706e9448b29f75 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:41:05 +0000 Subject: [PATCH 38/75] semver: drop range chains iteratively --- src/semver/SemverQuery.rs | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/semver/SemverQuery.rs b/src/semver/SemverQuery.rs index 5ee4cb2c15c..195f4c671f4 100644 --- a/src/semver/SemverQuery.rs +++ b/src/semver/SemverQuery.rs @@ -33,6 +33,17 @@ pub struct Query { pub next: Option>, } +impl Drop for Query { + fn drop(&mut self) { + // Unlink the chain iteratively so the derived recursive drop glue + // can't overflow the stack on very long AND chains. + let mut next = self.next.take(); + while let Some(mut node) = next { + next = node.next.take(); + } + } +} + pub struct QueryFormatter<'a> { query: &'a Query, buffer: &'a [u8], @@ -133,6 +144,17 @@ unsafe impl Send for List {} // `&List` exposes no unsynchronized interior mutability. unsafe impl Sync for List {} +impl Drop for List { + fn drop(&mut self) { + // Unlink the chain iteratively so the derived recursive drop glue + // can't overflow the stack on very long OR chains. + let mut next = self.next.take(); + while let Some(mut node) = next { + next = node.next.take(); + } + } +} + impl Clone for List { fn clone(&self) -> Self { let mut out = List { @@ -369,8 +391,8 @@ impl Group { writer.write_str("\"") } - // PORT NOTE: `deinit` deleted — `next: Option>` chains are freed by Drop. - // PERF(port): recursive Box drop could overflow stack on very long chains. + // PORT NOTE: `deinit` deleted — `next: Option>` chains are freed by the + // iterative `Drop` impls on `Query` and `List`. pub fn get_exact_version(&self) -> Option { let range = &self.head.head.range; @@ -402,7 +424,8 @@ impl Group { }, next: None, }, - ..Default::default() + tail: None, + next: None, }, ..Default::default() } From 238ef0f0027089b559dcd2f7a63debd2f8336a9e Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:41:30 +0000 Subject: [PATCH 39/75] sqlite: fix buffer ownership on deserialize failure --- src/jsc/bindings/sqlite/JSSQLStatement.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/jsc/bindings/sqlite/JSSQLStatement.cpp b/src/jsc/bindings/sqlite/JSSQLStatement.cpp index 4343288aefe..e729d99741f 100644 --- a/src/jsc/bindings/sqlite/JSSQLStatement.cpp +++ b/src/jsc/bindings/sqlite/JSSQLStatement.cpp @@ -1225,14 +1225,14 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementDeserialize, (JSC::JSGlobalObject * lexic } status = sqlite3_deserialize(db, "main", reinterpret_cast(data), byteLength, byteLength, deserializeFlags); + // SQLITE_DESERIALIZE_FREEONCLOSE transfers ownership of `data` to SQLite, + // which frees it itself when deserialization fails. Do not free it again here. if (status == SQLITE_BUSY) { - sqlite3_free(data); throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "SQLITE_BUSY"_s)); return {}; } if (status != SQLITE_OK) { - sqlite3_free(data); throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, status == SQLITE_ERROR ? "unable to deserialize database"_s : sqliteString(sqlite3_errstr(status)))); return {}; } From 30728af561f478b051e40b50a7bc1e94b847f918 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:41:50 +0000 Subject: [PATCH 40/75] inspector: check upgrade request origin --- src/js/internal/debugger.ts | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/js/internal/debugger.ts b/src/js/internal/debugger.ts index 4b400473692..0435558416e 100644 --- a/src/js/internal/debugger.ts +++ b/src/js/internal/debugger.ts @@ -349,6 +349,12 @@ class Debugger { }); } + if (!isOriginAllowed(headers.get("Origin"))) { + return new Response(null, { + status: 403, // Forbidden + }); + } + const data: Connection = { refEventLoop: headers.get("Ref-Event-Loop") === "0", }; @@ -591,6 +597,34 @@ function parseUrl(input: string): URL { return url; } +// Browsers always send an `Origin` header on WebSocket handshakes, so rejecting +// unexpected web origins prevents a malicious website from connecting to the +// inspector and evaluating code. This matters most when the user passes an +// explicit pathname to --inspect, which replaces the random UUID pathname that +// otherwise acts as a bearer token. Non-browser clients (IDEs, CLI tools) do +// not send an `Origin` header and are unaffected. +function isOriginAllowed(origin: string | null): boolean { + if (!origin) { + return true; + } + let url: URL; + try { + url = new URL(origin); + } catch { + // Includes the opaque "null" origin sent by sandboxed iframes and file://. + return false; + } + const { protocol, hostname } = url; + if (protocol !== "http:" && protocol !== "https:") { + // Privileged schemes (e.g. devtools://) cannot be claimed by a web page. + return true; + } + if (origin === "https://debug.bun.sh") { + return true; + } + return hostname === "localhost" || hostname === "[::1]" || /^127(\.\d{1,3}){3}$/.test(hostname); +} + function randomId() { return crypto.randomUUID(); } From deb169a54740d20e47ed7b7cd30eb8776d3939ee Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:42:29 +0000 Subject: [PATCH 41/75] ini: bound section header segment depth --- src/ast/e.rs | 17 +++++++++++------ src/ini/lib.rs | 19 ++++++++++++++++--- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/ast/e.rs b/src/ast/e.rs index 0c772fe2abc..89e6043c321 100644 --- a/src/ast/e.rs +++ b/src/ast/e.rs @@ -902,17 +902,22 @@ pub struct Rope { } impl Rope { pub fn append(&mut self, expr: Expr, bump: &Bump) -> Result<*mut Rope, AllocError> { - if let Some(mut next) = core::ptr::NonNull::new(self.next).map(StoreRef::from_non_null) { - // Arena-allocated Rope nodes are uniquely owned by the chain at this - // point in TOML parsing; route through `StoreRef::DerefMut` (the - // arena-backed handle whose deref is centralised in `nodes.rs`). - return next.append(expr, bump); + // Walk to the tail iteratively: recursing once per node overflows the + // native stack on adversarially deep ropes (e.g. an `.npmrc` section + // header with thousands of dot-separated segments). + // + // Arena-allocated Rope nodes are uniquely owned by the chain at this + // point; route through `StoreRef::DerefMut` (the arena-backed handle + // whose deref is centralised in `nodes.rs`). + let mut tail = StoreRef::from_bump(self); + while let Some(next) = core::ptr::NonNull::new(tail.next).map(StoreRef::from_non_null) { + tail = next; } let rope: *mut Rope = bump.alloc(Rope { head: expr, next: core::ptr::null_mut(), }); - self.next = rope; + tail.next = rope; Ok(rope) } diff --git a/src/ini/lib.rs b/src/ini/lib.rs index 07f2685af13..f55f9121b45 100644 --- a/src/ini/lib.rs +++ b/src/ini/lib.rs @@ -257,6 +257,13 @@ mod draft { type OOM = Result; + /// Hard cap on dot-separated segments in a section-header rope. The rope is + /// consumed by `E::Object::get_or_put_object`, which recurses once per + /// `rope.next` link, so an unbounded header overflows the stack. Past the + /// cap the remainder of the header (dots included) becomes the final + /// segment. Mirrors `MAX_DOTTED_KEY_SEGMENTS` in the TOML parser. + const MAX_SECTION_ROPE_SEGMENTS: usize = 512; + // ────────────────────────────────────────────────────────────────────────── // Parser // ────────────────────────────────────────────────────────────────────────── @@ -692,6 +699,7 @@ mod draft { // RopeT is *Rope when usage==Section, else unit. In Rust we just // keep an Option<&mut Rope> and ignore it for non-section usages. let mut rope: Option<&'a mut Rope> = None; + let mut rope_parts: usize = 0; let mut i: usize = 0; 'walk: while i < val.len() { @@ -779,8 +787,11 @@ mod draft { did_any_escape = true; } b'.' => { - if usage == Usage::Section { + if usage == Usage::Section + && rope_parts < MAX_SECTION_ROPE_SEGMENTS + { self.commit_rope_part(bump, ropealloc, &mut unesc, &mut rope)?; + rope_parts += 1; } else { unesc.push(b'.'); } @@ -1069,10 +1080,11 @@ mod draft { next: ptr::null_mut(), }); + let mut segments: usize = 1; while dot_idx + 1 < key.len() { let next_dot_idx = match next_dot(&key[dot_idx + 1..]) { - Some(n) => dot_idx + 1 + n, - None => { + Some(n) if segments < MAX_SECTION_ROPE_SEGMENTS => dot_idx + 1 + n, + _ => { let rest = &key[dot_idx + 1..]; let _ = rope_head .append(Expr::init(E::EString::init(rest), Loc::EMPTY), ropealloc)?; @@ -1082,6 +1094,7 @@ mod draft { let part = &key[dot_idx + 1..next_dot_idx]; let _ = rope_head.append(Expr::init(E::EString::init(part), Loc::EMPTY), ropealloc)?; + segments += 1; dot_idx = next_dot_idx; } From e32ce67ca7b404f3b7b4fd00b1a4d1b03229f1de Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:43:03 +0000 Subject: [PATCH 42/75] webcore: bound rsa prime count during key deserialization --- src/jsc/bindings/webcore/SerializedScriptValue.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/jsc/bindings/webcore/SerializedScriptValue.cpp b/src/jsc/bindings/webcore/SerializedScriptValue.cpp index 0cac7b71f96..adf0cb85de0 100644 --- a/src/jsc/bindings/webcore/SerializedScriptValue.cpp +++ b/src/jsc/bindings/webcore/SerializedScriptValue.cpp @@ -4067,6 +4067,12 @@ class CloneDeserializer : public CloneBase { if (primeCount < 2) return false; + // Each additional prime is encoded as three length-prefixed byte vectors, so it + // requires at least 3 * sizeof(uint32_t) bytes of remaining input. Reject counts + // that could not possibly be satisfied to avoid a huge up-front allocation. + if (static_cast(primeCount - 2) > static_cast(m_end - m_ptr) / (3 * sizeof(uint32_t))) + return false; + CryptoKeyRSAComponents::PrimeInfo firstPrimeInfo; CryptoKeyRSAComponents::PrimeInfo secondPrimeInfo; Vector otherPrimeInfos(primeCount - 2); From dd8e4f2098e2a0c8b59ec998c8b339f6d99dc513 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:43:11 +0000 Subject: [PATCH 43/75] structured-clone: validate bigint length against remaining input --- src/jsc/bindings/webcore/SerializedScriptValue.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/jsc/bindings/webcore/SerializedScriptValue.cpp b/src/jsc/bindings/webcore/SerializedScriptValue.cpp index adf0cb85de0..f57569e472f 100644 --- a/src/jsc/bindings/webcore/SerializedScriptValue.cpp +++ b/src/jsc/bindings/webcore/SerializedScriptValue.cpp @@ -4707,6 +4707,11 @@ class CloneDeserializer : public CloneBase { #endif } + if (lengthInUint64 > static_cast(m_end - m_ptr) / sizeof(uint64_t)) { + fail(); + return JSValue(); + } + #if USE(BIGINT32) static_assert(sizeof(JSBigInt::Digit) == sizeof(uint64_t)); if (lengthInUint64 == 1) { From 6f2dbf1f701f6017867e5144f81a886b0b1aa31e Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:43:32 +0000 Subject: [PATCH 44/75] structured-clone: manage bio lifetime with raii during key deserialization --- src/jsc/bindings/webcore/SerializedScriptValue.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/jsc/bindings/webcore/SerializedScriptValue.cpp b/src/jsc/bindings/webcore/SerializedScriptValue.cpp index f57569e472f..78501540c86 100644 --- a/src/jsc/bindings/webcore/SerializedScriptValue.cpp +++ b/src/jsc/bindings/webcore/SerializedScriptValue.cpp @@ -4643,14 +4643,15 @@ class CloneDeserializer : public CloneBase { return JSValue(); } - BIO* bio = nullptr; - if (!read(&bio, pemSize)) { + BIO* rawBio = nullptr; + if (!read(&rawBio, pemSize)) { fail(); return JSValue(); } + ncrypto::BIOPointer bio(rawBio); if (keyType == CryptoKeyType::Public) { - EVP_PKEY* pkey = PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr); + EVP_PKEY* pkey = PEM_read_bio_PUBKEY(bio.get(), nullptr, nullptr, nullptr); if (!pkey) { fail(); return JSValue(); @@ -4660,7 +4661,7 @@ class CloneDeserializer : public CloneBase { return JSPublicKeyObject::create(vm, structure, m_globalObject, WTF::move(keyObject)); } - EVP_PKEY* pkey = PEM_read_bio_PrivateKey(bio, nullptr, nullptr, nullptr); + EVP_PKEY* pkey = PEM_read_bio_PrivateKey(bio.get(), nullptr, nullptr, nullptr); if (!pkey) { fail(); return JSValue(); From fbc1243aa0ed6e95573c1b6e3f817c30108b3955 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:43:56 +0000 Subject: [PATCH 45/75] bundler: escape source path comments in js output --- src/bundler/linker_context/postProcessJSChunk.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/bundler/linker_context/postProcessJSChunk.rs b/src/bundler/linker_context/postProcessJSChunk.rs index f1757d01638..8148d71b907 100644 --- a/src/bundler/linker_context/postProcessJSChunk.rs +++ b/src/bundler/linker_context/postProcessJSChunk.rs @@ -680,8 +680,19 @@ pub fn post_process_js_chunk( } } - j.push_static(pretty); - line_offset.advance(pretty); + // A `*/` in the path would terminate the block comment early and + // turn the rest of the path into generated JavaScript. + if matches!(comment_type, CommentType::Multiline) && strings::contains(pretty, b"*/") { + let mut sanitized = pretty.to_vec(); + while let Some(i) = strings::index_of(&sanitized, b"*/") { + sanitized[i + 1] = b'_'; + } + line_offset.advance(&sanitized); + j.push_owned(sanitized.into_boxed_slice()); + } else { + j.push_static(pretty); + line_offset.advance(pretty); + } if emit_targets_in_commands { j.push_static(b" ("); From 305ca889c06df1d37e77f98aabcc79a202efd8c8 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 08:44:26 +0000 Subject: [PATCH 46/75] websocket: bound buffered handshake response size --- .../WebSocketUpgradeClient.rs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs b/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs index a4b06e31f69..41d4b0a8f5c 100644 --- a/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs +++ b/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs @@ -993,6 +993,11 @@ impl HTTPClient { let me = unsafe { &mut *this }; let mut body = data; if !me.body.is_empty() { + if me.body.len().saturating_add(data.len()) > bun_http::max_http_header_size() { + // SAFETY: `me`'s last use is above; no `&mut Self` spans this call. + unsafe { Self::terminate(this, ErrorCode::InvalidResponse) }; + return; + } me.body.extend_from_slice(data); body = &me.body; } @@ -1020,6 +1025,11 @@ impl HTTPClient { } Err(picohttp::ParseResponseError::ShortRead) => { if me.body.is_empty() { + if data.len() > bun_http::max_http_header_size() { + // SAFETY: `me`'s last use is above; no `&mut Self` spans this call. + unsafe { Self::terminate(this, ErrorCode::InvalidResponse) }; + return; + } me.body.extend_from_slice(data); } return; @@ -1233,6 +1243,11 @@ impl HTTPClient { // Process as if it came directly from the socket let mut body = data; if !me.body.is_empty() { + if me.body.len().saturating_add(data.len()) > bun_http::max_http_header_size() { + // SAFETY: `me`'s last use is above; no `&mut Self` spans this call. + unsafe { Self::terminate(this, ErrorCode::InvalidResponse) }; + return; + } me.body.extend_from_slice(data); body = &me.body; } @@ -1257,6 +1272,11 @@ impl HTTPClient { } Err(picohttp::ParseResponseError::ShortRead) => { if me.body.is_empty() { + if data.len() > bun_http::max_http_header_size() { + // SAFETY: `me`'s last use is above; no `&mut Self` spans this call. + unsafe { Self::terminate(this, ErrorCode::InvalidResponse) }; + return; + } me.body.extend_from_slice(data); } return; From 9f824de6e4622c7c042d95d033983acf08de5596 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 09:21:58 +0000 Subject: [PATCH 47/75] [autofix.ci] apply automated fixes --- src/ini/lib.rs | 3 +-- src/install/isolated_install.rs | 6 ++---- src/install/lockfile/Package/Scripts.rs | 28 ++++++++++++------------- src/runtime/bake/DevServer.rs | 5 ++++- src/shell_parser/parse.rs | 6 ++---- src/valkey/valkey_protocol.rs | 5 ++--- 6 files changed, 25 insertions(+), 28 deletions(-) diff --git a/src/ini/lib.rs b/src/ini/lib.rs index f55f9121b45..bab318fc112 100644 --- a/src/ini/lib.rs +++ b/src/ini/lib.rs @@ -787,8 +787,7 @@ mod draft { did_any_escape = true; } b'.' => { - if usage == Usage::Section - && rope_parts < MAX_SECTION_ROPE_SEGMENTS + if usage == Usage::Section && rope_parts < MAX_SECTION_ROPE_SEGMENTS { self.commit_rope_part(bump, ropealloc, &mut unesc, &mut rope)?; rope_parts += 1; diff --git a/src/install/isolated_install.rs b/src/install/isolated_install.rs index e2909a27da5..1894b07dd41 100644 --- a/src/install/isolated_install.rs +++ b/src/install/isolated_install.rs @@ -1309,10 +1309,8 @@ pub fn install_isolated_packages( dep_name, pkg_names[pkg_id as usize].slice(string_buf), pkg_res, - ) - || trusted_from_update.contains( - &(dep_name_hash as crate::TruncatedPackageNameHash), - ) + ) || trusted_from_update + .contains(&(dep_name_hash as crate::TruncatedPackageNameHash)) { break 'eligible false; } diff --git a/src/install/lockfile/Package/Scripts.rs b/src/install/lockfile/Package/Scripts.rs index 39befcfce17..dd02cbcfad7 100644 --- a/src/install/lockfile/Package/Scripts.rs +++ b/src/install/lockfile/Package/Scripts.rs @@ -309,20 +309,20 @@ impl Scripts { ) -> Result, bun_core::Error> { // TODO(port): narrow error set if self.has_any() { - let add_node_gyp_rebuild_script = if lockfile - .has_trusted_dependency(folder_name, folder_name, resolution) - && self.install.is_empty() - && self.preinstall.is_empty() - { - // `defer save.restore()` — `save()` returns an RAII guard that - // restores the path length on Drop and derefs to the path. - let mut save = folder_path.save(); - let _ = save.append(b"binding.gyp"); // OOM/capacity: Zig aborts; port keeps fire-and-forget - - bun_sys::exists(save.slice()) - } else { - false - }; + let add_node_gyp_rebuild_script = + if lockfile.has_trusted_dependency(folder_name, folder_name, resolution) + && self.install.is_empty() + && self.preinstall.is_empty() + { + // `defer save.restore()` — `save()` returns an RAII guard that + // restores the path length on Drop and derefs to the path. + let mut save = folder_path.save(); + let _ = save.append(b"binding.gyp"); // OOM/capacity: Zig aborts; port keeps fire-and-forget + + bun_sys::exists(save.slice()) + } else { + false + }; return Ok(self.create_list( lockfile, diff --git a/src/runtime/bake/DevServer.rs b/src/runtime/bake/DevServer.rs index d5155566076..8808771b725 100644 --- a/src/runtime/bake/DevServer.rs +++ b/src/runtime/bake/DevServer.rs @@ -1521,7 +1521,10 @@ fn host_forbidden(resp: AnyResponse) { fn origin_forbidden(resp: AnyResponse) { resp.corked(move || { resp.write_status(b"403 Forbidden"); - resp.end(b"Blocked: Origin header does not match the dev server", false); + resp.end( + b"Blocked: Origin header does not match the dev server", + false, + ); }); } diff --git a/src/shell_parser/parse.rs b/src/shell_parser/parse.rs index b7259b427b0..e791035d4b0 100644 --- a/src/shell_parser/parse.rs +++ b/src/shell_parser/parse.rs @@ -3491,10 +3491,8 @@ impl<'bump, const ENCODING: StringEncoding> Lexer<'bump, ENCODING> { // flush them as a quoted-text token so the parser does not re-interpret // a leading `~` as tilde expansion. if self.chars.state == CharState::Normal { - self.tokens.push(Token::DoubleQuotedText(TextRange { - start, - end: self.j, - })); + self.tokens + .push(Token::DoubleQuotedText(TextRange { start, end: self.j })); self.word_start = self.j; } Ok(()) diff --git a/src/valkey/valkey_protocol.rs b/src/valkey/valkey_protocol.rs index c3dacef0af5..b1891b49dee 100644 --- a/src/valkey/valkey_protocol.rs +++ b/src/valkey/valkey_protocol.rs @@ -550,9 +550,8 @@ impl<'a> ValkeyReader<'a> { // Read the rest of the data let data_len = usize::try_from(len - 1).expect("int cast"); - let mut data = Vec::with_capacity( - self.take_prealloc_budget(data_len, size_of::()), - ); + let mut data = + Vec::with_capacity(self.take_prealloc_budget(data_len, size_of::())); // errdefer cleanup handled by Vec Drop on `?` let mut i: usize = 0; while i < data_len { From 9a20bac3fd6ba308245587d862dbe61c8445418a Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 09:58:35 +0000 Subject: [PATCH 48/75] address review: compare normalized origin in inspector allowlist --- src/js/internal/debugger.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/internal/debugger.ts b/src/js/internal/debugger.ts index 0435558416e..46df6908d5a 100644 --- a/src/js/internal/debugger.ts +++ b/src/js/internal/debugger.ts @@ -619,7 +619,7 @@ function isOriginAllowed(origin: string | null): boolean { // Privileged schemes (e.g. devtools://) cannot be claimed by a web page. return true; } - if (origin === "https://debug.bun.sh") { + if (url.origin === "https://debug.bun.sh") { return true; } return hostname === "localhost" || hostname === "[::1]" || /^127(\.\d{1,3}){3}$/.test(hostname); From e36f02d701f03faee83319c31d9330815447440a Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 09:58:35 +0000 Subject: [PATCH 49/75] address review: grow prime info vector incrementally during deserialization --- src/jsc/bindings/webcore/SerializedScriptValue.cpp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/jsc/bindings/webcore/SerializedScriptValue.cpp b/src/jsc/bindings/webcore/SerializedScriptValue.cpp index 78501540c86..180351b7888 100644 --- a/src/jsc/bindings/webcore/SerializedScriptValue.cpp +++ b/src/jsc/bindings/webcore/SerializedScriptValue.cpp @@ -4075,7 +4075,7 @@ class CloneDeserializer : public CloneBase { CryptoKeyRSAComponents::PrimeInfo firstPrimeInfo; CryptoKeyRSAComponents::PrimeInfo secondPrimeInfo; - Vector otherPrimeInfos(primeCount - 2); + Vector otherPrimeInfos; if (!read(firstPrimeInfo.primeFactor)) return false; @@ -4088,12 +4088,14 @@ class CloneDeserializer : public CloneBase { if (!read(secondPrimeInfo.factorCRTCoefficient)) return false; for (unsigned i = 2; i < primeCount; ++i) { - if (!read(otherPrimeInfos[i - 2].primeFactor)) + CryptoKeyRSAComponents::PrimeInfo info; + if (!read(info.primeFactor)) return false; - if (!read(otherPrimeInfos[i - 2].factorCRTExponent)) + if (!read(info.factorCRTExponent)) return false; - if (!read(otherPrimeInfos[i - 2].factorCRTCoefficient)) + if (!read(info.factorCRTCoefficient)) return false; + otherPrimeInfos.append(WTF::move(info)); } auto keyData = CryptoKeyRSAComponents::createPrivateWithAdditionalData(modulus, exponent, privateExponent, firstPrimeInfo, secondPrimeInfo, otherPrimeInfos); From 38dd86d84a2850271dffc56d29c5845057e1eef2 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 09:58:35 +0000 Subject: [PATCH 50/75] address review: clarify merge-key comparison cap scope in docs --- src/parsers/yaml.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/parsers/yaml.rs b/src/parsers/yaml.rs index 1675a6b5f84..f7ecc6308a7 100644 --- a/src/parsers/yaml.rs +++ b/src/parsers/yaml.rs @@ -2321,8 +2321,9 @@ pub struct Parser<'i, Enc: Encoding> { pub stack_check: StackCheck, /// Total key-equality comparisons performed while deduplicating `<<` merge - /// keys across the whole document. Bounded so that aliased anchors cannot - /// turn a small input into quadratic work. + /// keys across the entire parse invocation (all documents in a + /// multi-document stream; `parse_document` does not reset it). Bounded so + /// that aliased anchors cannot turn a small input into quadratic work. pub merge_key_comparisons: u64, } @@ -3098,9 +3099,10 @@ pub struct MappingProps { impl MappingProps { /// Upper bound on the total number of key-equality comparisons performed - /// while deduplicating `<<` merge keys across a single document. Aliases - /// make re-merging a large anchor nearly free in input bytes, so without - /// a cap a small document can force quadratic work. + /// while deduplicating `<<` merge keys across an entire parse invocation + /// (cumulative over all documents in a stream). Aliases make re-merging a + /// large anchor nearly free in input bytes, so without a cap a small input + /// can force quadratic work. pub const MAX_MERGE_KEY_COMPARISONS: u64 = 1 << 24; pub fn init() -> Self { From 6fe1a52bf5c9c89fdd9c34f146004e169559dcdd Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 09:58:35 +0000 Subject: [PATCH 51/75] address review: reject malformed authorities in dev server host parsing --- src/runtime/bake/DevServer.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/runtime/bake/DevServer.rs b/src/runtime/bake/DevServer.rs index 8808771b725..5438f9fe369 100644 --- a/src/runtime/bake/DevServer.rs +++ b/src/runtime/bake/DevServer.rs @@ -1458,17 +1458,24 @@ pub(crate) fn is_allowed_dev_host(dev: &DevServer, req: &Request) -> bool { } /// `host[":" port]` / `"[" v6 "]" [":" port]` → host (brackets retained for IPv6). +/// Malformed authorities (missing `]`, non-numeric port, trailing garbage) +/// yield an empty slice so callers fail closed. fn host_without_port(host: &[u8]) -> &[u8] { - if host.first() == Some(&b'[') { + let (host, rest) = if host.first() == Some(&b'[') { match strings::index_of_scalar(host, b']') { - Some(end) => &host[..=end], - None => host, + Some(end) => (&host[..=end], &host[end + 1..]), + None => return b"", } } else { match strings::last_index_of_char(host, b':') { - Some(colon) => &host[..colon], - None => host, + Some(colon) => (&host[..colon], &host[colon..]), + None => (host, &host[host.len()..]), } + }; + match rest { + [] => host, + [b':', port @ ..] if port.iter().all(u8::is_ascii_digit) => host, + _ => b"", } } From 762303de72bdf0446059b0950c1dd85497fc8e8b Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 09:58:35 +0000 Subject: [PATCH 52/75] address review: filter encoded C1 controls from error report output --- .../bake/DevServer/ErrorReportRequest.rs | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/runtime/bake/DevServer/ErrorReportRequest.rs b/src/runtime/bake/DevServer/ErrorReportRequest.rs index 20ae72de2fa..ad13b914cb5 100644 --- a/src/runtime/bake/DevServer/ErrorReportRequest.rs +++ b/src/runtime/bake/DevServer/ErrorReportRequest.rs @@ -581,19 +581,35 @@ fn read_string32<'a>( /// CORS "simple request" POST from any origin, and these strings are printed /// to the developer's terminal. Replace C0 control bytes (except `\t`/`\n`) /// and DEL so the payload cannot inject ANSI/OSC escape sequences (cursor -/// movement, OSC 52 clipboard writes, hyperlinks). +/// movement, OSC 52 clipboard writes, hyperlinks). UTF-8-encoded C1 controls +/// (U+0080..=U+009F, i.e. `0xC2 0x80..=0x9F`) are also replaced: xterm-family +/// terminals decode them back to C1, so `0xC2 0x9B` would otherwise act as CSI. fn sanitize_for_terminal<'a>(s: &'a [u8], arena: &'a Arena) -> &'a [u8] { - fn is_disallowed(b: u8) -> bool { - (b < 0x20 && b != b'\t' && b != b'\n') || b == 0x7f + fn is_disallowed(prev: u8, b: u8) -> bool { + // Lone 0x80..=0x9F bytes are continuation bytes of legitimate + // multi-byte characters and must not be blanked; only the encoded C1 + // form (a 0xC2 lead byte followed by 0x80..=0x9F) reaches the + // terminal as a control. + (b < 0x20 && b != b'\t' && b != b'\n') + || b == 0x7f + || (prev == 0xc2 && (0x80..=0x9f).contains(&b)) } - if !s.iter().any(|&b| is_disallowed(b)) { + let mut prev = 0u8; + if !s.iter().any(|&b| { + let bad = is_disallowed(prev, b); + prev = b; + bad + }) { return s; } let copy = arena.alloc_slice_copy(s); + let mut prev = 0u8; for b in copy.iter_mut() { - if is_disallowed(*b) { + let cur = *b; + if is_disallowed(prev, cur) { *b = b' '; } + prev = cur; } copy } From f3df66a42e64e8d19709cb7045523b7c66cdf712 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 09:58:35 +0000 Subject: [PATCH 53/75] address review: normalize trailing characters before batch file check --- src/which/lib.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/which/lib.rs b/src/which/lib.rs index 12d4c240edf..5433511a4ac 100644 --- a/src/which/lib.rs +++ b/src/which/lib.rs @@ -146,10 +146,17 @@ pub fn ends_with_extension(str: &[u8]) -> bool { /// CVE-2024-24576 / CVE-2024-27980). Spawn paths must not pass untrusted /// arguments to one without checking [`batch_arg_has_cmd_metachars`]. pub fn is_batch_file(path: &[u8]) -> bool { - if path.len() < 4 || path[path.len() - 4] != b'.' { + // Windows strips trailing ASCII spaces and periods from the final path + // component, so `foo.cmd.` / `foo.cmd ` still run `foo.cmd` through + // cmd.exe (CVE-2024-43402). Trim them before checking the extension. + let mut end = path.len(); + while end > 0 && matches!(path[end - 1], b' ' | b'.') { + end -= 1; + } + if end < 4 || path[end - 4] != b'.' { return false; } - let file_ext = &path[path.len() - 3..]; + let file_ext = &path[end - 3..end]; strings::eql_case_insensitive_asciii_check_length(file_ext, b"cmd") || strings::eql_case_insensitive_asciii_check_length(file_ext, b"bat") } From 39fe081dc280e257b88dbd4ce08b13731aa1e892 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 10:16:50 +0000 Subject: [PATCH 54/75] http2: count only live streams toward session memory --- src/runtime/api/bun/h2_frame_parser.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/runtime/api/bun/h2_frame_parser.rs b/src/runtime/api/bun/h2_frame_parser.rs index 3c4f2f0a2e6..2701a6506f5 100644 --- a/src/runtime/api/bun/h2_frame_parser.rs +++ b/src/runtime/api/bun/h2_frame_parser.rs @@ -5532,9 +5532,22 @@ impl H2FrameParser { impl H2FrameParser { // get memory usage in MB fn get_session_memory_usage(&self) -> usize { + // Count only live streams: entries stay in the map until connection + // teardown, so counting every entry would grow monotonically over the + // life of a keep-alive connection and eventually trip the session cap + // for a well-behaved peer making sequential requests. + let live_streams = self + .streams + .get() + .iter() + .filter(|(_, item)| { + // SAFETY: item is &*mut Stream from streams.iter(); the boxed Stream outlives the iteration + unsafe { &***item }.state != StreamState::CLOSED + }) + .count(); (self.write_buffer.get().len_u32() as usize + self.queued_data_size.get() as usize - + self.streams.get().len() * core::mem::size_of::()) + + live_streams * core::mem::size_of::()) / 1024 / 1024 } From d5266a0265a70afb551595997e7c1a831df44ea8 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 10:16:50 +0000 Subject: [PATCH 55/75] bunx: derive command name from unscoped package name in string-bin fallback --- src/runtime/cli/bunx_command.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/runtime/cli/bunx_command.rs b/src/runtime/cli/bunx_command.rs index bbbaa2746d7..1cf8ae96a34 100644 --- a/src/runtime/cli/bunx_command.rs +++ b/src/runtime/cli/bunx_command.rs @@ -334,8 +334,15 @@ impl BunxCommand { ExprData::EString(_) => { if let Some(name_expr) = expr.get(b"name") { if let Some(name) = name_expr.as_string(&bump) { - if Self::is_safe_bin_name(name) { - return Ok(Box::<[u8]>::from(name)); + // A scoped `name` (`@scope/pkg`) is legitimate here; + // the command name is its unscoped portion. + let bin_name = if name.is_empty() { + name + } else { + bun_install::dependency::unscoped_package_name(name) + }; + if Self::is_safe_bin_name(bin_name) { + return Ok(Box::<[u8]>::from(bin_name)); } } } From 4e0ca3e87dae60c5315d71797628756b73c2daa4 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 10:16:50 +0000 Subject: [PATCH 56/75] create: only skip postinstall tasks on explicit opt-out --- src/runtime/cli/create_command.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/runtime/cli/create_command.rs b/src/runtime/cli/create_command.rs index 3cae3911f52..7fc891a77cc 100644 --- a/src/runtime/cli/create_command.rs +++ b/src/runtime/cli/create_command.rs @@ -1428,6 +1428,11 @@ impl CreateCommand { let mut npm_client_: Option = None; + // Remember whether the user explicitly opted out (`--no-install`) + // before this is widened to also cover dependency-less templates: + // the flag must skip template tasks, but a template with no + // dependencies should still run its documented postinstall hooks. + let user_skipped_install = create_options.skip_install; create_options.skip_install = create_options.skip_install || !has_dependencies; if !create_options.skip_git { @@ -1511,7 +1516,7 @@ impl CreateCommand { let _ = process?; } - if npm_client_.is_some() && !postinstall_tasks.is_empty() { + if !user_skipped_install && !postinstall_tasks.is_empty() { for task in &postinstall_tasks { exec_task(task, destination, path_env, npm_client_); } From adc05352352e166ae378ec4787f93e68626aa557 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 10:24:53 +0000 Subject: [PATCH 57/75] install: reject drive-relative bin targets --- src/install/bin.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/install/bin.rs b/src/install/bin.rs index c705efbb779..e44cd95a545 100644 --- a/src/install/bin.rs +++ b/src/install/bin.rs @@ -766,6 +766,14 @@ pub fn bin_target_escapes_package_dir(target: &[u8]) -> bool { if path::is_absolute(target) { return true; } + // Windows drive-relative paths (`C:foo`, `C:..\evil`) are not "absolute" + // (no separator after the colon) and their `C:..` component is not a bare + // `..`, so they would slip past the depth walk below while still resolving + // outside the package directory. A legitimate relative bin target never + // contains a colon (it is also the NTFS alternate-data-stream separator). + if target.contains(&b':') { + return true; + } let mut depth: isize = 0; for component in target.split(|&b| b == b'/' || b == b'\\') { match component { From 14b73f31eaa9bd078bdf254128dc7a0c01ae716b Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 10:41:56 +0000 Subject: [PATCH 58/75] address review: only reject colons in the leading bin target component --- src/install/bin.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/install/bin.rs b/src/install/bin.rs index e44cd95a545..96158eb6630 100644 --- a/src/install/bin.rs +++ b/src/install/bin.rs @@ -769,9 +769,15 @@ pub fn bin_target_escapes_package_dir(target: &[u8]) -> bool { // Windows drive-relative paths (`C:foo`, `C:..\evil`) are not "absolute" // (no separator after the colon) and their `C:..` component is not a bare // `..`, so they would slip past the depth walk below while still resolving - // outside the package directory. A legitimate relative bin target never - // contains a colon (it is also the NTFS alternate-data-stream separator). - if target.contains(&b':') { + // outside the package directory. A colon in the *first* component can only + // be a drive prefix (or an NTFS alternate-data-stream on the leading + // segment) — reject it. Colons in later components are left alone so Unix + // filenames containing `:` keep working. + if target + .split(|&b| b == b'/' || b == b'\\') + .next() + .is_some_and(|first| first.contains(&b':')) + { return true; } let mut depth: isize = 0; From 6f4dbd5a16be7726ae7604f23e567807eec5f94c Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 11:00:50 +0000 Subject: [PATCH 59/75] install: align streaming extractor file-open flags with buffered extractor --- src/install/TarballStream.rs | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/install/TarballStream.rs b/src/install/TarballStream.rs index 069b05d4c63..9183e0e1465 100644 --- a/src/install/TarballStream.rs +++ b/src/install/TarballStream.rs @@ -837,9 +837,16 @@ impl TarballStream { FileKind::File => { #[cfg(windows)] let mode: Mode = 0; + // Mask to permission bits so setuid/setgid/sticky bits from the + // archive never reach `openat`'s mode argument. #[cfg(not(windows))] - let mode: Mode = Mode::try_from(entry.perm() | 0o666).expect("int cast"); - let fd = open_output_file(dest, path, path_slice, mode)?; + let mode: Mode = + Mode::try_from((entry.perm() & 0o777) | 0o666).expect("int cast"); + #[cfg(unix)] + let nofollow = !self.created_symlinks.is_empty(); + #[cfg(not(unix))] + let nofollow = false; + let fd = open_output_file(dest, path, path_slice, mode, nofollow)?; self.entry_count += 1; #[cfg(any(target_os = "linux", target_os = "android"))] @@ -1286,8 +1293,20 @@ fn open_output_file( path: OSPathZ, path_slice: &[OSPathChar], mode: Mode, + nofollow: bool, ) -> Result { - let flags = O::WRONLY | O::CREAT | O::TRUNC; + // `path_traverses_created_symlink` is a lexical check: on filesystems that + // alias differently-encoded names (Unicode NFC/NFD normalization on + // APFS/HFS+), a path component can reach a created symlink without + // byte-matching its recorded path. Once this extraction has created any + // symlink, ask the kernel to refuse to follow symlinks while opening file + // entries. `NOFOLLOW_ANY` is 0 on non-Darwin targets. Same defense as the + // buffered extractor in `Archiver::extract_to_dir`. + let flags = if nofollow { + O::WRONLY | O::CREAT | O::TRUNC | O::NOFOLLOW_ANY + } else { + O::WRONLY | O::CREAT | O::TRUNC + }; #[cfg(windows)] { let _ = mode; From a3be8195235aa9cc3abd15df336399b9bf93b228 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 11:02:38 +0000 Subject: [PATCH 60/75] wasi: re-check containment after path resolution --- src/js/node/wasi.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/js/node/wasi.ts b/src/js/node/wasi.ts index 712ae7365b9..48422fbd2d8 100644 --- a/src/js/node/wasi.ts +++ b/src/js/node/wasi.ts @@ -1540,6 +1540,30 @@ var require_wasi = __commonJS({ throw e; } } + // RESOLVE_PATH is a lexical check on the guest-supplied path; + // realpathSync above follows symlinks on disk, so a symlink + // inside the directory can still point the resolved path + // outside of it. Re-check containment before the path is + // opened or recorded as a new directory base in FD_MAP. The + // resolved path must stay under the directory's lexical + // location or its own resolved location (the latter matters + // when the preopened directory is itself reached via a + // symlink). + { + const contained = base => { + if (full === base) return true; + const rel = path.relative(base, full); + return rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel); + }; + const lexicalBase = path.resolve(stats.path); + let realBase = lexicalBase; + try { + realBase = fs.realpathSync(lexicalBase); + } catch {} + if (!contained(lexicalBase) && !contained(realBase)) { + throw new types_1.WASIError(constants_1.WASI_ENOTCAPABLE); + } + } let isDirectory; if (write) { try { From 5819f769ff07944dac384e1f97ac1298aa2bdbde Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 11:04:38 +0000 Subject: [PATCH 61/75] [autofix.ci] apply automated fixes --- src/install/TarballStream.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/install/TarballStream.rs b/src/install/TarballStream.rs index 9183e0e1465..e205a1b0520 100644 --- a/src/install/TarballStream.rs +++ b/src/install/TarballStream.rs @@ -840,8 +840,7 @@ impl TarballStream { // Mask to permission bits so setuid/setgid/sticky bits from the // archive never reach `openat`'s mode argument. #[cfg(not(windows))] - let mode: Mode = - Mode::try_from((entry.perm() & 0o777) | 0o666).expect("int cast"); + let mode: Mode = Mode::try_from((entry.perm() & 0o777) | 0o666).expect("int cast"); #[cfg(unix)] let nofollow = !self.created_symlinks.is_empty(); #[cfg(not(unix))] From 7d3faf9dae01c742a6b73b92479974aaf744827e Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 21 May 2026 11:26:07 +0000 Subject: [PATCH 62/75] address review: blank the lead byte of filtered two-byte sequences --- src/runtime/bake/DevServer/ErrorReportRequest.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/runtime/bake/DevServer/ErrorReportRequest.rs b/src/runtime/bake/DevServer/ErrorReportRequest.rs index ad13b914cb5..08a69ea17b6 100644 --- a/src/runtime/bake/DevServer/ErrorReportRequest.rs +++ b/src/runtime/bake/DevServer/ErrorReportRequest.rs @@ -604,10 +604,15 @@ fn sanitize_for_terminal<'a>(s: &'a [u8], arena: &'a Arena) -> &'a [u8] { } let copy = arena.alloc_slice_copy(s); let mut prev = 0u8; - for b in copy.iter_mut() { - let cur = *b; + for i in 0..copy.len() { + let cur = copy[i]; if is_disallowed(prev, cur) { - *b = b' '; + copy[i] = b' '; + // For an encoded C1 control, blank the 0xC2 lead byte too so the + // output stays valid UTF-8 instead of leaving a dangling lead byte. + if prev == 0xc2 && i > 0 { + copy[i - 1] = b' '; + } } prev = cur; } From b182ccd8b8087ec532216bbc699ec57f21e9fd73 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Fri, 22 May 2026 00:38:20 +0000 Subject: [PATCH 63/75] yaml: rely on the parser's existing stack guard instead of a merge work cap --- src/parsers/yaml.rs | 52 ++++++--------------------------------------- 1 file changed, 7 insertions(+), 45 deletions(-) diff --git a/src/parsers/yaml.rs b/src/parsers/yaml.rs index f7ecc6308a7..73863d95286 100644 --- a/src/parsers/yaml.rs +++ b/src/parsers/yaml.rs @@ -791,8 +791,6 @@ pub enum ParseError { MultipleYamlDirectives, #[error("InvalidIndentation")] InvalidIndentation, - #[error("MergeKeyLimitExceeded")] - MergeKeyLimitExceeded, #[error("StackOverflow")] StackOverflow, } @@ -2160,7 +2158,6 @@ pub enum ParseResultError { UnexpectedDocumentEnd { pos: Pos }, MultipleYamlDirectives { pos: Pos }, InvalidIndentation { pos: Pos }, - MergeKeyLimitExceeded { pos: Pos }, } impl ParseResultError { @@ -2211,9 +2208,6 @@ impl ParseResultError { ParseResultError::InvalidIndentation { pos } => { log.add_error(Some(source), pos.loc(), b"Invalid indentation"); } - ParseResultError::MergeKeyLimitExceeded { pos } => { - log.add_error(Some(source), pos.loc(), b"Merge key expansion is too large"); - } } Ok(()) } @@ -2271,9 +2265,6 @@ impl ParseResult { ParseError::InvalidIndentation => { ParseResultError::InvalidIndentation { pos: parser.pos } } - ParseError::MergeKeyLimitExceeded => ParseResultError::MergeKeyLimitExceeded { - pos: parser.token.start, - }, }; ParseResult::Err(e) } @@ -2319,12 +2310,6 @@ pub struct Parser<'i, Enc: Encoding> { pub whitespace_buf: Vec>, pub stack_check: StackCheck, - - /// Total key-equality comparisons performed while deduplicating `<<` merge - /// keys across the entire parse invocation (all documents in a - /// multi-document stream; `parse_document` does not reset it). Bounded so - /// that aliased anchors cannot turn a small input into quadratic work. - pub merge_key_comparisons: u64, } impl<'i, Enc: Encoding> Parser<'i, Enc> { @@ -2347,7 +2332,6 @@ impl<'i, Enc: Encoding> Parser<'i, Enc> { tag_handles: StringHashMap::default(), whitespace_buf: Vec::new(), stack_check: StackCheck::init(), - merge_key_comparisons: 0, } } @@ -2706,7 +2690,7 @@ impl<'i, Enc: Encoding> Parser<'i, Enc> { })?; } else { let value = self.parse_node(ParseNodeOptions::default())?; - props.append_maybe_merge(key, value, &mut self.merge_key_comparisons)?; + props.append_maybe_merge(key, value)?; } if matches!(self.token.data, TokenData::CollectEntry) { @@ -2961,7 +2945,7 @@ impl<'i, Enc: Encoding> Parser<'i, Enc> { } }; - props.append_maybe_merge(first_key, value, &mut self.merge_key_comparisons)?; + props.append_maybe_merge(first_key, value)?; } if self.context.get() == Context::FlowIn { @@ -3068,7 +3052,7 @@ impl<'i, Enc: Encoding> Parser<'i, Enc> { } }; - props.append_maybe_merge(key, value, &mut self.merge_key_comparisons)?; + props.append_maybe_merge(key, value)?; } Ok(Expr::init( @@ -3098,13 +3082,6 @@ pub struct MappingProps { } impl MappingProps { - /// Upper bound on the total number of key-equality comparisons performed - /// while deduplicating `<<` merge keys across an entire parse invocation - /// (cumulative over all documents in a stream). Aliases make re-merging a - /// large anchor nearly free in input bytes, so without a cap a small input - /// can force quadratic work. - pub const MAX_MERGE_KEY_COMPARISONS: u64 = 1 << 24; - pub fn init() -> Self { Self { list: bun_alloc::AstAlloc::vec(), @@ -3116,19 +3093,11 @@ impl MappingProps { Ok(()) } - pub fn merge( - &mut self, - merge_props: &[G::Property], - comparisons: &mut u64, - ) -> Result<(), ParseError> { + pub fn merge(&mut self, merge_props: &[G::Property]) -> Result<(), AllocError> { self.list.reserve(merge_props.len()); // PERF(port): was ensureUnusedCapacity 'next_merge_prop: for merge_prop in merge_props.iter().rev() { let merge_key = merge_prop.key.as_ref().unwrap(); - *comparisons = comparisons.saturating_add(self.list.len() as u64); - if *comparisons > Self::MAX_MERGE_KEY_COMPARISONS { - return Err(ParseError::MergeKeyLimitExceeded); - } for existing_prop in self.list.iter() { let existing_key = existing_prop.key.as_ref().unwrap(); if Parser::::yaml_merge_key_expr_eql(existing_key, merge_key) { @@ -3151,12 +3120,7 @@ impl MappingProps { Ok(()) } - pub fn append_maybe_merge( - &mut self, - key: Expr, - value: Expr, - comparisons: &mut u64, - ) -> Result<(), ParseError> { + pub fn append_maybe_merge(&mut self, key: Expr, value: Expr) -> Result<(), AllocError> { let is_merge_key = match &key.data { ast::ExprData::EString(key_str) => key_str.eql_comptime(b"<<"), _ => false, @@ -3173,16 +3137,14 @@ impl MappingProps { } match &value.data { - ast::ExprData::EObject(value_obj) => { - self.merge(value_obj.properties.slice(), comparisons) - } + ast::ExprData::EObject(value_obj) => self.merge(value_obj.properties.slice()), ast::ExprData::EArray(value_arr) => { for item in value_arr.items.slice() { let item_obj = match &item.data { ast::ExprData::EObject(obj) => obj, _ => continue, }; - self.merge(item_obj.properties.slice(), comparisons)?; + self.merge(item_obj.properties.slice())?; } Ok(()) } From 64ae77295f1be093b58ec91e022d7e3a1fe8fcc1 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Fri, 22 May 2026 01:21:47 +0000 Subject: [PATCH 64/75] address review: enforce decode output cap after each chunk --- src/brotli/lib.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/brotli/lib.rs b/src/brotli/lib.rs index 7ff0f78035c..249d90422f5 100644 --- a/src/brotli/lib.rs +++ b/src/brotli/lib.rs @@ -208,6 +208,13 @@ impl<'a> BrotliReaderArrayList<'a> { unsafe { bun_core::vec::commit_spare(self.list_ptr, bytes_written) }; self.total_in += bytes_read; + // Enforce the cap after every write so a chunk that ends the + // stream (`success`) cannot push the output past the limit. + if self.list_ptr.len() > self.max_output_size { + self.state = ReaderState::Error; + return Err(err!("BrotliDecompressionError")); + } + match result { c::BrotliDecoderResult::success => { debug_assert!(BrotliDecoder::is_finished(self.brotli())); From 471cc1c4d5e3c09fd2933a1bae01dcdc2ce52c22 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Fri, 22 May 2026 01:21:47 +0000 Subject: [PATCH 65/75] address review: anchor multipart boundary parameter parsing --- src/bun_core/util.rs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/bun_core/util.rs b/src/bun_core/util.rs index 211b00076e3..172840cdc2e 100644 --- a/src/bun_core/util.rs +++ b/src/bun_core/util.rs @@ -5790,11 +5790,13 @@ pub mod form_data { /// Parameters are `;`-delimited per RFC 7231 and the parameter *name* must /// be exactly `boundary`, so a different parameter (`xboundary=FAKE`) or a /// `boundary=` substring inside another parameter's value is not picked up - /// by an unanchored substring search. + /// by an unanchored substring search. A `;` inside a quoted parameter + /// value (RFC 7230 quoted-string, `\` escapes the next byte) does not + /// delimit parameters. pub fn get_boundary(content_type: &[u8]) -> Option<&[u8]> { let mut rest = content_type; loop { - let semi = crate::strings_impl::index_of_char(rest, b';')?; + let semi = index_of_unquoted_semicolon(rest)?; rest = &rest[semi + 1..]; let Some(begin) = crate::strings_impl::trim_left(rest, b" \t").strip_prefix(b"boundary=") @@ -5816,6 +5818,23 @@ pub mod form_data { } } + /// Index of the next `;` in `s` that is not inside an RFC 7230 + /// quoted-string (`\` escapes the following byte inside quotes). + fn index_of_unquoted_semicolon(s: &[u8]) -> Option { + let mut in_quotes = false; + let mut i = 0; + while i < s.len() { + match s[i] { + b'"' => in_quotes = !in_quotes, + b'\\' if in_quotes => i += 1, + b';' if !in_quotes => return Some(i), + _ => {} + } + i += 1; + } + None + } + /// `FormData.AsyncFormData` — heap-allocated, owns its `Encoding`. /// PORT NOTE: Zig stored `std.mem.Allocator param`; deleted (non-AST /// crate, global mimalloc per §Allocators). `deinit` becomes `Drop` on the From a775854faf6c55fa09e5e2130d945e4d518ad0b6 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Fri, 22 May 2026 01:21:47 +0000 Subject: [PATCH 66/75] address review: include argv0 in batch-file argument validation --- src/runtime/api/bun/js_bun_spawn_bindings.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/runtime/api/bun/js_bun_spawn_bindings.rs b/src/runtime/api/bun/js_bun_spawn_bindings.rs index f693381ee15..d4ff66ddba4 100644 --- a/src/runtime/api/bun/js_bun_spawn_bindings.rs +++ b/src/runtime/api/bun/js_bun_spawn_bindings.rs @@ -263,6 +263,17 @@ fn get_argv( // quoting cannot make that safe, so reject arguments that cmd.exe would // reinterpret. let is_batch_file = cfg!(windows) && bun_which::is_batch_file(argv0_result.argv0.as_bytes()); + if is_batch_file && bun_which::batch_arg_has_cmd_metachars(argv0_result.arg0.as_bytes()) { + return Err(global_this + .err( + jsc::ErrorCode::INVALID_ARG_VALUE, + format_args!( + "The command name contains a cmd.exe special character and cannot be safely passed to a .bat/.cmd file. Received {}", + bun_fmt::quote(argv0_result.arg0.as_bytes()) + ), + ) + .throw()); + } *argv0 = Some(argv0_result.argv0.as_ptr()); argv.push(argv0_result.arg0.as_ptr()); From 542585fa5a116004197bbce57e4d42636dd73bfc Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Fri, 22 May 2026 01:21:47 +0000 Subject: [PATCH 67/75] address review: reject empty port suffix in host parsing --- src/runtime/bake/DevServer.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/runtime/bake/DevServer.rs b/src/runtime/bake/DevServer.rs index 5438f9fe369..f638ed92975 100644 --- a/src/runtime/bake/DevServer.rs +++ b/src/runtime/bake/DevServer.rs @@ -1458,8 +1458,8 @@ pub(crate) fn is_allowed_dev_host(dev: &DevServer, req: &Request) -> bool { } /// `host[":" port]` / `"[" v6 "]" [":" port]` → host (brackets retained for IPv6). -/// Malformed authorities (missing `]`, non-numeric port, trailing garbage) -/// yield an empty slice so callers fail closed. +/// Malformed authorities (missing `]`, empty or non-numeric port, trailing +/// garbage) yield an empty slice so callers fail closed. fn host_without_port(host: &[u8]) -> &[u8] { let (host, rest) = if host.first() == Some(&b'[') { match strings::index_of_scalar(host, b']') { @@ -1474,7 +1474,7 @@ fn host_without_port(host: &[u8]) -> &[u8] { }; match rest { [] => host, - [b':', port @ ..] if port.iter().all(u8::is_ascii_digit) => host, + [b':', port @ ..] if !port.is_empty() && port.iter().all(u8::is_ascii_digit) => host, _ => b"", } } From 3671277702b4e07baca7353ed397d7b6baac4668 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Fri, 22 May 2026 01:21:47 +0000 Subject: [PATCH 68/75] address review: validate negative aggregate lengths in reply scanner --- src/valkey/valkey_protocol.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/valkey/valkey_protocol.rs b/src/valkey/valkey_protocol.rs index b1891b49dee..17df3283d80 100644 --- a/src/valkey/valkey_protocol.rs +++ b/src/valkey/valkey_protocol.rs @@ -704,23 +704,36 @@ impl ReplyScanner { return Err(RedisError::NestingDepthExceeded); } let len = reader.read_integer()?; - Ok(Some(u64::try_from(len).unwrap_or(0))) + // Mirror the tree parser: only `*-1` (RESP2 null array) is a + // legal non-positive aggregate length here. + match ty { + RESPType::Array if len < 0 => Ok(Some(0)), + RESPType::Set if len < 0 => Err(RedisError::InvalidSet), + RESPType::Push if len <= 0 => Err(RedisError::InvalidPush), + _ => Ok(Some(u64::try_from(len).expect("int cast"))), + } } RESPType::Map => { if depth >= ValkeyReader::MAX_NESTING_DEPTH { return Err(RedisError::NestingDepthExceeded); } let len = reader.read_integer()?; - Ok(Some(u64::try_from(len).unwrap_or(0).saturating_mul(2))) + if len < 0 { + return Err(RedisError::InvalidMap); + } + Ok(Some(u64::try_from(len).expect("int cast").saturating_mul(2))) } RESPType::Attribute => { if depth >= ValkeyReader::MAX_NESTING_DEPTH { return Err(RedisError::NestingDepthExceeded); } let len = reader.read_integer()?; + if len < 0 { + return Err(RedisError::InvalidAttribute); + } Ok(Some( u64::try_from(len) - .unwrap_or(0) + .expect("int cast") .saturating_mul(2) .saturating_add(1), )) From fa6e4008d8ef05d759520321a5fa9376bf333955 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Fri, 22 May 2026 01:21:48 +0000 Subject: [PATCH 69/75] address review: enforce inflate output cap after each write --- src/zlib/lib.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/zlib/lib.rs b/src/zlib/lib.rs index b2b9651514e..4c00bdd31b6 100644 --- a/src/zlib/lib.rs +++ b/src/zlib/lib.rs @@ -514,14 +514,18 @@ impl<'a> ZlibReaderArrayList<'a> { // flush parameter). if self.zlib.avail_out == 0 { - if self.zlib.total_out as usize >= self.max_output_size { + let produced = self.zlib.total_out as usize; + let remaining_budget = self.max_output_size.saturating_sub(produced); + if remaining_budget == 0 { self.state = ZlibReaderArrayListState::Error; return Err(ZlibError::ZlibError); } // SAFETY: zlib writes the tail; len is truncated to `total_out` before any read. - let (next_out, avail_out) = unsafe { self.list_ptr.reserve_expand_tail(4096) }; + let (next_out, avail_out) = + unsafe { self.list_ptr.reserve_expand_tail(remaining_budget.min(4096)) }; self.zlib.next_out = next_out; - self.zlib.avail_out = avail_out as uInt; + // Clamp so a single inflate call cannot write past `max_output_size`. + self.zlib.avail_out = avail_out.min(remaining_budget) as uInt; } // Try to inflate even if avail_in is 0, as this could be a valid empty gzip stream From 39b6631165331b8db5ddeca7e7d148e92581d465 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Fri, 22 May 2026 01:21:48 +0000 Subject: [PATCH 70/75] address review: enforce decompress output cap after each write --- src/zstd/lib.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/zstd/lib.rs b/src/zstd/lib.rs index 767141c9f9f..875d95a8e01 100644 --- a/src/zstd/lib.rs +++ b/src/zstd/lib.rs @@ -384,7 +384,10 @@ impl<'a> ZstdReaderArrayList<'a> { return Ok(()); } - if self.list_ptr.len() >= self.max_output_size { + // Decompression-bomb guard: clamp the output space handed to a single + // ZSTD_decompressStream call so one call can never write past the cap. + let remaining_output = self.max_output_size.saturating_sub(self.list_ptr.len()); + if remaining_output == 0 { self.state = State::Error; return Err(ZstdError::ZstdDecompressionError); } @@ -399,7 +402,7 @@ impl<'a> ZstdReaderArrayList<'a> { }; let mut out_buf = c::ZSTD_outBuffer { dst: spare.as_mut_ptr().cast::(), - size: spare.len(), + size: spare.len().min(remaining_output), pos: 0, }; From cefd8c9a67b0892ee6f4f4b22dcc6ce05b248823 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 01:23:47 +0000 Subject: [PATCH 71/75] [autofix.ci] apply automated fixes --- src/valkey/valkey_protocol.rs | 4 +++- src/zlib/lib.rs | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/valkey/valkey_protocol.rs b/src/valkey/valkey_protocol.rs index 17df3283d80..76e4fb5d21f 100644 --- a/src/valkey/valkey_protocol.rs +++ b/src/valkey/valkey_protocol.rs @@ -721,7 +721,9 @@ impl ReplyScanner { if len < 0 { return Err(RedisError::InvalidMap); } - Ok(Some(u64::try_from(len).expect("int cast").saturating_mul(2))) + Ok(Some( + u64::try_from(len).expect("int cast").saturating_mul(2), + )) } RESPType::Attribute => { if depth >= ValkeyReader::MAX_NESTING_DEPTH { diff --git a/src/zlib/lib.rs b/src/zlib/lib.rs index 4c00bdd31b6..c474c7fcc87 100644 --- a/src/zlib/lib.rs +++ b/src/zlib/lib.rs @@ -521,8 +521,10 @@ impl<'a> ZlibReaderArrayList<'a> { return Err(ZlibError::ZlibError); } // SAFETY: zlib writes the tail; len is truncated to `total_out` before any read. - let (next_out, avail_out) = - unsafe { self.list_ptr.reserve_expand_tail(remaining_budget.min(4096)) }; + let (next_out, avail_out) = unsafe { + self.list_ptr + .reserve_expand_tail(remaining_budget.min(4096)) + }; self.zlib.next_out = next_out; // Clamp so a single inflate call cannot write past `max_output_size`. self.zlib.avail_out = avail_out.min(remaining_budget) as uInt; From f6811394b53bc5d7d034ea1c7580dadacb310d0f Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Fri, 22 May 2026 01:57:35 +0000 Subject: [PATCH 72/75] shell: keep literal tilde after interpolation literal --- src/shell_parser/parse.rs | 13 +++++++++---- test/js/bun/shell/bunshell.test.ts | 11 +++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/shell_parser/parse.rs b/src/shell_parser/parse.rs index e791035d4b0..ef4ba47f028 100644 --- a/src/shell_parser/parse.rs +++ b/src/shell_parser/parse.rs @@ -3487,10 +3487,15 @@ impl<'bump, const ENCODING: StringEncoding> Lexer<'bump, ENCODING> { } let start = self.j; self.append_string_to_str_pool(bunstr)?; - // Interpolated values are data, not shell syntax. In the unquoted state, - // flush them as a quoted-text token so the parser does not re-interpret - // a leading `~` as tilde expansion. - if self.chars.state == CharState::Normal { + // Interpolated values are data, not shell syntax. If the value would + // begin its Text token with `~`, flush it as a quoted-text token so the + // parser does not re-interpret it as tilde expansion. Values that + // cannot be misread stay coalesced with the surrounding word so that + // literal source text after the interpolation (e.g. `${name}~bak`) + // keeps its meaning. + if self.chars.state == CharState::Normal + && self.strpool.get(start as usize) == Some(&b'~') + { self.tokens .push(Token::DoubleQuotedText(TextRange { start, end: self.j })); self.word_start = self.j; diff --git a/test/js/bun/shell/bunshell.test.ts b/test/js/bun/shell/bunshell.test.ts index f6a668f349b..c597aad0e89 100644 --- a/test/js/bun/shell/bunshell.test.ts +++ b/test/js/bun/shell/bunshell.test.ts @@ -483,6 +483,17 @@ describe("bunshell", () => { .stdout("/home/user/bin\n") .runAsTest("cmd subst as last atom"); }); + + describe("interpolated values", async () => { + // A `~` that comes from an interpolated value is data, not syntax. + TestBuilder.command`echo ${"~"}/x`.stdout("~/x\n").runAsTest("interpolated tilde stays literal"); + TestBuilder.command`echo ${"~/secret"}`.stdout("~/secret\n").runAsTest("interpolated tilde path stays literal"); + // A literal `~` in the source after an interpolation keeps its meaning. + TestBuilder.command`echo ${"a b"}~/x`.stdout("a b~/x\n").runAsTest("literal tilde after interpolated value"); + TestBuilder.command`echo ${"a b"}~bak` + .stdout("a b~bak\n") + .runAsTest("backup-suffix idiom after interpolated value"); + }); }); // Ported from GNU bash "quote.tests" From ec8451854aabc07657f804bcb7da28ee5acfc425 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Fri, 22 May 2026 01:57:36 +0000 Subject: [PATCH 73/75] sqlite: close database handle on deserialize error paths --- src/jsc/bindings/sqlite/JSSQLStatement.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/jsc/bindings/sqlite/JSSQLStatement.cpp b/src/jsc/bindings/sqlite/JSSQLStatement.cpp index e729d99741f..d09de269a82 100644 --- a/src/jsc/bindings/sqlite/JSSQLStatement.cpp +++ b/src/jsc/bindings/sqlite/JSSQLStatement.cpp @@ -1211,6 +1211,11 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementDeserialize, (JSC::JSGlobalObject * lexic sqlite3* db = nullptr; if (sqlite3_open_v2(":memory:", &db, openFlags, nullptr) != SQLITE_OK) { + // Ownership of `data` is only transferred by sqlite3_deserialize below; + // on open failure it must be released here. A failed open can still + // return a partially-initialized handle that needs sqlite3_close(). + sqlite3_free(data); + sqlite3_close(db); throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Failed to open SQLite"_s)); return {}; } @@ -1228,12 +1233,15 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementDeserialize, (JSC::JSGlobalObject * lexic // SQLITE_DESERIALIZE_FREEONCLOSE transfers ownership of `data` to SQLite, // which frees it itself when deserialization fails. Do not free it again here. if (status == SQLITE_BUSY) { + sqlite3_close(db); throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "SQLITE_BUSY"_s)); return {}; } if (status != SQLITE_OK) { - throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, status == SQLITE_ERROR ? "unable to deserialize database"_s : sqliteString(sqlite3_errstr(status)))); + auto message = status == SQLITE_ERROR ? "unable to deserialize database"_s : sqliteString(sqlite3_errstr(status)); + sqlite3_close(db); + throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, message)); return {}; } From 8860b859bcc34b63ebbb941cc4751c0e535d0313 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 01:59:31 +0000 Subject: [PATCH 74/75] [autofix.ci] apply automated fixes --- src/shell_parser/parse.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/shell_parser/parse.rs b/src/shell_parser/parse.rs index ef4ba47f028..b626a983e1f 100644 --- a/src/shell_parser/parse.rs +++ b/src/shell_parser/parse.rs @@ -3493,8 +3493,7 @@ impl<'bump, const ENCODING: StringEncoding> Lexer<'bump, ENCODING> { // cannot be misread stay coalesced with the surrounding word so that // literal source text after the interpolation (e.g. `${name}~bak`) // keeps its meaning. - if self.chars.state == CharState::Normal - && self.strpool.get(start as usize) == Some(&b'~') + if self.chars.state == CharState::Normal && self.strpool.get(start as usize) == Some(&b'~') { self.tokens .push(Token::DoubleQuotedText(TextRange { start, end: self.j })); From c041aa6cb296fdb64b1dce30f854971067433d7d Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Fri, 22 May 2026 02:26:17 +0000 Subject: [PATCH 75/75] wasi: resolve parent directory before containment check when target does not exist --- src/js/node/wasi.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/js/node/wasi.ts b/src/js/node/wasi.ts index 48422fbd2d8..b91fa0c3ba1 100644 --- a/src/js/node/wasi.ts +++ b/src/js/node/wasi.ts @@ -1535,7 +1535,27 @@ var require_wasi = __commonJS({ full = fs.realpathSync(fullUnresolved); } catch (e) { if (e?.code === "ENOENT") { - full = fullUnresolved; + // The final component may legitimately not exist yet (e.g. + // O_CREAT), but the rest of the path must not be redirected + // by symlinks: resolve the parent directory and re-attach + // the final component. A dangling symlink as the final + // component would still redirect the create, so reject it. + const parentDir = path.dirname(fullUnresolved); + const lastComponent = path.basename(fullUnresolved); + let realParent = parentDir; + try { + realParent = fs.realpathSync(parentDir); + } catch (e2) { + if (e2?.code !== "ENOENT") throw e2; + } + full = path.join(realParent, lastComponent); + let finalIsLink = false; + try { + finalIsLink = fs.lstatSync(full).isSymbolicLink(); + } catch {} + if (finalIsLink) { + throw new types_1.WASIError(constants_1.WASI_ENOTCAPABLE); + } } else { throw e; }