diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 91580a6d82c..d2d7c8d613e 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -1119,8 +1119,15 @@ struct us_socket_t *us_internal_ssl_on_data(struct us_socket_t *s, char *data, i read += just_read; if (read == LIBUS_RECV_BUFFER_LENGTH) { + char *saved_input = loop_ssl_data->ssl_read_input; + unsigned int saved_length = loop_ssl_data->ssl_read_input_length; + unsigned int saved_offset = loop_ssl_data->ssl_read_input_offset; s = us_dispatch_data(s, loop_ssl_data->ssl_read_output + LIBUS_RECV_BUFFER_PADDING, read); if (!s || ssl_gone(s)) return NULL; + loop_ssl_data->ssl_read_input = saved_input; + loop_ssl_data->ssl_read_input_length = saved_length; + loop_ssl_data->ssl_read_input_offset = saved_offset; + loop_ssl_data->ssl_socket = s; read = 0; goto restart; } diff --git a/packages/bun-uws/src/HttpContext.h b/packages/bun-uws/src/HttpContext.h index fec409f8d8f..603a70ff11c 100644 --- a/packages/bun-uws/src/HttpContext.h +++ b/packages/bun-uws/src/HttpContext.h @@ -319,7 +319,7 @@ struct HttpContext { /* Route the method and URL */ selectedRouter->getUserData() = {(HttpResponse *) s, httpRequest}; - if (!selectedRouter->route(httpRequest->getCaseSensitiveMethod(), httpRequest->getUrl())) { + if (!selectedRouter->route(httpRequest->getCaseSensitiveMethod(), httpRequest->getUrlForRouting())) { /* We have to force close this socket as we have no handler for it */ us_socket_close((us_socket_t *) s, 0, nullptr); return nullptr; diff --git a/packages/bun-uws/src/HttpParser.h b/packages/bun-uws/src/HttpParser.h index 864882bafbd..9e35d48333f 100644 --- a/packages/bun-uws/src/HttpParser.h +++ b/packages/bun-uws/src/HttpParser.h @@ -321,6 +321,27 @@ namespace uWS return headers->value; } + std::string_view getUrlForRouting() + { + std::string_view url = getUrl(); + if (url.length() && url[0] != '/') { + size_t schemeLength = 0; + if (url.length() >= 7 && strncasecmp(url.data(), "http://", 7) == 0) { + schemeLength = 7; + } else if (url.length() >= 8 && strncasecmp(url.data(), "https://", 8) == 0) { + schemeLength = 8; + } + if (schemeLength) { + size_t pathStart = url.find('/', schemeLength); + if (pathStart == std::string_view::npos) { + return "/"; + } + return url.substr(pathStart); + } + } + return url; + } + /* Hack: this should be getMethod */ std::string_view getCaseSensitiveMethod() { diff --git a/src/http/HTTPContext.rs b/src/http/HTTPContext.rs index 5b074ce775b..f91ce2a42ae 100644 --- a/src/http/HTTPContext.rs +++ b/src/http/HTTPContext.rs @@ -1292,6 +1292,7 @@ impl Handler { } bun_core::scoped_log!(HTTPContext, "Unexpected data on socket"); + HTTPContext::::terminate_socket(socket); return; } diff --git a/src/http/lib.rs b/src/http/lib.rs index cf5213f3c99..baf99502548 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -211,6 +211,7 @@ pub struct Flags { /// Set after the first H3 retry so a stale-session/GOAWAY race retries /// once on a fresh connection but never loops. pub h3_retried: bool, + pub is_node_http_client: bool, } impl Default for Flags { @@ -233,6 +234,7 @@ impl Default for Flags { force_http1: false, force_http3: false, h3_retried: false, + is_node_http_client: false, } } } @@ -768,7 +770,7 @@ use core::ffi::c_uint; use bstr::BStr; use bun_boringssl as boringssl; -use bun_collections::ArrayHashMap; +use bun_collections::{ArrayHashMap, VecExt}; use bun_core::StringBuilder; use bun_core::{FeatureFlags, Global, Output, err}; use bun_core::{OwnedString, String as BunString, Tag as BunStringTag, immutable as strings}; @@ -2260,7 +2262,10 @@ impl<'a> HTTPClient<'a> { picohttp::Header::new(CONTENT_LENGTH_HEADER_NAME, value); header_count += 1; } - } else if let Some(content_length) = original_content_length { + } else if let Some(content_length) = original_content_length + && (self.flags.is_node_http_client + || matches!(bun_core::parse_unsigned::(content_length, 10), Ok(0))) + { request_headers_buf[header_count] = picohttp::Header::new(CONTENT_LENGTH_HEADER_NAME, content_length); header_count += 1; @@ -3183,7 +3188,7 @@ impl<'a> HTTPClient<'a> { // if less than 16 it will always be a ShortRead if to_read!().len() < 16 { bun_core::scoped_log!(fetch, "handleShortRead"); - self.handle_short_read::(incoming_data, socket, needs_move); + self.handle_short_read::(to_read!(), socket, needs_move); return; } @@ -3206,7 +3211,7 @@ impl<'a> HTTPClient<'a> { self.close_and_fail::(err!(ResponseHeadersTooLarge), socket); return; } - self.handle_short_read::(incoming_data, socket, needs_move); + self.handle_short_read::(to_read!(), socket, needs_move); return; } Err(e) => { @@ -3248,6 +3253,13 @@ impl<'a> HTTPClient<'a> { bun_core::scoped_log!(fetch, "information headers"); self.state.pending_response = None; + if !needs_move { + let remaining = to_read!().len(); + let buffer = &mut self.state.response_message_buffer.list; + let consumed = buffer.len().saturating_sub(remaining); + buffer.drain_front(consumed); + to_read = bun_ptr::RawSlice::new(buffer.as_slice()); + } if to_read!().is_empty() { // we only received 1XX responses, we wanna wait for the next status code return; @@ -4045,6 +4057,11 @@ impl<'a> HTTPClient<'a> { ) -> Result { debug_assert!(self.state.transfer_encoding == Encoding::Identity); let content_length = self.state.content_length; + if let Some(len) = content_length + && incoming_data.len() > len.saturating_sub(self.state.total_body_received) + { + self.state.flags.allow_keepalive = false; + } // is it exactly as much as we need? if is_only_buffer && let Some(len) = content_length diff --git a/src/http_jsc/websocket_client.rs b/src/http_jsc/websocket_client.rs index 18c9886998d..7bf9a5e07cb 100644 --- a/src/http_jsc/websocket_client.rs +++ b/src/http_jsc/websocket_client.rs @@ -1840,8 +1840,8 @@ impl WebSocket { ws: NonNull::new(outgoing).map(|p| unsafe { CppWebSocketRef::new(p) }), })); // Backref so `handle_data` can drain the buffered slice ahead of - // fresh socket data, and so `deinit()` can reclaim the box if - // process.exit() races ahead of the microtask drain. + // fresh socket data, and so `deinit()` can detach from the box if + // teardown races ahead of the microtask drain. ws_ref.initial_data_handler = NonNull::new(initial_data); // Use a higher-priority callback for the initial onData handler @@ -2053,18 +2053,19 @@ impl WebSocket { this_ref.clear_data(); // deflate already dropped in clear_data; this is defensive parity with Zig this_ref.deflate = None; - // The queued microtask normally owns the handler box and clears this - // field via `handle_without_deinit` before freeing it. Reaching - // `deinit()` with the field still set means the microtask never - // drained (process.exit() under the API lock during onopen) and JSC - // is being torn down — reclaim the box so LSan doesn't flag it. In - // normal operation the adopted-socket I/O ref keeps `ref_count > 0` - // until after microtasks drain, so this branch is not hit. if let Some(handler) = this_ref.initial_data_handler.take() { - // SAFETY: allocated via `heap::into_raw` in init()/init_with_tunnel(); - // sole remaining owner — JSC's microtask queue only holds the - // pointer encoded as a JSValue double and will not run again. - drop(unsafe { bun_core::heap::take(handler.as_ptr()) }); + // SAFETY: the handler box was allocated via `heap::into_raw` in + // init()/init_with_tunnel() and is normally freed by the queued + // microtask in `InitialDataHandler::handle`; this field still + // being set means that microtask has not run yet, so the box is + // live and the raw field write does not alias any borrow. + unsafe { core::ptr::addr_of_mut!((*handler.as_ptr()).adopted).write(None) }; + if this_ref.global_this.bun_vm().is_shutting_down() { + // SAFETY: same allocation as above; the VM is shutting down, so + // the queued microtask can no longer run and this is the sole + // remaining owner of the box. + drop(unsafe { bun_core::heap::take(handler.as_ptr()) }); + } } bun_core::scoped_log!(alloc, "destroy({}) = {:p}", Self::ALLOC_TYPE_NAME, this); // SAFETY: this was allocated via heap::alloc in init/init_with_tunnel diff --git a/src/install/PackageManager/PackageManagerOptions.rs b/src/install/PackageManager/PackageManagerOptions.rs index 303d074cd2c..14e4d966948 100644 --- a/src/install/PackageManager/PackageManagerOptions.rs +++ b/src/install/PackageManager/PackageManagerOptions.rs @@ -597,11 +597,21 @@ impl Options { || registry_.starts_with(b"http://")) { let prev_scope = self.scope.clone(); + let prev_url = prev_scope.url.url(); + let new_url = bun_url::URL::parse(registry_); + let token = if bun_core::without_trailing_slash(new_url.host) + == bun_core::without_trailing_slash(prev_url.host) + && (new_url.is_https() || !prev_url.is_https()) + { + prev_scope.token + } else { + Box::default() + }; // PORT NOTE: was `std.mem.zeroes(Api.NpmRegistry)`; zeroed slices are // invalid in Rust — use Default (empty strings) which is semantically equivalent. let api_registry = Api::NpmRegistry { url: registry_.into(), - token: prev_scope.token, + token, ..Default::default() }; self.scope = Npm::registry::Scope::from_api(b"", api_registry, env)?; diff --git a/src/install/PackageManager/security_scanner.rs b/src/install/PackageManager/security_scanner.rs index 201923d444c..7cbad673dc9 100644 --- a/src/install/PackageManager/security_scanner.rs +++ b/src/install/PackageManager/security_scanner.rs @@ -535,11 +535,6 @@ impl<'a> PackageCollector<'a> { continue; } - let dep_res = &pkg_resolutions[dep_pkg_id as usize]; - if dep_res.tag != bun_install::resolution::Tag::Npm { - continue; - } - if self.dedupe.get_or_put(dep_pkg_id)?.found_existing { continue; } @@ -572,11 +567,6 @@ impl<'a> PackageCollector<'a> { continue; } - let dep_res = &pkg_resolutions[dep_pkg_id as usize]; - if dep_res.tag != bun_install::resolution::Tag::Npm { - continue; - } - if self.dedupe.get_or_put(dep_pkg_id)?.found_existing { continue; } @@ -609,10 +599,6 @@ impl<'a> PackageCollector<'a> { if update_pkg_id != req.package_id { continue; } - if pkg_resolutions[update_pkg_id as usize].tag != bun_install::resolution::Tag::Npm - { - continue; - } let mut update_dep_id: DependencyID = invalid_dependency_id; let mut parent_pkg_id: PackageID = invalid_package_id; @@ -682,16 +668,18 @@ impl<'a> PackageCollector<'a> { let pkg_id = item.pkg_id; let _ = item.dep_id; // Could be useful in the future for dependency-specific processing - let pkg_path_copy: Box<[PackageID]> = item.pkg_path.clone().into_boxed_slice(); - let dep_path_copy: Box<[DependencyID]> = item.dep_path.clone().into_boxed_slice(); + if pkg_resolutions[pkg_id as usize].tag == bun_install::resolution::Tag::Npm { + let pkg_path_copy: Box<[PackageID]> = item.pkg_path.clone().into_boxed_slice(); + let dep_path_copy: Box<[DependencyID]> = item.dep_path.clone().into_boxed_slice(); - self.package_paths.put( - pkg_id, - PackagePath { - pkg_path: pkg_path_copy, - dep_path: dep_path_copy, - }, - )?; + self.package_paths.put( + pkg_id, + PackagePath { + pkg_path: pkg_path_copy, + dep_path: dep_path_copy, + }, + )?; + } let pkg_deps = pkg_dependencies[pkg_id as usize]; for _next_dep_id in pkg_deps.begin()..pkg_deps.end() { @@ -703,11 +691,6 @@ impl<'a> PackageCollector<'a> { continue; } - let next_pkg_res = &pkg_resolutions[next_pkg_id as usize]; - if next_pkg_res.tag != bun_install::resolution::Tag::Npm { - continue; - } - if self.dedupe.get_or_put(next_pkg_id)?.found_existing { continue; } @@ -773,9 +756,9 @@ impl<'a> JSONBuilder<'a> { json_buf.extend_from_slice(b",\n"); } - // SAFETY: `PackageCollector::collect_packages_from_root` only inserts - // packages whose resolution tag is `Tag::Npm` into `package_paths`, - // so the `npm` union variant is the active field here. + // SAFETY: `PackageCollector::process_queue` only inserts packages + // whose resolution tag is `Tag::Npm` into `package_paths`, so the + // `npm` union variant is the active field here. let npm = pkg_res.npm(); if dep_id == invalid_dependency_id { write!( @@ -889,14 +872,10 @@ fn attempt_security_scan_with_retry( // PORT NOTE: destructure `collector` here to release its `&PackageManager` // borrow before constructing `SecurityScanSubprocess` (which needs `&mut`). - // Only `package_paths` and the dedupe count are read past this point. - let PackageCollector { - dedupe, - package_paths, - .. - } = collector; + // Only `package_paths` is read past this point. + let PackageCollector { package_paths, .. } = collector; let mut package_paths = package_paths; - let packages_scanned = dedupe.count(); + let packages_scanned = package_paths.count(); let mut code: Vec = Vec::new(); diff --git a/src/install/TarballStream.rs b/src/install/TarballStream.rs index 8e12209402d..1be8e8b41a6 100644 --- a/src/install/TarballStream.rs +++ b/src/install/TarballStream.rs @@ -665,19 +665,20 @@ impl TarballStream { // Tag::Extract` for streaming tarballs). let tarball = &self.extract_task.request_extract().tarball; let (_, basename) = tarball.name_and_basename(); - if !tarball.resolution.tag.is_git() - && tarball.resolution.tag != ResolutionTag::LocalTarball - && !crate::dependency::is_safe_install_folder_name(&basename[0..basename.len().min(32)]) - { - self.invalid_name = true; - return Err(bun_core::err!("InstallFailed")); - } + let truncated_basename = &basename[0..basename.len().min(32)]; + let tmpname_suffix: &[u8] = + if crate::dependency::is_safe_install_folder_name(truncated_basename) { + truncated_basename + } else if tarball.resolution.tag.is_git() + || tarball.resolution.tag == ResolutionTag::LocalTarball + { + b"package" + } else { + self.invalid_name = true; + return Err(bun_core::err!("InstallFailed")); + }; let mut buf = PathBuffer::uninit(); - let tmpname = FileSystem::tmpname( - &basename[0..basename.len().min(32)], - &mut buf[..], - bun_core::fast_random(), - )?; + let tmpname = FileSystem::tmpname(tmpname_suffix, &mut buf[..], bun_core::fast_random())?; // allocator.dupeZ → owned NUL-terminated copy. self.tmpname = ZBox::from_bytes(tmpname.as_bytes()); diff --git a/src/install/extract_tarball.rs b/src/install/extract_tarball.rs index bbaf7aa9c37..2160d9dff76 100644 --- a/src/install/extract_tarball.rs +++ b/src/install/extract_tarball.rs @@ -243,29 +243,29 @@ impl ExtractTarball { // `open_dir_at_windows_a` boundary, not here. let mut tmpname_buf = PathBuffer::uninit(); let (name, basename) = self.name_and_basename(); - if !self.resolution.tag.is_git() - && self.resolution.tag != ResolutionTag::LocalTarball - && !bun_install::dependency::is_safe_install_folder_name( - &basename[0..basename.len().min(32)], - ) - { - log.add_error_fmt( - None, - bun_ast::Loc::EMPTY, - format_args!( - "Refusing to install package with invalid name \"{}\"", - bun_fmt::s(name), - ), - ); - return Err(bun_core::err!("InstallFailed")); - } + let truncated_basename = &basename[0..basename.len().min(32)]; + let tmpname_suffix: &[u8] = + if bun_install::dependency::is_safe_install_folder_name(truncated_basename) { + truncated_basename + } else if self.resolution.tag.is_git() + || self.resolution.tag == ResolutionTag::LocalTarball + { + b"package" + } else { + log.add_error_fmt( + None, + bun_ast::Loc::EMPTY, + format_args!( + "Refusing to install package with invalid name \"{}\"", + bun_fmt::s(name), + ), + ); + return Err(bun_core::err!("InstallFailed")); + }; let mut resolved: &'static [u8] = b""; - let tmpname = FileSystem::tmpname( - &basename[0..basename.len().min(32)], - &mut tmpname_buf.0, - bun_core::fast_random(), - )?; + let tmpname = + FileSystem::tmpname(tmpname_suffix, &mut tmpname_buf.0, bun_core::fast_random())?; { let extract_destination = match bun_sys::make_path::make_open_path( tmpdir, diff --git a/src/install/lockfile.rs b/src/install/lockfile.rs index 8f766b1113f..ea10730b6ab 100644 --- a/src/install/lockfile.rs +++ b/src/install/lockfile.rs @@ -3347,10 +3347,17 @@ impl Lockfile { alias }; let hash = SemverStringBuilder::string_hash(trusted_name) as u32; - return match trusted_dependencies.get(&hash) { + let name_is_trusted = match trusted_dependencies.get(&hash) { Some(name) => !name.is_empty() && **name == *trusted_name, None => false, }; + if !name_is_trusted { + return false; + } + if resolution.tag == ResolutionTag::Npm { + return true; + } + return self.declared_by_root_or_workspace(alias, resolution); } // Only allow default trusted dependencies for npm packages. Check the @@ -3386,6 +3393,35 @@ impl Lockfile { }; url == canonical_url.as_slice() } + + fn declared_by_root_or_workspace(&self, alias: &[u8], resolution: &Resolution) -> bool { + let buf = self.buffers.string_bytes.as_slice(); + let packages = self.packages.slice(); + let resolutions = packages.items_resolution(); + let dependencies_lists = packages.items_dependencies(); + for (pkg_resolution, dependencies) in resolutions.iter().zip(dependencies_lists.iter()) { + if pkg_resolution.tag != ResolutionTag::Workspace + && pkg_resolution.tag != ResolutionTag::Root + { + continue; + } + for dep_id in dependencies.begin()..dependencies.end() { + let dep = &self.buffers.dependencies[dep_id as usize]; + if dep.name.slice(buf) != alias { + continue; + } + let package_id = self.buffers.resolutions[dep_id as usize]; + if package_id == invalid_package_id || package_id as usize >= resolutions.len() { + continue; + } + if resolutions[package_id as usize].eql(resolution, buf, buf) { + return true; + } + } + } + + false + } } // ──────────────────────────────────────────────────────────────────────────── diff --git a/src/install/yarn.rs b/src/install/yarn.rs index 8c7d2e35f07..ab68b4b9138 100644 --- a/src/install/yarn.rs +++ b/src/install/yarn.rs @@ -1156,6 +1156,9 @@ pub(crate) fn migrate_yarn_lockfile<'a>( )); } + // Yarn v1 lockfiles legitimately contain entries without an integrity field + // (workspace deps, file:, codeload tarballs), so migration intentionally + // accepts off-registry tarball URLs without integrity instead of failing. if Entry::is_remote_tarball(resolved) || resolved.ends_with(b".tgz") { break 'blk Resolution::init(ResolutionValue::RemoteTarball( sbuf!().append(resolved)?, diff --git a/src/io/PipeReader.rs b/src/io/PipeReader.rs index 6cb0b7971b6..b8e502163eb 100644 --- a/src/io/PipeReader.rs +++ b/src/io/PipeReader.rs @@ -828,13 +828,13 @@ impl PosixBufferedReader { if streaming { // Per-loop scratch buffer; single-threaded event loop (see // `EventLoopCtx::pipe_read_buffer_mut`). - let stack_buffer = parent.vtable.event_loop().pipe_read_buffer_mut(); - let stack_buffer_len = stack_buffer.len(); + let event_loop = parent.vtable.event_loop(); + let stack_buffer_len = event_loop.pipe_read_buffer_mut().len(); while parent._buffer.capacity() == 0 { let stack_buffer_cutoff = stack_buffer_len / 2; let mut head_start = 0usize; // index into stack_buffer where the unwritten head begins while stack_buffer_len - head_start > 16 * 1024 { - let buf = &mut stack_buffer[head_start..]; + let buf = &mut event_loop.pipe_read_buffer_mut()[head_start..]; match sys_fn(fd, buf, parent._offset) { sys::Result::Ok(bytes_read) => { @@ -849,9 +849,10 @@ impl PosixBufferedReader { if bytes_read == 0 { parent.close_without_reporting(); if head_start > 0 { - let _ = parent - .vtable - .on_read_chunk(&stack_buffer[..head_start], ReadState::Eof); + let _ = parent.vtable.on_read_chunk( + &event_loop.pipe_read_buffer_mut()[..head_start], + ReadState::Eof, + ); } if !parent.flags.contains(PosixFlags::IS_DONE) { parent.done(); @@ -876,7 +877,7 @@ impl PosixBufferedReader { // returns the remaining bytes then 0, so // draining to `bytes_read == 0` is bounded. if !parent.vtable.on_read_chunk( - &stack_buffer[..head_start], + &event_loop.pipe_read_buffer_mut()[..head_start], if received_hup { ReadState::Eof } else { @@ -901,7 +902,7 @@ impl PosixBufferedReader { if head_start > 0 { let _ = parent.vtable.on_read_chunk( - &stack_buffer[..head_start], + &event_loop.pipe_read_buffer_mut()[..head_start], ReadState::Drained, ); } @@ -910,7 +911,7 @@ impl PosixBufferedReader { if head_start > 0 { let _ = parent.vtable.on_read_chunk( - &stack_buffer[..head_start], + &event_loop.pipe_read_buffer_mut()[..head_start], ReadState::Progress, ); } @@ -922,7 +923,7 @@ impl PosixBufferedReader { if head_start > 0 { if !parent.vtable.on_read_chunk( - &stack_buffer[..head_start], + &event_loop.pipe_read_buffer_mut()[..head_start], if received_hup { ReadState::Eof } else { diff --git a/src/js/internal/assert/assertion_error.ts b/src/js/internal/assert/assertion_error.ts index e328f75221a..2465cfddf22 100644 --- a/src/js/internal/assert/assertion_error.ts +++ b/src/js/internal/assert/assertion_error.ts @@ -166,7 +166,11 @@ function getSimpleDiff(originalActual, actual: string, originalExpected, expecte const isStringComparison = typeof originalActual === "string" && typeof originalExpected === "string"; // colored myers diff if (isStringComparison && colors.hasColors) { - return getColoredMyersDiff(actual, expected); + try { + return getColoredMyersDiff(actual, expected); + } catch { + return getStackedDiff(actual, expected); + } } return getStackedDiff(actual, expected); @@ -213,13 +217,24 @@ function createErrDiff(actual, expected, operator, customMessage) { header = ""; } else { const checkCommaDisparity = actual != null && typeof actual === "object"; - const diff = myersDiff(inspectedActual, inspectedExpected, checkCommaDisparity, true); - - const myersDiffMessage = printMyersDiff(diff); - message = myersDiffMessage.message; + let myersDiffMessage; + try { + const diff = myersDiff(inspectedActual, inspectedExpected, checkCommaDisparity, true); + myersDiffMessage = printMyersDiff(diff); + } catch { + myersDiffMessage = undefined; + } - if (myersDiffMessage.skipped) { + if (myersDiffMessage === undefined) { + message = `${ArrayPrototypeJoin.$call(ArrayPrototypeSlice.$call(inspectedSplitActual, 0, 50), "\n")}\n...`; + header = ""; skipped = true; + } else { + message = myersDiffMessage.message; + + if (myersDiffMessage.skipped) { + skipped = true; + } } } diff --git a/src/js/internal/sql/shared.ts b/src/js/internal/sql/shared.ts index 1e3af96df52..51301ef8c57 100644 --- a/src/js/internal/sql/shared.ts +++ b/src/js/internal/sql/shared.ts @@ -860,8 +860,12 @@ function parseOptions( } } + // Explicit tls/ssl options request an encrypted connection: if the server + // declines TLS, the connection is aborted instead of continuing in plaintext. + // Certificate verification is only enabled when explicitly requested + // (ca, rejectUnauthorized, or a verify-* sslmode). if (tls && sslMode === SSLMode.disable) { - sslMode = SSLMode.prefer; + sslMode = SSLMode.require; } port = Number(port); diff --git a/src/js/node/_http2_upgrade.ts b/src/js/node/_http2_upgrade.ts index 479aee1ebf2..5064b2f0414 100644 --- a/src/js/node/_http2_upgrade.ts +++ b/src/js/node/_http2_upgrade.ts @@ -226,11 +226,9 @@ function socketHandshake( tlsSocket.destroy(verifyError); return; } - } else { + } else if (tlsSocket._requestCert) { tlsSocket.authorized = true; } - } else { - tlsSocket.authorized = true; } // Invoke the H2 connectionListener which creates a ServerHttp2Session. diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 30906ac0c8c..5cb14482ea8 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -423,11 +423,9 @@ const ServerHandlers: SocketHandler = { self.destroy(verifyError); return; } - } else { + } else if (self._requestCert) { self.authorized = true; } - } else { - self.authorized = true; } const connectionListener = server[bunSocketServerOptions]?.connectionListener; if (typeof connectionListener === "function") { diff --git a/src/jsc/bindings/FormatStackTraceForJS.cpp b/src/jsc/bindings/FormatStackTraceForJS.cpp index e1e9239b26b..03e4fc86e34 100644 --- a/src/jsc/bindings/FormatStackTraceForJS.cpp +++ b/src/jsc/bindings/FormatStackTraceForJS.cpp @@ -702,6 +702,18 @@ JSC_DEFINE_CUSTOM_GETTER(errorInstanceLazyStackCustomGetter, (JSGlobalObject * g result = computeErrorInfoToJSValue(vm, emptyTrace, line, column, sourceURL, errorObject, nullptr); } else { auto ownedStackTrace = makeUnique>(WTF::move(*stackTrace)); + JSC::MarkedArgumentBuffer protectedFrameCells; + protectedFrameCells.ensureCapacity(ownedStackTrace->size() * 2); + for (auto& frame : *ownedStackTrace) { + if (auto* callee = frame.callee()) + protectedFrameCells.append(callee); + if (auto* codeBlock = frame.codeBlock()) + protectedFrameCells.append(codeBlock); + } + if (protectedFrameCells.hasOverflowed()) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return {}; + } result = computeErrorInfoToJSValue(vm, *ownedStackTrace, line, column, sourceURL, errorObject, nullptr); errorObject->setStackFrames(vm, {}); } diff --git a/src/jsc/bindings/SQLClient.cpp b/src/jsc/bindings/SQLClient.cpp index e67a738459a..c4b5ba653a6 100644 --- a/src/jsc/bindings/SQLClient.cpp +++ b/src/jsc/bindings/SQLClient.cpp @@ -370,7 +370,7 @@ static JSC::JSValue toJS(JSC::Structure* structure, DataCell* cells, uint32_t co auto name = names.value()[structureOffsetIndex++]; object->putDirect(vm, Identifier::fromString(vm, name.name.toWTFString()), value); } - } else { + } else if (structure && structure->isValidOffset(structureOffsetIndex)) { object->putDirectOffset(vm, structureOffsetIndex++, value); } } else if (cell.isDuplicateColumn()) { diff --git a/src/jsc/bindings/bun-spawn.cpp b/src/jsc/bindings/bun-spawn.cpp index 8a87a15abca..6ed66116c7f 100644 --- a/src/jsc/bindings/bun-spawn.cpp +++ b/src/jsc/bindings/bun-spawn.cpp @@ -38,7 +38,7 @@ static inline int getMaxFd(int start, int end) #else int maxfd = 1024; #endif - if (maxfd < 0 || maxfd > 65536) maxfd = 1024; + if (maxfd < 0 || maxfd > 65536) maxfd = 65536; // Respect the end parameter if it's a valid bound (not INT_MAX sentinel) if (end >= start && end < INT_MAX) { maxfd = std::min(maxfd, end + 1); // +1 because end is inclusive diff --git a/src/jsc/bindings/sqlite/JSSQLStatement.cpp b/src/jsc/bindings/sqlite/JSSQLStatement.cpp index c4c9b78aa12..c68c7c2e8ba 100644 --- a/src/jsc/bindings/sqlite/JSSQLStatement.cpp +++ b/src/jsc/bindings/sqlite/JSSQLStatement.cpp @@ -45,6 +45,7 @@ #include "wtf/BitVector.h" #include "wtf/FastBitVector.h" #include "wtf/Vector.h" +#include #include #include "wtf/LazyRef.h" #include "wtf/text/StringToIntegerConversion.h" @@ -237,6 +238,7 @@ class SQLiteSingleton { }; static SQLiteSingleton* _instance = nullptr; +static WTF::Lock databasesLock; static Vector& databases() { @@ -250,11 +252,30 @@ static Vector& databases() return _instance->databases; } +static size_t registerDatabase(VersionSqlite3* versionDB) +{ + WTF::Locker locker { databasesLock }; + auto& dbs = databases(); + size_t index = dbs.size(); + dbs.append(versionDB); + return index; +} + +static VersionSqlite3* databaseForHandle(int32_t handle) +{ + WTF::Locker locker { databasesLock }; + auto& dbs = databases(); + if (handle < 0 || static_cast(handle) >= dbs.size()) + return nullptr; + return dbs[static_cast(handle)]; +} + extern "C" void Bun__closeAllSQLiteDatabasesForTermination() { if (!_instance) { return; } + WTF::Locker locker { databasesLock }; auto& dbs = _instance->databases; for (auto& db : dbs) { @@ -1279,8 +1300,7 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementDeserialize, (JSC::JSGlobalObject * lexic return {}; } - auto count = databases().size(); - databases().append(new VersionSqlite3(db)); + auto count = registerDatabase(new VersionSqlite3(db)); RELEASE_AND_RETURN(scope, JSValue::encode(jsNumber(count))); } @@ -1297,12 +1317,13 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementSerialize, (JSC::JSGlobalObject * lexical } int32_t dbIndex = callFrame->argument(0).toInt32(lexicalGlobalObject); - if (dbIndex < 0 || dbIndex >= databases().size()) [[unlikely]] { + VersionSqlite3* versionDB = databaseForHandle(dbIndex); + if (!versionDB) [[unlikely]] { throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Invalid database handle"_s)); return {}; } - sqlite3* db = databases()[dbIndex]->db; + sqlite3* db = versionDB->db; if (!db) [[unlikely]] { throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Can't do this on a closed database"_s)); return {}; @@ -1338,7 +1359,8 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementLoadExtensionFunction, (JSC::JSGlobalObje } int32_t dbIndex = callFrame->argument(0).toInt32(lexicalGlobalObject); - if (dbIndex < 0 || dbIndex >= databases().size()) [[unlikely]] { + VersionSqlite3* versionDB = databaseForHandle(dbIndex); + if (!versionDB) [[unlikely]] { throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Invalid database handle"_s)); return {}; } @@ -1352,7 +1374,7 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementLoadExtensionFunction, (JSC::JSGlobalObje auto extensionString = extension.toWTFString(lexicalGlobalObject); RETURN_IF_EXCEPTION(scope, {}); - sqlite3* db = databases()[dbIndex]->db; + sqlite3* db = versionDB->db; if (!db) [[unlikely]] { throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Can't do this on a closed database"_s)); return {}; @@ -1406,11 +1428,12 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementExecuteFunction, (JSC::JSGlobalObject * l } int32_t handle = callFrame->argument(0).toInt32(lexicalGlobalObject); - if (handle < 0 || handle >= databases().size()) [[unlikely]] { + VersionSqlite3* versionDB = databaseForHandle(handle); + if (!versionDB) [[unlikely]] { throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Invalid database handle"_s)); return {}; } - sqlite3* db = databases()[handle]->db; + sqlite3* db = versionDB->db; if (!db) [[unlikely]] { throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Database has closed"_s)); @@ -1555,12 +1578,13 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementIsInTransactionFunction, (JSC::JSGlobalOb int32_t handle = dbNumber.toInt32(lexicalGlobalObject); - if (handle < 0 || handle >= databases().size()) [[unlikely]] { + VersionSqlite3* versionDB = databaseForHandle(handle); + if (!versionDB) [[unlikely]] { throwException(lexicalGlobalObject, scope, createRangeError(lexicalGlobalObject, "Invalid database handle"_s)); return {}; } - sqlite3* db = databases()[handle]->db; + sqlite3* db = versionDB->db; if (!db) [[unlikely]] { throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Database has closed"_s)); @@ -1594,12 +1618,13 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementPrepareStatementFunction, (JSC::JSGlobalO } int32_t handle = dbNumber.toInt32(lexicalGlobalObject); - if (handle < 0 || handle >= databases().size()) [[unlikely]] { + VersionSqlite3* versionDB = databaseForHandle(handle); + if (!versionDB) [[unlikely]] { throwException(lexicalGlobalObject, scope, createRangeError(lexicalGlobalObject, "Invalid database handle"_s)); return {}; } - sqlite3* db = databases()[handle]->db; + sqlite3* db = versionDB->db; if (!db) { throwException(lexicalGlobalObject, scope, createRangeError(lexicalGlobalObject, "Cannot use a closed database"_s)); return {}; @@ -1643,7 +1668,7 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementPrepareStatementFunction, (JSC::JSGlobalO int64_t memoryChange = sqlite_malloc_amount - currentMemoryUsage; JSSQLStatement* sqlStatement = JSSQLStatement::create( - static_cast(lexicalGlobalObject), statement, databases()[handle], memoryChange); + static_cast(lexicalGlobalObject), statement, versionDB, memoryChange); if (internalFlagsValue.isInt32()) { const int32_t internalFlags = internalFlagsValue.asInt32(); @@ -1735,12 +1760,11 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementOpenStatementFunction, (JSC::JSGlobalObje if (status != SQLITE_OK) { // TODO: log a warning here that defensive mode is unsupported. } - auto index = databases().size(); - - databases().append(new VersionSqlite3(db)); + auto* versionDB = new VersionSqlite3(db); + auto index = registerDatabase(versionDB); if (finalizationTarget.isObject()) { - vm.heap.addFinalizer(finalizationTarget.getObject(), [index](JSC::JSCell* ptr) -> void { - databases()[index]->release(); + vm.heap.addFinalizer(finalizationTarget.getObject(), [versionDB](JSC::JSCell* ptr) -> void { + versionDB->release(); }); } RELEASE_AND_RETURN(scope, JSValue::encode(jsNumber(index))); @@ -1774,14 +1798,15 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementCloseStatementFunction, (JSC::JSGlobalObj int dbIndex = dbNumber.toInt32(lexicalGlobalObject); - if (dbIndex < 0 || dbIndex >= databases().size()) { + VersionSqlite3* versionDB = databaseForHandle(dbIndex); + if (!versionDB) { throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Invalid database handle"_s)); return {}; } bool shouldThrowOnError = (throwOnError.isEmpty() || throwOnError.isUndefined()) ? false : throwOnError.toBoolean(lexicalGlobalObject); - sqlite3* db = databases()[dbIndex]->db; + sqlite3* db = versionDB->db; // no-op if already closed if (!db) { return JSValue::encode(jsUndefined()); @@ -1794,7 +1819,7 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementCloseStatementFunction, (JSC::JSGlobalObj return {}; } - databases()[dbIndex]->db = nullptr; + versionDB->db = nullptr; return JSValue::encode(jsUndefined()); } @@ -1828,12 +1853,13 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementFcntlFunction, (JSC::JSGlobalObject * lex int dbIndex = dbNumber.toInt32(lexicalGlobalObject); int op = opNumber.toInt32(lexicalGlobalObject); - if (dbIndex < 0 || dbIndex >= databases().size()) { + VersionSqlite3* versionDB = databaseForHandle(dbIndex); + if (!versionDB) { throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "Invalid database handle"_s)); return {}; } - sqlite3* db = databases()[dbIndex]->db; + sqlite3* db = versionDB->db; // no-op if already closed if (!db) { return JSValue::encode(jsUndefined()); diff --git a/src/jsc/bindings/sqlite/lazy_sqlite3.h b/src/jsc/bindings/sqlite/lazy_sqlite3.h index 6820baf1319..8e9ba2bd743 100644 --- a/src/jsc/bindings/sqlite/lazy_sqlite3.h +++ b/src/jsc/bindings/sqlite/lazy_sqlite3.h @@ -2,6 +2,7 @@ #include "root.h" #include "sqlite3.h" +#include #if !OS(WINDOWS) #include @@ -219,9 +220,11 @@ static const char* sqlite3_lib_path = "sqlite3"; #endif static HMODULE sqlite3_handle = nullptr; +static WTF::Lock sqlite3_handle_lock; static int lazyLoadSQLite() { + WTF::Locker locker { sqlite3_handle_lock }; if (sqlite3_handle) return 0; #if OS(WINDOWS) diff --git a/src/md/line_analysis.rs b/src/md/line_analysis.rs index e089860b360..1daa98cd031 100644 --- a/src/md/line_analysis.rs +++ b/src/md/line_analysis.rs @@ -637,6 +637,12 @@ impl Parser<'_> { } col_count += 1; + if col_count > types::TABLE_MAXCOLCOUNT { + return TableUnderlineResult { + is_underline: false, + col_count: 0, + }; + } // Skip whitespace while pos < self.size && helpers::is_blank(ch(self.text, pos)) { diff --git a/src/md/links.rs b/src/md/links.rs index e6c9990678c..92a89cde4fd 100644 --- a/src/md/links.rs +++ b/src/md/links.rs @@ -927,6 +927,7 @@ impl Parser<'_> { let mut uri_end = scheme_end + 1; while uri_end < content.len() && content[uri_end] != b'>' + && content[uri_end] != b'<' && !helpers::is_whitespace(content[uri_end]) { uri_end += 1; diff --git a/src/md/ref_defs.rs b/src/md/ref_defs.rs index b4b56cc4414..ee69dac29d9 100644 --- a/src/md/ref_defs.rs +++ b/src/md/ref_defs.rs @@ -103,12 +103,8 @@ impl Parser<'_> { if normalized.is_empty() { return None; // whitespace-only labels are invalid } - for rd in self.ref_defs.iter() { - if rd.label[..] == normalized[..] { - return Some(rd); - } - } - None + let idx = self.ref_def_labels.map.get_index(&normalized)?; + self.ref_defs.get(idx) } /// Try to parse a link reference definition from merged paragraph text at position `pos`. diff --git a/src/parsers/yaml.rs b/src/parsers/yaml.rs index 699d71abd18..c6642091b98 100644 --- a/src/parsers/yaml.rs +++ b/src/parsers/yaml.rs @@ -793,6 +793,8 @@ pub enum ParseError { InvalidIndentation, #[error("StackOverflow")] StackOverflow, + #[error("ExcessiveAliasing")] + ExcessiveAliasing, } bun_core::oom_from_alloc!(ParseError); @@ -2161,6 +2163,7 @@ pub enum ParseResultError { UnexpectedDocumentEnd { pos: Pos }, MultipleYamlDirectives { pos: Pos }, InvalidIndentation { pos: Pos }, + ExcessiveAliasing { pos: Pos }, } impl ParseResultError { @@ -2211,6 +2214,9 @@ impl ParseResultError { ParseResultError::InvalidIndentation { pos } => { log.add_error(Some(source), pos.loc(), b"Invalid indentation"); } + ParseResultError::ExcessiveAliasing { pos } => { + log.add_error(Some(source), pos.loc(), b"Excessive aliasing"); + } } Ok(()) } @@ -2268,6 +2274,9 @@ impl ParseResult { ParseError::InvalidIndentation => { ParseResultError::InvalidIndentation { pos: parser.pos } } + ParseError::ExcessiveAliasing => ParseResultError::ExcessiveAliasing { + pos: parser.token.start, + }, }; ParseResult::Err(e) } @@ -2315,9 +2324,18 @@ pub struct Parser<'i, Enc: Encoding> { pub stack_check: StackCheck, pub merge_props_budget: usize, + pub alias_expansion_budget: usize, } impl<'i, Enc: Encoding> Parser<'i, Enc> { + /// Total number of nodes that may be reached through alias expansion in a + /// single document. Repeated merges of the same anchor (`<<: [*a, *a, ...]`) + /// charge the anchor's full subtree per occurrence even though merge keys + /// deduplicate, so this needs enough headroom for legitimate documents that + /// reuse a large anchor many times while still rejecting exponential + /// (billion-laughs style) expansion. + pub const MAX_ALIAS_EXPANSION: usize = 16 * 1024 * 1024; + pub fn init(bump: &'i bun_alloc::Arena, input: &'i [Enc::Unit]) -> Self { Self { input, @@ -2338,6 +2356,7 @@ impl<'i, Enc: Encoding> Parser<'i, Enc> { whitespace_buf: Vec::new(), stack_check: StackCheck::init(), merge_props_budget: MappingProps::MAX_MERGED_PROPERTIES, + alias_expansion_budget: Self::MAX_ALIAS_EXPANSION, } } @@ -3672,6 +3691,33 @@ impl<'i, Enc: Encoding> Parser<'i, Enc> { Ok(e_node) } + fn charge_alias_expansion(&mut self, root: Expr) -> Result<(), ParseError> { + let mut stack: Vec = vec![root]; + while let Some(node) = stack.pop() { + self.alias_expansion_budget = self + .alias_expansion_budget + .checked_sub(1) + .ok_or(ParseError::ExcessiveAliasing)?; + match &node.data { + ast::ExprData::EArray(arr) => { + stack.extend_from_slice(arr.items.slice()); + } + ast::ExprData::EObject(obj) => { + for prop in obj.properties.slice() { + if let Some(key) = prop.key { + stack.push(key); + } + if let Some(value) = prop.value { + stack.push(value); + } + } + } + _ => {} + } + } + Ok(()) + } + fn parse_node(&mut self, opts: ParseNodeOptions) -> Result { if !self.stack_check.is_safe_to_recurse() { return Err(ParseError::StackOverflow); @@ -3741,6 +3787,8 @@ impl<'i, Enc: Encoding> Parser<'i, Enc> { } }; + self.charge_alias_expansion(copy)?; + // update position from the anchor node to the alias node. copy.loc = alias_start.loc(); diff --git a/src/runtime/api/bun/h2_frame_parser.rs b/src/runtime/api/bun/h2_frame_parser.rs index 7dfb71e5972..fa0f9ae741c 100644 --- a/src/runtime/api/bun/h2_frame_parser.rs +++ b/src/runtime/api/bun/h2_frame_parser.rs @@ -653,6 +653,17 @@ fn is_malformed_field_value(value: &[u8]) -> bool { value.iter().any(|&c| c == 0 || c == b'\r' || c == b'\n') } +#[inline] +fn is_forbidden_connection_specific_header(name: &[u8], value: &[u8]) -> bool { + if name == b"te" { + return !value.eq_ignore_ascii_case(b"trailers"); + } + matches!( + name, + b"connection" | b"keep-alive" | b"proxy-connection" | b"transfer-encoding" | b"upgrade" + ) +} + const SINGLE_VALUE_HEADERS_LEN: usize = 40; /// Returns a stable index in `0..SINGLE_VALUE_HEADERS_LEN` for headers that @@ -1635,21 +1646,21 @@ impl Stream { return FlushState::NoAction; } // try to flush one frame - let Some(frame) = self.data_frame_queue.peek_front() else { + let Some(front) = self.data_frame_queue.peek_front() else { return FlushState::NoAction; }; - // PORT NOTE: reshaped for borrowck — `frame` aliases self.data_frame_queue; - // capture pointers and rely on stable Vec backing within this scope. - let frame: *mut PendingFrame = frame; - // SAFETY: frame is a stable element of self.data_frame_queue's Vec backing store; not moved while this borrow lives (no push/pop until after use) - let frame = unsafe { &mut *frame }; + let frame_len = front.len; + let frame_remaining = front.slice().len(); - let mut is_flow_control_limited = false; + let mut owned_frame: Option = None; let no_backpressure: bool = 'brk: { let mut writer = client.to_writer(); - if frame.len == 0 { + if frame_len == 0 { // flush a zero payload frame + let Some(frame) = self.data_frame_queue.dequeue() else { + return FlushState::NoAction; + }; let data_header = FrameHeader { type_: FrameType::HTTP_FRAME_DATA as u8, flags: if frame.end_stream && !self.wait_for_trailers { @@ -1660,14 +1671,10 @@ impl Stream { stream_identifier: self.id, length: 0, }; + owned_frame = Some(frame); break 'brk data_header.write(&mut writer); } else { - // Inline `frame.slice()` so the borrow is on `frame.buffer` - // alone — `frame.offset` / `frame.end_stream` stay disjoint - // and can be mutated/read below while the slice is live. - let frame_slice: &[u8] = &frame.buffer[frame.offset as usize..frame.len as usize]; - let max_size = frame_slice - .len() + let max_size = frame_remaining .min( (self .remote_window_size @@ -1686,7 +1693,7 @@ impl Stream { bun_output::scoped_log!( H2FrameParser, "dataFrame flow control limited {} {} {} {} {} {}", - frame_slice.len(), + frame_remaining, self.remote_window_size, self.remote_used_window_size, client.remote_window_size.get(), @@ -1702,11 +1709,13 @@ impl Stream { FlushState::NoAction }; } - if max_size < frame_slice.len() { - is_flow_control_limited = true; + if max_size < frame_remaining { // we need to break the frame into smaller chunks + let Some(frame) = self.data_frame_queue.peek_front() else { + return FlushState::NoAction; + }; + let able_to_send = frame.slice()[0..max_size].to_vec(); frame.offset += u32::try_from(max_size).expect("int cast"); - let able_to_send = &frame_slice[0..max_size]; client .queued_data_size .set(client.queued_data_size.get() - able_to_send.len() as u64); @@ -1758,10 +1767,15 @@ impl Stream { writer.write_all(&buffer[0..payload_size]).is_ok() }); } else { - break 'brk writer.write_all(able_to_send).is_ok(); + break 'brk writer.write_all(&able_to_send).is_ok(); } } else { // flush with some payload + owned_frame = self.data_frame_queue.dequeue(); + let Some(frame) = owned_frame.as_ref() else { + return FlushState::NoAction; + }; + let frame_slice: &[u8] = frame.slice(); client .queued_data_size .set(client.queued_data_size.get() - frame_slice.len() as u64); @@ -1822,10 +1836,9 @@ impl Stream { } }; - // defer block from Zig (only when !is_flow_control_limited) - if !is_flow_control_limited { + // defer block from Zig (only when the full frame was flushed) + if let Some(_frame) = owned_frame { // only call the callback + free the frame if we write to the socket the full frame - let mut _frame = self.data_frame_queue.dequeue().unwrap(); client .outbound_queue_size .set(client.outbound_queue_size.get() - 1); @@ -3317,6 +3330,8 @@ impl H2FrameParser { let mut sensitive_headers: JSValue = JSValue::UNDEFINED; let mut malformed = false; + let mut single_value_headers = [false; SINGLE_VALUE_HEADERS_LEN]; + let mut seen_regular_header = false; // Stream-level limit violations seen mid-decode. The loop must consume // the whole block regardless: the HPACK dynamic table is @@ -3374,10 +3389,28 @@ impl H2FrameParser { continue; } + let is_pseudo_header = header.name.first() == Some(&b':'); + if is_pseudo_header { + if seen_regular_header { + malformed = true; + } + } else { + seen_regular_header = true; + } + if is_pseudo_header || header.name == b"content-length" { + if let Some(idx) = single_value_headers_index_of(header.name) { + if single_value_headers[idx] { + malformed = true; + } + single_value_headers[idx] = true; + } + } + if malformed || is_malformed_field_name(header.name) || is_malformed_field_value(header.value) - || (header.name.first() == Some(&b':') + || is_forbidden_connection_specific_header(header.name, header.value) + || (is_pseudo_header && !if self.is_server.get() { is_valid_request_pseudo_header(header.name) } else { @@ -7369,8 +7402,9 @@ impl H2FrameParser { // closure so `?` short-circuits to the `result` binding instead of out // of the function, and the window-size update still runs on the error // path. + let array_buffer = buffer.as_pinned_arraybuffer(global_object); let result = (|| { - if let Some(array_buffer) = buffer.as_array_buffer(global_object) { + if let Some(array_buffer) = &array_buffer { let mut bytes = array_buffer.byte_slice(); // read all the bytes while !bytes.is_empty() { @@ -7383,6 +7417,9 @@ impl H2FrameParser { .throw(format_args!("Expected data to be a Buffer or ArrayBuffer"))) } })(); + if let Some(array_buffer) = &array_buffer { + array_buffer.unpin(); + } this.increment_window_size_if_needed(); result } diff --git a/src/runtime/bake/DevServer.rs b/src/runtime/bake/DevServer.rs index 14b721ffda7..f851fb64bfa 100644 --- a/src/runtime/bake/DevServer.rs +++ b/src/runtime/bake/DevServer.rs @@ -1696,7 +1696,7 @@ impl ResponseLike for bun_uws_sys::response::Response { fn get_remote_socket_info(&mut self) -> Option { bun_uws_sys::response::Response::::get_remote_socket_info(self).map(|a| { bun_uws::SocketAddress { - ip: a.ip.to_vec().into_boxed_slice(), + ip: a.ip().to_vec().into_boxed_slice(), port: a.port, is_ipv6: a.is_ipv6, } diff --git a/src/runtime/bake/dev_server/mod.rs b/src/runtime/bake/dev_server/mod.rs index 4a8b0e0a4f6..8fbd9ce3914 100644 --- a/src/runtime/bake/dev_server/mod.rs +++ b/src/runtime/bake/dev_server/mod.rs @@ -340,12 +340,11 @@ impl ResponseLike for bun_uws::AnyResponse { *self } fn get_remote_socket_info(&mut self) -> Option { - // `bun_uws_sys::SocketAddress<'static>` borrows the socket's IP buffer; - // re-box into the owned `bun_uws::SocketAddress` shape this trait uses. + // Re-box into the owned `bun_uws::SocketAddress` shape this trait uses. (*self) .get_remote_socket_info() .map(|a| bun_uws::SocketAddress { - ip: a.ip.to_vec().into_boxed_slice(), + ip: a.ip().to_vec().into_boxed_slice(), port: a.port, is_ipv6: a.is_ipv6, }) diff --git a/src/runtime/cli/pack_command.rs b/src/runtime/cli/pack_command.rs index 41bac8ea940..b4aff161a20 100644 --- a/src/runtime/cli/pack_command.rs +++ b/src/runtime/cli/pack_command.rs @@ -2025,7 +2025,7 @@ pub(crate) fn pack( } } } - if package_name.is_empty() { + if package_name.is_empty() || has_unsafe_tarball_filename_part(package_name) { return Err(PackError::InvalidPackageName); } @@ -2036,7 +2036,7 @@ pub(crate) fn pack( let mut package_version = package_version_expr .as_string_cloned(bump)? .ok_or(PackError::InvalidPackageVersion)?; - if package_version.is_empty() { + if package_version.is_empty() || has_unsafe_tarball_filename_part(package_version) { return Err(PackError::InvalidPackageVersion); } @@ -2258,7 +2258,7 @@ pub(crate) fn pack( package_name = package_name_expr .as_string_cloned(bump)? .ok_or(PackError::InvalidPackageName)?; - if package_name.is_empty() { + if package_name.is_empty() || has_unsafe_tarball_filename_part(package_name) { return Err(PackError::InvalidPackageName); } @@ -2269,7 +2269,7 @@ pub(crate) fn pack( package_version = package_version_expr .as_string_cloned(bump)? .ok_or(PackError::InvalidPackageVersion)?; - if package_version.is_empty() { + if package_version.is_empty() || has_unsafe_tarball_filename_part(package_version) { return Err(PackError::InvalidPackageVersion); } } @@ -3047,6 +3047,18 @@ fn run_lifecycle_script( // tarball name / destination // ─────────────────────────────────────────────────────────────────────────── +/// The output tarball filename is derived from the package.json `name` and +/// `version` fields. Reject values that could steer the formed filename +/// outside the destination directory (`.`/`..` path components, backslashes, +/// drive/ADS colons, NUL); other unusual-but-harmless names (e.g. empty scope +/// segments) keep packing as before. +fn has_unsafe_tarball_filename_part(value: &[u8]) -> bool { + value + .split(|&c| c == b'/') + .any(|component| component == b"." || component == b"..") + || value.iter().any(|&c| matches!(c, b'\\' | b':' | 0)) +} + fn tarball_destination<'a>( pack_destination: &[u8], pack_filename: &[u8], diff --git a/src/runtime/cli/package_manager_command.rs b/src/runtime/cli/package_manager_command.rs index 84233add76d..9a1176ef650 100644 --- a/src/runtime/cli/package_manager_command.rs +++ b/src/runtime/cli/package_manager_command.rs @@ -8,13 +8,13 @@ use bun_install::dependency::Dependency; use bun_install::lockfile::{LoadResult, Lockfile, package::PackageColumns as _, tree}; use bun_install::npm as Npm; use bun_install::package_manager_real::{ - CommandLineArguments, Subcommand, get_cache_directory, package_manager_options::LogLevel, - setup_global_dir, + CommandLineArguments, Subcommand, fetch_cache_directory_path, get_cache_directory, + package_manager_options::LogLevel, setup_global_dir, }; use bun_install::{DependencyID, PackageID, PackageManager, migration}; use bun_paths::{self as Path, PathBuffer}; use bun_resolver::fs as Fs; -use bun_sys::{self, Dir, Fd, FdExt as _, File}; +use bun_sys::{self, Dir, Fd, File}; use crate::cli::Command; use crate::cli::pm_pkg_command::PmPkgCommand; @@ -370,28 +370,40 @@ Learn more about these at https://bun.com/docs/cli/pm.\n"; .has_meta_hash_changed(true, pm.lockfile.packages.len())?; Global::exit(0); } else if strings::eql_comptime(subcommand, b"cache") { - let mut dir = PathBuffer::uninit(); - let fd = get_cache_directory(pm); - let outpath = match bun_sys::get_fd_path(fd, &mut dir) { - Ok(p) => &p[..], - Err(err) => { - Output::pretty_errorln(format_args!( - "{} getting cache directory", - bun_core::Error::from(err).name(), - )); - Global::crash(); - } - }; - if pm.options.positionals.len() > 1 && strings::eql_comptime(pm.options.positionals[1], b"rm") { - fd.close(); - let mut had_err = false; - if let Err(err) = bun_sys::delete_tree_absolute(outpath) { - Output::err(err, "Could not delete {s}", (bstr::BStr::new(outpath),)); + let mut env_map = bun_dotenv::Map::init(); + let mut process_env = bun_dotenv::Loader::init(&mut env_map); + process_env.load_process()?; + let cache_dir = fetch_cache_directory_path(&mut process_env, None); + let mut rm_buf = PathBuffer::uninit(); + let rm_dir = match Dir::cwd().make_open_path(&cache_dir.path, Default::default()) { + Ok(d) => d, + Err(err) => { + Output::pretty_errorln(format_args!( + "{} getting cache directory", + err.name(), + )); + Global::crash(); + } + }; + let rm_path = match rm_dir.get_fd_path(&mut rm_buf) { + Ok(p) => &p[..], + Err(err) => { + Output::pretty_errorln(format_args!( + "{} getting cache directory", + bun_core::Error::from(err).name(), + )); + Global::crash(); + } + }; + rm_dir.close(); + + if let Err(err) = bun_sys::delete_tree_absolute(rm_path) { + Output::err(err, "Could not delete {s}", (bstr::BStr::new(rm_path),)); had_err = true; } Output::prettyln(format_args!("Cleared 'bun install' cache")); @@ -459,6 +471,18 @@ Learn more about these at https://bun.com/docs/cli/pm.\n"; Global::exit(if had_err { 1 } else { 0 }); } + let mut dir = PathBuffer::uninit(); + let fd = get_cache_directory(pm); + let outpath = match bun_sys::get_fd_path(fd, &mut dir) { + Ok(p) => &p[..], + Err(err) => { + Output::pretty_errorln(format_args!( + "{} getting cache directory", + bun_core::Error::from(err).name(), + )); + Global::crash(); + } + }; let _ = Output::writer().write_all(outpath); Global::exit(0); } else if strings::eql_comptime(subcommand, b"default-trusted") { diff --git a/src/runtime/cli/repl.rs b/src/runtime/cli/repl.rs index e58b749f974..a97937ff9a3 100644 --- a/src/runtime/cli/repl.rs +++ b/src/runtime/cli/repl.rs @@ -287,10 +287,12 @@ impl History { content.push(b'\n'); } - let file = match sys::open_a(path, sys::O::WRONLY | sys::O::CREAT | sys::O::TRUNC, 0o644) { + let file = match sys::open_a(path, sys::O::WRONLY | sys::O::CREAT | sys::O::TRUNC, 0o600) { sys::Result::Ok(fd) => sys::File::from_fd(fd), sys::Result::Err(_) => return, }; + #[cfg(unix)] + let _ = sys::fchmod(file.fd(), 0o600); match file.write_all(&content) { sys::Result::Ok(()) => {} sys::Result::Err(_) => return, diff --git a/src/runtime/crypto/pwhash.rs b/src/runtime/crypto/pwhash.rs index 1be043d26a5..11c069b13d9 100644 --- a/src/runtime/crypto/pwhash.rs +++ b/src/runtime/crypto/pwhash.rs @@ -52,6 +52,10 @@ pub mod argon2 { const DEFAULT_SALT_LEN: usize = 32; const DEFAULT_HASH_LEN: u32 = 32; + const MAX_VERIFY_TIME_COST: u32 = 1 << 16; + const MAX_VERIFY_MEMORY_COST: u32 = 1 << 22; + const MAX_VERIFY_PARALLELISM: u32 = 64; + /// `std.crypto.pwhash.argon2.Mode` #[derive(Copy, Clone, Eq, PartialEq)] pub enum Mode { @@ -222,6 +226,36 @@ pub mod argon2 { } }; + if let Some(after_dollar) = normalised.strip_prefix('$') { + if let Some(sep) = after_dollar.find('$') { + let mut rest = &after_dollar[sep + 1..]; + if let Some(after_version) = rest.strip_prefix("v=") { + rest = match after_version.find('$') { + Some(end) => &after_version[end + 1..], + None => "", + }; + } + let params = &rest[..rest.find('$').unwrap_or(rest.len())]; + for pair in params.split(',') { + let Some((key, value)) = pair.split_once('=') else { + continue; + }; + let Ok(value) = value.parse::() else { + continue; + }; + let limit = match key { + "m" => MAX_VERIFY_MEMORY_COST, + "t" => MAX_VERIFY_TIME_COST, + "p" => MAX_VERIFY_PARALLELISM, + _ => continue, + }; + if value > limit { + return Err(bun_core::err!("WeakParameters")); + } + } + } + } + match vendor::verify_encoded(&normalised, password) { Ok(true) => Ok(()), // `rust-argon2` constant-time compares and returns `Ok(false)` on diff --git a/src/runtime/node/assert/myers_diff.rs b/src/runtime/node/assert/myers_diff.rs index 964206bc675..85363fac5b1 100644 --- a/src/runtime/node/assert/myers_diff.rs +++ b/src/runtime/node/assert/myers_diff.rs @@ -53,6 +53,7 @@ impl Default for Options { // // TODO: make this configurable in `Options`? const MAXLEN: u64 = u32::MAX as u64; +const MAX_TRACE_BYTES: usize = 64 * 1024 * 1024; // Type aliasing to make future refactors easier #[allow(non_camel_case_types)] type uint = u32; @@ -224,6 +225,12 @@ impl Differ()); + if trace_bytes > MAX_TRACE_BYTES { + return Err(Error::DiffTooLarge); + } // const new_trace = try TraceFrame.initCapacity(trace_alloc, graph.len); let new_trace: Box<[uint]> = graph.clone().into_boxed_slice(); // PERF(port): was appendAssumeCapacity — profile if it shows up on a hot path diff --git a/src/runtime/node/node_fs_watcher.rs b/src/runtime/node/node_fs_watcher.rs index 310c79bc27e..473c56331bf 100644 --- a/src/runtime/node/node_fs_watcher.rs +++ b/src/runtime/node/node_fs_watcher.rs @@ -1033,8 +1033,22 @@ impl FSWatcher { // SAFETY: `FileSystem::instance()` returns the process-global singleton // initialized at startup; never null once init has run. let cwd = bun_resolver::fs::FileSystem::get().top_level_dir; - let file_path: &bun_core::ZStr = - Path::join_abs_string_buf_z::(cwd, &mut joined_buf[..], &[slice]); + let joined_buf_len = joined_buf.len(); + let Some(joined) = Path::join_abs_string_buf_checked::( + cwd, + &mut joined_buf[..joined_buf_len - 1], + &[slice], + ) else { + return Err(bun_sys::Error { + errno: SystemErrno::ENAMETOOLONG as _, + syscall: bun_sys::Tag::watch, + path: args.path.slice().into(), + ..Default::default() + }); + }; + let joined_len = joined.len(); + joined_buf[joined_len] = 0; + let file_path: &bun_core::ZStr = bun_core::ZStr::from_buf(&joined_buf[..], joined_len); let vm = args.global_this.bun_vm_ptr(); // `bun_vm()` is the audited safe `&'static VirtualMachine` accessor — diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 4aeef2dcf01..9743289832a 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -3560,6 +3560,11 @@ where ctx_log!("render"); self.set_response(response); + if matches!(response.status_code(), 101 | 103 | 204 | 205 | 304) { + self.do_render_blob(); + return; + } + self.do_render(); } @@ -3856,11 +3861,11 @@ where pub fn get_remote_socket_info(&self) -> Option { let resp = self.resp?; // `AnyResponse::get_remote_socket_info` returns the uws_sys - // borrowed-slice variant; convert to the owned `bun_uws::SocketAddress`. + // variant; convert to the owned `bun_uws::SocketAddress`. // SAFETY: FFI handle let info = resp.get_remote_socket_info()?; Some(uws::SocketAddress { - ip: info.ip.to_vec().into_boxed_slice(), + ip: info.ip().to_vec().into_boxed_slice(), port: info.port, is_ipv6: info.is_ipv6, }) diff --git a/src/runtime/socket/Listener.rs b/src/runtime/socket/Listener.rs index 640fa44ce6d..205eea54f6e 100644 --- a/src/runtime/socket/Listener.rs +++ b/src/runtime/socket/Listener.rs @@ -1071,9 +1071,12 @@ impl Listener { // SAFETY: caller passes a live TLSSocket let prev = unsafe { &*prev_ptr }; if let Some(prev_handlers) = prev.handlers.get() { - // SAFETY: prev_handlers was heap-allocated; shared - // reborrow is scoped to this expression. - if unsafe { (*prev_handlers.as_ptr()).active_connections.get() } == 0 { + if prev.flags.get().contains(SocketFlags::OWNS_HANDLERS) + // SAFETY: prev_handlers was heap-allocated; shared + // reborrow is scoped to this expression. + && unsafe { (*prev_handlers.as_ptr()).active_connections.get() } + == 0 + { // SAFETY: prev_handlers was heap-allocated and unreferenced. unsafe { drop(bun_core::heap::take(prev_handlers.as_ptr())) }; } @@ -1168,9 +1171,12 @@ impl Listener { let prev = unsafe { &*prev_ptr }; debug_assert!(!prev.this_value.get().is_empty()); if let Some(prev_handlers) = prev.handlers.get() { - // SAFETY: prev_handlers was heap-allocated; shared - // reborrow is scoped to this expression. - if unsafe { (*prev_handlers.as_ptr()).active_connections.get() } == 0 { + if prev.flags.get().contains(SocketFlags::OWNS_HANDLERS) + // SAFETY: prev_handlers was heap-allocated; shared + // reborrow is scoped to this expression. + && unsafe { (*prev_handlers.as_ptr()).active_connections.get() } + == 0 + { // SAFETY: prev_handlers was heap-allocated and unreferenced. unsafe { drop(bun_core::heap::take(prev_handlers.as_ptr())) }; } @@ -1422,9 +1428,11 @@ fn connect_finish( // holding it. If a `data`/`close` handler synchronously re-entered // `connect`, `Scope::exit` (via `Handlers::mark_inactive`) frees it // once the in-flight callback unwinds; freeing here would be a UAF. - // SAFETY: prev_handlers was heap-allocated; shared reborrow is - // scoped to this expression. - if unsafe { (*prev_handlers.as_ptr()).active_connections.get() } == 0 { + if prev.flags.get().contains(SocketFlags::OWNS_HANDLERS) + // SAFETY: prev_handlers was heap-allocated; shared reborrow is + // scoped to this expression. + && unsafe { (*prev_handlers.as_ptr()).active_connections.get() } == 0 + { // SAFETY: prev_handlers was heap-allocated and unreferenced. unsafe { drop(bun_core::heap::take(prev_handlers.as_ptr())) }; } diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index 85fa3c8e6bf..1dda99cdacf 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -2656,11 +2656,13 @@ impl NewSocket { .expect("No handlers set on Socket") .as_ptr(); // SAFETY: `p` is the freely-aliased raw pointer; no `&Handlers` borrow - // is live across the read/writes below (single-threaded event loop, - // and `from_js` cannot reenter this socket's handlers). + // is live across the read/writes below (single-threaded event loop). let prev_mode = unsafe { (*p).mode }; let handlers = Handlers::from_js(global, socket_obj, prev_mode == super::SocketMode::Server)?; + if this.handlers.get().map(|n| n.as_ptr()) != Some(p) { + return Ok(JSValue::UNDEFINED); + } // Preserve runtime state across the struct assignment. `Handlers.fromJS` returns a // fresh struct with `active_connections = 0` and `mode` limited to `.server`/`.client`, // but this socket (and any in-flight callback scope) still holds references that were @@ -2668,7 +2670,8 @@ impl NewSocket { // `.duplex_server`. Losing the counter causes the next `markInactive` to either free // the heap-allocated client `Handlers` while the socket still points at it, or // underflow on the server path. - // SAFETY: raw-pointer-only access; see `get_handlers` contract. + // SAFETY: `this.handlers` still points at `p` (checked above), so the + // allocation is live; raw-pointer-only access; see `get_handlers` contract. unsafe { let active_connections = (*p).active_connections.get(); core::ptr::drop_in_place(p); // Zig: this_handlers.deinit() diff --git a/src/runtime/webcore/Request.rs b/src/runtime/webcore/Request.rs index e4d7855b3e2..cfae88827ca 100644 --- a/src/runtime/webcore/Request.rs +++ b/src/runtime/webcore/Request.rs @@ -3,6 +3,7 @@ use core::cell::Cell; use core::ffi::c_uint; use core::ptr::NonNull; +use std::borrow::Cow; use bun_jsc::JsCell; use enumset::EnumSet; @@ -874,7 +875,7 @@ impl Request { if let Some(req) = self.request_context.get_request() { // S008: `uws::Request` is an `opaque_ffi!` ZST handle — safe deref. let req = bun_opaque::opaque_deref(req); - let req_url = req.url(); + let req_url = Self::request_target_path(req.url()); if !req_url.is_empty() && req_url[0] == b'/' { if let Some(host) = req.header(b"host") { // With `port: None`, HostFormatter always emits exactly `host`, so the @@ -898,6 +899,32 @@ impl Request { b"http://" } + fn request_target_path(target: &[u8]) -> Cow<'_, [u8]> { + let scheme_len = if strings::has_prefix_case_insensitive(target, b"https://") { + b"https://".len() + } else if strings::has_prefix_case_insensitive(target, b"http://") { + b"http://".len() + } else { + return Cow::Borrowed(target); + }; + + let path_start = strings::index_of_char_pos(target, b'/', scheme_len); + let query_start = strings::index_of_char_pos(target, b'?', scheme_len); + match (path_start, query_start) { + (Some(path_start), None) => Cow::Borrowed(&target[path_start..]), + (Some(path_start), Some(query_start)) if path_start < query_start => { + Cow::Borrowed(&target[path_start..]) + } + (_, Some(query_start)) => { + let mut path = Vec::with_capacity(1 + target.len() - query_start); + path.push(b'/'); + path.extend_from_slice(&target[query_start..]); + Cow::Owned(path) + } + _ => Cow::Borrowed(b"/"), + } + } + pub fn ensure_url(&self) -> Result<(), AllocError> { if !self.url.get().is_empty() { return Ok(()); @@ -906,7 +933,7 @@ impl Request { if let Some(req) = self.request_context.get_request() { // S008: `uws::Request` is an `opaque_ffi!` ZST handle — safe deref. let req = bun_opaque::opaque_deref(req); - let req_url = req.url(); + let req_url = Self::request_target_path(req.url()); if !req_url.is_empty() && req_url[0] == b'/' { if let Some(host) = req.header(b"host") { // With `port: None`, HostFormatter always emits exactly `host`. Compute the @@ -927,7 +954,7 @@ impl Request { at += protocol.len(); buffer[at..at + host.len()].copy_from_slice(host); at += host.len(); - buffer[at..at + req_url.len()].copy_from_slice(req_url); + buffer[at..at + req_url.len()].copy_from_slice(&req_url); at += req_url.len(); &buffer[..at] }; @@ -951,7 +978,7 @@ impl Request { return Ok(()); } - if strings::is_all_ascii(host) && strings::is_all_ascii(req_url) { + if strings::is_all_ascii(host) && strings::is_all_ascii(&req_url) { let (new_url, bytes) = BunString::create_uninitialized_latin1(url_bytelength); self.url.set(new_url); @@ -960,13 +987,13 @@ impl Request { let (b, c) = rest.split_at_mut(host.len()); a.copy_from_slice(protocol); b.copy_from_slice(host); - c.copy_from_slice(req_url); + c.copy_from_slice(&req_url); } else { // slow path let mut temp_url: Vec = Vec::with_capacity(url_bytelength); temp_url.extend_from_slice(protocol); temp_url.extend_from_slice(host); - temp_url.extend_from_slice(req_url); + temp_url.extend_from_slice(&req_url); // `defer bun.default_allocator.free(temp_url)` → Vec drops at scope end self.url.set(BunString::clone_utf8(&temp_url)); } @@ -983,7 +1010,7 @@ impl Request { #[cfg(debug_assertions)] debug_assert!(self.size_of_url() == req_url.len()); - self.url.set(BunString::clone_utf8(req_url)); + self.url.set(BunString::clone_utf8(&req_url)); } Ok(()) } diff --git a/src/runtime/webcore/fetch.rs b/src/runtime/webcore/fetch.rs index a3b078dd24d..437d46c6852 100644 --- a/src/runtime/webcore/fetch.rs +++ b/src/runtime/webcore/fetch.rs @@ -1997,6 +1997,7 @@ fn fetch_impl( force_http2, force_http3, force_http1, + is_node_http_client: ALLOW_GET_BODY, check_server_identity: if check_server_identity.is_empty_or_undefined_or_null() { jsc::strong::Optional::empty() } else { diff --git a/src/runtime/webcore/fetch/FetchTasklet.rs b/src/runtime/webcore/fetch/FetchTasklet.rs index 6ea6034534b..2af0b7bba9f 100644 --- a/src/runtime/webcore/fetch/FetchTasklet.rs +++ b/src/runtime/webcore/fetch/FetchTasklet.rs @@ -1909,6 +1909,7 @@ impl FetchTasklet { http_client.client.flags.force_http2 = fetch_options.force_http2; http_client.client.flags.force_http3 = fetch_options.force_http3; http_client.client.flags.force_http1 = fetch_options.force_http1; + http_client.client.flags.is_node_http_client = fetch_options.is_node_http_client; fetch_tasklet.is_waiting_request_stream_start = is_stream; if is_stream { // Intrusive `ref_count` starts at 2 (one for the main thread, one for the HTTP @@ -2414,6 +2415,7 @@ pub struct FetchOptions { pub force_http2: bool, pub force_http3: bool, pub force_http1: bool, + pub is_node_http_client: bool, } impl Default for FetchOptions { @@ -2447,6 +2449,7 @@ impl Default for FetchOptions { force_http2: false, force_http3: false, force_http1: false, + is_node_http_client: false, } } } diff --git a/src/runtime/webcore/s3/list_objects.rs b/src/runtime/webcore/s3/list_objects.rs index ea39199c8fb..1143e73bbf1 100644 --- a/src/runtime/webcore/s3/list_objects.rs +++ b/src/runtime/webcore/s3/list_objects.rs @@ -255,6 +255,8 @@ pub fn parse_s3_list_objects_result(xml: &[u8]) -> S3ListObjectsV2Result<'_> { { object_key = Some(&xml[i..i + __tag_end]); i = i + __tag_end + 6; + } else { + i = xml.len(); } } else if inner_tag_name_or_tag_end == b"LastModified" { if let Some(__tag_end) = @@ -262,6 +264,8 @@ pub fn parse_s3_list_objects_result(xml: &[u8]) -> S3ListObjectsV2Result<'_> { { last_modified = Some(&xml[i..i + __tag_end]); i = i + __tag_end + 15; + } else { + i = xml.len(); } } else if inner_tag_name_or_tag_end == b"Size" { if let Some(__tag_end) = @@ -271,6 +275,8 @@ pub fn parse_s3_list_objects_result(xml: &[u8]) -> S3ListObjectsV2Result<'_> { object_size = bun_core::fmt::parse_decimal::(size); i = i + __tag_end + 7; + } else { + i = xml.len(); } } else if inner_tag_name_or_tag_end == b"StorageClass" { if let Some(__tag_end) = @@ -278,6 +284,8 @@ pub fn parse_s3_list_objects_result(xml: &[u8]) -> S3ListObjectsV2Result<'_> { { storage_class = Some(&xml[i..i + __tag_end]); i = i + __tag_end + 15; + } else { + i = xml.len(); } } else if inner_tag_name_or_tag_end == b"ChecksumType" { if let Some(__tag_end) = @@ -285,6 +293,8 @@ pub fn parse_s3_list_objects_result(xml: &[u8]) -> S3ListObjectsV2Result<'_> { { checksum_type = Some(&xml[i..i + __tag_end]); i = i + __tag_end + 15; + } else { + i = xml.len(); } } else if inner_tag_name_or_tag_end == b"ChecksumAlgorithm" { if let Some(__tag_end) = @@ -292,6 +302,8 @@ pub fn parse_s3_list_objects_result(xml: &[u8]) -> S3ListObjectsV2Result<'_> { { checksum_algorithme = Some(&xml[i..i + __tag_end]); i = i + __tag_end + 20; + } else { + i = xml.len(); } } else if inner_tag_name_or_tag_end == b"ETag" { if let Some(__tag_end) = @@ -311,6 +323,8 @@ pub fn parse_s3_list_objects_result(xml: &[u8]) -> S3ListObjectsV2Result<'_> { } i = i + __tag_end + 7; + } else { + i = xml.len(); } } else if inner_tag_name_or_tag_end == b"Owner" { if let Some(__tag_end) = @@ -344,17 +358,20 @@ pub fn parse_s3_list_objects_result(xml: &[u8]) -> S3ListObjectsV2Result<'_> { } } } + } else { + i = xml.len(); } } else if inner_tag_name_or_tag_end == b"RestoreStatus" { if let Some(__tag_end) = strings::index_of(&xml[i..], b"") { i = i + __tag_end + 16; + } else { + i = xml.len(); } } } else { - // char is not > - i += 1; + i = xml.len(); } } else { // char is not < @@ -391,31 +408,43 @@ pub fn parse_s3_list_objects_result(xml: &[u8]) -> S3ListObjectsV2Result<'_> { if let Some(_end) = strings::index_of(&xml[i..], b"") { result.name = Some(&xml[i..i + _end]); i += _end; + } else { + break; } } else if tag_name == b"Delimiter" { if let Some(_end) = strings::index_of(&xml[i..], b"") { result.delimiter = Some(&xml[i..i + _end]); i += _end; + } else { + break; } } else if tag_name == b"NextContinuationToken" { if let Some(_end) = strings::index_of(&xml[i..], b"") { result.next_continuation_token = Some(&xml[i..i + _end]); i += _end; + } else { + break; } } else if tag_name == b"ContinuationToken" { if let Some(_end) = strings::index_of(&xml[i..], b"") { result.continuation_token = Some(&xml[i..i + _end]); i += _end; + } else { + break; } } else if tag_name == b"StartAfter" { if let Some(_end) = strings::index_of(&xml[i..], b"") { result.start_after = Some(&xml[i..i + _end]); i += _end; + } else { + break; } } else if tag_name == b"EncodingType" { if let Some(_end) = strings::index_of(&xml[i..], b"") { result.encoding_type = Some(&xml[i..i + _end]); i += _end; + } else { + break; } } else if tag_name == b"KeyCount" { if let Some(_end) = strings::index_of(&xml[i..], b"") { @@ -423,6 +452,8 @@ pub fn parse_s3_list_objects_result(xml: &[u8]) -> S3ListObjectsV2Result<'_> { result.key_count = bun_core::fmt::parse_decimal::(key_count); i += _end; + } else { + break; } } else if tag_name == b"MaxKeys" { if let Some(_end) = strings::index_of(&xml[i..], b"") { @@ -430,6 +461,8 @@ pub fn parse_s3_list_objects_result(xml: &[u8]) -> S3ListObjectsV2Result<'_> { result.max_keys = bun_core::fmt::parse_decimal::(max_keys); i += _end; + } else { + break; } } else if tag_name == b"Prefix" { if let Some(_end) = strings::index_of(&xml[i..], b"") { @@ -440,6 +473,8 @@ pub fn parse_s3_list_objects_result(xml: &[u8]) -> S3ListObjectsV2Result<'_> { } i += _end; + } else { + break; } } else if tag_name == b"IsTruncated" { if let Some(_end) = strings::index_of(&xml[i..], b"") { @@ -452,6 +487,8 @@ pub fn parse_s3_list_objects_result(xml: &[u8]) -> S3ListObjectsV2Result<'_> { } i += _end; + } else { + break; } } else if tag_name == b"CommonPrefixes" { if let Some(_end) = strings::index_of(&xml[i..], b"") { @@ -470,15 +507,19 @@ pub fn parse_s3_list_objects_result(xml: &[u8]) -> S3ListObjectsV2Result<'_> { { common_prefixes.push(&common_prefixes_string[j..j + __end]); j += __end; + } else { + break; } } else { break; } } + } else { + break; } } } else { - i += 1; + break; } } diff --git a/src/sql/mysql/protocol/ColumnDefinition41.rs b/src/sql/mysql/protocol/ColumnDefinition41.rs index 83169d13bd5..c8976bbcbc9 100644 --- a/src/sql/mysql/protocol/ColumnDefinition41.rs +++ b/src/sql/mysql/protocol/ColumnDefinition41.rs @@ -83,7 +83,7 @@ impl ColumnDefinition41 { pub fn decode_internal( &mut self, reader: &mut NewReader, - ) -> Result<(), bun_core::Error> { + ) -> Result { // Length encoded strings self.catalog = reader.encode_len_string()?; bun_core::scoped_log!( @@ -155,16 +155,22 @@ impl ColumnDefinition41 { // ASAN quarantine (test/regression/issue/28632). let unchanged = matches!(&self.name_or_index, ColumnIdentifier::Name(existing) if existing.slice() == self.name.slice()); + let mut changed = false; if !unchanged { let name_view = Data::Temporary(bun_ptr::RawSlice::new(self.name.slice())); - self.name_or_index = ColumnIdentifier::init(name_view)?; + let rebuilt = ColumnIdentifier::init(name_view)?; + changed = match (&self.name_or_index, &rebuilt) { + (ColumnIdentifier::Index(prev), ColumnIdentifier::Index(curr)) => prev != curr, + _ => true, + }; + self.name_or_index = rebuilt; } // https://mariadb.com/kb/en/result-set-packets/#column-definition-packet // According to mariadb, there seem to be extra 2 bytes at the end that is not being used reader.skip(2); - Ok(()) + Ok(changed) } // TODO(refactor): `decoderWrap(ColumnDefinition41, decodeInternal).decode` is a comptime @@ -173,7 +179,7 @@ impl ColumnDefinition41 { pub fn decode( &mut self, reader: &mut NewReader, - ) -> Result<(), bun_core::Error> { + ) -> Result { self.decode_internal(reader) } } diff --git a/src/sql_jsc/mysql/MySQLConnection.rs b/src/sql_jsc/mysql/MySQLConnection.rs index 4299fac286f..2f3a1b94f24 100644 --- a/src/sql_jsc/mysql/MySQLConnection.rs +++ b/src/sql_jsc/mysql/MySQLConnection.rs @@ -1444,7 +1444,6 @@ impl MySQLConnection { statement.columns.len(), header.field_count ); - statement.cached_structure = Default::default(); if !statement.columns.is_empty() { // Clear the slice before the fallible alloc below. If the alloc // fails, MySQLStatement.deinit() would otherwise iterate and free @@ -1463,6 +1462,8 @@ impl MySQLConnection { columns.resize_with(field_count, ColumnDefinition41::default); statement.columns = columns; statement.columns_received = 0; + statement.cached_structure = Default::default(); + statement.fields_flags = Default::default(); } statement .execution_flags @@ -1472,7 +1473,15 @@ impl MySQLConnection { .insert(mysql_statement::ExecutionFlags::HEADER_RECEIVED); return Ok(()); } else if (statement.columns_received as usize) < statement.columns.len() { - statement.columns[statement.columns_received as usize].decode(&mut reader)?; + let changed = statement.columns[statement.columns_received as usize] + .decode(&mut reader)?; + if changed { + statement.cached_structure = Default::default(); + statement.fields_flags = Default::default(); + statement + .execution_flags + .insert(mysql_statement::ExecutionFlags::NEEDS_DUPLICATE_CHECK); + } statement.columns_received += 1; } else { // A 0xFE-prefixed packet at this point is either the end-of-result diff --git a/src/standalone_graph/StandaloneModuleGraph.rs b/src/standalone_graph/StandaloneModuleGraph.rs index 121e5385056..e562dad6b79 100644 --- a/src/standalone_graph/StandaloneModuleGraph.rs +++ b/src/standalone_graph/StandaloneModuleGraph.rs @@ -1091,7 +1091,7 @@ pub(crate) fn inject( #[cfg(unix)] { // Make the file writable so we can delete it - let _ = Syscall::fchmod(fd, 0o777); + let _ = Syscall::fchmod(fd, 0o700); } fd.close(); let _ = Syscall::unlink(name); @@ -1170,7 +1170,7 @@ pub(crate) fn inject( for retry in 0..3 { match Syscall::open( zname, - bun_sys::O::CLOEXEC | bun_sys::O::RDWR | bun_sys::O::CREAT, + bun_sys::O::CLOEXEC | bun_sys::O::RDWR | bun_sys::O::CREAT | bun_sys::O::EXCL, 0, ) { Ok(res) => break 'brk2 res, @@ -1342,7 +1342,7 @@ pub(crate) fn inject( #[cfg(not(windows))] { // SAFETY: libc fchmod on a valid native fd. - unsafe { bun_sys::c::fchmod(cloned_executable_fd.native(), 0o777) }; + unsafe { bun_sys::c::fchmod(cloned_executable_fd.native(), 0o755) }; } return cloned_executable_fd; } @@ -1396,7 +1396,7 @@ pub(crate) fn inject( #[cfg(not(windows))] { // SAFETY: libc fchmod on a valid native fd. - unsafe { bun_sys::c::fchmod(cloned_executable_fd.native(), 0o777) }; + unsafe { bun_sys::c::fchmod(cloned_executable_fd.native(), 0o755) }; } return cloned_executable_fd; } @@ -1453,7 +1453,7 @@ pub(crate) fn inject( #[cfg(not(windows))] { // SAFETY: libc fchmod on a valid native fd. - unsafe { bun_sys::c::fchmod(cloned_executable_fd.native(), 0o777) }; + unsafe { bun_sys::c::fchmod(cloned_executable_fd.native(), 0o755) }; } return cloned_executable_fd; } @@ -1530,7 +1530,7 @@ pub(crate) fn inject( #[cfg(not(windows))] { // SAFETY: libc fchmod on a valid native fd. - unsafe { bun_sys::c::fchmod(cloned_executable_fd.native(), 0o777) }; + unsafe { bun_sys::c::fchmod(cloned_executable_fd.native(), 0o755) }; } return cloned_executable_fd; diff --git a/src/url/lib.rs b/src/url/lib.rs index 96759387d94..b885f91552b 100644 --- a/src/url/lib.rs +++ b/src/url/lib.rs @@ -426,13 +426,11 @@ impl<'a> URL<'a> { } pub fn s3_path(&self) -> &'a [u8] { - // we need to remove protocol if exists and ignore searchParams, should be host + pathname - let href = if !self.protocol.is_empty() && self.href.len() > self.protocol.len() + 2 { + if !self.protocol.is_empty() && self.href.len() > self.protocol.len() + 2 { &self.href[self.protocol.len() + 2..] } else { self.href - }; - &href[0..href.len() - (self.search.len() + self.hash.len())] + } } pub fn display_host(&self) -> bun_fmt::HostFormatter<'_> { diff --git a/src/uws/lib.rs b/src/uws/lib.rs index c36520010d8..57f4b2ab972 100644 --- a/src/uws/lib.rs +++ b/src/uws/lib.rs @@ -103,10 +103,9 @@ pub use bun_uws_sys::{ // `verifyErrorToJS`) live as extension traits in the *_jsc crate per PORTING.md. pub use bun_uws_sys::{Opcode, SendStatus, create_bun_socket_error_t, us_bun_verify_error_t}; -/// Owned socket-address shape (boxed IP) used where the borrowed -/// `bun_uws_sys::SocketAddress<'a>` would tie a lifetime to a transient -/// `uws_res` buffer. Distinct from the sys type by design — that one is the -/// zero-copy borrow returned from `Response::get_remote_socket_info`. +/// Owned socket-address shape (boxed IP). Distinct from the sys type by +/// design — that one stores the IP text inline as returned from +/// `Response::get_remote_socket_info`. pub struct SocketAddress { pub ip: Box<[u8]>, pub port: i32, diff --git a/src/uws_sys/Response.rs b/src/uws_sys/Response.rs index 6300202ffc7..0d6e1dae5b5 100644 --- a/src/uws_sys/Response.rs +++ b/src/uws_sys/Response.rs @@ -16,31 +16,44 @@ use core::marker::{PhantomData, PhantomPinned}; use crate::thunk; use crate::thunk::OpaqueHandle; use crate::us_socket_t; -use bun_core::Fd; +use bun_core::{BoundedArray, Fd}; // ─── Forward-declared opaques (cycle-break: were `bun_uws::*`, tier > 0) ─── -/// Remote socket address as returned by uWS. `ip` borrows uWS-owned memory -/// valid for the lifetime of the response/connection that produced it. +/// Remote socket address as returned by uWS. The IP text is copied into +/// inline storage at construction. /// /// Canonical definition moved down from `bun_uws` /// (Zig: `uws.SocketAddress = struct { ip: []const u8, port: i32, is_ipv6: bool }`). /// Higher tiers (`bun_uws`, `bun_runtime`) re-export this as /// `pub use bun_uws_sys::SocketAddress;`. -pub struct SocketAddress<'a> { - pub ip: &'a [u8], +pub struct SocketAddress { + ip: BoundedArray, pub port: i32, pub is_ipv6: bool, } -impl SocketAddress<'_> { +impl SocketAddress { + pub(crate) fn new(ip: &[u8], port: i32, is_ipv6: bool) -> SocketAddress { + let ip = &ip[..ip.len().min(64)]; + SocketAddress { + ip: BoundedArray::from_slice(ip).expect("clamped to capacity"), + port, + is_ipv6, + } + } + + pub fn ip(&self) -> &[u8] { + self.ip.as_slice() + } + pub fn is_loopback(&self) -> bool { + let ip = self.ip(); // IPv4 loopback addresses - if self.ip.starts_with(b"127.") { + if ip.starts_with(b"127.") { return true; } // IPv6 loopback addresses - if self.ip.starts_with(b"::ffff:127.") || self.ip == b"::1" || self.ip == b"0:0:0:0:0:0:0:1" - { + if ip.starts_with(b"::ffff:127.") || ip == b"::1" || ip == b"0:0:0:0:0:0:0:1" { return true; } false @@ -315,7 +328,7 @@ impl Response { } } - pub fn get_remote_socket_info(&mut self) -> Option> { + pub fn get_remote_socket_info(&mut self) -> Option { let mut ip_ptr: *const u8 = core::ptr::null(); let mut port: i32 = 0; let mut is_ipv6: bool = false; @@ -325,14 +338,13 @@ impl Response { let ip_len = c::uws_res_get_remote_address_info(self.as_raw(), &mut ip_ptr, &mut port, &mut is_ipv6); if ip_len > 0 { - // SocketAddress is defined locally (moved down from bun_uws); `ip` - // borrows uWS-owned memory valid while the response lives. - Some(SocketAddress { - // SAFETY: uws populated ip_ptr/ip_len with bytes valid while the response lives. - ip: unsafe { bun_core::ffi::slice(ip_ptr, ip_len) }, + Some(SocketAddress::new( + // SAFETY: uws populated ip_ptr/ip_len with bytes valid until the next + // address lookup on this thread; copied before returning. + unsafe { bun_core::ffi::slice(ip_ptr, ip_len) }, port, is_ipv6, - }) + )) } else { None } @@ -713,7 +725,7 @@ impl AnyResponse { any_dispatch!(self, |r| r.get_socket_data()) } - pub fn get_remote_socket_info(self) -> Option> { + pub fn get_remote_socket_info(self) -> Option { any_dispatch!(self, |r| r.get_remote_socket_info()) } diff --git a/src/uws_sys/h3.rs b/src/uws_sys/h3.rs index 178a594ccaf..6f3e3867836 100644 --- a/src/uws_sys/h3.rs +++ b/src/uws_sys/h3.rs @@ -232,7 +232,7 @@ impl Response { pub fn get_socket_data(&mut self) -> *mut c_void { c::uws_h3_res_get_socket_data(self) } - pub fn get_remote_socket_info(&mut self) -> Option> { + pub fn get_remote_socket_info(&mut self) -> Option { let mut port: i32 = 0; let mut is_ipv6: bool = false; let mut ip_ptr: *const u8 = ptr::null(); @@ -240,10 +240,10 @@ impl Response { if len == 0 { return None; } - // SAFETY: uws returns a pointer+len pair valid while the response is alive + // SAFETY: uws returns a pointer+len pair valid until the next address lookup + // on this thread; copied before returning. let ip = unsafe { bun_core::ffi::slice(ip_ptr, len) }; - // TODO(port): SocketAddress.ip is a borrowed slice in Zig; Rust field type TBD - Some(SocketAddress { ip, port, is_ipv6 }) + Some(SocketAddress::new(ip, port, is_ipv6)) } pub fn force_close(&mut self) { c::uws_h3_res_force_close(self) diff --git a/test/cli/install/bun-install-lifecycle-scripts.test.ts b/test/cli/install/bun-install-lifecycle-scripts.test.ts index ce596980edc..e9543d9106a 100644 --- a/test/cli/install/bun-install-lifecycle-scripts.test.ts +++ b/test/cli/install/bun-install-lifecycle-scripts.test.ts @@ -3804,3 +3804,98 @@ test.concurrent( expect(await exited).toBe(0); }, ); + +test.concurrent( + "trustedDependencies entries for non-npm dependencies only apply to dependencies declared by the root or a workspace", + async () => { + using ctx = await setupTest(); + const { packageDir, packageJson, env } = ctx; + + // A transitive dependency picks the aliases of its own dependencies. An alias + // that happens to match an entry in the root's `trustedDependencies` must not + // grant lifecycle-script trust to a tarball/git/folder package the root never + // declared itself. + const tarballUrl = `http://localhost:${verdaccio.port}/electron/-/electron-1.0.0.tgz`; + const middleDir = join(packageDir, "middle"); + await mkdir(middleDir, { recursive: true }); + await writeFile( + join(middleDir, "package.json"), + JSON.stringify({ + name: "middle", + version: "1.0.0", + dependencies: { + "trusted-native-addon": tarballUrl, + }, + }), + ); + + await writeFile( + packageJson, + JSON.stringify({ + name: "foo", + version: "1.0.0", + dependencies: { + "middle": "file:./middle", + }, + trustedDependencies: ["trusted-native-addon"], + }), + ); + + let { stdout, stderr, exited } = spawn({ + cmd: [bunExe(), "install"], + cwd: packageDir, + stdout: "pipe", + stdin: "ignore", + stderr: "pipe", + env, + }); + + let err = await stderr.text(); + let out = await stdout.text(); + expect(err).toContain("Saved lockfile"); + expect(err).not.toContain("error:"); + // The remote tarball was introduced by `middle`, not by the root, so its + // preinstall must stay blocked even though the alias matches an entry in the + // root's trustedDependencies. + expect(out).toContain("Blocked 1 postinstall"); + expect(await exited).toBe(0); + expect(await exists(join(packageDir, "node_modules", "trusted-native-addon", "preinstall.txt"))).toBeFalse(); + expect( + await exists( + join(packageDir, "node_modules", "middle", "node_modules", "trusted-native-addon", "preinstall.txt"), + ), + ).toBeFalse(); + + // The same tarball declared by the root itself under the trusted alias still + // runs its lifecycle scripts. + await rm(join(packageDir, "node_modules"), { recursive: true, force: true }); + await rm(join(packageDir, "bun.lock"), { force: true }); + await writeFile( + packageJson, + JSON.stringify({ + name: "foo", + version: "1.0.0", + dependencies: { + "trusted-native-addon": tarballUrl, + }, + trustedDependencies: ["trusted-native-addon"], + }), + ); + + ({ stdout, stderr, exited } = spawn({ + cmd: [bunExe(), "install"], + cwd: packageDir, + stdout: "pipe", + stdin: "ignore", + stderr: "pipe", + env, + })); + + err = await stderr.text(); + out = await stdout.text(); + expect(err).not.toContain("error:"); + expect(out).not.toContain("Blocked"); + expect(await exited).toBe(0); + expect(await exists(join(packageDir, "node_modules", "trusted-native-addon", "preinstall.txt"))).toBeTrue(); + }, +); diff --git a/test/cli/install/bun-install-registry.test.ts b/test/cli/install/bun-install-registry.test.ts index e49b00c32b6..1ab503b6fab 100644 --- a/test/cli/install/bun-install-registry.test.ts +++ b/test/cli/install/bun-install-registry.test.ts @@ -8966,3 +8966,134 @@ registry = { url = "http://localhost:${port}/", token = "${token}" } expect(received).toEqual([]); expect(await exited).not.toBe(0); }); + +test("registry override from a project .env only keeps the saved token when the host matches and the scheme is not downgraded", async () => { + // `bun install` loads the project's `.env` before computing installer + // options, so a repo-committed `.env` can point BUN_CONFIG_REGISTRY at a + // different registry host. The token configured for the default registry + // scope is host-scoped and must only be attached to requests for that host. + const received: { url: string; authorization: string | null }[] = []; + using otherRegistry = Bun.serve({ + port: 0, + fetch(req) { + received.push({ url: req.url, authorization: req.headers.get("authorization") }); + return new Response("not found", { status: 404 }); + }, + }); + + const token = "default-registry-secret-token"; + + // Case 1: the .env points the registry at a different host. The manifest + // request must reach that host without the default registry's token. + await Promise.all([ + write( + join(packageDir, "bunfig.toml"), + ` +[install] +cache = false +registry = { url = "http://localhost:${port}/", token = "${token}" } +`, + ), + write( + packageJson, + JSON.stringify({ + name: "foo", + version: "1.0.0", + dependencies: { + "no-deps": "1.0.0", + }, + }), + ), + write(join(packageDir, ".env"), `BUN_CONFIG_REGISTRY=http://127.0.0.1:${otherRegistry.port}/\n`), + ]); + + { + const { stdout, stderr, exited } = spawn({ + cmd: [bunExe(), "install"], + cwd: packageDir, + stdout: "pipe", + stdin: "pipe", + stderr: "pipe", + env, + }); + await stderr.text(); + await stdout.text(); + + // The .env override must take effect: the manifest request goes to the + // overridden registry... + expect(received.length).toBeGreaterThan(0); + // ...but the token configured for the localhost registry must not be sent + // to the different host. + expect(received.filter(r => r.authorization !== null)).toEqual([]); + // The overridden registry returned 404, so this install fails. + expect(await exited).not.toBe(0); + } + + // Case 2: when the override points at the same host the token was + // configured for, the token is still sent. + received.length = 0; + await Promise.all([ + rm(join(packageDir, "bun.lock"), { force: true }), + rm(join(packageDir, "bun.lockb"), { force: true }), + write( + join(packageDir, "bunfig.toml"), + ` +[install] +cache = false +registry = { url = "http://127.0.0.1:${otherRegistry.port}/", token = "${token}" } +`, + ), + write(join(packageDir, ".env"), `BUN_CONFIG_REGISTRY=http://127.0.0.1:${otherRegistry.port}/\n`), + ]); + + { + const { stdout, stderr, exited } = spawn({ + cmd: [bunExe(), "install"], + cwd: packageDir, + stdout: "pipe", + stdin: "pipe", + stderr: "pipe", + env, + }); + await stderr.text(); + await stdout.text(); + + expect(received.length).toBeGreaterThan(0); + expect(received.some(r => r.authorization === `Bearer ${token}`)).toBe(true); + expect(await exited).not.toBe(0); + } + + // Case 3: the override points at the same host but downgrades https to + // http. The token configured for the https registry must not be sent. + received.length = 0; + await Promise.all([ + rm(join(packageDir, "bun.lock"), { force: true }), + rm(join(packageDir, "bun.lockb"), { force: true }), + write( + join(packageDir, "bunfig.toml"), + ` +[install] +cache = false +registry = { url = "https://127.0.0.1:${otherRegistry.port}/", token = "${token}" } +`, + ), + write(join(packageDir, ".env"), `BUN_CONFIG_REGISTRY=http://127.0.0.1:${otherRegistry.port}/\n`), + ]); + + { + const { stdout, stderr, exited } = spawn({ + cmd: [bunExe(), "install"], + cwd: packageDir, + stdout: "pipe", + stdin: "pipe", + stderr: "pipe", + env, + }); + await stderr.text(); + await stdout.text(); + + expect(received.length).toBeGreaterThan(0); + expect(received.filter(r => r.authorization !== null)).toEqual([]); + expect(await exited).not.toBe(0); + } +}); diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index b18d169ba6d..b12b323cfed 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -9353,3 +9353,87 @@ it("does not install transitive file: dependencies with overlong folder targets" expect(out).not.toContain("2 packages installed"); expect(exitCode).toBe(1); }); + +it("does not extract a local file: tarball outside the temp dir for a dependency alias containing '..' path segments", async () => { + // For `file:` tarball dependencies, the dependency alias (the key in + // `dependencies`) is used to derive the temporary extraction folder name. + // Point bun's temp dir and cache at directories we control so an alias with + // '..' segments would have to land in one of the directories above the temp + // dir (or next to the fixture directories) to be observed. + using dir = tempDir("local-tarball-alias-segments", { + "zone/a/b/c/d/.keep": "", + "project/package.json": JSON.stringify({ + name: "foo", + version: "0.0.1", + dependencies: { + "../../../../../..": "file:./baz-0.0.3.tgz", + }, + }), + "project-ok/package.json": JSON.stringify({ + name: "bar", + version: "0.0.1", + dependencies: { + "baz-local": "file:./baz-0.0.3.tgz", + }, + }), + }); + const root = String(dir); + const zone = join(root, "zone"); + const bunTmp = join(zone, "a", "b", "c", "d"); + const testEnv = { + ...env, + BUN_TMPDIR: bunTmp, + TMPDIR: bunTmp, + BUN_INSTALL_CACHE_DIR: join(root, "cache"), + }; + await cp(join(import.meta.dir, "baz-0.0.3.tgz"), join(root, "project", "baz-0.0.3.tgz")); + await cp(join(import.meta.dir, "baz-0.0.3.tgz"), join(root, "project-ok", "baz-0.0.3.tgz")); + + const { stdout, stderr, exited } = spawn({ + cmd: [bunExe(), "install"], + cwd: join(root, "project"), + stdout: "pipe", + stdin: "pipe", + stderr: "pipe", + env: testEnv, + }); + const err = await stderr.text(); + const out = await stdout.text(); + const exitCode = await exited; + + // Nothing from the tarball may be written into the directories above bun's + // temp dir (zone/a/b/c/d). + expect(await readdirSorted(zone)).toEqual(["a"]); + expect(await readdirSorted(join(zone, "a"))).toEqual(["b"]); + expect(await readdirSorted(join(zone, "a", "b"))).toEqual(["c"]); + expect(await readdirSorted(join(zone, "a", "b", "c"))).toEqual(["d"]); + // The tarball's files (`index.js`, `package.json`) may not appear next to + // the fixture directories either. + expect(await exists(join(root, "package.json"))).toBe(false); + expect(await exists(join(root, "index.js"))).toBe(false); + // The unsafe alias is rejected as an install folder name and nothing is installed. + expect(err).toContain('Invalid dependency name "../../../../../.."'); + expect(out).not.toContain("1 package installed"); + expect(exitCode).not.toBe(0); + + // A normal alias for the same local tarball still installs. + const { + stdout: stdoutOk, + stderr: stderrOk, + exited: exitedOk, + } = spawn({ + cmd: [bunExe(), "install"], + cwd: join(root, "project-ok"), + stdout: "pipe", + stdin: "pipe", + stderr: "pipe", + env: testEnv, + }); + const errOk = await stderrOk.text(); + const outOk = await stdoutOk.text(); + const exitCodeOk = await exitedOk; + expect(await exists(join(root, "project-ok", "node_modules", "baz-local", "package.json"))).toBe(true); + expect(errOk).not.toContain("error:"); + expect(outOk).toContain("1 package installed"); + expect(exitCodeOk).toBe(0); +}); diff --git a/test/cli/install/bun-pack.test.ts b/test/cli/install/bun-pack.test.ts index bb625565298..da986d1f0b0 100644 --- a/test/cli/install/bun-pack.test.ts +++ b/test/cli/install/bun-pack.test.ts @@ -83,6 +83,52 @@ test("in subdirectory", async () => { }); describe("package.json names and versions", () => { + test("rejects name and version containing parent directory components", async () => { + const projectDir = join(packageDir, "nested", "project"); + await mkdir(projectDir, { recursive: true }); + await Promise.all([ + write( + join(projectDir, "package.json"), + JSON.stringify({ + name: "../../outside-pkg", + version: "1.0.0", + }), + ), + write(join(projectDir, "index.js"), "console.log('hello ./index.js')"), + ]); + + const { err } = await packExpectError(projectDir, bunEnv); + expect(err).toContain("package.json `name` and `version` fields"); + + // the tarball must not be created at the location the ".." segments resolve to, + // nor anywhere inside the project directory + expect(await exists(join(packageDir, "outside-pkg-1.0.0.tgz"))).toBeFalse(); + expect(await exists(join(projectDir, "outside-pkg-1.0.0.tgz"))).toBeFalse(); + + // a version with ".." segments is rejected the same way + await write( + join(projectDir, "package.json"), + JSON.stringify({ + name: "pack-traversal-check", + version: "../1.0.0", + }), + ); + const { err: versionErr } = await packExpectError(projectDir, bunEnv); + expect(versionErr).toContain("package.json `name` and `version` fields"); + + // a normal name and version still packs into the project directory + await write( + join(projectDir, "package.json"), + JSON.stringify({ + name: "pack-traversal-check", + version: "1.0.0", + }), + ); + await pack(projectDir, bunEnv); + const tarball = readTarball(join(projectDir, "pack-traversal-check-1.0.0.tgz")); + expect(tarball.entries).toHaveLength(2); + }); + const tests = [ { desc: "missing name", diff --git a/test/cli/install/bun-pm.test.ts b/test/cli/install/bun-pm.test.ts index a1437ca3e34..35996f2e27f 100644 --- a/test/cli/install/bun-pm.test.ts +++ b/test/cli/install/bun-pm.test.ts @@ -1,7 +1,7 @@ import { spawn } from "bun"; import { afterAll, afterEach, beforeAll, beforeEach, expect, it, test } from "bun:test"; import { exists, mkdir, writeFile } from "fs/promises"; -import { bunEnv, bunExe, bunEnv as env, readdirSorted, tmpdirSync } from "harness"; +import { bunEnv, bunExe, bunEnv as env, readdirSorted, tempDir, tmpdirSync } from "harness"; import { cpSync } from "node:fs"; import { join } from "path"; import { @@ -586,3 +586,80 @@ test("bun list --all shows full dependency tree", async () => { `); expect(exitCode).toBe(0); }); + +test("bun pm cache rm resolves the cache directory from the process environment, ignoring project-local .env overrides", async () => { + using dir = tempDir("pm-cache-rm-project-env", { + "package.json": JSON.stringify({ name: "cache-rm-project-env", version: "1.0.0" }), + "unrelated/keep.txt": "do not delete", + "bun-install/install/cache/cached-package.txt": "cached artifact", + }); + const dirStr = String(dir); + const unrelatedDir = join(dirStr, "unrelated"); + const bunInstallDir = join(dirStr, "bun-install"); + const realCacheDir = join(bunInstallDir, "install", "cache"); + + // Project-local .env points the cache directory at an unrelated directory full of data. + await writeFile(join(dirStr, ".env"), `BUN_INSTALL_CACHE_DIR=${unrelatedDir}\n`); + + // The process environment derives the cache location from BUN_INSTALL only; + // BUN_INSTALL_CACHE_DIR is intentionally absent so only the project .env names one. + const spawnEnv: NodeJS.Dict = { + ...env, + BUN_INSTALL: bunInstallDir, + XDG_CACHE_HOME: join(dirStr, "xdg-cache"), + HOME: dirStr, + }; + delete spawnEnv.BUN_INSTALL_CACHE_DIR; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "pm", "cache", "rm"], + cwd: dirStr, + stdout: "pipe", + stderr: "pipe", + env: spawnEnv, + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + + // The directory named only by the project-local .env must remain intact. + expect(await exists(join(unrelatedDir, "keep.txt"))).toBeTrue(); + // The cache derived from the process environment (BUN_INSTALL/install/cache) is what gets cleared. + expect(await exists(join(realCacheDir, "cached-package.txt"))).toBeFalse(); + expect(stdout).toInclude("Cleared 'bun install' cache"); + expect(exitCode).toBe(0); +}); + +test("bun pm cache rm does not create the directory named by a project-local .env override", async () => { + using dir = tempDir("pm-cache-rm-no-create", { + "package.json": JSON.stringify({ name: "cache-rm-no-create", version: "1.0.0" }), + "bun-install/install/cache/cached-package.txt": "cached artifact", + }); + const dirStr = String(dir); + const bunInstallDir = join(dirStr, "bun-install"); + const realCacheDir = join(bunInstallDir, "install", "cache"); + const overrideDir = join(dirStr, "env-named-cache"); + + await writeFile(join(dirStr, ".env"), `BUN_INSTALL_CACHE_DIR=${overrideDir}\n`); + + const spawnEnv: NodeJS.Dict = { + ...env, + BUN_INSTALL: bunInstallDir, + XDG_CACHE_HOME: join(dirStr, "xdg-cache"), + HOME: dirStr, + }; + delete spawnEnv.BUN_INSTALL_CACHE_DIR; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "pm", "cache", "rm"], + cwd: dirStr, + stdout: "pipe", + stderr: "pipe", + env: spawnEnv, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(await exists(overrideDir)).toBeFalse(); + expect(await exists(join(realCacheDir, "cached-package.txt"))).toBeFalse(); + expect(stdout).toInclude("Cleared 'bun install' cache"); + expect(stderr).not.toContain("error"); + expect(exitCode).toBe(0); +}); diff --git a/test/js/bun/http/bun-serve-routes.test.ts b/test/js/bun/http/bun-serve-routes.test.ts index f2e851bd617..e3f70535d6a 100644 --- a/test/js/bun/http/bun-serve-routes.test.ts +++ b/test/js/bun/http/bun-serve-routes.test.ts @@ -717,3 +717,104 @@ it("route precedence for mix of method-specific routes and any routes", async () "POST /test/ANY/POST", ); }); + +it("routes absolute-form request targets by path and derives request.url from the Host header", async () => { + const seen: { matched: string; url: string }[] = []; + await using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + routes: { + "/admin/secret": req => { + seen.push({ matched: "route", url: req.url }); + return new Response("named route"); + }, + }, + fetch(req) { + seen.push({ matched: "fallback", url: req.url }); + return new Response("fallback"); + }, + }); + + const hostHeader = `127.0.0.1:${server.port}`; + + // Send an absolute-form request-target (RFC 9112 §3.2.2) over a raw socket; + // fetch() always uses origin-form so we have to write the request line ourselves. + const responseText = await new Promise((resolve, reject) => { + let received = ""; + Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + socket: { + open(socket) { + socket.write( + `GET https://spoofed.example/admin/secret HTTP/1.1\r\nHost: ${hostHeader}\r\nConnection: close\r\n\r\n`, + ); + }, + data(socket, chunk) { + received += chunk.toString(); + }, + close() { + resolve(received); + }, + error(socket, err) { + reject(err); + }, + }, + }).catch(reject); + }); + + // The named route handles the request, not the catch-all fetch handler. + expect(responseText).toContain("named route"); + expect(responseText).toContain("200"); + expect(seen).toHaveLength(1); + expect(seen[0].matched).toBe("route"); + + // request.url is derived from the Host header, not from the authority in the request line. + expect(seen[0].url).not.toContain("spoofed.example"); + const url = new URL(seen[0].url); + expect(url.protocol).toBe("http:"); + expect(url.host).toBe(hostHeader); + expect(url.pathname).toBe("/admin/secret"); + + // A normal origin-form request still hits the same named route. + seen.length = 0; + const res = await fetch(new URL("/admin/secret", server.url)); + expect(await res.text()).toBe("named route"); + expect(seen).toHaveLength(1); + expect(seen[0].matched).toBe("route"); + expect(new URL(seen[0].url).pathname).toBe("/admin/secret"); + + for (const target of ["http://spoofed.example?a=b", "http://spoofed.example?redirect=/elsewhere"]) { + seen.length = 0; + const rawResponse = await new Promise((resolve, reject) => { + let received = ""; + Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + socket: { + open(socket) { + socket.write(`GET ${target} HTTP/1.1\r\nHost: ${hostHeader}\r\nConnection: close\r\n\r\n`); + }, + data(socket, chunk) { + received += chunk.toString(); + }, + close() { + resolve(received); + }, + error(socket, err) { + reject(err); + }, + }, + }).catch(reject); + }); + + expect(rawResponse).toContain("fallback"); + expect(seen).toHaveLength(1); + expect(seen[0].matched).toBe("fallback"); + expect(seen[0].url).not.toContain("spoofed.example"); + const rawUrl = new URL(seen[0].url); + expect(rawUrl.host).toBe(hostHeader); + expect(rawUrl.pathname).toBe("/"); + expect(rawUrl.search).toBe(new URL(target).search); + } +}); diff --git a/test/js/bun/http/serve.test.ts b/test/js/bun/http/serve.test.ts index 8615760f235..b31e07eeeaa 100644 --- a/test/js/bun/http/serve.test.ts +++ b/test/js/bun/http/serve.test.ts @@ -1120,6 +1120,45 @@ describe("status code text", () => { } }); +it("does not write body bytes for null body statuses", async () => { + for (const status of [204, 205, 304]) { + using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch() { + return new Response("hey", { status }); + }, + }); + + const received: Buffer[] = []; + const { resolve, reject, promise } = Promise.withResolvers(); + await using connection = await Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + socket: { + data(socket, data) { + received.push(data); + }, + end() { + resolve(); + }, + error(socket, error) { + reject(error); + }, + close() { + resolve(); + }, + }, + }); + connection.write(`GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n`); + connection.flush(); + await promise; + const raw = Buffer.concat(received).toString(); + expect(raw).toStartWith(`HTTP/1.1 ${status} `); + expect(raw.slice(raw.indexOf("\r\n\r\n") + 4)).toBe(""); + } +}); + it("should support multiple Set-Cookie headers", async () => { await runTest( { diff --git a/test/js/bun/md/md-edge-cases.test.ts b/test/js/bun/md/md-edge-cases.test.ts index b811c32c716..1e290a9b5c5 100644 --- a/test/js/bun/md/md-edge-cases.test.ts +++ b/test/js/bun/md/md-edge-cases.test.ts @@ -985,3 +985,140 @@ describe("pathological emphasis inputs", () => { expect(Markdown.html("*a **b** c*\n")).toBe("

a b c

\n"); }, 90_000); }); + +// ============================================================================ +// Pathological inputs: reference-definition floods. Every `[label]` reference +// used to do a linear scan over the whole ref-definition list (one +// normalized-label allocation plus a byte compare per stored definition), so a +// document with ~100k definitions and ~120k references cost O(refs x defs) — +// minutes of CPU for a ~3 MB document. Lookups now go through the label index; +// the child process is killed after 30s so a regression fails fast instead of +// hanging the test runner. +// ============================================================================ + +describe("pathological reference definition inputs", () => { + test("documents with many reference definitions and references render in linear time", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const pad = n => String(n).padStart(6, "0"); + const NDEFS = 100000; + const NREFS = 120000; + const lines = []; + for (let i = 0; i < NDEFS; i++) lines.push("[r" + pad(i) + "]: /x" + i); + lines.push(""); + for (let i = 0; i < NREFS; i++) { + // 6-digit labels starting at 500000 are never defined, so every lookup misses. + lines.push("[r" + pad(500000 + i) + "]"); + lines.push(""); + } + lines.push("first [r" + pad(0) + "] last [r" + pad(NDEFS - 1) + "] missing [r-none]"); + const html = Bun.markdown.html(lines.join("\\n")); + if (!html.includes('r000000')) throw new Error("first definition did not resolve: " + JSON.stringify(html.slice(-300))); + if (!html.includes('r099999')) throw new Error("last definition did not resolve"); + if (!html.includes("[r500000]")) throw new Error("undefined reference should stay literal text"); + if (!html.includes("[r-none]")) throw new Error("undefined reference should stay literal text"); + console.log("DONE"); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + timeout: 30_000, + killSignal: "SIGKILL", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout).toContain("DONE"); + expect(exitCode).toBe(0); + + // Reference resolution semantics are unchanged for ordinary documents. + const resolved = Markdown.html("[a]: /url\n\n[a] and [text][a] and [missing]\n"); + expect(resolved).toContain('a'); + expect(resolved).toContain('text'); + expect(resolved).toContain("[missing]"); + }, 90_000); +}); + +// ============================================================================ +// Pathological inputs: table delimiter rows declaring huge column counts. The +// column count taken from the delimiter row used to be unbounded, and every +// body row is padded to that count, so a delimiter row declaring N columns +// followed by M bare `|` body rows emitted N*M empty cells — gigabytes of HTML +// from a ~100 KB document. The count is now capped at 128 columns (md4c +// parity); wider delimiter rows are not tables at all, keeping output linear +// in input size. +// ============================================================================ + +describe("pathological table inputs", () => { + test("table delimiter rows declaring more than 128 columns are not parsed as tables", () => { + const cols = 1000; + const rows = 2000; + const input = "|" + "h|".repeat(cols) + "\n" + "|" + "-|".repeat(cols) + "\n" + "|\n".repeat(rows); + const out = Markdown.html(input); + expect(out).not.toContain(""); + expect(out).toContain("|h|h|"); + // Without the cap this emitted ~cols*rows empty cells (tens of MB of HTML); + // the non-table rendering stays proportional to the ~26 KB input. + expect(out.length).toBeLessThan(1_000_000); + + // The cap matches md4c: 128 columns still renders as a table... + const table = (n: number) => + "|" + "h|".repeat(n) + "\n" + "|" + "-|".repeat(n) + "\n" + "|" + "d|".repeat(n) + "\n"; + const ok = Markdown.html(table(128)); + expect(ok).toContain("
"); + expect(ok).toContain("
"); + // ...and one more column does not. + expect(Markdown.html(table(129))).not.toContain(""); + }, 30_000); +}); + +// ============================================================================ +// Pathological inputs: unclosed scheme autolink openers. Every `` and only stopped +// at `>`, whitespace, or end of content, so a paragraph of repeated ` { + test("unclosed scheme autolink openers render in linear time", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const fill = (n, unit) => Buffer.alloc(n * unit.length, unit).toString(); + const flood = "x " + fill(300000, " then " + fill(50000, "https://example.com/x')) { + throw new Error("real autolink did not render: " + JSON.stringify(mixed.slice(0, 200))); + } + console.log("DONE"); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + timeout: 30_000, + killSignal: "SIGKILL", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout).toContain("DONE"); + expect(exitCode).toBe(0); + + // Ordinary autolinks are unaffected. + expect(Markdown.html("\n")).toContain( + 'https://example.com/a', + ); + }, 90_000); +}); diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index d1517d7d9ee..ead9e8d856e 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -1101,6 +1101,136 @@ it("writing to an established TLS socket from another TLS client's open() does n } }, 30_000); +it("TLS mid-read boundary dispatch: writing to another TLS socket from data() does not corrupt the rest of the stream", async () => { + // When SSL_read fills the per-loop 512KiB output buffer exactly, uSockets + // dispatches that chunk to the data() callback mid-read and then continues + // decrypting the rest of the same TCP read. If the callback does TLS work on + // another socket on the same loop (here: write() to a second TLS client), + // the per-loop ssl_read_input/offset/length and ssl_socket must be restored + // afterwards — otherwise the remaining ciphertext is dropped and the stream + // desyncs (bad record MAC / truncated payload). + const BOUNDARY_CHUNK = 512 * 1024; + const PAYLOAD_SIZE = 12 * 1024 * 1024; + const block = Buffer.alloc(64 * 1024); + for (let i = 0; i < block.length; i++) block[i] = i & 0xff; + const payload = Buffer.concat(Array(PAYLOAD_SIZE / block.length).fill(block)); + const expectedHash = new Bun.CryptoHasher("sha256").update(payload).digest("hex"); + + const downloadDone = Promise.withResolvers(); + const sideReceived = Promise.withResolvers(); + + // Server that streams the 12MiB payload once the client asks for it. + let sent = 0; + const pump = (socket: Socket) => { + while (sent < payload.length) { + const written = socket.write(payload.subarray(sent, Math.min(sent + 1024 * 1024, payload.length))); + if (written <= 0) break; + sent += written; + } + socket.flush(); + }; + const payloadServer = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + tls, + socket: { + open() {}, + data(socket) { + pump(socket); + }, + drain(socket) { + pump(socket); + }, + close() {}, + error() {}, + }, + }); + + // Second TLS server + client on the same loop; the download's data() handler + // writes to this client from inside the boundary dispatch. + const sideServer = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + tls, + socket: { + open() {}, + data() { + sideReceived.resolve(); + }, + close() {}, + error() {}, + }, + }); + + let sideSocket: Socket | undefined; + let downloadSocket: Socket | undefined; + const hasher = new Bun.CryptoHasher("sha256"); + let receivedBytes = 0; + let boundaryChunks = 0; + let sideWrites = 0; + + try { + sideSocket = await Bun.connect({ + hostname: "127.0.0.1", + port: sideServer.port, + tls: { ...tls, rejectUnauthorized: false }, + socket: { + open() {}, + data() {}, + close() {}, + error() {}, + }, + }); + + downloadSocket = await Bun.connect({ + hostname: "127.0.0.1", + port: payloadServer.port, + tls: { ...tls, rejectUnauthorized: false }, + socket: { + open() {}, + handshake(socket) { + socket.write("GO\n"); + }, + data(_socket, chunk) { + hasher.update(chunk); + receivedBytes += chunk.byteLength; + + const isBoundary = chunk.byteLength === BOUNDARY_CHUNK; + if (isBoundary) boundaryChunks += 1; + // Write to the second TLS socket from the boundary dispatch; if this + // run never produces an exact 512KiB chunk, fall back to writing on + // every data event so the re-entrancy is still exercised. + if (isBoundary || boundaryChunks === 0) { + sideWrites += 1; + sideSocket!.write("ping\n"); + } + + if (receivedBytes >= PAYLOAD_SIZE) { + downloadDone.resolve("done"); + } + }, + close() { + downloadDone.resolve(`closed after ${receivedBytes} bytes`); + }, + error(_socket, err) { + downloadDone.resolve(`error: ${err} after ${receivedBytes} bytes`); + }, + }, + }); + + expect(await downloadDone.promise).toBe("done"); + expect(receivedBytes).toBe(PAYLOAD_SIZE); + expect(hasher.digest("hex")).toBe(expectedHash); + expect(sideWrites).toBeGreaterThan(0); + await sideReceived.promise; + } finally { + downloadSocket?.end(); + sideSocket?.end(); + payloadServer.stop(true); + sideServer.stop(true); + } +}, 60_000); + // Bun.connect() on a Windows named pipe takes a dedicated early branch in // Listener.connectInner that heap-allocates a standalone Handlers block. That // block's `.mode` must be `.client` so Handlers.markInactive() destroys it on @@ -1213,3 +1343,218 @@ describe.skipIf(!isWindows)("Bun.connect named-pipe client Handlers lifecycle", }); }); }); + +it("reload() backs out cleanly when a handler getter closes the socket mid-reload", async () => { + // socket.reload() reads the new callbacks off the user object property by + // property, so a getter can run arbitrary JS — including terminating the + // very socket being reloaded, which releases its current handlers. The + // reload must then back out instead of writing through the released + // handlers, and reload() on a live socket must keep working. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + using server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + open() {}, + data(s, buf) { s.write("polo"); }, + close() {}, + error() {}, + }, + }); + + // 1) reload() whose "data" getter terminates the socket mid-reload. + { + const closed = Promise.withResolvers(); + const sock = await Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + socket: { + open() {}, + data() {}, + close() { closed.resolve(); }, + error() {}, + }, + }); + sock.reload({ + socket: { + get data() { + sock.terminate(); + return () => {}; + }, + open() {}, + drain() {}, + close() {}, + error() {}, + }, + }); + await closed.promise; + console.log("reload-with-terminate-ok"); + } + + // 2) A normal reload() on a live socket still swaps the handlers. + { + const got = Promise.withResolvers(); + const closed = Promise.withResolvers(); + const sock = await Bun.connect({ + hostname: "127.0.0.1", + port: server.port, + socket: { + open() {}, + data() { got.resolve("old-handler"); }, + close() { closed.resolve(); }, + error() {}, + }, + }); + sock.reload({ + socket: { + data(_s, buf) { got.resolve(buf.toString()); }, + drain() {}, + close() { closed.resolve(); }, + error() {}, + }, + }); + sock.write("marco"); + console.log("second-reload:" + (await got.promise)); + sock.end(); + await closed.promise; + } + + Bun.gc(true); + console.log("DONE"); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + timeout: 15_000, + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe("reload-with-terminate-ok\nsecond-reload:polo\nDONE\n"); + expect(exitCode).toBe(0); + void stderr; +}); + +it("node:net connect() reusing a server-accepted handle keeps the listener's handlers working", async () => { + // A Bun.listen()-accepted socket wrapper does not own its handlers — they + // live inside the listener. Reusing such a wrapper as the handle for an + // outbound node:net connect must not release the listener's handlers: the + // outbound connect gets its own handlers and works, and the listener keeps + // accepting and dispatching new connections afterwards. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const net = require("node:net"); + + // Target server for the outbound connect. + using target = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + open(s) { s.write("target-hello"); }, + data() {}, + close() {}, + error() {}, + }, + }); + + // Listener whose accepted-socket wrapper is captured and reused. + let accepted; + let openCount = 0; + const acceptedOpen = Promise.withResolvers(); + const acceptedClosed = Promise.withResolvers(); + const secondAccepted = Promise.withResolvers(); + using listener = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + open(s) { + openCount += 1; + if (openCount === 1) { + accepted = s; + acceptedOpen.resolve(); + } else { + s.write("second-accept"); + secondAccepted.resolve(); + } + }, + data() {}, + close() { + if (openCount === 1) acceptedClosed.resolve(); + }, + error() {}, + }, + }); + + // First inbound connection, then the peer disconnects so the accepted + // wrapper is left closed with no active connections on the listener. + const firstClosed = Promise.withResolvers(); + const first = await Bun.connect({ + hostname: "127.0.0.1", + port: listener.port, + socket: { + open() {}, + data() {}, + close() { firstClosed.resolve(); }, + error() {}, + }, + }); + await acceptedOpen.promise; + first.end(); + await acceptedClosed.promise; + await firstClosed.promise; + await new Promise((r) => setImmediate(r)); + console.log("STEP1"); + + // Reuse the closed server-accepted wrapper as the handle for an + // outbound node:net connect. + const outboundResult = Promise.withResolvers(); + let outboundData = ""; + const outbound = new net.Socket(); + outbound._handle = accepted; + outbound.on("data", (d) => { + outboundData += d.toString(); + if (outboundData.includes("target-hello")) outboundResult.resolve("connected+data"); + }); + outbound.on("error", (e) => outboundResult.resolve("error:" + (e && e.code))); + outbound.on("close", () => outboundResult.resolve("closed:" + outboundData)); + outbound.connect(target.port, "127.0.0.1"); + console.log("STEP2:" + (await outboundResult.promise)); + + // The original listener still dispatches to its own handlers. + const verify = Promise.withResolvers(); + const verifyClient = await Bun.connect({ + hostname: "127.0.0.1", + port: listener.port, + socket: { + open() {}, + data(_s, buf) { verify.resolve(buf.toString()); }, + close() {}, + error() {}, + }, + }); + await secondAccepted.promise; + console.log("STEP3:" + (await verify.promise)); + + outbound.destroy(); + verifyClient.end(); + console.log("DONE"); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + timeout: 20_000, + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe("STEP1\nSTEP2:connected+data\nSTEP3:second-accept\nDONE\n"); + expect(exitCode).toBe(0); + void stderr; +}); diff --git a/test/js/bun/repl/repl.test.ts b/test/js/bun/repl/repl.test.ts index 277638b6ea9..39d3830e4e8 100644 --- a/test/js/bun/repl/repl.test.ts +++ b/test/js/bun/repl/repl.test.ts @@ -1,6 +1,7 @@ // Tests for Bun REPL import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { chmodSync, statSync } from "node:fs"; import path from "path"; // Helper to run REPL with piped stdin (non-TTY mode) and capture output @@ -1079,3 +1080,76 @@ describe.todoIf(isWindows)("Bun REPL (Terminal)", () => { }); }); }); + +// History file written on REPL exit must be owner-only (0600), since it can +// contain pasted credentials. See src/runtime/cli/repl.rs History::save. +describe.skipIf(isWindows)("REPL history file permissions", () => { + test("persists history readable only by the owner", async () => { + using dir = tempDir("repl-history-perms", {}); + const home = String(dir); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "repl"], + stdin: Buffer.from(['const dbUrl = "postgres://user:hunter2@db.internal/prod"', ".exit", ""].join("\n")), + stdout: "pipe", + stderr: "pipe", + env: { + ...bunEnv, + TERM: "dumb", + NO_COLOR: "1", + HOME: home, + }, + }); + + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + + // Legitimate behavior still works: the typed line is persisted to + // $HOME/.bun_repl_history on exit. + const historyPath = path.join(home, ".bun_repl_history"); + const content = await Bun.file(historyPath).text(); + expect(content).toContain("const dbUrl"); + + // The file must not be readable or writable by group/other, while the + // owner keeps read/write access. + const mode = statSync(historyPath).mode & 0o777; + expect(mode & 0o077).toBe(0); + expect(mode & 0o600).toBe(0o600); + + expect(stripAnsi(stdout)).toContain("Welcome to Bun"); + expect(exitCode).toBe(0); + }); + + test("tightens permissions on a pre-existing history file", async () => { + using dir = tempDir("repl-history-perms-existing", { + ".bun_repl_history": "1 + 1\n", + }); + const home = String(dir); + const historyPath = path.join(home, ".bun_repl_history"); + chmodSync(historyPath, 0o644); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "repl"], + stdin: Buffer.from(['const dbUrl = "postgres://user:hunter2@db.internal/prod"', ".exit", ""].join("\n")), + stdout: "pipe", + stderr: "pipe", + env: { + ...bunEnv, + TERM: "dumb", + NO_COLOR: "1", + HOME: home, + }, + }); + + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + + const content = await Bun.file(historyPath).text(); + expect(content).toContain("const dbUrl"); + + const mode = statSync(historyPath).mode & 0o777; + expect(mode & 0o077).toBe(0); + expect(mode & 0o600).toBe(0o600); + + expect(stripAnsi(stdout)).toContain("Welcome to Bun"); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/bun/s3/s3-list-encode-overflow.test.ts b/test/js/bun/s3/s3-list-encode-overflow.test.ts index 1148e4b3a4e..d5eb6a769d4 100644 --- a/test/js/bun/s3/s3-list-encode-overflow.test.ts +++ b/test/js/bun/s3/s3-list-encode-overflow.test.ts @@ -12,3 +12,38 @@ describe("S3Client.list() option encoding", () => { }, ); }); + +describe("S3 object keys containing '?' or '#'", () => { + it("includes the full object key in the presigned URL path", () => { + // Keys are signed/encoded locally by presign(); no network request is made. + const client = new S3Client({ + accessKeyId: "test", + secretAccessKey: "test", + bucket: "bucket", + region: "us-east-1", + endpoint: "https://s3.example.com", + }); + + // A key containing '?' must be percent-encoded into the signed path, + // not cut off at the '?'. + { + const presigned = client.presign("confidential-report.pdf?x=.png"); + const url = new URL(presigned); + expect(url.pathname).toBe("/bucket/confidential-report.pdf%3Fx%3D.png"); + } + + // A key containing '#' after a '/' must also keep the remainder. + { + const presigned = client.presign("reports/2024#final.pdf"); + const url = new URL(presigned); + expect(url.pathname).toBe("/bucket/reports/2024%23final.pdf"); + } + + // Ordinary keys keep working as before. + { + const presigned = client.presign("plain-image.png"); + const url = new URL(presigned); + expect(url.pathname).toBe("/bucket/plain-image.png"); + } + }); +}); diff --git a/test/js/bun/s3/s3-list-objects.test.ts b/test/js/bun/s3/s3-list-objects.test.ts index 175e2d1edaa..3bcd517ad47 100644 --- a/test/js/bun/s3/s3-list-objects.test.ts +++ b/test/js/bun/s3/s3-list-objects.test.ts @@ -1173,3 +1173,37 @@ describe.skipIf(!optionsFromEnv.accessKeyId)("S3 - CI - List Objects", () => { expect(storedFile.owner!.id).toBeString(); }); }); + +it("parses a large list response containing repeated unclosed Key tags quickly", async () => { + // ListObjectsV2 body with a valid followed by ~5MB of opening tags + // that never have a matching closing tag. + const malformed = `my_bucket${Buffer.alloc(5_000_000, "").toString()}`; + + using server = createBunServer(async () => { + return new Response(malformed, { + headers: { + "Content-Type": "application/xml", + }, + status: 200, + }); + }); + + const client = new S3Client({ + ...options, + endpoint: server.url.href, + }); + + const start = performance.now(); + const res = await client.list(); + const elapsed = performance.now() - start; + + // Fields parsed before the malformed section are still returned; the unterminated + // entries are ignored instead of producing bogus contents. + expect(res).toEqual({ + name: "my_bucket", + }); + + // Parsing must scale linearly with the response size. Even on slow debug/ASAN builds + // a single 5MB response should be handled in well under 10 seconds. + expect(elapsed).toBeLessThan(10_000); +}, 600_000); diff --git a/test/js/bun/sqlite/sqlite.test.js b/test/js/bun/sqlite/sqlite.test.js index a9efc0090ac..a5e39a58c76 100644 --- a/test/js/bun/sqlite/sqlite.test.js +++ b/test/js/bun/sqlite/sqlite.test.js @@ -1887,3 +1887,92 @@ it("decodes declared types leniently and accepts single-character declared types expect(s.declaredTypes).toEqual(["INT\uFFFDGER"]); db.close(); }); + +// The process-global SQLite database registry is shared by every Worker +// thread. Concurrent opens, prepares, serialize/deserialize, and closes from +// several Workers must not corrupt the registry while its backing storage +// grows. Run in a subprocess so a crash shows up as a non-zero exit code +// instead of taking down the test runner. +it("keeps database handles working when many Workers open databases concurrently", async () => { + const dir = tempDirWithFiles("sqlite-worker-registry", { + "main.js": ` + import { Database } from "bun:sqlite"; + + const WORKER_COUNT = 4; + const workerUrl = new URL("./worker.js", import.meta.url).href; + + const results = await Promise.all( + Array.from({ length: WORKER_COUNT }, () => { + return new Promise((resolve, reject) => { + const worker = new Worker(workerUrl); + worker.onmessage = event => { + resolve(event.data); + worker.terminate(); + }; + worker.onerror = event => { + reject(new Error(event.message ?? "worker error")); + worker.terminate(); + }; + }); + }), + ); + + // The main thread's own database still works after the Workers churned + // the shared registry. + const db = new Database(":memory:"); + db.exec("CREATE TABLE t (a INTEGER)"); + db.run("INSERT INTO t VALUES (42)"); + const main = db.query("SELECT a FROM t").get().a; + db.close(); + + console.log(JSON.stringify({ workers: results, main })); + `, + "worker.js": ` + import { Database } from "bun:sqlite"; + + const ROUNDS = 12; + const DBS_PER_ROUND = 8; + const ROWS = 4; + + let total = 0; + for (let round = 0; round < ROUNDS; round++) { + const dbs = []; + for (let i = 0; i < DBS_PER_ROUND; i++) { + const db = new Database(":memory:"); + db.exec("CREATE TABLE t (a INTEGER, b TEXT)"); + const insert = db.query("INSERT INTO t (a, b) VALUES (?1, ?2)"); + for (let j = 0; j < ROWS; j++) insert.run(j, "row" + j); + total += db.query("SELECT count(*) AS n FROM t").get().n; + + // serialize() and deserialize() index into / append to the same + // process-wide registry as open(). + const restored = Database.deserialize(db.serialize()); + total += restored.query("SELECT count(*) AS n FROM t").get().n; + restored.close(); + + dbs.push(db); + } + for (const db of dbs) db.close(); + } + + const expected = ROUNDS * DBS_PER_ROUND * ROWS * 2; + postMessage(total === expected ? "ok" : "bad total: " + total + " expected " + expected); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.js"], + env: bunEnv, + cwd: dir, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ + stdout: JSON.stringify({ workers: ["ok", "ok", "ok", "ok"], main: 42 }), + stderr: "", + exitCode: 0, + }); +}, 30000); diff --git a/test/js/bun/util/password.test.ts b/test/js/bun/util/password.test.ts index 0235132f1ab..99926d4aa7b 100644 --- a/test/js/bun/util/password.test.ts +++ b/test/js/bun/util/password.test.ts @@ -388,3 +388,36 @@ for (let algorithmValue of algorithms) { } }); } + +test("verify rejects encoded argon2 hashes with cost parameters above the supported maximums", async () => { + // Hash with small, fast parameters so this test stays cheap on debug builds. + const hashed = password.hashSync("correct horse", { + algorithm: "argon2id", + memoryCost: 8, + timeCost: 1, + }); + expect(hashed).toContain("$m=8,t=1,p=1$"); + + // The untampered hash still verifies. + expect(password.verifySync("correct horse", hashed)).toBeTrue(); + + // A time cost far above the verification ceiling embedded in the encoded + // hash must be rejected up front instead of being honored. + const hugeTime = hashed.replace(",t=1,", ",t=100000,"); + expect(hugeTime).not.toBe(hashed); + expect(() => password.verifySync("correct horse", hugeTime)).toThrow("WeakParameters"); + await expect(password.verify("correct horse", hugeTime)).rejects.toThrow("WeakParameters"); + + // A memory cost above the ceiling is rejected before any allocation is + // sized from the encoded string. + const hugeMemory = hashed.replace("$m=8,", "$m=4294967294,"); + expect(hugeMemory).not.toBe(hashed); + expect(() => password.verifySync("correct horse", hugeMemory)).toThrow("WeakParameters"); + await expect(password.verify("correct horse", hugeMemory)).rejects.toThrow("WeakParameters"); + + // A parallelism value above the ceiling is rejected as well. + const hugeParallelism = hashed.replace(",p=1$", ",p=65$"); + expect(hugeParallelism).not.toBe(hashed); + expect(() => password.verifySync("correct horse", hugeParallelism)).toThrow("WeakParameters"); + await expect(password.verify("correct horse", hugeParallelism)).rejects.toThrow("WeakParameters"); +}); diff --git a/test/js/bun/yaml/yaml.test.ts b/test/js/bun/yaml/yaml.test.ts index 1005c5b9c04..fb43ae43b43 100644 --- a/test/js/bun/yaml/yaml.test.ts +++ b/test/js/bun/yaml/yaml.test.ts @@ -1,6 +1,6 @@ import { YAML, file } from "bun"; import { describe, expect, test } from "bun:test"; -import { isASAN, isDebug } from "harness"; +import { bunEnv, bunExe, isASAN, isDebug, tempDir } from "harness"; import { join } from "path"; describe("Bun.YAML", () => { @@ -3981,3 +3981,68 @@ test("limits how many properties merge keys can materialize from a small documen expect(() => YAML.parse(input)).toThrow(); }, 30_000); + +test("bounds alias expansion for parsed and imported YAML documents", async () => { + // A document with a few levels of anchors, where each level is a sequence of + // aliases to the previous one, expands to width^depth nodes even though the + // source is only ~1 KB. The parser must cap the total number of nodes + // reachable through alias expansion and report an error instead of letting + // the .yaml import / bundler paths materialize the full expansion. + const width = 30; + const levelNames = ["a", "b", "c", "d"]; + const lines: string[] = [`a: &a [${new Array(width).fill("0").join(", ")}]`]; + for (let i = 1; i < levelNames.length; i++) { + lines.push(`${levelNames[i]}: &${levelNames[i]} [${new Array(width).fill(`*${levelNames[i - 1]}`).join(", ")}]`); + } + lines.push(`e: [${new Array(width).fill("*d").join(", ")}]`); + const payload = lines.join("\n") + "\n"; + + // Ordinary anchor/alias reuse still parses. + const legit = YAML.parse("base: &base [1, 2, 3]\nuses: [*base, *base, *base]\n") as { + base: number[]; + uses: number[][]; + }; + expect(legit.uses).toEqual([ + [1, 2, 3], + [1, 2, 3], + [1, 2, 3], + ]); + + // The payload's aliases would expand to ~24 million nodes (30^5). The parser + // rejects it instead of materializing the expansion. + expect(() => YAML.parse(payload)).toThrow(); + + // The same document reaches the parser through the runtime .yaml import path. + // A reasonable document still imports; the over-expanding one fails with a + // catchable parse error instead of allocating memory proportional to the + // expanded node count. + using dir = tempDir("yaml-alias-budget", { + "ok.yaml": "base: &base\n retries: 3\n region: us-east-1\ncopy: *base\n", + "payload.yaml": payload, + "index.ts": ` + const ok = (await import("./ok.yaml")).default; + console.log("ok:" + JSON.stringify(ok.copy)); + try { + const big = (await import("./payload.yaml")).default; + console.log("payload:" + Object.keys(big).length); + } catch (err) { + console.log("rejected:" + String((err && err.name) || err)); + } + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain('ok:{"retries":3,"region":"us-east-1"}'); + expect(stdout).toContain("rejected:"); + expect(stdout).not.toContain("payload:"); + expect(exitCode).toBe(0); +}, 60_000); diff --git a/test/js/node/assert/assert-typedarray-deepequal.test.ts b/test/js/node/assert/assert-typedarray-deepequal.test.ts index 31774a7ec80..b3fe6fa7c32 100644 --- a/test/js/node/assert/assert-typedarray-deepequal.test.ts +++ b/test/js/node/assert/assert-typedarray-deepequal.test.ts @@ -105,3 +105,37 @@ describe("TypedArray deepEqual", () => { } }); }); + +test("assert.deepStrictEqual diff message stays bounded for large arrays with many differing elements", () => { + // Two 20,000-element arrays that differ at every 40th index. Their inspected + // forms are ~20,002 lines each and the shortest edit script between them is + // ~1,000 insert/delete operations, which is well past the point where the + // native myers diff must stop retaining a per-level trace frame (each frame + // is ~320 KB here). The AssertionError message must stay a small truncated + // preview instead of growing with the size and difference count of the + // operands. + const length = 20_000; + const actual = Array.from({ length }, (_, i) => i); + const expected = actual.slice(); + for (let i = 0; i < length; i += 40) { + expected[i] = -1; + } + + let error: Error | undefined; + try { + assert.deepStrictEqual(actual, expected); + } catch (e) { + error = e as Error; + } + + expect(error).toBeInstanceOf(assert.AssertionError); + expect(error!.message).toContain("Expected values to be strictly deep-equal"); + // The message is a truncated preview of the first lines of the inspected + // value, not a line-by-line diff of all 20,000 elements: it stays small and + // does not include entries from the tail of the arrays. + expect(error!.message.split("\n").length).toBeLessThan(100); + expect(error!.message).not.toContain("19960"); + + // Identical large arrays still compare equal without throwing. + expect(() => assert.deepStrictEqual(actual, actual.slice())).not.toThrow(); +}, 30_000); diff --git a/test/js/node/http2/node-http2.test.js b/test/js/node/http2/node-http2.test.js index 983e4f13ca9..84b0ee3cd44 100644 --- a/test/js/node/http2/node-http2.test.js +++ b/test/js/node/http2/node-http2.test.js @@ -2404,3 +2404,310 @@ it("http2 server resets streams whose request headers contain CR, LF, or NUL oct server.close(); } }); + +it("http2 server rejects requests carrying connection-specific or repeated pseudo-headers", async () => { + // RFC 9113 Section 8.2.2: connection-specific fields (transfer-encoding, + // connection, keep-alive, ...) make an HTTP/2 request malformed, and + // Section 8.3.1 forbids repeating pseudo-header fields. Either must be + // answered with a stream error instead of being handed to the application, + // otherwise a proxy that copies req.headers re-serializes them into an + // HTTP/1.1 upstream request. + const deliveredRequests = []; + const server = http2.createServer(); + server.on("stream", (stream, headers) => { + deliveredRequests.push(headers); + stream.respond({ ":status": 200 }); + stream.end("ok"); + }); + + const { promise: listening, resolve: onListening } = Promise.withResolvers(); + server.listen(0, "127.0.0.1", onListening); + await listening; + const port = server.address().port; + + // HPACK string literal: 7-bit length prefix, no Huffman coding. + const literal = str => { + const bytes = Buffer.from(str, "latin1"); + return Buffer.concat([Buffer.from([bytes.length]), bytes]); + }; + const malformedHeaderBlocks = { + "transfer-encoding header": Buffer.concat([ + Buffer.from([0x82]), // :method: GET (static table index 2) + Buffer.from([0x86]), // :scheme: http (static table index 6) + Buffer.from([0x84]), // :path: / (static table index 4) + Buffer.from([0x01]), // :authority (literal without indexing, name index 1) + literal("localhost"), + Buffer.from([0x00]), // literal header field without indexing, new name + literal("transfer-encoding"), + literal("chunked"), + ]), + "connection: keep-alive header": Buffer.concat([ + Buffer.from([0x82]), // :method: GET + Buffer.from([0x86]), // :scheme: http + Buffer.from([0x84]), // :path: / + Buffer.from([0x01]), // :authority + literal("localhost"), + Buffer.from([0x00]), // literal header field without indexing, new name + literal("connection"), + literal("keep-alive"), + ]), + "repeated :path pseudo-header": Buffer.concat([ + Buffer.from([0x82]), // :method: GET + Buffer.from([0x86]), // :scheme: http + Buffer.from([0x84]), // :path: / + Buffer.from([0x84]), // :path: / (repeated) + Buffer.from([0x01]), // :authority + literal("localhost"), + ]), + }; + + async function exchange(headerBlock) { + const frames = []; + const { promise: exchanged, resolve: onExchanged, reject: onSocketError } = Promise.withResolvers(); + const socket = net.connect(port, "127.0.0.1", () => { + socket.write(http2utils.kClientMagic); + socket.write(new http2utils.SettingsFrame(false).data); + // HEADERS frame on stream 1 with END_HEADERS | END_STREAM. + socket.write(new http2utils.HeadersFrame(1, headerBlock, 0, true, true).data); + // PING acts as a barrier: by the time its ACK (or a GOAWAY) arrives the + // server has fully processed the HEADERS frame above. + socket.write(new http2utils.PingFrame(false).data); + }); + socket.on("error", onSocketError); + let received = Buffer.alloc(0); + socket.on("data", chunk => { + received = Buffer.concat([received, chunk]); + while (received.length >= 9) { + const length = received.readUIntBE(0, 3); + if (received.length < 9 + length) break; + const frame = { + type: received[3], + flags: received[4], + streamId: received.readUInt32BE(5) & 0x7fffffff, + payload: Buffer.from(received.subarray(9, 9 + length)), + }; + received = received.subarray(9 + length); + frames.push(frame); + if ((frame.type === 6 && (frame.flags & 1) !== 0) || frame.type === 7) { + onExchanged(); + return; + } + } + }); + socket.on("close", () => onExchanged()); + try { + await exchanged; + } finally { + socket.destroy(); + } + return frames; + } + + let client; + try { + for (const [caseName, headerBlock] of Object.entries(malformedHeaderBlocks)) { + const frames = await exchange(headerBlock); + // The malformed request never reaches the application. + expect({ caseName, delivered: deliveredRequests.length }).toEqual({ caseName, delivered: 0 }); + // The stream is reset with PROTOCOL_ERROR instead of being answered. + const rst = frames.find(f => f.type === 3 && f.streamId === 1); + expect({ caseName, rstCode: rst?.payload?.readUInt32BE(0) }).toEqual({ + caseName, + rstCode: http2.constants.NGHTTP2_PROTOCOL_ERROR, + }); + expect(frames.find(f => f.type === 1 && f.streamId === 1)).toBeUndefined(); + } + + // A request without connection-specific or repeated headers still reaches + // the application and gets a response. + client = http2.connect(`http://127.0.0.1:${port}`); + client.on("error", () => {}); + const { promise: responded, resolve: onResponse, reject: onError } = Promise.withResolvers(); + const req = client.request({ ":path": "/", "x-clean": "yes" }); + req.on("response", onResponse); + req.on("error", onError); + req.resume(); + req.end(); + const headers = await responded; + expect(headers[":status"]).toBe(200); + expect(deliveredRequests.length).toBe(1); + expect(deliveredRequests[0]["x-clean"]).toBe("yes"); + } finally { + client?.close(); + server.close(); + } +}); + +it("http2 client survives session teardown from a socket write while flushing queued DATA frames", async () => { + // A flow-control-limited DATA frame sits in the native outbound queue until + // the peer reopens the window. The flush that follows writes to the JS + // socket (options.createConnection), and an application may tear the whole + // session down from inside that write -- e.g. an error handler reacting to a + // failed write. The teardown drops every queued frame, so the in-progress + // flush must not keep using the frame it was sending. Run in a subprocess so + // a crash shows up as a failed assertion instead of taking down the runner. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const http2 = require("node:http2"); + const { Duplex } = require("node:stream"); + + function frame(type, flags, streamId, payload = Buffer.alloc(0)) { + const header = Buffer.alloc(9); + header.writeUIntBE(payload.length, 0, 3); + header[3] = type; + header[4] = flags; + header.writeUInt32BE(streamId, 5); + return Buffer.concat([header, payload]); + } + function windowUpdate(streamId, increment) { + const payload = Buffer.alloc(4); + payload.writeUInt32BE(increment, 0); + return frame(8, 0, streamId, payload); + } + + let armed = false; + let tornDown = false; + + const socket = new Duplex({ + writableHighWaterMark: 4 * 1024 * 1024, + read() {}, + write(chunk, encoding, callback) { + // Once the WINDOW_UPDATE has been delivered, the first DATA frame + // header for stream 1 is the flush of the queued DATA frame. + // Destroy the whole session from inside that write. + if ( + armed && + !tornDown && + chunk.length >= 9 && + chunk[3] === 0x00 && + chunk.readUIntBE(0, 3) > 0 && + (chunk.readUInt32BE(5) & 0x7fffffff) === 1 + ) { + tornDown = true; + client.destroy(); + setImmediate(() => { + console.log("TEARDOWN_DURING_FLUSH_OK"); + process.exit(0); + }); + } + callback(); + }, + }); + + const client = http2.connect("http://localhost", { createConnection: () => socket }); + client.on("error", () => {}); + client.on("connect", () => { + // Server preface: empty SETTINGS plus an ACK of the client's SETTINGS. + socket.push(Buffer.concat([frame(4, 0, 0), frame(4, 1, 0)])); + }); + client.once("remoteSettings", () => { + const req = client.request({ ":method": "POST", ":path": "/" }); + req.on("error", () => {}); + // 65535 bytes fit in the initial flow-control window and go out right + // away; the remaining 32 KiB is queued natively until the window reopens. + req.write(Buffer.alloc(65535 + 32768, "a")); + console.log("DATA_QUEUED"); + armed = true; + // Reopen the connection-level and stream-level windows so the queued + // DATA frames are flushed. + socket.push(Buffer.concat([windowUpdate(0, 1048576), windowUpdate(1, 1048576)])); + }); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain("DATA_QUEUED"); + expect(stdout).toContain("TEARDOWN_DURING_FLUSH_OK"); + expect(exitCode).toBe(0); +}); + +it("http2 client keeps parsing a socket chunk whose ArrayBuffer is transferred by a frame event handler", async () => { + // With a user-supplied connection (options.createConnection), the exact + // Buffer handed to the socket "data" listener is fed to the native HTTP/2 + // frame parser, and per-frame events (like "ping") fire synchronously while + // the parser is still iterating over that chunk. If a handler transfers the + // chunk's ArrayBuffer mid-parse, the remaining frames must still be parsed + // from the original contents rather than from memory the application now + // owns and overwrites. Run in a subprocess so a crash shows up as a failed + // assertion instead of taking down the test runner. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const http2 = require("node:http2"); + const { Duplex } = require("node:stream"); + + function frame(type, flags, streamId, payload = Buffer.alloc(0)) { + const header = Buffer.alloc(9); + header.writeUIntBE(payload.length, 0, 3); + header[3] = type; + header[4] = flags; + header.writeUInt32BE(streamId, 5); + return Buffer.concat([header, payload]); + } + + const socket = new Duplex({ + read() {}, + write(chunk, encoding, callback) { + callback(); + }, + }); + + const client = http2.connect("http://localhost", { createConnection: () => socket }); + client.on("error", () => {}); + + // Two PING frames in a single chunk backed by its own ArrayBuffer. The + // first ping's handler transfers that ArrayBuffer mid-parse; the second + // ping must still surface its original payload. + const ping1 = frame(6, 0, 0, Buffer.alloc(8, "A")); + const ping2 = frame(6, 0, 0, Buffer.alloc(8, "B")); + const chunkArrayBuffer = new ArrayBuffer(ping1.length + ping2.length); + const pingChunk = Buffer.from(chunkArrayBuffer); + ping1.copy(pingChunk, 0); + ping2.copy(pingChunk, ping1.length); + + const pings = []; + client.on("ping", payload => { + pings.push(Buffer.from(payload).toString("hex")); + if (pings.length === 1) { + try { + const moved = chunkArrayBuffer.transfer(); + new Uint8Array(moved).fill(0xff); + } catch { + // The runtime may refuse to detach a buffer it is still reading from. + } + setImmediate(() => { + console.log("PINGS:" + JSON.stringify(pings)); + process.exit(0); + }); + } + }); + + client.on("connect", () => { + // Server preface (empty SETTINGS) in its own, separate chunk. + socket.push(frame(4, 0, 0)); + }); + client.once("remoteSettings", () => { + socket.push(pingChunk); + }); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toContain('PINGS:["4141414141414141","4242424242424242"]'); + expect(exitCode).toBe(0); +}); diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 142114feb42..322f161deb7 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -722,3 +722,75 @@ it("connectionListener should emit the right amount of times, and with alpnProto await Promise.all(promises); expect(count).toBe(50); }); + +it("leaves socket.authorized false unless a client certificate was requested and verified", async () => { + // A server that never requested a client certificate must not report the + // connection as authorized (matches Node.js fail-closed semantics). + { + const { promise, resolve, reject } = Promise.withResolvers(); + const server: Server = createServer(COMMON_CERT, socket => { + resolve(socket.authorized); + socket.end(); + }); + server.on("error", reject); + server.listen(0); + await once(server, "listening"); + const address = server.address() as AddressInfo; + const client = connect({ + port: address.port, + host: "127.0.0.1", + rejectUnauthorized: false, + }); + client.on("error", reject); + try { + expect(await promise).toBe(false); + } finally { + client.end(); + server.close(); + } + } + + // The legitimate mutual-TLS case still works: when the server requests a + // certificate and the client presents one that verifies against the + // server's CA, the socket is reported as authorized. + { + const fixtures = join(import.meta.dir, "fixtures"); + const agent1Key = readFileSync(join(fixtures, "agent1-key.pem"), "utf8"); + const agent1Cert = readFileSync(join(fixtures, "agent1-cert.pem"), "utf8"); + const ca1 = readFileSync(join(fixtures, "ca1-cert.pem"), "utf8"); + + const { promise, resolve, reject } = Promise.withResolvers(); + const server: Server = createServer( + { + key: agent1Key, + cert: agent1Cert, + ca: [ca1], + requestCert: true, + rejectUnauthorized: false, + }, + socket => { + resolve(socket.authorized); + socket.end(); + }, + ); + server.on("error", reject); + server.listen(0); + await once(server, "listening"); + const address = server.address() as AddressInfo; + const client = connect({ + port: address.port, + host: "127.0.0.1", + key: agent1Key, + cert: agent1Cert, + ca: [ca1], + rejectUnauthorized: false, + }); + client.on("error", reject); + try { + expect(await promise).toBe(true); + } finally { + client.end(); + server.close(); + } + } +}); diff --git a/test/js/node/watch/fs.watch.test.ts b/test/js/node/watch/fs.watch.test.ts index 57fc3f738bd..51d65271e28 100644 --- a/test/js/node/watch/fs.watch.test.ts +++ b/test/js/node/watch/fs.watch.test.ts @@ -1018,3 +1018,57 @@ test.skipIf(!isWindows)( }, 30000, ); + +// FSWatcher::init joins the user-supplied watch path with the process cwd into a +// fixed pooled path buffer. The raw-path length validator only bounds the path +// itself, so a relative path just under the platform path limit used to overflow +// the buffer during the join and abort the whole process (panic=abort) instead of +// surfacing an error to JavaScript. Must run in a subprocess: on an unfixed build +// the abort would take down the test runner itself. +test("fs.watch reports an error for relative paths that no longer fit in the path buffer once joined with the cwd", async () => { + using dir = tempDir("fswatch-long-relative", { + "watch-me.txt": "hello", + }); + const base = String(dir); + + const fixture = /* js */ ` + const fs = require("node:fs"); + + // Longest relative path that still passes the per-platform raw-path length + // validation (MAX_PATH_BYTES); once joined with the cwd the normalized result + // no longer fits in the destination path buffer. + const maxPathBytes = { linux: 4096, darwin: 1024, win32: 32767 * 3 + 1 }[process.platform] ?? 1024; + const segment = "a/"; + const longRelativePath = segment.repeat(Math.floor((maxPathBytes - 2) / segment.length)); + + try { + const watcher = fs.watch(longRelativePath, () => {}); + watcher.close(); + throw new Error("expected watching the overlong relative path to fail"); + } catch (err) { + if (err.code !== "ENAMETOOLONG") throw err; + if (err.syscall !== "watch") throw new Error("unexpected syscall: " + err.syscall); + } + + // A normal relative path must still work after the rejected one. + const ok = fs.watch("watch-me.txt", () => {}); + ok.close(); + + console.log("OK"); + `; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + cwd: base, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(stdout.trim()).toBe("OK"); + // Unfixed builds overflow the pooled path buffer during the cwd join and abort + // the subprocess instead of throwing a catchable error. + expect(exitCode).toBe(0); +}); diff --git a/test/js/sql/sql-mysql.test.ts b/test/js/sql/sql-mysql.test.ts index d2ed9c22d0e..0dfefb88949 100644 --- a/test/js/sql/sql-mysql.test.ts +++ b/test/js/sql/sql-mysql.test.ts @@ -292,6 +292,43 @@ if (isDockerEnabled()) { expect().pass(); }, 10_000); + test("rebuilds row object shape when a reused statement's result columns change", async () => { + // Result-set column metadata is re-read from the wire on every execution + // of a cached prepared statement. When the column count stays the same + // but the names change (e.g. ALTER TABLE between executions of the same + // query text), the cached row-object structure must be rebuilt so values + // are written under the current column names and never past the end of + // the previously-shaped object. + await using db = new SQL({ ...getOptions(), max: 1, idleTimeout: 5 }); + using sql = await db.reserve(); + + // Same column count, different names across two executions of the same query text. + const t = "rs_" + randomUUIDv7("hex").replaceAll("-", ""); + await sql`CREATE TEMPORARY TABLE ${sql(t)} (a INT, b INT)`; + await sql`INSERT INTO ${sql(t)} VALUES (1, 2)`; + const first = await sql`SELECT * FROM ${sql(t)}`; + expect(first[0]).toEqual({ a: 1, b: 2 }); + await sql`ALTER TABLE ${sql(t)} CHANGE a c INT, CHANGE b d INT`; + const second = await sql`SELECT * FROM ${sql(t)}`; + expect(second[0]).toEqual({ c: 1, d: 2 }); + + // Duplicate column names collapse into a single property on the first + // execution; once a rename makes them distinct, the same cached + // statement must produce every property of the new column list. + const ta = "rsa_" + randomUUIDv7("hex").replaceAll("-", ""); + const tb = "rsb_" + randomUUIDv7("hex").replaceAll("-", ""); + await sql`CREATE TEMPORARY TABLE ${sql(ta)} (x INT, y INT)`; + await sql`CREATE TEMPORARY TABLE ${sql(tb)} (x INT, y INT)`; + await sql`INSERT INTO ${sql(ta)} VALUES (1, 2)`; + await sql`INSERT INTO ${sql(tb)} VALUES (3, 4)`; + const dupFirst = await sql`SELECT * FROM ${sql(ta)} CROSS JOIN ${sql(tb)}`; + // Last one wins for duplicate names, so only x and y exist. + expect(Object.keys(dupFirst[0]).sort()).toEqual(["x", "y"]); + await sql`ALTER TABLE ${sql(tb)} CHANGE x z INT, CHANGE y w INT`; + const dupSecond = await sql`SELECT * FROM ${sql(ta)} CROSS JOIN ${sql(tb)}`; + expect(dupSecond[0]).toEqual({ x: 1, y: 2, z: 3, w: 4 }); + }); + test("Handles numeric column names", async () => { // deliberately out of order const result = await sql`select 1 as "1", 2 as "2", 3 as "3", 0 as "0"`; diff --git a/test/js/sql/sql.test.ts b/test/js/sql/sql.test.ts index 219a8db52e5..f05e49a2385 100644 --- a/test/js/sql/sql.test.ts +++ b/test/js/sql/sql.test.ts @@ -12757,3 +12757,123 @@ test("rejects Postgres connection options containing null bytes", async () => { expect(ok).toBeDefined(); await ok.close(); }); + +// A Postgres server controls two independent column counts: the +// RowDescription's field list (which sizes the per-row cell buffer and the +// cached row Structure) and each DataRow's own column count. When a DataRow +// declares fewer columns than the RowDescription, the unfilled cells stay in +// their default "named null" state; building the row object must only write +// as many properties as the Structure actually has, leaving the missing +// columns as null instead of writing past the row object's property storage. +// The row description here uses 62 identically-named fields so the cached +// Structure has a single property while the cell buffer has 62 entries. +// Runs in a subprocess because a regression corrupts the JS heap of the +// process that parses the response. +test("data row that omits columns declared in the row description yields nulls for the missing columns", async () => { + const fixtureDir = tempDirWithFiles("pg-short-data-row", { + "fixture.ts": ` +import { SQL } from "bun"; +import net from "node:net"; + +function pkt(type, body) { + const header = Buffer.alloc(5); + header.write(type, 0); + header.writeInt32BE(body.length + 4, 1); + return Buffer.concat([header, body]); +} +const int16 = n => { const b = Buffer.alloc(2); b.writeInt16BE(n, 0); return b; }; +const int32 = n => { const b = Buffer.alloc(4); b.writeInt32BE(n, 0); return b; }; +const cstr = s => Buffer.concat([Buffer.from(s), Buffer.from([0])]); + +// 62 text columns (oid 25, format 0) that all share the same name "c", so the +// cached row Structure has a single property and the other 61 fields are +// duplicates. +const COLUMNS = 62; +const rowDescription = pkt("T", Buffer.concat([ + int16(COLUMNS), + ...Array.from({ length: COLUMNS }, () => + Buffer.concat([cstr("c"), int32(0), int16(0), int32(25), int16(-1), int32(-1), int16(0)]), + ), +])); +function dataRow(values) { + const cols = Buffer.concat( + values.map(v => { + const bytes = Buffer.from(v); + return Buffer.concat([int32(bytes.length), bytes]); + }), + ); + return pkt("D", Buffer.concat([int16(values.length), cols])); +} +const authenticationOk = pkt("R", int32(0)); +const readyForQuery = pkt("Z", Buffer.from("I")); +const commandComplete = pkt("C", cstr("SELECT 1")); + +async function run(label, rowValues) { + const server = net.createServer(socket => { + let startup = true; + socket.on("data", data => { + if (startup) { + startup = false; + socket.write(Buffer.concat([authenticationOk, readyForQuery])); + return; + } + if (data[0] !== 0x51 /* 'Q' */) return; + socket.write(Buffer.concat([rowDescription, dataRow(rowValues), commandComplete, readyForQuery])); + }); + socket.on("error", () => {}); + }); + await new Promise(r => server.listen(0, "127.0.0.1", () => r())); + const port = server.address().port; + const sql = new SQL({ + url: "postgres://u@127.0.0.1:" + port + "/db", + max: 1, + idleTimeout: 5, + connectionTimeout: 5, + }); + try { + const rows = await sql\`select c\`.simple(); + console.log(label + " " + JSON.stringify(rows[0])); + } catch (e) { + console.log(label + "_ERROR " + (e.code || e.message)); + } finally { + await sql.close().catch(() => {}); + await new Promise(r => server.close(() => r())); + } +} + +// The DataRow declares zero of the 62 described columns: the row's single +// named property must come back as null and nothing else may be written. +await run("EMPTY_ROW", []); +// A DataRow that supplies all 62 declared columns still resolves the duplicate +// column name following the established "last one wins" rule. +await run("FULL_ROW", Array.from({ length: COLUMNS }, (_, i) => "v" + i)); +console.log("FIXTURE_DONE"); +`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.ts"], + cwd: fixtureDir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + timeout: 10_000, + killSignal: "SIGKILL", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const filteredStderr = stderr + .split(/\r?\n/) + .filter(l => l && !l.startsWith("WARNING: ASAN interferes")) + .join("\n"); + + // The row built from the short DataRow has exactly the structure's one + // property, valued null; the fully-populated row still maps the duplicate + // column name to the last column's value (last one wins, matching the + // existing duplicate-column-name behavior). + expect(stdout).toContain('EMPTY_ROW {"c":null}'); + expect(stdout).toContain('FULL_ROW {"c":"v61"}'); + expect(stdout).toContain("FIXTURE_DONE"); + expect(filteredStderr).toBe(""); + expect(exitCode).toBe(0); +}, 30_000); diff --git a/test/js/sql/tls-sql.test.ts b/test/js/sql/tls-sql.test.ts index dab2a4ccb09..915759bb96f 100644 --- a/test/js/sql/tls-sql.test.ts +++ b/test/js/sql/tls-sql.test.ts @@ -348,3 +348,75 @@ test("postgres client refuses protocol messages received in place of the SSLRequ await new Promise(resolve => server.close(() => resolve())); } }); + +// Uses a minimal mock PostgreSQL server, so it runs without Docker. +test("postgres client aborts the connection when the server declines TLS that was explicitly requested", async () => { + // `tls: true` (or any tls object) is an explicit request for an encrypted + // connection. When the server answers the 8-byte SSLRequest with 'N' + // ("SSL not available"), the client must abort the connection instead of + // silently continuing the protocol in plaintext, which would put the + // startup message and the password on the unencrypted socket. + const password = "hunter2-must-not-appear-on-the-wire"; + const sslRequest = [0x00, 0x00, 0x00, 0x08, 0x04, 0xd2, 0x16, 0x2f]; + // AuthenticationCleartextPassword: 'R', int32 length 8, int32 auth type 3. + const cleartextPasswordRequest = Buffer.from([0x52, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x03]); + + for (const tls of [true, { rejectUnauthorized: false }] as const) { + let preTlsClientBytes = Buffer.alloc(0); + let declinedTls = false; + const plaintextAfterDecline: Buffer[] = []; + const clientContinuedInPlaintext = Promise.withResolvers(); + const sockets = new Set(); + + const server = net.createServer(socket => { + sockets.add(socket); + socket.on("error", () => {}); + socket.on("data", data => { + if (!declinedTls) { + preTlsClientBytes = Buffer.concat([preTlsClientBytes, data]); + if (preTlsClientBytes.length < 8) return; + declinedTls = true; + // The legitimate "SSL not available" answer to an SSLRequest. + socket.write(Buffer.from("N")); + return; + } + // Anything received from here on is the client continuing the protocol + // on the unencrypted socket (startup message, password, ...). + plaintextAfterDecline.push(Buffer.from(data)); + clientContinuedInPlaintext.resolve(); + // A downgraded client would answer this with the cleartext password. + socket.write(cleartextPasswordRequest); + }); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as import("node:net").AddressInfo; + + try { + await using sql = new SQL({ + url: `postgres://postgres:${password}@127.0.0.1:${port}/bun_sql_test`, + adapter: "postgres", + max: 1, + tls, + }); + const outcome = await Promise.race([ + sql`select 1`.then( + () => ({ kind: "connected" }), + e => ({ kind: "rejected", code: e?.code ?? String(e) }), + ), + clientContinuedInPlaintext.promise.then(() => ({ kind: "continued in plaintext" })), + ]); + + // The only plaintext bytes the client may ever send are the SSLRequest itself. + expect(Array.from(preTlsClientBytes)).toEqual(sslRequest); + // After the server declines TLS, nothing further -- least of all the + // password -- may be written to the unencrypted socket. + expect(Buffer.concat(plaintextAfterDecline).toString("latin1")).not.toContain(password); + expect(plaintextAfterDecline.length).toBe(0); + // The connection must fail cleanly instead of downgrading to plaintext. + expect(outcome).toEqual({ kind: "rejected", code: "ERR_POSTGRES_TLS_NOT_AVAILABLE" }); + } finally { + for (const socket of sockets) socket.destroy(); + await new Promise(resolve => server.close(() => resolve())); + } + } +}); diff --git a/test/js/web/fetch/fetch.test.ts b/test/js/web/fetch/fetch.test.ts index 77485d45249..b5bbca34e6e 100644 --- a/test/js/web/fetch/fetch.test.ts +++ b/test/js/web/fetch/fetch.test.ts @@ -7,6 +7,7 @@ import { exampleSite, exampleHtml as fixture, gc, + isASAN, isBroken, isFlaky, isMacOS, @@ -2643,3 +2644,189 @@ it("fetch() with a fixed-size body drops a caller-supplied Transfer-Encoding hea expect(bodyParts.join("\r\n\r\n")).toBe(body); } }); + +it("fetch() does not forward a caller-supplied Content-Length on a request without a body", async () => { + // The Content-Length emitted on the wire must always describe the body fetch() is + // actually about to send. A Content-Length copied from an inbound request (e.g. by a + // gateway forwarding headers wholesale) on a request with no body would make the + // upstream wait for body bytes that never arrive and read the start of the next + // request on a kept-alive connection as that body. + const requests: string[] = []; + await using server = net.createServer(socket => { + let raw = ""; + socket.on("data", data => { + raw += data.toString("latin1"); + const headerEnd = raw.indexOf("\r\n\r\n"); + if (headerEnd === -1) return; + const head = raw.slice(0, headerEnd); + const method = head.split(" ")[0]; + if (method !== "GET") { + // wait for the declared body before replying + const contentLength = Number(/^content-length:\s*(\d+)\s*$/im.exec(head)?.[1] ?? 0); + if (raw.length < headerEnd + 4 + contentLength) return; + } + requests.push(raw); + raw = ""; + socket.end("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nOK"); + }); + }); + await once(server.listen(0, "localhost"), "listening"); + const { port } = server.address() as AddressInfo; + + // GET request with no body: a caller-supplied Content-Length must not reach the wire. + const bodyless = await fetch(`http://localhost:${port}/`, { + headers: { "Content-Length": "52", "X-Custom": "still-forwarded" }, + }); + expect(await bodyless.text()).toBe("OK"); + + // POST request with a real body: the emitted Content-Length is computed from the + // body, not taken from the caller-supplied header. + const withBody = await fetch(`http://localhost:${port}/`, { + method: "POST", + headers: { "Content-Length": "999" }, + body: "hi", + }); + expect(await withBody.text()).toBe("OK"); + + expect(requests).toHaveLength(2); + const headerLinesOf = (request: string) => + request + .split("\r\n\r\n")[0] + .split("\r\n") + .slice(1) + .map(line => line.toLowerCase()); + + const bodylessHeaders = headerLinesOf(requests[0]); + // No Content-Length at all on the bodyless request: the bogus value is dropped. + expect(bodylessHeaders.filter(line => line.startsWith("content-length:"))).toEqual([]); + // Other caller-supplied headers are still forwarded. + expect(bodylessHeaders).toContain("x-custom: still-forwarded"); + + const withBodyHeaders = headerLinesOf(requests[1]); + expect(withBodyHeaders.filter(line => line.startsWith("content-length:"))).toEqual(["content-length: 2"]); +}); + +it("releases interim 1xx response bytes as they are parsed while waiting for the final response", async () => { + // A misbehaving origin can stream an arbitrarily long sequence of interim (1xx) + // responses before the final status line. Bytes belonging to interim responses that + // have already been parsed must be released from the header accumulation buffer as + // they are consumed, instead of being retained (and re-parsed) for the lifetime of + // the request. The flood below totals ~48 MB of interim responses, so process RSS + // must not grow by anywhere near that amount while the request is still waiting for + // its final status line, and the final response must still be delivered normally. + const informational = "HTTP/1.1 103 Early Hints\r\nx-filler: " + "a".repeat(1024) + "\r\n\r\n"; + const responseLength = informational.length; + const writeSize = 256 * 1024 - 13; // never a multiple of responseLength, so writes end mid-response + const pattern = Buffer.from(informational.repeat(Math.ceil(writeSize / responseLength) + 2), "latin1"); + const floodBytes = 48 * 1024 * 1024; + + let floodedBytes = 0; + const { promise: floodDone, resolve: floodDoneResolve } = Promise.withResolvers(); + const sockets: net.Socket[] = []; + const server = net.createServer(socket => { + sockets.push(socket); + socket.once("data", () => { + const writeMore = () => { + while (floodedBytes < floodBytes) { + const offset = floodedBytes % responseLength; + const slice = pattern.subarray(offset, offset + writeSize); + floodedBytes += slice.length; + if (!socket.write(slice)) { + socket.once("drain", writeMore); + return; + } + } + floodDoneResolve(); + }; + writeMore(); + }); + }); + await once(server.listen(0, "localhost"), "listening"); + const { port } = server.address() as AddressInfo; + + try { + Bun.gc(true); + const rssBefore = process.memoryUsage.rss(); + const responsePromise = fetch(`http://localhost:${port}/`); + await floodDone; + Bun.gc(true); + const rssDuringFlood = process.memoryUsage.rss(); + + // Complete the partially written interim response, then send the real response. + const socket = sockets[0]; + const tail = floodedBytes % responseLength; + if (tail !== 0) socket.write(pattern.subarray(tail, responseLength)); + socket.end("HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 4\r\nConnection: close\r\n\r\ndone"); + + // The final response after the interim responses is still delivered normally. + const response = await responsePromise; + expect(await response.text()).toBe("done"); + + // Only a small parse tail may be retained while the interim responses stream in; + // the ~48 MB of already-consumed 1xx bytes must not accumulate in the process. + const deltaMB = (rssDuringFlood - rssBefore) / 1024 / 1024; + expect(deltaMB).toBeLessThan(isASAN ? 48 : 16); + } finally { + for (const socket of sockets) socket.destroy(); + server.close(); + } +}, 60_000); + +it("does not reuse a keep-alive connection whose response carried more bytes than its Content-Length", async () => { + // Surplus bytes past the declared Content-Length mean the connection's framing can + // no longer be trusted: anything still buffered on (or later delivered to) that + // socket would be parsed as the response to whichever request next reuses it from + // the keep-alive pool. The mis-framed response itself is still delivered (truncated + // to its declared length), but the connection must be closed instead of pooled. + let connections = 0; + const sockets: net.Socket[] = []; + const server = net.createServer(socket => { + connections++; + sockets.push(socket); + let buffered = ""; + socket.on("data", data => { + buffered += data.toString("latin1"); + while (true) { + const headerEnd = buffered.indexOf("\r\n\r\n"); + if (headerEnd === -1) break; + const head = buffered.slice(0, headerEnd); + buffered = buffered.slice(headerEnd + 4); + const path = head.split("\r\n")[0].split(" ")[1]; + if (path === "/overshoot") { + // Declares 5 body bytes but sends those 5 plus a complete pipelined + // "injected" response that the declared framing never accounted for. + socket.write( + "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 5\r\nConnection: keep-alive\r\n\r\nhello" + + "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 8\r\n\r\ninjected", + ); + } else { + socket.write( + "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 6\r\nConnection: keep-alive\r\n\r\nlegit!", + ); + } + } + }); + }); + await once(server.listen(0, "localhost"), "listening"); + const { port } = server.address() as AddressInfo; + + try { + // The mis-framed response is still delivered, truncated to its declared length. + const first = await fetch(`http://localhost:${port}/overshoot`); + expect(await first.text()).toBe("hello"); + + // The follow-up request must go out on a fresh connection, so it can never be + // answered by the leftover "injected" bytes on the desynchronized socket. + const second = await fetch(`http://localhost:${port}/after`); + expect(await second.text()).toBe("legit!"); + expect(connections).toBe(2); + + // A correctly framed keep-alive response is still pooled and reused. + const third = await fetch(`http://localhost:${port}/again`); + expect(await third.text()).toBe("legit!"); + expect(connections).toBe(2); + } finally { + for (const socket of sockets) socket.destroy(); + server.close(); + } +}); diff --git a/test/js/web/websocket/websocket-client-short-read.test.ts b/test/js/web/websocket/websocket-client-short-read.test.ts index 6007a09d044..7df1c518396 100644 --- a/test/js/web/websocket/websocket-client-short-read.test.ts +++ b/test/js/web/websocket/websocket-client-short-read.test.ts @@ -1,5 +1,6 @@ import { TCPSocketListener } from "bun"; import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, normalizeBunSnapshot } from "harness"; import { WebSocket } from "ws"; const hostname = process.env.HOST || "127.0.0.1"; @@ -98,3 +99,108 @@ describe("WebSocket", () => { } }); }); + +describe("WebSocket buffered handshake data", () => { + test("terminating the client from its open handler while handshake bytes are buffered shuts down cleanly", async () => { + // A raw TCP "websocket server" that appends a complete text frame to the 101 + // response in the same packet, so the client buffers those bytes for a + // deferred initial-data callback. Scenario 1 tears the client down from the + // open handler before that callback runs; scenario 2 checks the buffered + // bytes still arrive as a message when the client stays open. + const script = String.raw` + function makeAccept(key) { + const hasher = new Bun.CryptoHasher("sha1"); + hasher.update(key); + hasher.update("258EAFA5-E914-47DA-95CA-C5AB0DC85B11"); + return hasher.digest("base64"); + } + + function startServer(afterHandshake) { + return Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + data(socket, data) { + const request = data.toString("utf-8"); + const match = /Sec-WebSocket-Key: (.*)\r\n/.exec(request); + if (!match) return; + const head = + "HTTP/1.1 101 Switching Protocols\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "Sec-WebSocket-Accept: " + makeAccept(match[1]) + "\r\n" + + "\r\n"; + const headBytes = new TextEncoder().encode(head); + // Complete 2-byte text frame ("hi") appended to the handshake + // response in the same write. + const frame = Uint8Array.from([0x81, 0x02, 0x68, 0x69]); + const packet = new Uint8Array(headBytes.length + frame.length); + packet.set(headBytes, 0); + packet.set(frame, headBytes.length); + socket.write(packet); + socket.flush(); + afterHandshake(socket); + }, + }, + }); + } + + async function scenarioTeardownFromOpen() { + const settled = Promise.withResolvers(); + // Server ends the connection right after the handshake packet. + const server = startServer(socket => socket.end()); + const ws = new WebSocket("ws://127.0.0.1:" + server.port); + ws.addEventListener("open", () => { + console.log("scenario-1 open"); + // Tear the client down synchronously while the buffered handshake + // bytes are still waiting on their deferred callback. + ws.terminate(); + }); + ws.addEventListener("close", () => settled.resolve()); + ws.addEventListener("error", () => settled.resolve()); + await settled.promise; + console.log("scenario-1 settled"); + server.stop(true); + } + + async function scenarioMessageStillDelivered() { + const received = Promise.withResolvers(); + const server = startServer(() => {}); + const ws = new WebSocket("ws://127.0.0.1:" + server.port); + ws.addEventListener("message", event => received.resolve(event.data)); + ws.addEventListener("error", () => received.resolve("error")); + console.log("scenario-2 message " + (await received.promise)); + ws.close(); + server.stop(true); + } + + scenarioTeardownFromOpen() + .then(scenarioMessageStillDelivered) + .then(() => { + Bun.gc(true); + console.log("done"); + }) + .catch(err => { + console.log("error " + err); + process.exit(1); + }); + `; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(normalizeBunSnapshot(stdout).split("\n")).toEqual([ + "scenario-1 open", + "scenario-1 settled", + "scenario-2 message hi", + "done", + ]); + expect(exitCode).toBe(0); + }); +});