Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/bun-uws/src/HttpParser.h
Original file line number Diff line number Diff line change
Expand Up @@ -750,6 +750,11 @@ namespace uWS
/* Error: invalid chars in field name */
return HttpParserResult::error(HTTP_ERROR_400_BAD_REQUEST, HTTP_PARSER_ERROR_INVALID_HEADER_TOKEN);
}
/* Reject zero-length header names (RFC 9110 §5.6.3): an empty key is
* indistinguishable from the headers-array terminator and would truncate iteration. */
if (postPaddedBuffer == preliminaryKey) [[unlikely]] {
return HttpParserResult::error(HTTP_ERROR_400_BAD_REQUEST, HTTP_PARSER_ERROR_INVALID_HEADER_TOKEN);
}
postPaddedBuffer++;

preliminaryValue = postPaddedBuffer;
Expand Down
6 changes: 5 additions & 1 deletion packages/bun-uws/src/HttpResponse.h
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,11 @@ struct HttpResponse : public AsyncSocket<SSL> {

/* Note: OpenSSL can be used here to speed this up somewhat */
char secWebSocketAccept[29] = {};
WebSocketHandshake::generate(secWebSocketKey.data(), secWebSocketAccept);
/* WebSocketHandshake::generate reads exactly 24 bytes; the client-controlled
* header may be shorter, so copy into a zero-padded fixed buffer first. */
char secWebSocketKeyBuf[24] = {};
secWebSocketKey.copy(secWebSocketKeyBuf, sizeof(secWebSocketKeyBuf));
WebSocketHandshake::generate(secWebSocketKeyBuf, secWebSocketAccept);

writeStatus("101 Switching Protocols")
->writeHeader("Upgrade", "websocket")
Expand Down
4 changes: 3 additions & 1 deletion src/base64/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,9 @@ pub mod vlq {
let encoded_ = &encoded[start..][0..(encoded.len() - start).min(VLQ_MAX_IN_BYTES + 1)];

// inlining helps for the 1 or 2 byte case, hurts a little for larger
for i in 0..(VLQ_MAX_IN_BYTES + 1) {
// Bound by the clamped slice length, not VLQ_MAX_IN_BYTES + 1, to avoid
// OOB reads on truncated/attacker-controlled mappings.
for i in 0..encoded_.len() {
if ASSERT_VALID {
debug_assert!(encoded_[i] < U7_MAX); // invalid base64 character
}
Expand Down
22 changes: 20 additions & 2 deletions src/bundler/linker_context/postProcessCSSChunk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,26 @@ pub fn post_process_css_chunk(
j.push_static(b"/* ");
line_offset.advance(b"/* ");

j.push_static(pretty);
line_offset.advance(pretty);
// Escape `*/` so a hostile package path can't terminate the
// comment and inject arbitrary CSS into the bundle output.
if bun_core::immutable::contains(pretty, b"*/") {
let mut escaped: Vec<u8> = Vec::with_capacity(pretty.len() + 1);
let mut i = 0;
while i < pretty.len() {
if pretty[i] == b'*' && pretty.get(i + 1) == Some(&b'/') {
escaped.extend_from_slice(b"*\\/");
i += 2;
} else {
escaped.push(pretty[i]);
i += 1;
}
}
line_offset.advance(&escaped);
j.push_owned(escaped.into_boxed_slice());
} else {
j.push_static(pretty);
line_offset.advance(pretty);
}

j.push_static(b" */\n");
line_offset.advance(b" */\n");
Expand Down
22 changes: 20 additions & 2 deletions src/bundler/linker_context/postProcessJSChunk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -681,8 +681,26 @@ pub fn post_process_js_chunk(
}
}

j.push_static(pretty);
line_offset.advance(pretty);
// Escape `*/` so a hostile package path can't terminate the multiline
// comment and inject JS. (Single-line is safe: no line terminators above.)
if matches!(comment_type, CommentType::Multiline) && strings::contains(pretty, b"*/") {
let mut escaped: Vec<u8> = Vec::with_capacity(pretty.len() + 1);
let mut i = 0;
while i < pretty.len() {
if pretty[i] == b'*' && pretty.get(i + 1) == Some(&b'/') {
escaped.extend_from_slice(b"*\\/");
i += 2;
} else {
escaped.push(pretty[i]);
i += 1;
}
}
line_offset.advance(&escaped);
j.push_owned(escaped.into_boxed_slice());
} else {
j.push_static(pretty);
line_offset.advance(pretty);
}

if emit_targets_in_commands {
j.push_static(b" (");
Expand Down
19 changes: 15 additions & 4 deletions src/bundler/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2600,10 +2600,21 @@ pub(crate) fn path_template_print<W: bun_io::Write>(
};

match field {
PlaceholderField::Dir => PathTemplate::write_replacing_slashes_on_windows(
writer,
if !dir.is_empty() { dir } else { b"." },
)?,
PlaceholderField::Dir => {
// Rewrite leading `..` segments to `_.._` so `[dir]` can't place
// output above `outdir` (mirrors esbuild's `outbase` behavior).
let mut d: &[u8] = if !dir.is_empty() { dir } else { b"." };
while d.starts_with(b"../") || d.starts_with(b"..\\") {
writer.write_all(b"_.._")?;
PathTemplate::write_replacing_slashes_on_windows(writer, &d[2..3])?;
d = &d[3..];
}
if d == b".." {
writer.write_all(b"_.._")?;
} else {
PathTemplate::write_replacing_slashes_on_windows(writer, d)?;
}
}
PlaceholderField::Name => {
PathTemplate::write_replacing_slashes_on_windows(writer, name)?
}
Expand Down
18 changes: 17 additions & 1 deletion src/css/css_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -668,7 +668,16 @@ fn parse_nested_block<T>(
let saved_stop_before = parser.stop_before;
parser.stop_before = closing_delimiter;
parser.at_start_of = None;
let result = parser.parse_entirely((), |(), p| parsefn(p));
// Bound recursive descent so deeply nested blocks (e.g. `calc(calc(...))`)
// can't overflow the native stack; the error path leaves the parser recoverable.
let result = if parser.nesting_depth >= MAX_NESTING_DEPTH {
Err(parser.new_custom_error(ParserError::maximum_nesting_depth))
} else {
parser.nesting_depth += 1;
let r = parser.parse_entirely((), |(), p| parsefn(p));
parser.nesting_depth -= 1;
r
};
Comment on lines +671 to +680

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n -C1 'scopeguard::defer!' src/css/css_parser.rs

Repository: oven-sh/bun

Length of output: 280


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check if scopeguard is imported
echo "=== Checking scopeguard imports ==="
rg -n 'use.*scopeguard|^extern crate scopeguard' src/css/css_parser.rs | head -20

# Get context around the proposed change location
echo -e "\n=== Context around lines 671-680 ==="
sed -n '665,685p' src/css/css_parser.rs

# Get context around the existing defer! usage at line 2165
echo -e "\n=== Context around line 2165 (existing defer! usage) ==="
sed -n '2160,2175p' src/css/css_parser.rs

Repository: oven-sh/bun

Length of output: 91


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the file section around 671-680 directly
echo "=== Lines 665-690 of css_parser.rs ==="
head -690 src/css/css_parser.rs | tail -26

# Also check first 50 lines for imports
echo -e "\n=== First 50 lines (imports section) ==="
head -50 src/css/css_parser.rs

# Check if scopeguard is imported via module
echo -e "\n=== Search for scopeguard anywhere in file ==="
rg -n 'scopeguard' src/css/css_parser.rs

Repository: oven-sh/bun

Length of output: 3499


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search entire file for any scopeguard import or use statement
echo "=== Full file grep for scopeguard context ==="
rg -B5 -A2 'scopeguard' src/css/css_parser.rs

# Check if scopeguard is a dependency
echo -e "\n=== Checking Cargo.toml for scopeguard ==="
find . -name "Cargo.toml" -type f | head -5 | while read f; do
  if rg -q 'scopeguard' "$f"; then
    echo "Found in: $f"
    rg -n 'scopeguard' "$f"
  fi
done

# Also check if there's a module that might re-export it
echo -e "\n=== Check imports near line 2165 ==="
sed -n '2150,2170p' src/css/css_parser.rs

Repository: oven-sh/bun

Length of output: 2582


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search the ENTIRE file for any use/import of scopeguard
echo "=== Searching entire file for scopeguard import ==="
rg -n '^use.*scopeguard|^extern.*scopeguard' src/css/css_parser.rs

# If not found, check the prelude or module structure
echo -e "\n=== Checking if scopeguard is in scope via prelude (check all use statements) ==="
rg -n '^use ' src/css/css_parser.rs | head -30

# Get full context around lines 671-680 to understand closure behavior
echo -e "\n=== Full context: lines 660-695 ==="
sed -n '660,695p' src/css/css_parser.rs

# Check if parse_entirely can return early
echo -e "\n=== Search for parse_entirely function signature ==="
rg -n 'fn parse_entirely' src/css/css_parser.rs -A3

Repository: oven-sh/bun

Length of output: 107


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Get broader context around line 2165 to see if it's gated
echo "=== Context lines 2140-2180 (checking for cfg gates) ==="
sed -n '2140,2180p' src/css/css_parser.rs

# Also check lines 650-700 for function signature and any gates
echo -e "\n=== Context lines 650-700 (around proposed change location) ==="
sed -n '650,700p' src/css/css_parser.rs

# Check if scopeguard is available via glob imports
echo -e "\n=== Check for pub use or glob imports that might bring scopeguard in ==="
rg -n 'pub use|use .*::\*' src/css/css_parser.rs | head -20

Repository: oven-sh/bun

Length of output: 5476


Use RAII (scopeguard::defer!) for nesting_depth cleanup to match the pattern already used in this file.

The manual decrement-after-parse pattern is error-prone; the same issue with composes_refs cleanup is solved at line 2165 using scopeguard::defer!. This ensures nesting_depth is decremented on all exit paths (including early returns from parser.parse_entirely) and aligns with the coding guideline to prefer Drop-based cleanup.

Suggested change
     let result = if parser.nesting_depth >= MAX_NESTING_DEPTH {
         Err(parser.new_custom_error(ParserError::maximum_nesting_depth))
     } else {
         parser.nesting_depth += 1;
+        scopeguard::defer! {
+            parser.nesting_depth -= 1;
+        }
-        let r = parser.parse_entirely((), |(), p| parsefn(p));
-        parser.nesting_depth -= 1;
-        r
+        parser.parse_entirely((), |(), p| parsefn(p))
     };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/css/css_parser.rs` around lines 671 - 680, Replace the manual
increment/decrement of parser.nesting_depth with an RAII-based cleanup using
scopeguard::defer!: when entering the block (after checking MAX_NESTING_DEPTH
and before calling parser.parse_entirely) increment parser.nesting_depth and
register scopeguard::defer! to decrement it, so that parser.nesting_depth is
always decremented even if parser.parse_entirely or parsefn returns early; keep
the existing error branch
(Err(parser.new_custom_error(ParserError::maximum_nesting_depth))) unchanged and
still call parser.parse_entirely((), |(), p| parsefn(p)) for the normal path.

if let Some(block_type2) = parser.at_start_of.take() {
consume_until_end_of_block(block_type2, &mut parser.input.tokenizer);
}
Expand Down Expand Up @@ -3439,8 +3448,14 @@ pub struct Parser<'a> {
/// materialises a fresh short-lived `&mut` instead.
pub import_records: Option<core::ptr::NonNull<Vec<ImportRecord>>>,
pub extra: Option<&'a mut ParserExtra>,
/// Current nested-block depth; bounded by [`MAX_NESTING_DEPTH`] to prevent
/// stack overflow from deeply nested input.
pub nesting_depth: u32,
}

/// Hard cap on nested-block depth (matches WebKit's CSS tokenizer limit).
pub const MAX_NESTING_DEPTH: u32 = 128;
Comment on lines +3451 to +3457

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n -C2 --type rust '\bMAX_NESTING_DEPTH\b|\bnesting_depth\b' -g '!src/css/css_parser.rs'

Repository: oven-sh/bun

Length of output: 2313


Narrow the visibility of nesting guard internals to pub(crate) or private.

The nesting_depth field and MAX_NESTING_DEPTH constant are implementation details used only within this module and should not be part of the public API. No external code references them. Restricting their visibility prevents accidental dependence on what should remain an internal hardening mechanism.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/css/css_parser.rs` around lines 3451 - 3457, The nesting guard internals
are currently exposed publicly; change the visibility of the `nesting_depth`
field and the `MAX_NESTING_DEPTH` constant to internal-only (either `pub(crate)`
or private) so they are not part of the public API; locate the `nesting_depth`
field in the struct definition and change `pub nesting_depth: u32` to
`pub(crate) nesting_depth: u32` (or `nesting_depth: u32`), and change `pub const
MAX_NESTING_DEPTH: u32 = 128;` to `pub(crate) const MAX_NESTING_DEPTH: u32 =
128;` (or private `const`) and run tests/build to ensure no external references
break.


impl<'a> Parser<'a> {
pub fn add_symbol_for_name(
&mut self,
Expand Down Expand Up @@ -3563,6 +3578,7 @@ impl<'a> Parser<'a> {
flags,
import_records,
extra,
nesting_depth: 0,
}
}

Expand Down
23 changes: 14 additions & 9 deletions src/http_jsc/websocket_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -310,16 +310,21 @@ impl<const SSL: bool> WebSocket<SSL> {
// null is well-defined (BoringSSL returns null).
let servername =
unsafe { boringssl::c::SSL_get_servername(ssl_ptr, TLSEXT_NAMETYPE_HOST_NAME) };
if !servername.is_null() {
// SNI may be null (e.g. IP literal); with no hostname there is nothing
// to verify the certificate against, so reject rather than skip verification.
let hostname: &[u8] = if !servername.is_null() {
// SAFETY: servername is a NUL-terminated C string owned by the SSL session.
let hostname = unsafe { bun_core::ffi::cstr(servername) }.to_bytes();
// SAFETY: ssl_ptr is non-null (connected SSL socket on the handshake path).
if !ssl_ptr.is_null()
&& !boringssl::check_server_identity(unsafe { &mut *ssl_ptr }, hostname)
{
self.outgoing_websocket = None;
ws_ref.did_abrupt_close(ErrorCode::FailedToConnect);
}
unsafe { bun_core::ffi::cstr(servername) }.to_bytes()
} else {
b""
};
if ssl_ptr.is_null()
|| hostname.is_empty()
// SAFETY: ssl_ptr is non-null (checked above) for the open socket's lifetime.
|| !boringssl::check_server_identity(unsafe { &mut *ssl_ptr }, hostname)
{
self.outgoing_websocket = None;
ws_ref.did_abrupt_close(ErrorCode::FailedToConnect);
Comment on lines +313 to +327

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🌐 Web query:

In BoringSSL/OpenSSL, does SSL_get_servername(ssl, TLSEXT_NAMETYPE_host_name) return NULL when the client connects to an IP literal or otherwise does not send SNI, and should certificate identity verification use the original requested host/IP (including IP SANs) rather than the negotiated SNI value?

💡 Result:

Yes, in both BoringSSL and OpenSSL, SSL_get_servername(ssl, TLSEXT_NAMETYPE_host_name) returns NULL when the client connects to an IP literal or otherwise does not send an SNI extension [1][2][3][4][5][6]. In BoringSSL source code, if no SNI extension is present in ClientHello, ssl->s3->hostname remains nullptr, and SSL_get_servername returns it [7][6]. OpenSSL documentation explicitly states it returns NULL if no servername was requested by the client [2][3][4][5]. When connecting to an IP literal, clients typically do not send SNI (as SNI must be a hostname per RFC 6066, not IP [8]), so NULL is returned [1][8]. Yes, certificate identity verification should use the original requested host/IP (including checking IP SANs) rather than the (possibly absent or different) negotiated SNI value [9][10][11]. OpenSSL's built-in verification via X509_check_host or SSL_CTX_set_verify uses the application-provided reference identity from functions like SSL_add1_host or X509_VERIFY_PARAM_set1_host, which is set to the originally requested endpoint (host or IP string) [9][10]. This checks against the peer certificate's CN/SAN DNS names (for hostnames) or IP SANs (for IPs) [9]. It does not use the SNI value, which is a separate server-side extension for virtual hosting and unavailable without client SNI [9][11]. Custom verification callbacks receive the original connection details independently of SNI [2].

Citations:


🏁 Script executed:

# First, let's find the exact location and full context of the handle_handshake method
cd src/http_jsc && grep -n "fn handle_handshake" websocket_client.rs

Repository: oven-sh/bun

Length of output: 88


🏁 Script executed:

# Let's examine the WebSocket struct definition and fields to see if it stores the original URL/host
rg "struct WebSocket" src/http_jsc/ -A 20

Repository: oven-sh/bun

Length of output: 5485


🏁 Script executed:

# Check the full handle_handshake method signature and available context
sed -n '280,330p' src/http_jsc/websocket_client.rs

Repository: oven-sh/bun

Length of output: 2642


🏁 Script executed:

# Check the Socket<SSL> definition to see if it stores the original host
rg "pub struct Socket" src/http_jsc/ -A 10

Repository: oven-sh/bun

Length of output: 37


🏁 Script executed:

# Look for where handle_handshake is called and what context is available
rg "handle_handshake" src/http_jsc/ -B 3 -A 1

Repository: oven-sh/bun

Length of output: 1763


🏁 Script executed:

# Check WebSocket initialization to see if original URL/host is stored
sed -n '1,100p' src/http_jsc/websocket_client.rs | head -60

Repository: oven-sh/bun

Length of output: 2318


🏁 Script executed:

# Check if CppWebSocket stores the original host/URL
rg "impl CppWebSocket" src/http_jsc/ -A 30 | head -60

Repository: oven-sh/bun

Length of output: 5254


🏁 Script executed:

# Look for WebSocket construction and initialization
rg "fn new\|fn init\|pub fn create" src/http_jsc/websocket_client.rs -A 5

Repository: oven-sh/bun

Length of output: 37


🏁 Script executed:

# Search for where the WebSocket URL/host originates from
rg "class WebSocket\|new WebSocket" src/ -t cpp -t h --max-count 20

Repository: oven-sh/bun

Length of output: 37


Verify against the originally requested host, not the negotiated SNI value.

SSL_get_servername() returns NULL for IP-literal connections and clients that don't send SNI. This code incorrectly rejects those connections outright, breaking valid wss:// connections that rely on IP SANs in the certificate. Certificate identity verification should check against the original requested host/IP (from the WebSocket URL), not the absence of SNI. Query the requested hostname from the C++ CppWebSocket object or thread it into the callback, then verify the certificate against that rather than rejecting when SNI is absent.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/http_jsc/websocket_client.rs` around lines 313 - 327, The code currently
rejects the connection if SSL_get_servername() is NULL; instead, obtain the
originally requested host (the host/IP from the WebSocket URL supplied by the
CppWebSocket instance or pass that hostname into this callback) and use that
value when calling boringssl::check_server_identity rather than the negotiated
SNI; keep the existing ssl_ptr null checks and safety comments, build a
byte-slice from the requested host (like the current cstr handling) and call
check_server_identity(unsafe { &mut *ssl_ptr }, requested_host_bytes); only set
self.outgoing_websocket = None and call
ws_ref.did_abrupt_close(ErrorCode::FailedToConnect) if check_server_identity
returns false (not merely when servername is NULL).

}
}
// If reject_unauthorized is false, we accept the connection regardless of SSL errors
Expand Down
32 changes: 32 additions & 0 deletions src/install/bin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,17 @@ pub fn normalized_bin_name(name: &[u8]) -> &[u8] {
name
}

/// True when `abs_target` is contained in `package_dir`. `bin` targets are
/// untrusted package.json values; a `../../..` target would be symlinked + chmod 0o777.
fn bin_target_within_package_dir(abs_target: &[u8], package_dir: &[u8]) -> bool {
debug_assert!(matches!(package_dir.last(), Some(&b'/') | Some(&b'\\')));
let dir = strings::without_trailing_slash(package_dir);
if !strings::has_prefix(abs_target, dir) {
return false;
}
matches!(abs_target.get(dir.len()), None | Some(&b'/') | Some(&b'\\'))
}
Comment on lines +740 to +749

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Lexical containment still allows symlink escapes.

bin_target_within_package_dir() only checks the joined path bytes. A package can still place a symlink inside the package that resolves outside the package root, and the later link path explicitly accepts EntryKind::SymLink and then calls chmod(abs_target, ... | 0o777) on POSIX. That preserves an external-target write/permission escalation path even though ../../ traversal is blocked. Resolve the final target (or lstat/open with O_NOFOLLOW and reject symlinks) before linking/chmodding.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/install/bin.rs` around lines 740 - 749, bin_target_within_package_dir
currently only checks byte-prefix containment and can be bypassed by symlinks;
update the installation flow to validate the resolved target before performing
operations like chmod on EntryKind::SymLink: either resolve the final target and
ensure it stays within package_dir (canonicalize/realpath and compare) or reject
symlinks up-front by using lstat/open with O_NOFOLLOW and failing if the entry
is a symlink, and ensure the chmod call is only performed after this safe check.


pub struct Linker<'a> {
pub bin: Bin,

Expand Down Expand Up @@ -1436,6 +1447,11 @@ impl<'a> Linker<'a> {
target,
unscoped_package_name,
);
// SECURITY: refuse `bin` targets that traverse outside the
// package directory (would otherwise be symlinked + chmod 0o777).
if !bin_target_within_package_dir(r.as_bytes(), package_dir) {
return;
}
// SAFETY: `resolve_bin_target` writes into the thread-local
// `PARSER_JOIN_INPUT_BUFFER` (via `join_abs_string_z`); the
// returned slice does not actually borrow `self` or
Expand Down Expand Up @@ -1476,6 +1492,11 @@ impl<'a> Linker<'a> {
target,
normalized_name,
);
// SECURITY: refuse `bin` targets that traverse outside the
// package directory (would otherwise be symlinked + chmod 0o777).
if !bin_target_within_package_dir(r.as_bytes(), package_dir) {
return;
}
// SAFETY: thread-local buffer; see Tag::File above.
ZStr::from_raw(r.as_bytes().as_ptr(), r.len())
};
Expand Down Expand Up @@ -1521,6 +1542,12 @@ impl<'a> Linker<'a> {
bin_target,
normalized_bin_dest,
);
// SECURITY: refuse `bin` targets that traverse outside the
// package directory (would otherwise be symlinked + chmod 0o777).
if !bin_target_within_package_dir(r.as_bytes(), package_dir) {
i += 2;
continue;
}
// SAFETY: thread-local buffer; see Tag::File above.
ZStr::from_raw(r.as_bytes().as_ptr(), r.len())
};
Expand Down Expand Up @@ -1551,6 +1578,11 @@ impl<'a> Linker<'a> {
let package_dir = &self.abs_target_buf[0..package_dir_len];
let r =
resolve_path::join_abs_string_z::<PlatformAuto>(package_dir, &[target]);
// SECURITY: refuse `directories.bin` targets that traverse
// outside the package directory.
if !bin_target_within_package_dir(r.as_bytes(), package_dir) {
return;
}
// SAFETY: `join_abs_string_z` writes into the thread-local
// `PARSER_JOIN_INPUT_BUFFER`; result does not borrow
// `package_dir`. Detached so `abs_target_buf` can be
Expand Down
7 changes: 5 additions & 2 deletions src/js/bun/sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -744,8 +744,11 @@ const SQL: typeof Bun.SQL = function SQL(
if (!$isCallable(savepoint_callback)) {
throw $ERR_INVALID_ARG_VALUE("fn", callback, "must be a function");
}
// matchs the format of the savepoint name in postgres package
const save_point_name = `s${savepoints++}${name ? `_${name}` : ""}`;
// matchs the format of the savepoint name in postgres package.
// Strip non-[A-Za-z0-9_] from the user-supplied name; it is interpolated
// unquoted into SAVEPOINT / RELEASE / ROLLBACK statements.
const safe_name = name ? String(name).replace(/[^A-Za-z0-9_]/g, "") : "";
const save_point_name = `s${savepoints++}${safe_name ? `_${safe_name}` : ""}`;
const promise = run_internal_savepoint(save_point_name, savepoint_callback);
transactionSavepoints.add(promise);
return await promise.finally(onSavepointFinished.bind(null, promise));
Expand Down
4 changes: 3 additions & 1 deletion src/js/internal/debugger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -592,7 +592,9 @@ function parseUrl(input: string): URL {
}

function randomId() {
return Math.random().toString(36).slice(2);
// Sole auth token for the debugger WebSocket — must be unpredictable.
// Strip hyphens to keep the historical `[a-z0-9]+` path shape.
return crypto.randomUUID().replace(/-/g, "");
}

const { enableANSIColors } = Bun;
Expand Down
10 changes: 9 additions & 1 deletion src/js/internal/sql/mysql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -539,7 +539,15 @@ class MySQLAdapter
};
}

validateTransactionOptions(_options: string): { valid: boolean; error?: string } {
validateTransactionOptions(options: string): { valid: boolean; error?: string } {
// Options are interpolated unquoted into `START TRANSACTION ${options}`;
// restrict to letters/spaces/commas to prevent statement injection.
if (options && !/^[a-zA-Z, ]+$/.test(options)) {
return {
valid: false,
error: "Transaction options may only contain letters, spaces, and commas.",
};
}
return { valid: true };
}

Expand Down
20 changes: 17 additions & 3 deletions src/js/internal/sql/postgres.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,13 +204,20 @@ function arrayValueSerializer(type: ArrayType, is_numeric: boolean, is_json: boo
return `"${arrayEscape(JSON.stringify(value))}"`;
}
}
// `arrayType` is interpolated unquoted as `$N::<arrayType>[]`; restrict to
// PostgreSQL type-name characters to prevent SQL injection.
const VALID_ARRAY_TYPE_RE = /^[A-Z_][A-Z0-9_]*(?:[ .][A-Z_][A-Z0-9_]*)*$/;
function getArrayType(typeNameOrID: number | ArrayType | undefined = undefined): ArrayType {
const typeOfType = typeof typeNameOrID;
if (typeOfType === "number") {
return getPostgresArrayType(typeNameOrID as number) ?? "JSON";
}
if (typeOfType === "string") {
return (typeNameOrID as string)?.toUpperCase();
const arrayType = (typeNameOrID as string).toUpperCase();
if (!VALID_ARRAY_TYPE_RE.test(arrayType)) {
throw new Error(`Invalid PostgreSQL array type: ${JSON.stringify(typeNameOrID)}`);
}
return arrayType;
}
// default to JSON so we accept most of the types
return "JSON";
Expand Down Expand Up @@ -766,8 +773,15 @@ class PostgresAdapter
};
}

validateTransactionOptions(_options: string): { valid: boolean; error?: string } {
// PostgreSQL accepts any transaction options
validateTransactionOptions(options: string): { valid: boolean; error?: string } {
// Options are interpolated unquoted into `BEGIN ${options}` (simple-query
// protocol allows stacked statements); restrict to letters/spaces/commas.
if (options && !/^[a-zA-Z, ]+$/.test(options)) {
return {
valid: false,
error: "Transaction options may only contain letters, spaces, and commas.",
};
}
return { valid: true };
}

Expand Down
9 changes: 9 additions & 0 deletions src/js/internal/sql/sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,15 @@ class SQLiteAdapter implements DatabaseAdapter<BunSQLiteModule.Database, BunSQLi
return { valid: true };
}

// Options are interpolated unquoted into `BEGIN ${options}`; restrict to
// letters/spaces to prevent statement injection.
if (!/^[a-zA-Z ]+$/.test(options)) {
return {
valid: false,
error: "Transaction options may only contain letters and spaces.",
};
}

const upperOptions = options.toUpperCase();
if (upperOptions === "READONLY" || upperOptions === "READ") {
return {
Expand Down
Loading
Loading