diff --git a/packages/bun-lambda/runtime.ts b/packages/bun-lambda/runtime.ts index 79183c6a65c..8949c4f404e 100755 --- a/packages/bun-lambda/runtime.ts +++ b/packages/bun-lambda/runtime.ts @@ -320,9 +320,10 @@ function formatHttpEventV1(event: HttpEventV1): Request { headers.append(name, value); } } - const hostname = headers.get("Host") ?? request.domainName; + const hostname = request.domainName ?? headers.get("Host"); const proto = headers.get("X-Forwarded-Proto") ?? "http"; - const url = new URL(request.path, `${proto}://${hostname}/`); + const url = new URL(`${proto}://${hostname}/`); + url.pathname = request.path; for (const [name, values] of Object.entries(event.multiValueQueryStringParameters ?? {})) { for (const value of values ?? []) { url.searchParams.append(name, value); @@ -367,9 +368,10 @@ function formatHttpEventV2(event: HttpEventV2): Request { for (const cookie of event.cookies ?? []) { headers.append("Set-Cookie", cookie); } - const hostname = headers.get("Host") ?? request.domainName; + const hostname = request.domainName ?? headers.get("Host"); const proto = headers.get("X-Forwarded-Proto") ?? "http"; - const url = new URL(request.http.path, `${proto}://${hostname}/`); + const url = new URL(`${proto}://${hostname}/`); + url.pathname = request.http.path; for (const [name, values] of Object.entries(event.queryStringParameters ?? {})) { url.searchParams.append(name, values); } @@ -431,7 +433,7 @@ function formatWebSocketUpgrade(event: WebSocketEvent): Request { headers.append(name, value); } } - const hostname = headers.get("Host") ?? request.domainName; + const hostname = request.domainName ?? headers.get("Host"); const proto = headers.get("X-Forwarded-Proto") ?? "http"; const url = new URL(`${proto}://${hostname}/${request.stage}`); return new Request(url.toString(), { diff --git a/scripts/verify-baseline-static/src/main.rs b/scripts/verify-baseline-static/src/main.rs index 3756c0c5b9b..1963814bcde 100644 --- a/scripts/verify-baseline-static/src/main.rs +++ b/scripts/verify-baseline-static/src/main.rs @@ -152,6 +152,14 @@ fn is_harmless_on_nehalem(insn: &Instruction) -> bool { return true; } + // CLDEMOTE encodes in hint/NOP space (0f 1c /0) and is architecturally + // treated as a NOP on CPUs that don't enumerate it (SDM vol. 2A). Newer + // UCRT string routines (e.g. strpbrk) emit it unconditionally as a cache + // hint; on Nehalem it NOPs and the routine behaves identically. + if insn.mnemonic() == Mnemonic::Cldemote { + return true; + } + false } diff --git a/src/bun_core/util.rs b/src/bun_core/util.rs index 9a5cb2e640c..cf7b58797c2 100644 --- a/src/bun_core/util.rs +++ b/src/bun_core/util.rs @@ -59,8 +59,13 @@ impl Unaligned { /// Reinterpret `&[Unaligned]` as `&[T]` once the caller has proven /// `ptr` is naturally aligned (Zig `@alignCast`). Panics in debug if not. + /// Empty slices need no cast: their dangling pointer has align 1, not + /// `align_of::()`, so they are returned as `&[]` directly. #[inline] pub fn slice_align_cast(slice: &[Unaligned]) -> &[T] { + if slice.is_empty() { + return &[]; + } debug_assert!( (slice.as_ptr() as usize).is_multiple_of(core::mem::align_of::()), "Unaligned::slice_align_cast: pointer is not {}-byte aligned", @@ -75,6 +80,9 @@ impl Unaligned { /// Mutable counterpart of [`slice_align_cast`]. #[inline] pub fn slice_align_cast_mut(slice: &mut [Unaligned]) -> &mut [T] { + if slice.is_empty() { + return &mut []; + } debug_assert!( (slice.as_ptr() as usize).is_multiple_of(core::mem::align_of::()), "Unaligned::slice_align_cast_mut: pointer is not {}-byte aligned", diff --git a/src/bundler_jsc/analyze_jsc.rs b/src/bundler_jsc/analyze_jsc.rs index 831f4718f4f..d4313d4eb65 100644 --- a/src/bundler_jsc/analyze_jsc.rs +++ b/src/bundler_jsc/analyze_jsc.rs @@ -40,6 +40,21 @@ pub(crate) extern "C" fn zig__ModuleInfoDeserialized__toJSModuleRecord( let buffer: &[StringID] = res.buffer(); let record_kinds: &[RecordKind] = res.record_kinds(); + let identifier_count = strings_lens.len(); + let is_valid_string_id = + |id: StringID| (id.0 as usize) < identifier_count || id.0 >= StringID::STAR_NAMESPACE.0; + if !buffer.iter().copied().all(is_valid_string_id) + || !requested_modules_keys + .iter() + .copied() + .all(is_valid_string_id) + || !requested_modules_values + .iter() + .all(|&v| (v.0 as usize) < identifier_count || v.0 >= RequestedModuleValue::Json.0) + { + return core::ptr::null_mut(); + } + let identifiers = IdentifierArray::create(strings_lens.len()); // SAFETY: `identifiers` is non-null (returned by `create`); destroyed exactly once at scope exit, // mirroring Zig's `defer identifiers.destroy()` (runs on both success and early-return paths). diff --git a/src/jsc/bindings/NodeHTTP.cpp b/src/jsc/bindings/NodeHTTP.cpp index 37959b9a913..287742270eb 100644 --- a/src/jsc/bindings/NodeHTTP.cpp +++ b/src/jsc/bindings/NodeHTTP.cpp @@ -21,6 +21,7 @@ #include "ZigGeneratedClasses.h" #include #include +#include #include "JSSocketAddressDTO.h" #include "node/JSNodeHTTPServerSocket.h" #include "node/JSNodeHTTPServerSocketPrototype.h" @@ -110,6 +111,63 @@ static EncodedJSValue assignHeadersFromFetchHeaders(FetchHeaders& impl, JSObject return JSValue::encode(tuple); } +enum class RequestHeaderKind : uint8_t { + Joinable, + Singleton, + Cookie, + SetCookie, +}; + +static RequestHeaderKind requestHeaderKind(WebCore::HTTPHeaderName name) +{ + switch (name) { + case WebCore::HTTPHeaderName::SetCookie: + return RequestHeaderKind::SetCookie; + case WebCore::HTTPHeaderName::Cookie: + return RequestHeaderKind::Cookie; + case WebCore::HTTPHeaderName::Age: + case WebCore::HTTPHeaderName::Authorization: + case WebCore::HTTPHeaderName::ContentLength: + case WebCore::HTTPHeaderName::ContentType: + case WebCore::HTTPHeaderName::ETag: + case WebCore::HTTPHeaderName::Expires: + case WebCore::HTTPHeaderName::Host: + case WebCore::HTTPHeaderName::IfModifiedSince: + case WebCore::HTTPHeaderName::IfUnmodifiedSince: + case WebCore::HTTPHeaderName::LastModified: + case WebCore::HTTPHeaderName::Location: + case WebCore::HTTPHeaderName::ProxyAuthorization: + case WebCore::HTTPHeaderName::Referer: + case WebCore::HTTPHeaderName::UserAgent: + return RequestHeaderKind::Singleton; + default: + return RequestHeaderKind::Joinable; + } +} + +static RequestHeaderKind requestHeaderKind(const WTF::String& lowercasedName) +{ + if (lowercasedName == "from"_s || lowercasedName == "max-forwards"_s || lowercasedName == "retry-after"_s || lowercasedName == "server"_s) + return RequestHeaderKind::Singleton; + return RequestHeaderKind::Joinable; +} + +// Builds the value for a duplicated, non-singleton request header: the +// existing value, the kind's separator, and the new value as one flat +// string — never a rope. +static JSString* joinedRequestHeaderValue(JSC::JSGlobalObject* globalObject, JSC::VM& vm, JSString* existing, RequestHeaderKind kind, const WTF::String& value) +{ + auto scope = DECLARE_THROW_SCOPE(vm); + auto existingValue = existing->value(globalObject); + RETURN_IF_EXCEPTION(scope, nullptr); + String merged = tryMakeString(existingValue.data, kind == RequestHeaderKind::Cookie ? "; "_s : ", "_s, value); + if (merged.isNull()) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return nullptr; + } + return jsString(vm, merged); +} + static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSValue methodString, MarkedArgumentBuffer& args, JSC::JSGlobalObject* globalObject, JSC::VM& vm) { auto scope = DECLARE_THROW_SCOPE(vm); @@ -143,8 +201,6 @@ static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSVal MarkedArgumentBuffer arrayValues; HTTPHeaderIdentifiers& identifiers = WebCore::clientData(vm)->httpHeaderIdentifiers(); - args.append(headersObject); - for (auto it = request->begin(); it != request->end(); ++it) { auto pair = *it; StringView nameView = StringView(std::span { reinterpret_cast(pair.first.data()), pair.first.length() }); @@ -159,20 +215,23 @@ static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSVal Identifier nameIdentifier; JSString* nameString = nullptr; + WTF::String lowercasedName; // `findHTTPHeaderName` only writes `name` when it returns true, so the // SetCookie check must be gated on a successful lookup rather than on the // (otherwise indeterminate) `name` value. set-cookie is always a known // header name, so an unrecognized header is never set-cookie. + bool knownHeader = WebCore::findHTTPHeaderName(nameView, name); bool isSetCookie = false; - if (WebCore::findHTTPHeaderName(nameView, name)) { + if (knownHeader) { nameString = identifiers.stringFor(globalObject, name); nameIdentifier = identifiers.identifierFor(vm, name); isSetCookie = name == WebCore::HTTPHeaderName::SetCookie; } else { WTF::String wtfString = nameView.toString(); nameString = jsString(vm, wtfString); - nameIdentifier = Identifier::fromString(vm, wtfString.convertToASCIILowercase()); + lowercasedName = wtfString.convertToASCIILowercase(); + nameIdentifier = Identifier::fromString(vm, lowercasedName); } if (isSetCookie) { @@ -189,7 +248,37 @@ static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSVal RETURN_IF_EXCEPTION(scope, void()); } else { - headersObject->putDirectMayBeIndex(globalObject, nameIdentifier, jsValue); + if (std::optional index = parseIndex(nameIdentifier)) [[unlikely]] { + // Index-shaped names store through the indexed path. A numeric + // name is never a known header name, so duplicates comma-join. + JSValue existing = headersObject->getDirectIndex(globalObject, index.value()); + RETURN_IF_EXCEPTION(scope, void()); + JSValue valueToPut = jsValue; + if (existing) [[unlikely]] { + valueToPut = joinedRequestHeaderValue(globalObject, vm, asString(existing), RequestHeaderKind::Joinable, value); + RETURN_IF_EXCEPTION(scope, void()); + } + headersObject->putDirectIndex(globalObject, index.value(), valueToPut); + } else { + // Locate the property the same way putDirect's replace path + // would, before storing anything: on a duplicate the first + // value is still intact at the returned offset. + PropertyOffset offset = headersObject->getDirectOffset(vm, nameIdentifier); + if (offset != invalidOffset) [[unlikely]] { + // Duplicate header name, Node's rules: singleton headers + // keep the first value (nothing to store), Cookie joins + // with "; ", everything else joins with ", ". + RequestHeaderKind kind = knownHeader ? requestHeaderKind(name) : requestHeaderKind(lowercasedName); + if (kind != RequestHeaderKind::Singleton) { + JSString* merged = joinedRequestHeaderValue(globalObject, vm, asString(headersObject->getDirect(offset)), kind, value); + RETURN_IF_EXCEPTION(scope, void()); + headersObject->structure()->didReplaceProperty(offset); + headersObject->putDirectOffset(vm, offset, merged); + } + } else { + headersObject->putDirect(vm, nameIdentifier, jsValue, 0); + } + } RETURN_IF_EXCEPTION(scope, void()); arrayValues.append(nameString); arrayValues.append(jsValue); @@ -197,6 +286,8 @@ static void assignHeadersFromUWebSocketsForCall(uWS::HttpRequest* request, JSVal } } + args.append(headersObject); + JSC::JSArray* array; { @@ -347,9 +438,10 @@ static EncodedJSValue assignHeadersFromUWebSockets(uWS::HttpRequest* request, JS HTTPHeaderName name; WTF::String nameString; WTF::String lowercasedNameString; + bool knownHeader = WebCore::findHTTPHeaderName(nameView, name); bool isSetCookie = false; - if (WebCore::findHTTPHeaderName(nameView, name)) { + if (knownHeader) { nameString = WTF::httpHeaderNameStringImpl(name); lowercasedNameString = nameString; isSetCookie = name == WebCore::HTTPHeaderName::SetCookie; @@ -374,7 +466,39 @@ static EncodedJSValue assignHeadersFromUWebSockets(uWS::HttpRequest* request, JS RETURN_IF_EXCEPTION(scope, {}); } else { - headersObject->putDirect(vm, Identifier::fromString(vm, lowercasedNameString), jsValue, 0); + Identifier nameIdentifier = Identifier::fromString(vm, lowercasedNameString); + if (std::optional index = parseIndex(nameIdentifier)) [[unlikely]] { + // Index-shaped names store through the indexed path. A numeric + // name is never a known header name, so duplicates comma-join. + JSValue existing = headersObject->getDirectIndex(globalObject, index.value()); + RETURN_IF_EXCEPTION(scope, {}); + JSValue valueToPut = jsValue; + if (existing) [[unlikely]] { + valueToPut = joinedRequestHeaderValue(globalObject, vm, asString(existing), RequestHeaderKind::Joinable, value); + RETURN_IF_EXCEPTION(scope, {}); + } + headersObject->putDirectIndex(globalObject, index.value(), valueToPut); + } else { + // Locate the property the same way putDirect's replace path + // would, before storing anything: on a duplicate the first + // value is still intact at the returned offset. + PropertyOffset offset = headersObject->getDirectOffset(vm, nameIdentifier); + if (offset != invalidOffset) [[unlikely]] { + // Duplicate header name, Node's rules: singleton headers + // keep the first value (nothing to store), Cookie joins + // with "; ", everything else joins with ", ". + RequestHeaderKind kind = knownHeader ? requestHeaderKind(name) : requestHeaderKind(lowercasedNameString); + if (kind != RequestHeaderKind::Singleton) { + JSString* merged = joinedRequestHeaderValue(globalObject, vm, asString(headersObject->getDirect(offset)), kind, value); + RETURN_IF_EXCEPTION(scope, {}); + headersObject->structure()->didReplaceProperty(offset); + headersObject->putDirectOffset(vm, offset, merged); + } + } else { + headersObject->putDirect(vm, nameIdentifier, jsValue, 0); + } + } + RETURN_IF_EXCEPTION(scope, {}); array->putDirectIndex(globalObject, i++, jsString(vm, nameString)); array->putDirectIndex(globalObject, i++, jsValue); RETURN_IF_EXCEPTION(scope, {}); diff --git a/src/lolhtml_sys/lol_html.rs b/src/lolhtml_sys/lol_html.rs index febbd4d974e..53ca95f248c 100644 --- a/src/lolhtml_sys/lol_html.rs +++ b/src/lolhtml_sys/lol_html.rs @@ -1091,6 +1091,12 @@ unsafe extern "C" { content_len: usize, is_html: bool, ) -> c_int; + fn lol_html_comment_replace( + comment: *mut Comment, + content: *const u8, + content_len: usize, + is_html: bool, + ) -> c_int; safe fn lol_html_comment_remove(comment: &mut Comment); safe fn lol_html_comment_is_removed(comment: &Comment) -> bool; safe fn lol_html_comment_source_location_bytes(comment: &Comment) -> SourceLocationBytes; @@ -1126,10 +1132,9 @@ impl Comment { pub fn replace(&mut self, content: &[u8], is_html: bool) -> Result<(), Error> { auto_disable(); - // PORT NOTE: Zig source calls lol_html_comment_before here (likely an upstream bug); ported faithfully // SAFETY: content ptr/len describe a valid slice match unsafe { - lol_html_comment_before(self, ptr_without_panic(content), content.len(), is_html) + lol_html_comment_replace(self, ptr_without_panic(content), content.len(), is_html) } { 0 => Ok(()), -1 => Err(Error::Fail), diff --git a/src/runtime/api/glob.rs b/src/runtime/api/glob.rs index 94cd2db9616..c251fbf8e5f 100644 --- a/src/runtime/api/glob.rs +++ b/src/runtime/api/glob.rs @@ -47,6 +47,13 @@ impl ScanOpts { let cwd_str: Box<[u8]> = 'cwd_str: { let cwd_utf8 = cwd_string.to_utf8_without_ref(); + if cwd_utf8.slice().len() > MAX_PATH_BYTES { + return Err(global_this.throw(format_args!( + "{}: invalid `cwd`, longer than {} bytes", + fn_name, MAX_PATH_BYTES + ))); + } + // If its absolute return as is if resolve_path::Platform::AUTO.is_absolute(cwd_utf8.slice()) { break 'cwd_str Box::<[u8]>::from(cwd_utf8.slice()); diff --git a/src/runtime/api/zlib.classes.ts b/src/runtime/api/zlib.classes.ts index 37f80fc4763..074de35a155 100644 --- a/src/runtime/api/zlib.classes.ts +++ b/src/runtime/api/zlib.classes.ts @@ -10,7 +10,7 @@ function generate(name: string) { estimatedSize: true, klass: {}, JSType: "0b11101110", - values: ["writeCallback", "errorCallback", "dictionary", "pendingInput", "pendingOutput"], + values: ["writeCallback", "errorCallback", "dictionary", "pendingInput", "pendingOutput", "writeResult"], proto: { init: { fn: "init" }, diff --git a/src/runtime/cli/create/SourceFileProjectGenerator.rs b/src/runtime/cli/create/SourceFileProjectGenerator.rs index a3ffb9f1037..634f1a385a5 100644 --- a/src/runtime/cli/create/SourceFileProjectGenerator.rs +++ b/src/runtime/cli/create/SourceFileProjectGenerator.rs @@ -332,10 +332,7 @@ pub fn generate_files( } if !dependencies.is_empty() { - let mut argv: Vec<&[u8]> = Vec::new(); - argv.push(b"bun"); - argv.push(b"--only-missing"); - argv.push(b"install"); + let mut argv: Vec<&[u8]> = vec![b"bun", b"--only-missing", b"install", b"--"]; argv.extend(dependencies.iter().map(|d| &d[..])); run_install(&mut argv)?; } @@ -361,6 +358,7 @@ pub fn generate_files( shadcn_argv.push(b"--src-dir"); } shadcn_argv.push(b"-y"); + shadcn_argv.push(b"--"); shadcn_argv.extend(components.keys().iter().map(|k| &k[..])); // print "bun" but use bun.selfExePath() diff --git a/src/runtime/node/net/BlockList.rs b/src/runtime/node/net/BlockList.rs index b00731a852f..321e7a7e20f 100644 --- a/src/runtime/node/net/BlockList.rs +++ b/src/runtime/node/net/BlockList.rs @@ -305,7 +305,7 @@ impl BlockList { } Rule::Subnet { network, prefix } => { if let Some(ip_addr) = address.as_v4() { - if let Some(subnet_addr) = network.as_v4() { + if let Some(subnet_addr) = network.as_sin().map(|s| s.addr) { if *prefix == 32 { if ip_addr == subnet_addr { return Ok(JSValue::TRUE); @@ -313,6 +313,9 @@ impl BlockList { continue; } } + if *prefix == 0 { + return Ok(JSValue::TRUE); + } let one: u32 = 1; let mask_addr: u32 = ((one << (*prefix as u32)) - 1) << (32 - *prefix as u32); @@ -323,8 +326,18 @@ impl BlockList { } } } - if let (Some(addr6), Some(net6)) = (address.as_sin6(), network.as_sin6()) { - let ip_addr: u128 = u128::from_ne_bytes(addr6.addr); + if let Some(net6) = network.as_sin6() { + let ip_addr: u128 = if let Some(addr6) = address.as_sin6() { + u128::from_ne_bytes(addr6.addr) + } else if let Some(ip4) = address.as_v4() { + let mut mapped = [0u8; 16]; + mapped[10] = 255; + mapped[11] = 255; + mapped[12..16].copy_from_slice(&ip4.to_ne_bytes()); + u128::from_ne_bytes(mapped) + } else { + continue; + }; let subnet_addr: u128 = u128::from_ne_bytes(net6.addr); if *prefix == 128 { if ip_addr == subnet_addr { @@ -333,6 +346,9 @@ impl BlockList { continue; } } + if *prefix == 0 { + return Ok(JSValue::TRUE); + } let one: u128 = 1; let mask_addr = ((one << (*prefix as u32)) - 1) << (128 - *prefix as u32); let ip_net: u128 = ip_addr.swap_bytes() & mask_addr; diff --git a/src/runtime/node/node_zlib_binding.rs b/src/runtime/node/node_zlib_binding.rs index a3d4e02a28a..9de778280b3 100644 --- a/src/runtime/node/node_zlib_binding.rs +++ b/src/runtime/node/node_zlib_binding.rs @@ -225,22 +225,29 @@ pub(crate) trait CompressionStreamImpl: Sized + Taskable + 'static { /// deref lives in `BackRef::get`, so callers and impls are safe. fn global_this(&self) -> &JSGlobalObject; fn stream(&self) -> &JsCell; - fn write_result_ptr(&self) -> Option<*mut u32>; /// Write `(avail_out, avail_in)` into the JS-owned 2-element `Uint32Array` - /// (`this._writeState`). Single unsafe deref site for the set-once - /// `write_result: Cell>>` field so callers stay safe. + /// (`this._writeState`), re-resolving the cached `writeResult` typed array + /// on every call so a detached, resized, or replaced backing store is + /// skipped instead of written through a stale pointer. #[inline] - fn flush_write_result(&self) { - let Some(write_result) = self.write_result_ptr() else { + fn flush_write_result(&self, global: &JSGlobalObject, this_value: JSValue) { + let Some(write_result_value) = Self::write_result_get_cached(this_value) else { return; }; - // SAFETY: `write_result` points at a 2-element `u32[]` owned by JS - // (set in each impl's `init()`); both indices are in-bounds and the - // backing buffer is kept alive by `this._writeState` / - // `_handle[owner_symbol]`. - let (r1, r0) = unsafe { (&mut *write_result.add(1), &mut *write_result) }; - self.stream().with_mut(|s| s.update_write_result(r1, r0)); + if !write_result_value.is_cell() { + return; + } + let Some(mut write_result_buf) = write_result_value.as_array_buffer(global) else { + return; + }; + let write_result = write_result_buf.as_u32(); + if write_result.len() < 2 { + return; + } + let (r0, r1) = write_result.split_at_mut(1); + self.stream() + .with_mut(|s| s.update_write_result(&mut r1[0], &mut r0[0])); } fn poll_ref(&self) -> &JsCell; @@ -275,6 +282,7 @@ pub(crate) trait CompressionStreamImpl: Sized + Taskable + 'static { unsafe fn deref(this: *mut Self); // Per-class codegen (`T.js.*` cached-property accessors). + fn write_result_get_cached(this_value: JSValue) -> Option; fn write_callback_get_cached(this_value: JSValue) -> Option; fn error_callback_get_cached(this_value: JSValue) -> Option; fn error_callback_set_cached(this_value: JSValue, global: &JSGlobalObject, cb: JSValue); @@ -546,7 +554,7 @@ impl CompressionStream { return; } - this.flush_write_result(); + this.flush_write_result(global, this_value); this_value.ensure_still_alive(); let write_callback: JSValue = T::write_callback_get_cached(this_value).unwrap(); @@ -697,7 +705,7 @@ impl CompressionStream { this.stream().with_mut(|s| s.do_work()); if Self::check_error(this, global_this, this_value) { - this.flush_write_result(); + this.flush_write_result(global_this, this_value); this.write_in_progress().set(false); } // SAFETY: matching `ref_()` above. The bracketed `ref_()`/`deref()` @@ -980,7 +988,7 @@ pub(crate) fn native_zstd(global: &JSGlobalObject) -> JSValue { /// comptime duck-typed `CompressionStream(T)` mixin). /// /// All three `Native{Zlib,Brotli,Zstd}` structs share the exact field layout -/// (`global_this`, `stream`, `write_result`, `poll_ref`, `this_value`, +/// (`global_this`, `stream`, `poll_ref`, `this_value`, /// `write_in_progress`, `pending_close`, `pending_reset`, `closed`, `task`, /// `ref_count`), so the macro can stamp the impls uniformly. /// @@ -1001,7 +1009,7 @@ macro_rules! __impl_compression_stream { /// `generate-classes.ts` for the `values:` list in `zlib.classes.ts`. #[allow(unused)] pub(crate) mod js { - ::bun_jsc::codegen_cached_accessors!($type_name; writeCallback, errorCallback, dictionary, pendingInput, pendingOutput); + ::bun_jsc::codegen_cached_accessors!($type_name; writeCallback, errorCallback, dictionary, pendingInput, pendingOutput, writeResult); } impl $crate::node::node_zlib_binding::CompressionContext for $ctx { @@ -1019,7 +1027,6 @@ macro_rules! __impl_compression_stream { #[inline] fn global_this(&self) -> &::bun_jsc::JSGlobalObject { self.global_this.get() } #[inline] fn stream(&self) -> &::bun_jsc::JsCell { &self.stream } - #[inline] fn write_result_ptr(&self) -> Option<*mut u32> { self.write_result.get().map(|p| p.cast::()) } #[inline] fn poll_ref(&self) -> &::bun_jsc::JsCell<$crate::node::node_zlib_binding::CountedKeepAlive> { &self.poll_ref } #[inline] fn this_value(&self) -> &::bun_jsc::JsCell<::bun_jsc::StrongOptional> { &self.this_value } #[inline] fn task(&self) -> &::bun_jsc::JsCell<::bun_jsc::WorkPoolTask> { &self.task } @@ -1048,6 +1055,9 @@ macro_rules! __impl_compression_stream { unsafe { ::deref(this) } } + #[inline] fn write_result_get_cached(this_value: ::bun_jsc::JSValue) -> Option<::bun_jsc::JSValue> { + js::write_result_get_cached(this_value) + } #[inline] fn write_callback_get_cached(this_value: ::bun_jsc::JSValue) -> Option<::bun_jsc::JSValue> { js::write_callback_get_cached(this_value) } diff --git a/src/runtime/node/zlib/NativeBrotli.rs b/src/runtime/node/zlib/NativeBrotli.rs index 8bfe10b4c10..ef9761d7663 100644 --- a/src/runtime/node/zlib/NativeBrotli.rs +++ b/src/runtime/node/zlib/NativeBrotli.rs @@ -85,9 +85,6 @@ mod _impl { // centralises the single unsafe deref so the trait impl is safe. pub global_this: bun_ptr::BackRef, pub stream: JsCell, - /// Points into a JS `Uint32Array` (`this._writeState`). Kept alive because - /// the JS object is tied to the native handle as `_handle[owner_symbol]`. - pub write_result: Cell>, pub poll_ref: JsCell, // TODO(port): Strong on m_ctx self-ref → JsRef per PORTING.md §JSC (Strong back-ref to own wrapper leaks) pub this_value: JsCell, // Strong.Optional — empty-initialised @@ -145,7 +142,6 @@ mod _impl { // JSC_BORROW backref — the global outlives this m_ctx payload. global_this: bun_ptr::BackRef::new(global_this), stream: JsCell::new(stream), - write_result: Cell::new(None), poll_ref: JsCell::new(CountedKeepAlive::default()), this_value: JsCell::new(StrongOptional::empty()), write_in_progress: Cell::new(false), @@ -188,10 +184,7 @@ mod _impl { .throw()); } - // this does not get gc'd because it is stored in the JS object's - // `this._writeState`. and the JS object is tied to the native handle - // as `_handle[owner_symbol]`. - // `flush_write_result` writes two u32s through this pointer, so the + // `flush_write_result` writes two u32s into this array, so the // caller-supplied array must hold at least 2 elements. let write_result_value = arguments.ptr[1]; let Some(mut write_result_buf) = write_result_value.as_array_buffer(global_this) else { @@ -217,7 +210,6 @@ mod _impl { ) .throw()); } - let write_result = write_result_slice.as_mut_ptr(); let write_callback = validators::validate_function(global_this, "writeCallback", arguments.ptr[2])?; @@ -240,7 +232,7 @@ mod _impl { )); } - self.write_result.set(Some(write_result)); + js::write_result_set_cached(this_value, global_this, write_result_value); js::write_callback_set_cached( this_value, diff --git a/src/runtime/node/zlib/NativeZlib.rs b/src/runtime/node/zlib/NativeZlib.rs index eef9eeb4a13..eafe4afc869 100644 --- a/src/runtime/node/zlib/NativeZlib.rs +++ b/src/runtime/node/zlib/NativeZlib.rs @@ -44,7 +44,6 @@ mod _impl { // centralises the single unsafe deref so the trait impl is safe. pub global_this: bun_ptr::BackRef, pub stream: JsCell, - pub write_result: Cell>, pub poll_ref: JsCell, pub this_value: JsCell, // jsc.Strong.Optional pub write_in_progress: Cell, @@ -97,7 +96,6 @@ mod _impl { // JSC_BORROW backref — the global outlives this m_ctx payload. global_this: bun_ptr::BackRef::new(global), stream: JsCell::new(stream), - write_result: Cell::new(None), poll_ref: JsCell::new(CountedKeepAlive::default()), this_value: JsCell::new(StrongOptional::empty()), write_in_progress: Cell::new(false), @@ -141,8 +139,7 @@ mod _impl { validators::validate_int32(global, arguments.ptr[2], "memLevel", None, None)?; let strategy = validators::validate_int32(global, arguments.ptr[3], "strategy", None, None)?; - // this does not get gc'd because it is stored in the JS object's `this._writeState`. and the JS object is tied to the native handle as `_handle[owner_symbol]`. - // `flush_write_result` writes two u32s through this pointer, so the + // `flush_write_result` writes two u32s into this array, so the // caller-supplied array must hold at least 2 elements. let write_result_value = arguments.ptr[4]; let Some(mut write_result_buf) = write_result_value.as_array_buffer(global) else { @@ -168,7 +165,6 @@ mod _impl { ) .throw()); } - let write_result = write_result_slice.as_mut_ptr(); let write_callback = validators::validate_function(global, "writeCallback", arguments.ptr[5])?; // Bind the ArrayBuffer view to a local so the borrowed byte_slice() outlives @@ -191,7 +187,7 @@ mod _impl { Some(dictionary_buf.byte_slice()) }; - self.write_result.set(Some(write_result)); + js::write_result_set_cached(this_value, global, write_result_value); js::write_callback_set_cached( this_value, global, diff --git a/src/runtime/node/zlib/NativeZstd.rs b/src/runtime/node/zlib/NativeZstd.rs index d3a7aefd4c4..abebacec68f 100644 --- a/src/runtime/node/zlib/NativeZstd.rs +++ b/src/runtime/node/zlib/NativeZstd.rs @@ -42,8 +42,6 @@ mod _impl { // `BackRef` centralises the single unsafe deref so the trait impl is safe. pub global_this: bun_ptr::BackRef, pub stream: JsCell, - // LIFETIMES.tsv: BORROW_PARAM → Option<*mut u32> (points into JS Uint32Array backing store) - pub write_result: Cell>, pub poll_ref: JsCell, pub this_value: JsCell, // jsc.Strong.Optional pub write_in_progress: Cell, @@ -101,7 +99,6 @@ mod _impl { // wrapper is owned by that global's heap). global_this: bun_ptr::BackRef::new(global), stream: JsCell::new(stream), - write_result: Cell::new(None), poll_ref: JsCell::new(CountedKeepAlive::default()), this_value: JsCell::new(StrongOptional::empty()), write_in_progress: Cell::new(false), @@ -160,7 +157,7 @@ mod _impl { write_state_value, )); } - // `flush_write_result` writes two u32s through this pointer, so the + // `flush_write_result` writes two u32s into this array, so the // caller-supplied array must hold at least 2 elements. let write_state_slice = write_state.as_u32(); if write_state_slice.len() < 2 { @@ -171,7 +168,7 @@ mod _impl { ) .throw()); } - self.write_result.set(Some(write_state_slice.as_mut_ptr())); + js::write_result_set_cached(this_value, global, write_state_value); let write_js_callback = validators::validate_function(global, "processCallback", process_callback_value)?; diff --git a/src/sql_jsc/postgres/PostgresRequest.rs b/src/sql_jsc/postgres/PostgresRequest.rs index 107aca6dfb6..f62e966b4c1 100644 --- a/src/sql_jsc/postgres/PostgresRequest.rs +++ b/src/sql_jsc/postgres/PostgresRequest.rs @@ -43,6 +43,7 @@ pub enum MessageType { CopyOutResponse, CopyDone, CopyBothResponse, + NotificationResponse, } /// The PostgreSQL wire protocol uses 16-bit integers for parameter and column counts. @@ -494,10 +495,15 @@ pub(crate) fn on_data( b'H' => connection.on(M::CopyOutResponse, reader.reborrow())?, b'c' => connection.on(M::CopyDone, reader.reborrow())?, b'W' => connection.on(M::CopyBothResponse, reader.reborrow())?, + b'A' => connection.on(M::NotificationResponse, reader.reborrow())?, _ => { bun_core::scoped_log!(Postgres, "Unknown message: {}", c as char); - let to_skip = reader.length()?.saturating_sub(1); + let length = reader.length()?; + if length < 4 { + return Err(AnyPostgresError::InvalidMessageLength); + } + let to_skip = length.saturating_sub(4); bun_core::scoped_log!(Postgres, "to_skip: {}", to_skip); reader.skip(usize::try_from(to_skip).expect("int cast"))?; } diff --git a/src/sql_jsc/postgres/PostgresSQLConnection.rs b/src/sql_jsc/postgres/PostgresSQLConnection.rs index 1326ed303fb..6d10f4b0062 100644 --- a/src/sql_jsc/postgres/PostgresSQLConnection.rs +++ b/src/sql_jsc/postgres/PostgresSQLConnection.rs @@ -3055,6 +3055,10 @@ impl PostgresSQLConnection { let _resp = protocol::NoticeResponse::decode_internal(reader.reborrow())?; // _resp dropped at scope end } + MessageType::NotificationResponse => { + debug!("UNSUPPORTED NotificationResponse"); + let _resp = protocol::NotificationResponse::decode_internal(reader.reborrow())?; + } MessageType::EmptyQueryResponse => { reader.eat_message(&protocol::EMPTY_QUERY_RESPONSE)?; let request = self.current().ok_or(AnyPostgresError::ExpectedRequest)?; diff --git a/test/cli/create/__snapshots__/create-jsx.test.ts.snap b/test/cli/create/__snapshots__/create-jsx.test.ts.snap index 793466f6ca1..a118418d8be 100644 --- a/test/cli/create/__snapshots__/create-jsx.test.ts.snap +++ b/test/cli/create/__snapshots__/create-jsx.test.ts.snap @@ -20,7 +20,7 @@ create index.html html create index.client.tsx bun create package.json npm 📦 Auto-installing 3 detected dependencies -$ bun --only-missing install classnames react-dom@19 react@19 +$ bun --only-missing install -- classnames react-dom@19 react@19 bun add *.*.* installed classnames@*.*.* installed react-dom@*.*.* @@ -58,7 +58,7 @@ create index.client.tsx bun create bunfig.toml bun create package.json npm 📦 Auto-installing 4 detected dependencies -$ bun --only-missing install tailwindcss bun-plugin-tailwind react-dom@19 react@19 +$ bun --only-missing install -- tailwindcss bun-plugin-tailwind react-dom@19 react@19 bun add *.*.* installed tailwindcss@*.*.* installed bun-plugin-tailwind@*.*.* @@ -89,7 +89,7 @@ create package.json npm create tsconfig.json tsc create components.json shadcn 📦 Auto-installing 9 detected dependencies -$ bun --only-missing install lucide-react tailwindcss bun-plugin-tailwind tailwindcss-animate class-variance-authority clsx tailwind-merge react-dom@19 react@19 +$ bun --only-missing install -- lucide-react tailwindcss bun-plugin-tailwind tailwindcss-animate class-variance-authority clsx tailwind-merge react-dom@19 react@19 bun add *.*.* installed lucide-react@*.*.* installed tailwindcss@*.*.* @@ -102,7 +102,7 @@ installed react-dom@*.*.* installed react@*.*.* 12 packages installed [*ms] 😎 Setting up shadcn/ui components -$ bun x shadcn@canary add -y button badge card +$ bun x shadcn@canary add -y -- button badge card - components/ui/button.tsx - components/ui/badge.tsx - components/ui/card.tsx @@ -149,7 +149,7 @@ create index.html html create index.client.tsx bun create package.json npm 📦 Auto-installing 3 detected dependencies -$ bun --only-missing install classnames react-dom@19 react@19 +$ bun --only-missing install -- classnames react-dom@19 react@19 bun add *.*.* installed classnames@*.*.* installed react-dom@*.*.* @@ -186,7 +186,7 @@ create index.client.tsx bun create bunfig.toml bun create package.json npm 📦 Auto-installing 4 detected dependencies -$ bun --only-missing install tailwindcss bun-plugin-tailwind react-dom@19 react@19 +$ bun --only-missing install -- tailwindcss bun-plugin-tailwind react-dom@19 react@19 bun add *.*.* installed tailwindcss@*.*.* installed bun-plugin-tailwind@*.*.* @@ -217,7 +217,7 @@ create package.json npm create tsconfig.json tsc create components.json shadcn 📦 Auto-installing 9 detected dependencies -$ bun --only-missing install lucide-react tailwindcss bun-plugin-tailwind tailwindcss-animate class-variance-authority clsx tailwind-merge react-dom@19 react@19 +$ bun --only-missing install -- lucide-react tailwindcss bun-plugin-tailwind tailwindcss-animate class-variance-authority clsx tailwind-merge react-dom@19 react@19 bun add *.*.* installed lucide-react@*.*.* installed tailwindcss@*.*.* @@ -230,7 +230,7 @@ installed react-dom@*.*.* installed react@*.*.* 12 packages installed [*ms] 😎 Setting up shadcn/ui components -$ bun x shadcn@canary add -y button badge card +$ bun x shadcn@canary add -y -- button badge card - components/ui/button.tsx - components/ui/badge.tsx - components/ui/card.tsx diff --git a/test/cli/create/create-jsx.test.ts b/test/cli/create/create-jsx.test.ts index 41f1b312a5a..8612ea74469 100644 --- a/test/cli/create/create-jsx.test.ts +++ b/test/cli/create/create-jsx.test.ts @@ -1,7 +1,7 @@ import type { Subprocess } from "bun"; import { beforeEach, describe, expect, test } from "bun:test"; import { cp, readdir } from "fs/promises"; -import { bunEnv, bunExe, isCI, isWindows, tempDirWithFiles } from "harness"; +import { bunEnv, bunExe, isCI, isWindows, tempDir, tempDirWithFiles } from "harness"; import path from "path"; async function getServerUrl(process: Subprocess, all = { text: "" }) { @@ -323,6 +323,36 @@ for (const development of [true, false]) { }); } +test("auto-install passes detected dependencies as positionals", async () => { + using dir = tempDir("create-arg-separator", { + "Component.tsx": `import "--trust"; + +export default function Component() { + return
Hello
; +} +`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "create", "./Component.tsx"], + cwd: String(dir), + env: { + ...bunEnv, + // Unreachable registry so the spawned install fails fast offline. + BUN_CONFIG_REGISTRY: "http://localhost:1/", + }, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + + const installLine = stdout.split("\n").find(line => line.includes("--only-missing install")); + expect(installLine).toBeDefined(); + expect(installLine).toContain(" install -- "); +}); + function normalizeHTMLFn(development: boolean = true) { return (html: string) => html diff --git a/test/cli/run/transpiler-cache.test.ts b/test/cli/run/transpiler-cache.test.ts index 1254fcfc234..d9a09d5163d 100644 --- a/test/cli/run/transpiler-cache.test.ts +++ b/test/cli/run/transpiler-cache.test.ts @@ -1,6 +1,6 @@ import { Subprocess } from "bun"; import { beforeEach, describe, expect, test } from "bun:test"; -import { chmodSync, existsSync, mkdirSync, readdirSync, realpathSync, rmSync, writeFileSync } from "fs"; +import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "fs"; import { bunEnv, bunExe, bunRun, tmpdirSync } from "harness"; import { join } from "path"; @@ -262,3 +262,114 @@ describe("transpiler cache", () => { expect(newCacheCount()).toBe(0); // cache hit, order doesn't matter }); }); + +test("rejects cached module records containing out-of-range string indices", () => { + // When test isolation is enabled, the runtime transpiler cache stores a + // serialized ES module record ("esm_record") alongside the transpiled + // output. The string indices inside that record are used to index an + // identifier table when the record is converted back into a JSC module + // record, so any index beyond the table length (other than the reserved + // *-default / *-namespace sentinels near u32::MAX) must be rejected. + // + // Cache entry layout (src/jsc/RuntimeTranspilerCache.rs, Metadata::encode): + // 0: cache_version u32, 4: module_type u8, 5: output_encoding u8, + // then twelve u64 fields; esm_record_byte_offset @ 78, + // esm_record_byte_length @ 86, esm_record_hash @ 94. Payload follows @ 102. + // Serialized module record layout (src/bundler/analyze_transpiled_module.rs, + // serialize()): + // [record_kinds_len u32][record_kinds, 1 byte each][pad to 4] + // [buffer_len u32][buffer: u32 string index x buffer_len] ... + const ESM_RECORD_BYTE_OFFSET_AT = 78; + const ESM_RECORD_BYTE_LENGTH_AT = 86; + const ESM_RECORD_HASH_AT = 94; + const METADATA_SIZE = 102; + + function corruptModuleRecordStringIndices(file: string): boolean { + const data = readFileSync(file); + if (data.length < METADATA_SIZE) return false; + const esmOff = Number(data.readBigUInt64LE(ESM_RECORD_BYTE_OFFSET_AT)); + const esmLen = Number(data.readBigUInt64LE(ESM_RECORD_BYTE_LENGTH_AT)); + if (esmLen === 0 || esmOff + esmLen > data.length) return false; + + const recordKindsLen = data.readUInt32LE(esmOff); + const pad = (4 - (recordKindsLen % 4)) % 4; + let off = esmOff + 4 + recordKindsLen + pad; + const bufferLen = data.readUInt32LE(off); + off += 4; + if (bufferLen === 0) return false; + + // Point every string index in the record buffer far beyond the identifier + // table (but below the reserved sentinel range near u32::MAX). + for (let i = 0; i < bufferLen; i++) { + data.writeUInt32LE(0x7fffffff, off + i * 4); + } + // The cache loader skips esm-record content verification when the stored + // hash field is zero, so whoever writes the cache file controls exactly + // what reaches the module record deserializer. + data.writeBigUInt64LE(0n, ESM_RECORD_HASH_AT); + writeFileSync(file, data); + return true; + } + + // An ES module big enough to be eligible for the transpiler cache (>= 4 KiB) + // with imports, exports and top-level variables, so its module record + // contains string indices of every record kind. + const filler = ("// " + "x".repeat(120) + "\n").repeat(120); + writeFileSync( + join(temp_dir, "big-lib.js"), + `import { join } from "node:path"; +export const value = 42; +let counter = 0; +export function next() { + counter += 1; + return join("a", String(counter)); +} +${filler}`, + ); + writeFileSync( + join(temp_dir, "uses-lib.test.js"), + `import { test, expect } from "bun:test"; +import { value, next } from "./big-lib.js"; +test("cached module still works", () => { + expect(value).toBe(42); + expect(next().length).toBeGreaterThan(0); +});`, + ); + + const run = () => + Bun.spawnSync({ + // --isolate enables the isolation source-provider cache, which is the + // code path that converts the cached module record back into a JSC + // module record. + cmd: [bunExe(), "test", "--isolate", "./uses-lib.test.js"], + cwd: temp_dir, + env, + }); + + // First run transpiles the module and writes the cache entry, including the + // serialized module record. + const first = run(); + expect(first.stderr.toString() + first.stdout.toString()).toContain("1 pass"); + expect(existsSync(cache_dir)).toBeTrue(); + expect(first.exitCode).toBe(0); + + // Second run restores from the intact cache entry: the legitimate record is + // accepted and the module still works. + const second = run(); + expect(second.stderr.toString() + second.stdout.toString()).toContain("1 pass"); + expect(second.exitCode).toBe(0); + + // Rewrite the stored module record so every string index is out of range. + let corrupted = 0; + for (const name of readdirSync(cache_dir)) { + if (corruptModuleRecordStringIndices(join(cache_dir, name))) corrupted++; + } + expect(corrupted).toBeGreaterThanOrEqual(1); + + // Third run: the corrupted record must be rejected with a clean module load + // error and a normal (non-signal) process exit. + const third = run(); + expect(third.stderr.toString() + third.stdout.toString()).toContain("parseFromSourceCode failed"); + expect(third.signalCode).toBeUndefined(); + expect(third.exitCode).toBe(1); +}); diff --git a/test/integration/bun-lambda/bun-lambda.test.ts b/test/integration/bun-lambda/bun-lambda.test.ts new file mode 100644 index 00000000000..81c9aa568d6 --- /dev/null +++ b/test/integration/bun-lambda/bun-lambda.test.ts @@ -0,0 +1,134 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import path from "path"; + +const runtimePath = path.join(import.meta.dir, "..", "..", "..", "packages", "bun-lambda", "runtime.ts"); + +// The runtime only uses aws4fetch for outgoing WebSocket messages, which these +// tests never send, so a stub keeps the test offline. +const aws4fetchStub = `export class AwsClient { + constructor() {} + async fetch() { + return new Response(null, { status: 200 }); + } +} +`; + +test("lambda HTTP events cannot override the request authority", async () => { + const runtimeSource = await Bun.file(runtimePath).text(); + + using dir = tempDir("bun-lambda", { + "runtime.ts": runtimeSource, + "handler.ts": `export default { + async fetch(request) { + return new Response(request.url); + }, +}; +`, + "node_modules/aws4fetch/package.json": JSON.stringify({ name: "aws4fetch", version: "1.0.0", main: "index.js" }), + "node_modules/aws4fetch/index.js": aws4fetchStub, + }); + + const events = [ + { + requestId: "req-v2", + event: { + version: "2.0", + requestContext: { + requestId: "req-v2", + domainName: "api.example.com", + http: { method: "GET", path: "//attacker.example/reset" }, + }, + headers: { "Host": "evil.example", "X-Forwarded-Proto": "https" }, + isBase64Encoded: false, + }, + }, + { + requestId: "req-v1", + event: { + requestContext: { + requestId: "req-v1", + domainName: "api.example.com", + httpMethod: "GET", + path: "//attacker.example/reset", + }, + headers: {}, + multiValueHeaders: { "Host": ["evil.example"], "X-Forwarded-Proto": ["https"] }, + isBase64Encoded: false, + }, + }, + ]; + + let nextInvocation = 0; + const resolvers = new Map void>(); + const responses = new Map>(); + for (const { requestId } of events) { + responses.set(requestId, new Promise(resolve => resolvers.set(requestId, resolve))); + } + + using server = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url); + if (url.pathname === "/2018-06-01/runtime/invocation/next") { + if (nextInvocation >= events.length) { + // No more events: a non-ok status makes the runtime exit cleanly. + return new Response(null, { status: 500 }); + } + const { requestId, event } = events[nextInvocation++]; + return new Response(JSON.stringify(event), { + headers: { + "Content-Type": "application/json", + "Lambda-Runtime-Aws-Request-Id": requestId, + "Lambda-Runtime-Trace-Id": "trace-id", + "Lambda-Runtime-Invoked-Function-Arn": "arn:aws:lambda:us-east-1:123456789012:function:test", + "Lambda-Runtime-Deadline-Ms": String(Date.now() + 60_000), + }, + }); + } + const match = url.pathname.match(/^\/2018-06-01\/runtime\/invocation\/([^/]+)\/response$/); + if (match) { + resolvers.get(match[1])?.(await req.json()); + return new Response(null, { status: 202 }); + } + // Anything else (init/invocation errors) fails the assertions with useful context. + const failure = { unexpected: url.pathname, body: await req.text() }; + for (const resolve of resolvers.values()) { + resolve(failure); + } + return new Response(null, { status: 202 }); + }, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "runtime.ts"], + cwd: String(dir), + env: { + ...bunEnv, + AWS_LAMBDA_RUNTIME_API: `localhost:${server.port}`, + _HANDLER: "handler.fetch", + LAMBDA_TASK_ROOT: String(dir), + }, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + + const [v2Response, v1Response] = await Promise.all([responses.get("req-v2"), responses.get("req-v1")]); + + const decodeBody = (response: any): string => + response.isBase64Encoded ? Buffer.from(response.body, "base64").toString("utf8") : response.body; + + // Payload format v2: the path from the event must not be able to change the + // origin, and the authority comes from requestContext.domainName. + expect(v2Response.unexpected).toBeUndefined(); + const v2Url = new URL(decodeBody(v2Response)); + expect(v2Url.origin).toBe("https://api.example.com"); + expect(v2Url.pathname).toBe("//attacker.example/reset"); + + // Payload format v1. + expect(v1Response.unexpected).toBeUndefined(); + const v1Url = new URL(decodeBody(v1Response)); + expect(v1Url.origin).toBe("https://api.example.com"); + expect(v1Url.pathname).toBe("//attacker.example/reset"); +}); diff --git a/test/js/bun/glob/scan.test.ts b/test/js/bun/glob/scan.test.ts index 5c427e43ea8..c4fa1714f8b 100644 --- a/test/js/bun/glob/scan.test.ts +++ b/test/js/bun/glob/scan.test.ts @@ -188,6 +188,29 @@ describe("glob.match", async () => { return undefined; } }); + + test("oversized cwd throws instead of crashing", async () => { + const glob = new Glob("*.ts"); + const tooLong = Buffer.alloc(100_000, "x").toString(); + // relative cwd + expect(returnError(() => [...glob.scanSync({ cwd: tooLong })])).toBeDefined(); + expect(returnError(() => glob.scan({ cwd: tooLong }))).toBeDefined(); + // relative cwd that would be resolved against process.cwd() + expect(returnError(() => [...glob.scanSync({ cwd: tooLong, absolute: true })])).toBeDefined(); + // absolute cwd + expect(returnError(() => [...glob.scanSync({ cwd: "/" + tooLong })])).toBeDefined(); + expect(returnError(() => glob.scan({ cwd: "/" + tooLong }))).toBeDefined(); + + function returnError(cb: () => any): Error | undefined { + try { + cb(); + } catch (err) { + // @ts-expect-error + return err; + } + return undefined; + } + }); }); // From fast-glob regular.e2e.tes diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index a78115a5e00..6c3b2a1d25a 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -1628,6 +1628,132 @@ describe("HTTP Server Security Tests - Advanced", () => { await promise; expect(mockHandler).not.toHaveBeenCalled(); }); + + test("duplicate request headers follow Node.js precedence rules", async () => { + // Expected values verified against Node.js v24: singleton headers keep + // the first value, joinable headers are comma-joined, Cookie joins with + // "; ", and Set-Cookie becomes an array. + const { promise, resolve, reject } = Promise.withResolvers(); + server.on("request", (req, res) => { + try { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("ok"); + resolve({ + host: req.headers.host, + contentType: req.headers["content-type"], + authorization: req.headers.authorization, + accept: req.headers.accept, + xCustom: req.headers["x-custom"], + cookie: req.headers.cookie, + setCookie: req.headers["set-cookie"], + rawHostCount: req.rawHeaders.filter(h => h.toLowerCase() === "host").length, + }); + } catch (err) { + reject(err); + } + }); + + const msg = [ + "GET / HTTP/1.1", + "Host: first.example.com", + "Host: second.example.com", + "Content-Type: text/plain", + "Content-Type: text/html", + "Authorization: token1", + "Authorization: token2", + "Accept: application/json", + "Accept: text/html", + "X-Custom: one", + "X-Custom: two", + "Cookie: a=1", + "Cookie: b=2", + "Set-Cookie: x=1", + "Set-Cookie: y=2", + "Connection: close", + "", + "", + ].join("\r\n"); + + const response = await sendRequest(msg); + expect(response).toInclude("200"); + const headers: any = await promise; + // Singleton headers keep the first value. + expect(headers.host).toBe("first.example.com"); + expect(headers.contentType).toBe("text/plain"); + expect(headers.authorization).toBe("token1"); + // Other headers are joined with ", ". + expect(headers.accept).toBe("application/json, text/html"); + expect(headers.xCustom).toBe("one, two"); + // Cookie is joined with "; ". + expect(headers.cookie).toBe("a=1; b=2"); + // Set-Cookie is collected into an array. + expect(headers.setCookie).toEqual(["x=1", "y=2"]); + // rawHeaders still reports every received header. + expect(headers.rawHostCount).toBe(2); + }); + + test("duplicate request header edge cases follow Node.js precedence rules", async () => { + // Expected values verified against Node.js v24. + const { promise, resolve, reject } = Promise.withResolvers(); + server.on("request", (req, res) => { + try { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("ok"); + resolve({ + xTriple: req.headers["x-triple"], + xMixed: req.headers["x-mixed"], + xEmpty: req.headers["x-empty"], + server: req.headers.server, + retryAfter: req.headers["retry-after"], + numeric: req.headers["123"], + rawHeaderCount: req.rawHeaders.length, + }); + } catch (err) { + reject(err); + } + }); + + const msg = [ + "GET / HTTP/1.1", + "Host: localhost", + "X-Triple: one", + "X-Triple: two", + "X-Triple: three", + "x-MIXED: a", + "X-Mixed: b", + "X-Empty:", + "X-Empty: b", + "Server: apache", + "Server: nginx", + "Retry-After: 10", + "Retry-After: 20", + "123: a", + "123: b", + "Connection: close", + "", + "", + ].join("\r\n"); + + const response = await sendRequest(msg); + expect(response).toInclude("200"); + const headers: any = await promise; + expect(headers).toEqual({ + // Three or more occurrences are all joined, in order. + xTriple: "one, two, three", + // Names that differ only by case are the same header. + xMixed: "a, b", + // An empty first value still participates in the join. + xEmpty: ", b", + // Singleton headers keep the first value, including the ones WebCore + // has no HTTPHeaderName for (server, retry-after). + server: "apache", + retryAfter: "10", + // A header whose name parses as an array index joins like any other. + numeric: "a, b", + // rawHeaders still reports every received header (15 names + values). + rawHeaderCount: 30, + }); + }); }); describe("HTTP Protocol Violations", () => { diff --git a/test/js/node/net/node-net.test.ts b/test/js/node/net/node-net.test.ts index 27a15e38ab2..73ed0dffd45 100644 --- a/test/js/node/net/node-net.test.ts +++ b/test/js/node/net/node-net.test.ts @@ -3,7 +3,18 @@ import { heapStats } from "bun:jsc"; import { describe, expect, it } from "bun:test"; import { bunEnv, bunExe, expectMaxObjectTypeCount, isASAN, isDebug, isWindows, tmpdirSync } from "harness"; import { randomUUID } from "node:crypto"; -import { connect, createConnection, createServer, isIP, isIPv4, isIPv6, Server, Socket, Stream } from "node:net"; +import { + BlockList, + connect, + createConnection, + createServer, + isIP, + isIPv4, + isIPv6, + Server, + Socket, + Stream, +} from "node:net"; import { join } from "node:path"; const socket_domain = tmpdirSync(); @@ -37,6 +48,66 @@ it("should support net.isIPv6()", () => { expect(isIPv6("127.000.000.001")).toBe(false); }); +describe("net.BlockList subnet rules", () => { + // Expected values verified against Node.js v24. + it("matches IPv4-mapped IPv6 subnet rules against IPv4 and mapped addresses", () => { + const blockList = new BlockList(); + blockList.addSubnet("::ffff:1.1.1.0", 120, "ipv6"); + expect(blockList.check("1.1.1.1", "ipv4")).toBe(true); + expect(blockList.check("1.1.2.1", "ipv4")).toBe(false); + expect(blockList.check("::ffff:1.1.1.1", "ipv6")).toBe(true); + expect(blockList.check("::ffff:1.1.2.1", "ipv6")).toBe(false); + }); + + it("matches IPv4 subnet rules against IPv4-mapped IPv6 addresses", () => { + const blockList = new BlockList(); + blockList.addSubnet("1.1.1.0", 24, "ipv4"); + expect(blockList.check("::ffff:1.1.1.1", "ipv6")).toBe(true); + expect(blockList.check("::ffff:1.1.2.1", "ipv6")).toBe(false); + expect(blockList.check("::1", "ipv6")).toBe(false); + expect(blockList.check("1.1.1.255", "ipv4")).toBe(true); + expect(blockList.check("1.1.2.0", "ipv4")).toBe(false); + }); + + it("does not match IPv4 addresses against non-mapped IPv6 subnet rules", () => { + const blockList = new BlockList(); + blockList.addSubnet("8592:757c:efae:4e45::", 64, "ipv6"); + expect(blockList.check("1.1.1.1", "ipv4")).toBe(false); + expect(blockList.check("8592:757c:efae:4e45::f", "ipv6")).toBe(true); + expect(blockList.check("8592:757c:efaf:4e45::f", "ipv6")).toBe(false); + }); + + it("matches exact-prefix subnet rules", () => { + const v4 = new BlockList(); + v4.addSubnet("10.0.0.1", 32, "ipv4"); + expect(v4.check("10.0.0.1", "ipv4")).toBe(true); + expect(v4.check("10.0.0.2", "ipv4")).toBe(false); + expect(v4.check("::ffff:10.0.0.1", "ipv6")).toBe(true); + + const v6 = new BlockList(); + v6.addSubnet("::1", 128, "ipv6"); + expect(v6.check("::1", "ipv6")).toBe(true); + expect(v6.check("::2", "ipv6")).toBe(false); + + const mapped = new BlockList(); + mapped.addSubnet("::ffff:10.0.0.1", 128, "ipv6"); + expect(mapped.check("10.0.0.1", "ipv4")).toBe(true); + expect(mapped.check("10.0.0.2", "ipv4")).toBe(false); + }); + + it("matches zero-prefix subnet rules", () => { + const v4 = new BlockList(); + v4.addSubnet("0.0.0.0", 0, "ipv4"); + expect(v4.check("255.255.255.255", "ipv4")).toBe(true); + expect(v4.check("::1", "ipv6")).toBe(false); + + const v6 = new BlockList(); + v6.addSubnet("::", 0, "ipv6"); + expect(v6.check("8592:757c:efae:4e45::f", "ipv6")).toBe(true); + expect(v6.check("1.2.3.4", "ipv4")).toBe(true); + }); +}); + describe("net.Socket read", () => { var unix_servers = 0; for (let [message, label] of [ diff --git a/test/js/node/zlib/zlib-handle-bounds-check.test.ts b/test/js/node/zlib/zlib-handle-bounds-check.test.ts index 217c84d52fa..ffac59ab607 100644 --- a/test/js/node/zlib/zlib-handle-bounds-check.test.ts +++ b/test/js/node/zlib/zlib-handle-bounds-check.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; // Tests for bounds checking on native zlib handle write/writeSync methods. // These verify that user-controlled offset/length parameters are validated @@ -87,3 +88,61 @@ describe("zlib native handle bounds checking", () => { }).not.toThrow(); }); }); + +describe("zlib native handle writeState", () => { + test("writeSync updates the writeState array", () => { + const zlib = require("zlib"); + const deflate = zlib.createDeflateRaw(); + const handle = deflate._handle; + const ws = deflate._writeState; + const inBuf = Buffer.from("hello world ".repeat(10)); + const outBuf = Buffer.alloc(1024); + + ws[0] = 0; + ws[1] = 0xffffffff; + handle.writeSync(2 /* Z_SYNC_FLUSH */, inBuf, 0, inBuf.length, outBuf, 0, outBuf.length); + + // writeState receives (availOut, availIn) after the write completes. + expect(ws[0]).toBeGreaterThan(0); + expect(ws[0]).toBeLessThan(outBuf.length); + expect(ws[1]).toBe(0); + }); + + test("write completion with a detached writeState backing store does not crash", async () => { + // The native handle caches the writeState array passed to init(). If its + // backing ArrayBuffer is detached mid-stream, completing a write must + // re-resolve the array and skip the update rather than write through a + // stale pointer into freed/transferred memory. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const zlib = require("zlib"); + const deflate = zlib.createDeflateRaw(); + const handle = deflate._handle; + const ws = deflate._writeState; + const input = Buffer.from("hello world ".repeat(10)); + const out = Buffer.alloc(1024); + handle.writeSync(2, input, 0, input.length, out, 0, out.length); + // Detach the writeState backing store; the transferred copy is + // dropped immediately and collected. + structuredClone(ws.buffer, { transfer: [ws.buffer] }); + Bun.gc(true); + // This write completion must not touch the detached store. + handle.writeSync(2, Buffer.from("more data here"), 0, 14, out, 0, out.length); + // A fresh stream still works end-to-end. + const compressed = zlib.deflateRawSync("still works"); + console.log(zlib.inflateRawSync(compressed).toString()); + `, + ], + env: bunEnv, + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout.trim()).toBe("still works"); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/sql/postgres-multi-statement-fields.test.ts b/test/js/sql/postgres-multi-statement-fields.test.ts index ce5925f9524..40cd4f5f097 100644 --- a/test/js/sql/postgres-multi-statement-fields.test.ts +++ b/test/js/sql/postgres-multi-statement-fields.test.ts @@ -110,3 +110,58 @@ test("simple query with multiple statements uses each RowDescription's column na server.close(); } }); + +// NotificationResponse ('A', sent by NOTIFY) and unknown async messages can arrive +// between result sets. The protocol reader must consume exactly the message body so +// the following messages stay correctly framed. +for (const [name, asyncMessage] of [ + ["NotificationResponse", pkt("A", Buffer.concat([int32(4321), cstr("some_channel"), cstr("some payload")]))], + // 'v' = NegotiateProtocolVersion, which the client does not handle explicitly + ["unknown message type", pkt("v", Buffer.concat([int32(0), int32(0)]))], +] as const) { + test(`${name} between result sets does not corrupt message framing`, async () => { + 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; + // End the socket after the response so a mis-framed reader stalls into a + // connection error instead of waiting for more data forever. + socket.end( + Buffer.concat([ + rowDescription(["x"]), + dataRow(["1"]), + commandComplete("SELECT 1"), + asyncMessage, + rowDescription(["y"]), + dataRow(["2"]), + commandComplete("SELECT 1"), + readyForQuery, + ]), + ); + }); + }); + + await new Promise(r => server.listen(0, "127.0.0.1", () => r())); + const port = (server.address() as net.AddressInfo).port; + + const sql = new SQL({ + url: `postgres://u@127.0.0.1:${port}/db`, + max: 1, + idleTimeout: 5, + connectionTimeout: 5, + }); + + try { + const result = await sql`select 1 as x; select 2 as y`.simple(); + expect(result).toEqual([[{ x: "1" }], [{ y: "2" }]]); + } finally { + await sql.close(); + server.close(); + } + }); +} diff --git a/test/js/workerd/html-rewriter.test.js b/test/js/workerd/html-rewriter.test.js index 04a683b8247..833ce7ced3d 100644 --- a/test/js/workerd/html-rewriter.test.js +++ b/test/js/workerd/html-rewriter.test.js @@ -340,6 +340,41 @@ describe("HTMLRewriter", () => { remove: "

", }; + const commentMutationsMacro = async func => { + // before/after + let res = func(new HTMLRewriter(), comment => { + comment.before("before"); + comment.before("before html", { html: true }); + comment.after("after"); + comment.after("after html", { html: true }); + }).transform(new Response(commentsMutationsInput)); + expect(await res.text()).toBe(commentsMutationsExpected.beforeAfter); + + // replace + res = func(new HTMLRewriter(), comment => { + comment.replace("replace"); + }).transform(new Response(commentsMutationsInput)); + expect(await res.text()).toBe(commentsMutationsExpected.replace); + res = func(new HTMLRewriter(), comment => { + comment.replace("replace", { html: true }); + }).transform(new Response(commentsMutationsInput)); + expect(await res.text()).toBe(commentsMutationsExpected.replaceHtml); + + // remove + res = func(new HTMLRewriter(), comment => { + expect(comment.removed).toBe(false); + comment.remove(); + expect(comment.removed).toBe(true); + }).transform(new Response(commentsMutationsInput)); + expect(await res.text()).toBe(commentsMutationsExpected.remove); + }; + + it("HTMLRewriter: handles comment mutations", () => + commentMutationsMacro((rw, comments) => { + rw.on("p", { comments }); + return rw; + })); + const commentPropertiesMacro = async func => { const res = func(new HTMLRewriter(), comment => { expect(comment.removed).toBe(false);