Harden 36 reachable security findings across runtime, install, parsers, http#30722
Conversation
|
Updated 12:20 AM PT - May 15th, 2026
❌ @Jarred-Sumner, your commit d9f2e8f has 1 failures in
🧪 To try this PR locally: bunx bun-pr 30722That installs a local version of the PR into your bun-30722 --bun |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughThis PR hardens many subsystems: tracks TLS strictness for socket/session reuse, adds pre-write capacity checks, stages async randomFill output safely, validates lockfile/struct layouts, sanitizes headers and S3 inputs, prevents handler use-after-free, and enforces parser/explosion limits. ChangesSecurity Hardening Across HTTP, Serialization, and System APIs
Suggested reviewers
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with 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.
Inline comments:
In `@src/http_jsc/websocket_client.rs`:
- Around line 1322-1323: The close-frame reason cap is wrong: the WebSocket
close payload must include a 2-byte status code so the reason must be limited to
125 - 2 = 123 bytes. Update send_close_with_body (and the other similar sites
you noted) to use a 123-byte cap instead of 125: change the body buffer
type/size and any min(...) that currently uses 125 to 123 so reason_len + 2 <=
125, and ensure any code that writes the 2-byte status code still prepends those
two bytes before the reason. Also update the other functions/methods at the
other occurrences (the ones with body: Option<&mut [u8; 125]>, body_len: usize
or body_len.min(125)) to use 123 consistently.
In `@src/libarchive/lib.rs`:
- Around line 1325-1341: The current check in path_traverses_created_symlink
only looks at the in-memory created_symlinks list and misses symlink ancestors
that already exist on disk, allowing escapes; change the logic so that instead
of (or in addition to) consulting created_symlinks, you walk each path prefix
on-disk with no-follow semantics (e.g., use lstat/fstatat with
AT_SYMLINK_NOFOLLOW or equivalent) relative to the extraction dir FD and treat
any prefix that is a symlink as a traversal => return true. Update the function
(path_traverses_created_symlink) signature or add a new helper to accept the
target dir FD (or a closure to perform no-follow stat) and replace the callers
that currently rely only on created_symlinks (the checks around mkdirat/openat
where created_symlinks is passed) so they invoke the on-disk ancestor check
before creating directories/files. Ensure existing created_symlinks checks
remain for symlinks created earlier in this run, but always perform the on-disk
no-follow ancestor walk to close the vulnerability.
In `@src/runtime/node/node_crypto_binding.rs`:
- Around line 669-675: The allocation vec![0u8; size] inside the JobCtx creation
is fallible and can OOM panic; change it to a fallible allocation using
Vec::try_reserve_exact (or try_reserve) and handle the Err by returning a JS OOM
error instead of letting the process abort. Concretely, create a mutable
Vec<u8>, call try_reserve_exact(size) and if it returns Err convert/propagate
that into the function's JS OOM error path, otherwise resize the vec to size and
assign Some(scratch) to JobCtx.scratch; update the randomFill path that
constructs JobCtx to use this pattern instead of vec![0u8; size].
In `@src/sql_jsc/postgres/PostgresSQLConnection.rs`:
- Around line 2730-2744: Reject overly long base64-encoded salts before calling
bun_base64::decode_alloc to avoid large allocations: in the SASL handling code
that currently calls bun_base64::decode_alloc(cont.s.slice()) and then checks
server_salt_decoded_base64.len(), first check cont.s.slice().len() against a
safe encoded limit (compute encoded_max for decoded limit 1024, e.g.
ceil(4/3*1024)+padding) and return AnyPostgresError::InvalidMessage if the
encoded length exceeds that limit; only then call bun_base64::decode_alloc and
keep the existing decoded-length sanity check on server_salt_decoded_base64 and
the debug message.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 3bf516d6-b4c5-4a34-9f66-985e53caa19c
📒 Files selected for processing (29)
src/dotenv/env_loader.rssrc/http/HTTPContext.rssrc/http/h2_client/ClientSession.rssrc/http/lib.rssrc/http_jsc/websocket_client.rssrc/install/bin.rssrc/install/dependency.rssrc/install/lockfile/Package.rssrc/install/npm.rssrc/install/windows-shim/bun_shim_impl.rssrc/libarchive/lib.rssrc/parsers/toml.rssrc/parsers/yaml.rssrc/runtime/api/BunObject.rssrc/runtime/cli/bunx_command.rssrc/runtime/crypto/PBKDF2.rssrc/runtime/node/node_crypto_binding.rssrc/runtime/server/RequestContext.rssrc/runtime/server/server_body.rssrc/runtime/shell/states/Expansion.rssrc/runtime/socket/Listener.rssrc/runtime/socket/udp_socket.rssrc/runtime/webcore/Blob.rssrc/runtime/webcore/Crypto.rssrc/runtime/webcore/fetch.rssrc/s3_signing/credentials.rssrc/shell_parser/parse.rssrc/sql_jsc/mysql/MySQLConnection.rssrc/sql_jsc/postgres/PostgresSQLConnection.rs
|
Found 3 issues this PR may fix:
🤖 Generated with Claude Code |
…s, http Bounds checks and clean error returns replacing panics on untrusted input (lockfile, archive, lexer); reentrancy/lifetime fixes in env-proxy borrow, websocket teardown, and editor-context rollback; allocation caps for brace expansion and TOML key recursion; close-reason length clamp; fd cleanup in fetch fallback; chmod umask logic; SNI/CRLF/path-traversal input sanitization; IPv6 loopback exact-match; SCRAM/PBKDF2 parameter floors.
The decoded-length check ran after the allocation, so a malicious or MITM'd Postgres server could still force a large transient allocation before InvalidMessage was returned. Check the wire (base64) length first; 2048 chars is a generous cap for the 1024-byte decoded limit.
… OOM The resolution-tag check was a no-op: ResolutionTag is a #[repr(u8)] enum with all 11 variants enumerated in the matches!, so the compiler proves the negated check is always false and removes it. Worse, copying an out-of-range byte into ResolutionType.tag and reading it as the typed enum is already UB. Validate the raw u8 in the stream buffer before the copy_from_slice into the typed column. Also use try_reserve_exact + throw_out_of_memory for the randomFill scratch buffer instead of vec![0u8; size], which aborts on OOM. The 3-arg overload defaults size to the full remaining ArrayBuffer length which can be multi-GiB.
d5e15eb to
1c78d7d
Compare
There was a problem hiding this comment.
Actionable comments posted: 5
♻️ Duplicate comments (2)
src/sql_jsc/postgres/PostgresSQLConnection.rs (1)
2730-2749:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winTighten the pre-decode salt cap to the actual 1 KiB limit.
2048still letsdecode_allocreserve about 1536 decoded bytes before the later> 1024check rejects it, so the pre-allocation guard is still bypassable. If the decoded ceiling is 1024 bytes, the encoded ceiling here should be the corresponding base64 maximum (1368), not a generous round-up.🔧 Suggested fix
- // Bound the *encoded* length before allocating: a - // malicious/MITM'd server can otherwise force an - // arbitrarily large decode_alloc here. 1024 decoded - // bytes ≈ 1368 base64 chars; round up generously. + // Bound the *encoded* length before allocating: a + // malicious/MITM'd server can otherwise force a + // larger-than-allowed decode_alloc here. + const MAX_SCRAM_SALT_LEN: usize = 1024; + const MAX_SCRAM_SALT_B64_LEN: usize = + ((MAX_SCRAM_SALT_LEN + 2) / 3) * 4; let server_salt_b64 = cont.s.slice(); - if server_salt_b64.is_empty() || server_salt_b64.len() > 2048 { + if server_salt_b64.is_empty() + || server_salt_b64.len() > MAX_SCRAM_SALT_B64_LEN + { debug!( "SASLContinue encoded salt length out of range: {}", server_salt_b64.len() ); return Err(AnyPostgresError::InvalidMessage); } @@ - if server_salt_decoded_base64.is_empty() - || server_salt_decoded_base64.len() > 1024 + if server_salt_decoded_base64.is_empty() + || server_salt_decoded_base64.len() > MAX_SCRAM_SALT_LEN {🤖 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/sql_jsc/postgres/PostgresSQLConnection.rs` around lines 2730 - 2749, The pre-decode bound for the SASL server salt is too loose (uses 2048) allowing decode_alloc to over-allocate; tighten the check in the SASLContinue handling by replacing the server_salt_b64 length cap (the if that inspects server_salt_b64.is_empty() || server_salt_b64.len() > 2048) with the correct base64-encoded ceiling for 1024 decoded bytes (use 1368), so the check around cont.s.slice()/server_salt_b64 prevents decode_alloc from reserving >1024 bytes before bun_base64::decode_alloc and keeps the subsequent server_salt_decoded_base64.len() > 1024 check meaningful.src/http_jsc/websocket_client.rs (1)
1322-1344:⚠️ Potential issue | 🟠 Major | ⚡ Quick winClamp close reasons to 123 bytes, not 125.
The 125-byte WebSocket control-frame limit includes the 2-byte status code, so the reason budget is only 123 bytes. The current
min(125)/> 125checks still allow invalid close frames with2 + reason_len > 125.🔧 Suggested fix
fn send_close_with_body(&mut self, code: u16, body: Option<&mut [u8; 125]>, body_len: usize) { - let body_len = body_len.min(125); + let body_len = body_len.min(123); log!("Sending close with code {}", code); @@ - if wrote_len > 125 { + if wrote_len > 123 { break 'inner; }Also applies to: 1668-1674
🤖 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 1322 - 1344, The send_close_with_body function incorrectly allows a 125-byte reason (plus 2-byte status) which violates the 125-byte control-frame limit; change the clamping of the reason budget from body_len.min(125) to body_len.min(123) (and any other similar clamps) so that body_len + 2 <= 125, and ensure the header length calculation still uses (body_len + 2) truncated to 7 bits when calling header.set_len; update all analogous occurrences (e.g., the second close-frame emitter around the later block) to use the same 123-byte cap to prevent constructing invalid close frames.
🤖 Prompt for all review comments with 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.
Inline comments:
In `@src/http/HTTPContext.rs`:
- Around line 926-934: The pending-H2 enqueue path can coalesce strict callers
onto a lax PendingConnect because PendingConnect doesn't record
reject_unauthorized; update PendingConnect to store the initiating client's
flags.reject_unauthorized at creation (when constructing PendingConnect around
line ~1062), then add the same strictness guard when enqueuing onto
pending_h2_connects (check pc.matches(hostname, port, cfg_nn) &&
(!client.flags.reject_unauthorized ||
pc.established_with_reject_unauthorized_or_flag) — i.e. compare
client.flags.reject_unauthorized against the new PendingConnect field) so strict
callers never join a pending connect started with reject_unauthorized=false.
In `@src/install/bin.rs`:
- Around line 783-789: The current ensure_umask uses separate load/store
allowing multiple threads to run the umask(0)/umask(prev) sequence concurrently;
make it race-safe by performing an atomic compare-and-swap on HAS_SET_UMASK so
only the thread that successfully swaps false->true runs
sys::umask(0)/sys::umask(prev) and writes UMASK (use compare_exchange with
appropriate Acquire/Release orderings), and have other threads spin/wait (e.g.,
loop with an Acquire load) until HAS_SET_UMASK is true and UMASK is published so
they read the correct cached value; reference functions/vars: ensure_umask,
HAS_SET_UMASK, UMASK, and sys::umask.
- Around line 1650-1655: The new guard that checks unscoped_package_name.len()
against self.abs_dest_buf.len().saturating_sub(dest_off) and sets self.err =
Some(bun_core::err!("NameTooLong")) then returns is added for Tag::File but
missing for Tag::NamedFile and Tag::Map; add the exact same capacity check and
early return before any writes/copies into self.abs_dest_buf in the branches
handling Tag::NamedFile and Tag::Map (use the same variables:
unscoped_package_name, self.abs_dest_buf, dest_off, and set self.err to
bun_core::err!("NameTooLong") then return) so long bin names cannot panic during
unlink.
In `@src/install/windows-shim/bun_shim_impl.rs`:
- Around line 1149-1157: The shebang-related metadata parity and buffer-bounds
checks must be moved earlier and strengthened: in the metadata validation block
(around where shebang_arg_len_u8 and shebang_bin_path_len_bytes are parsed) add
parity checks for UTF-16 bytes (reject if (shebang_arg_len_u8 & 1) != 0 or
(shebang_bin_path_len_bytes & 1) != 0 and return LauncherMode::fail(...,
FailReason::InvalidShimBounds)); relocate the existing BUF2 capacity check that
compares sizes against BUF2_U16_LEN * 2 so it runs before any writes/copies
(i.e., before the operations at/around where the first copy occurs near the
previous line 1125); and change the raw store through write_ptr so it uses
write_ptr.write_unaligned(0) instead of unsafe deref assignment to avoid UB on
misaligned pointers—keep the same failure path (LauncherMode::fail with
FailReason::InvalidShimBounds) when checks fail.
In `@src/runtime/webcore/fetch.rs`:
- Around line 1747-1750: The unconditional opened_fd.close() is fragile; wrap
opened_fd in a scoped RAII guard (e.g., use scopeguard::guard or the project's
equivalent) immediately after the fd is created/duplicated so that the guard
calls opened_fd.close() in its drop handler; locate the code around opened_fd
and read_file (where opened_fd is passed as an Fd to read_file) and replace the
manual close() call with a scopeguard::guard that owns a closure calling
opened_fd.close(), ensuring the fd is always closed even on early returns or
panics.
---
Duplicate comments:
In `@src/http_jsc/websocket_client.rs`:
- Around line 1322-1344: The send_close_with_body function incorrectly allows a
125-byte reason (plus 2-byte status) which violates the 125-byte control-frame
limit; change the clamping of the reason budget from body_len.min(125) to
body_len.min(123) (and any other similar clamps) so that body_len + 2 <= 125,
and ensure the header length calculation still uses (body_len + 2) truncated to
7 bits when calling header.set_len; update all analogous occurrences (e.g., the
second close-frame emitter around the later block) to use the same 123-byte cap
to prevent constructing invalid close frames.
In `@src/sql_jsc/postgres/PostgresSQLConnection.rs`:
- Around line 2730-2749: The pre-decode bound for the SASL server salt is too
loose (uses 2048) allowing decode_alloc to over-allocate; tighten the check in
the SASLContinue handling by replacing the server_salt_b64 length cap (the if
that inspects server_salt_b64.is_empty() || server_salt_b64.len() > 2048) with
the correct base64-encoded ceiling for 1024 decoded bytes (use 1368), so the
check around cont.s.slice()/server_salt_b64 prevents decode_alloc from reserving
>1024 bytes before bun_base64::decode_alloc and keeps the subsequent
server_salt_decoded_base64.len() > 1024 check meaningful.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 5f8af78b-bd2f-4032-95a5-d9f5a1052388
📒 Files selected for processing (29)
src/dotenv/env_loader.rssrc/http/HTTPContext.rssrc/http/h2_client/ClientSession.rssrc/http/lib.rssrc/http_jsc/websocket_client.rssrc/install/bin.rssrc/install/dependency.rssrc/install/lockfile/Package.rssrc/install/npm.rssrc/install/windows-shim/bun_shim_impl.rssrc/libarchive/lib.rssrc/parsers/toml.rssrc/parsers/yaml.rssrc/runtime/api/BunObject.rssrc/runtime/cli/bunx_command.rssrc/runtime/crypto/PBKDF2.rssrc/runtime/node/node_crypto_binding.rssrc/runtime/server/RequestContext.rssrc/runtime/server/server_body.rssrc/runtime/shell/states/Expansion.rssrc/runtime/socket/Listener.rssrc/runtime/socket/udp_socket.rssrc/runtime/webcore/Blob.rssrc/runtime/webcore/Crypto.rssrc/runtime/webcore/fetch.rssrc/s3_signing/credentials.rssrc/shell_parser/parse.rssrc/sql_jsc/mysql/MySQLConnection.rssrc/sql_jsc/postgres/PostgresSQLConnection.rs
- bunx: accept Bun's own .bin symlinks during cache trust check (previous S_IFREG-only lstat rejected every legitimate cache hit and forced a reinstall on each invocation); follow once and require a uid-owned regular-file target. - websocket: cap close-frame reason at 123 bytes so the 2-byte status code fits the 125-byte control-frame payload budget. - bin linker: make ensure_umask single-winner via compare_exchange; add the same NameTooLong bounds guard to the NamedFile and Map unlink arms. - windows shim: reject odd UTF-16 byte lengths in shebang metadata, move the BUF2 capacity check before the first write into buf2, and store the NUL terminator with write_unaligned. - http: record reject_unauthorized on PendingConnect and apply the same strictness guard when coalescing onto a pending h2 connect, matching the active-session path. - fetch: wrap the read-file fd in an RAII guard to prevent future leaks.
…ose callbacks
- shell_parser: peek_any_ifclausetok and IfClauseTok::from_tok now require a
trailing delimiter (mirroring match_if_clausetok and the entry gate), so
fi$x / else$x / elif$x inside an if body stay ordinary command text and
fall through to a recoverable ParseError instead of the panic in
expect_if_clause_text_token.
- socket: on_close and handle_connect_error capture the Handlers pointer
before invoking the user callback. A synchronous reconnect from inside the
callback (Bun.connect({ socket: this })) repoints self.handlers at a fresh
allocation while the old one is still held by the in-flight Scope; the
deferred mark_inactive previously re-read the cell, underflowing the new
Handlers' active_connections and orphaning the old one with its protect()'d
callbacks. The cleanup guard now compares the cell against the captured
pointer: unchanged means the normal idle teardown runs; changed means it
only clears IS_ACTIVE and releases the lifecycle ref on the captured
Handlers, leaving the in-flight reconnect's this_value/poll_ref intact.
scope.exit() and the connectError scope guard get the same captured-pointer
comparison before nulling self.handlers.
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with 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.
Inline comments:
In `@src/runtime/cli/bunx_command.rs`:
- Around line 926-970: The trust check that uses bun_sys::lstat/stat to validate
ownership (the lstat_ok/stat_ok logic that sets trustworthy, flips
do_cache_bust, and breaks 'try_run_existing) is only applied to the initial
probe; apply the same validation to the alternate resolution/execution path (the
codepath that uses dest_or_cache2 / the later resolution of
`${bunx_cache_dir}/node_modules/.bin/<real-bin>`) so any resolved cached binary
is rejected unless owned by the current uid. Refactor the lstat/stat logic into
a small helper (e.g., is_trustworthy(destination, uid) or
validate_cached_binary) that encapsulates the existing lstat_ok/stat_ok checks
and symlink-follow behavior, then call that helper before allowing execution in
both the initial check and the dest_or_cache2 path and ensure you set
do_cache_bust / bail out the same way when it returns false.
In `@src/runtime/socket/socket_body.rs`:
- Around line 1506-1551: The guard that clears Flags::IS_ACTIVE for the captured
handlers can clear the shared IS_ACTIVE bit for a newly opened generation;
change the cleanup to only clear IS_ACTIVE when the current this_ref.handlers
still points to the captured_handlers (i.e., require
this_ref.handlers.get().map(|n| n.as_ptr()) == Some(h) before calling
update_flags to remove IS_ACTIVE); otherwise, do not touch this_ref.flags (but
still call Handlers::mark_inactive(h) for the captured h if the VM is not
shutting down) so the new generation's IS_ACTIVE bit and mark_active/on_open
logic remain correct.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 30d1197c-dd9b-4e20-9006-bbc3115bfef0
📒 Files selected for processing (9)
src/http/HTTPContext.rssrc/http/h2_client/PendingConnect.rssrc/http_jsc/websocket_client.rssrc/install/bin.rssrc/install/windows-shim/bun_shim_impl.rssrc/runtime/cli/bunx_command.rssrc/runtime/socket/socket_body.rssrc/runtime/webcore/fetch.rssrc/shell_parser/parse.rs
| Self::ssl_ctx_mut(ctx).release_socket( | ||
| socket, | ||
| self.flags.did_have_handshaking_error && !self.flags.reject_unauthorized, | ||
| self.flags.reject_unauthorized, |
There was a problem hiding this comment.
🟡 nit: These three release_socket call sites pass self.flags.reject_unauthorized (the current request's flag) as established_with_reject_unauthorized, so a strict-established socket that gets reused by a lax client and re-pooled is downgraded to established_with_reject_unauthorized=false — a subsequent strict client is then needlessly blocked at HTTPContext.rs:805 from reusing a socket whose hostname was validated. Contrast ClientSession.rs:903, which correctly carries self.established_with_reject_unauthorized (set once at create). This errs conservative (extra TLS handshake, not a security hole), so it's just a keepalive-efficiency nit; the proper fix is to thread the original handshake's flag through ExistingSocket → HTTPClient and pass it back here instead of re-deriving from the current request.
Extended reasoning...
What the bug is
Fix #83 adds an established_with_reject_unauthorized: bool field to PooledSocket so that existing_socket() can refuse to hand a strict (rejectUnauthorized: true) caller a socket whose TLS handshake never ran checkServerIdentity. The field is populated from the new release_socket() parameter. ClientSession (h2) does this correctly: it stores established_with_reject_unauthorized once at creation time from the establishing client (ClientSession.rs:241) and passes that stored value back to release_socket (ClientSession.rs:903). But the three HTTP/1.1 release_socket() call sites in lib.rs (lines 2318, 3595, 3817) instead pass self.flags.reject_unauthorized — the current request's flag, not the original handshake's strictness.
The code path that triggers it
For a fresh connection, the current request is the establishing request, so self.flags.reject_unauthorized happens to be correct. The divergence appears on the second pool round-trip:
- Strict client A establishes a TLS socket.
on_handshakerunscheck_server_identity. A finishes and callsrelease_socket(..., established_with_reject_unauthorized = A.flags.reject_unauthorized = true, ...). Pooled with the flag = true. ✓ - Lax client B (
rejectUnauthorized: false) callsexisting_socket(). The guard at HTTPContext.rs:797–808 only blocks strict→lax-socket, not lax→strict-socket, so B is handed the socket viaExistingSocket { socket, tunnel, h2_session }. ExistingSocket(HTTPContext.rs:271–278) carries only{ socket, tunnel, h2_session }— it does not carryestablished_with_reject_unauthorizedout of the pool. The provenance is dropped on the floor.- B finishes and calls
release_socket(..., established_with_reject_unauthorized = B.flags.reject_unauthorized = **false**, ...). ThePooledSocketis now marked as if it were lax-established. - Strict client C calls
existing_socket(). The guard at HTTPContext.rs:805 seesreject_unauthorized && !socket.established_with_reject_unauthorized→continue. C opens a fresh TLS connection even though the pooled socket's hostname was validated in step 1.
Why existing code doesn't prevent it
There is no field on HTTPClient that records the strictness of the handshake that produced the socket it's currently using; flags.reject_unauthorized is the request's policy, not the connection's provenance. And ExistingSocket doesn't carry the flag through the pool→client→pool round-trip, so once a lax client touches the socket the original strictness is unrecoverable. Contrast ClientSession, which is a per-connection object and stores the flag once at create().
Step-by-step proof
Consider a sequence of three fetch() calls to https://api.example.com over HTTP/1.1 keepalive:
fetch(url, { tls: { rejectUnauthorized: true } })— fresh connect,checkServerIdentityruns, request completes,release_socketat lib.rs:3595 pools it withestablished_with_reject_unauthorized = self.flags.reject_unauthorized = true.fetch(url, { tls: { rejectUnauthorized: false } })—existing_socket()finds the pooled entry;reject_unauthorized=falseso the guard at line 805 is skipped; socket reused. Request completes,release_socketat lib.rs:3595 pools it withestablished_with_reject_unauthorized = self.flags.reject_unauthorized = **false**.fetch(url, { tls: { rejectUnauthorized: true } })—existing_socket()finds the pooled entry;reject_unauthorized=true && !socket.established_with_reject_unauthorized→continue. The socket is left in the pool and a fresh TLS handshake is performed, even though the pooled socket's certificate was fully validated in step 1.
Impact
This errs on the safe side — it never lets a strict caller reuse an actually-unverified socket, so it is not a security hole. It is a keepalive-efficiency regression introduced by fix #83: in workloads that interleave strict and lax requests to the same origin (e.g. a default-strict app that occasionally probes with rejectUnauthorized: false), a strictly-validated socket is permanently "downgraded" after any lax reuse and can never serve a strict request again, costing one extra TLS handshake per such interleaving. This is a narrow scenario and the cost is bounded, hence nit severity — not a blocker for this PR.
How to fix
Thread the provenance through the reuse cycle the same way ClientSession does:
- Add
established_with_reject_unauthorized: booltoExistingSocketand populate it from thePooledSocketinexisting_socket()beforepending_sockets.put(). - Carry it on
HTTPClient(or instate) when an existing socket is adopted; for a fresh connect, set it fromself.flags.reject_unauthorizedat handshake time (matching whatClientSession::createdoes at line 241). - Pass that stored value, not
self.flags.reject_unauthorized, at the threelib.rsrelease_socketcall sites.
This is more invasive than the rest of fix #83 and is fine to defer to a follow-up.
There was a problem hiding this comment.
Agreed on the analysis — this can downgrade a strictly-handshaked pooled socket after a lax client touches it, costing a redundant TLS handshake on the next strict reuse. It never lets a strict caller reuse an unverified socket, so it stays conservative.
Threading the original handshake's flag through ExistingSocket → HTTPClient and back to release_socket (mirroring ClientSession's established_with_reject_unauthorized field) is the right shape, but it touches the pool/client/release surface across three files. Deferring to a follow-up so this PR stays focused on the security-relevant pieces.
Extract the lstat/uid/symlink-follow ownership validation into BunxCommand::is_trusted_cached_binary and call it before every Run::run_binary site that executes from the world-writable bunx cache: - the initial bin-name probe (already had the inline check; now uses the shared helper) - the dest_or_cache2 path that resolves the package's *real* bin name from package.json — packages whose bin name differs from the package name went through this path with no ownership check at all - both post-install probes, which can be raced by another local user planting a uid-mismatched entry between install and exec Also gate the deferred mark_inactive() in handle_connect_error on the pre-callback Handlers pointer: onConnectError can synchronously re-enter connect() via the UnixOrHost::Fd branch and reach on_open()/mark_active() for a fresh Handlers allocation; without the gate the deferred cleanup would tear down that newly activated connection. When no reconnect happened the socket never opened, so IS_ACTIVE is unset and the call was already a no-op.
| let mut i: usize = 0; | ||
| let mut it = self.map.iterator(); | ||
| while let Some(pair) = it.next() { | ||
| if i + pair.key_ptr.len() + 7 >= result.len() { |
There was a problem hiding this comment.
The file is full of magic numbers +2 +5 +7.
Can be replaced with meaningful constants?
…s, http (oven-sh#30722) Hardens 36 reachable security findings across the runtime, package manager, parsers, HTTP client/server, and SQL drivers. Three auto-applied fixes (oven-sh#61 SSL exception leak, oven-sh#68 YAML merge dedup, oven-sh#104 archive overwrite precheck) were dropped: oven-sh#61 introduced a use-after-free, oven-sh#68 stored a non-`'static` byte view in a `'static` field, and oven-sh#104 added dead gating that did not close the traversal. ### Memory safety / lifetime - oven-sh#2 — Dangling proxy slice across reentrant JS getter — copy `process.env` proxy href to an owned `Vec` before reentrant getters can free the env map (`Blob.rs`) - oven-sh#15 — Rollback restores dangling editor name pointer — preserve and restore `name_storage` on `detect_editor` failure (`BunObject.rs`) - oven-sh#81 — Reentrant reconnect frees live handlers — only free previous handlers when `active_connections == 0` (`Listener.rs`) - oven-sh#110 — Async randomFill uses stale resizable buffer pointer — fill a worker-owned scratch buffer; copy back on the JS thread after re-validating bounds (`node_crypto_binding.rs`) - oven-sh#119 — Null zero-length slice UB in DOMJIT fast path — use `ffi::slice` which tolerates `(null, 0)` (`Crypto.rs`) - oven-sh#67 — Raw serialization reads struct padding bytes — add explicit `_padding_*` fields with `offset_of!` proof asserts (`npm.rs`) - oven-sh#74 — TLS rejection path leaks websocket refcount — route SSL/auth failures through `self.fail()` which clears `outgoing_websocket` (`websocket_client.rs`) - oven-sh#108 — FD-backed fetch body leaks duplicated descriptor — close `opened_fd` unconditionally after `read_file` (`fetch.rs`) ### Untrusted-input bounds / panics - oven-sh#10 — Invalid lockfile tag causes panic DoS — replace `unreachable!()` with logged error + `Tag::Uninitialized` (`dependency.rs`) - oven-sh#20 — Unchecked lockfile string offsets cause OOB slice — bounds-check non-inline `String` pointers against `ctx.buffer` (`dependency.rs`) - oven-sh#91 — Panic on unvalidated resolution tag — validate `ResolutionTag` discriminants on lockfile load (`Package.rs`) - oven-sh#24 — Unwrap panic on unexpected 304 response — return `UnexpectedNotModified` when no cached manifest exists (`npm.rs`) - oven-sh#44 — UDP port getter unwrap panic on transient state — return `undefined` when `socket` is `None` (`udp_socket.rs`) - oven-sh#36 — Close reason length mismatch causes panic — clamp `body_len` to 125 and bail on overlong UTF-8 transcode (`websocket_client.rs`) - oven-sh#100 — Windows pipe name length panic DoS — `debug_assert` → real bounds check (`Listener.rs`) - oven-sh#60 / oven-sh#111 — Windows shim stack buffer overflows — bounds-check argument and filename writes against `BUF1_LEN`/`BUF2_U16_LEN` before `copy_nonoverlapping` (`bun_shim_impl.rs`) - oven-sh#76 / oven-sh#101 — Unchecked bin name/entry name copies — bounds-check before slicing into `abs_dest_buf` (`bin.rs`) - oven-sh#79 — `if` keyword misclassification causes parser panic — require a delimiter token before classifying (`shell_parser/parse.rs`) - oven-sh#32 — Bounds check occurs after UTF-16 write — pre-flight key/value lengths before `convert_utf8_to_utf16_in_buffer` (`env_loader.rs`) - oven-sh#95 — PBKDF2 digest validation allows panic-only algorithm — reject digests with no `EVP_MD` (`PBKDF2.rs`) ### DoS / resource caps - oven-sh#17 — Unbounded recursion on deep TOML dotted keys — cap dotted-key segments at 512 (`toml.rs`) - oven-sh#39 — Unbounded brace expansion preallocation — cap expansion count at 65536 in `Bun.$` and `Bun.braces` (`BunObject.rs`, `Expansion.rs`) - oven-sh#31 — SCRAM PBKDF2 parameters accepted from server — clamp iteration count to `[4096, 10M]`, salt length to `[1, 1024]` (`PostgresSQLConnection.rs`) ### Auth / injection / traversal - oven-sh#19 — Cleartext password sent after TLS downgrade — require `TLSStatus::SslOk`, not just `ssl_mode != Disable` (`MySQLConnection.rs`) - oven-sh#83 — Strict TLS request reuses lax-verified pooled socket — track `established_with_reject_unauthorized` and refuse pool reuse for strict callers (`HTTPContext.rs`, `lib.rs`, `ClientSession.rs`) - oven-sh#73 — IPv6 loopback prefix auth bypass — exact-match `::1` instead of `starts_with` (`server_body.rs`) - oven-sh#56 — Unsanitized filename injects response headers — reject `\r`/`\n`/NUL/`"` in `content-disposition` filenames (`RequestContext.rs`) - oven-sh#43 — Missing CRLF checks for signed host/auth headers — also validate `region`, `access_key_id`, and `host` (`s3_signing/credentials.rs`) - oven-sh#34 — Bucket slash enables S3 host confusion — reject buckets containing `/` (`s3_signing/credentials.rs`) - oven-sh#25 — Lexical symlink check permits extraction escape — track created symlinks during extraction and refuse paths that traverse them (`libarchive/lib.rs`) - oven-sh#71 — bunx executes untrusted temp-cache binary — `lstat` cached binary; refuse symlinks and other-uid files (`bunx_command.rs`) ### Permission hygiene - oven-sh#6 — Bin target chmod always sets mode 0777 — `0o777 & !umask` instead of `umask | 0o777` (`bin.rs`) - oven-sh#23 — Process umask cleared and never restored — restore umask after probing it in `ensure_umask` (`bin.rs`) ### Parser correctness - oven-sh#22 — Sign-prefixed scalar misparsed as infinity — fix Zig→Rust `&&`/`||` precedence transliteration (`yaml.rs`)
Hardens 36 reachable security findings across the runtime, package manager, parsers, HTTP client/server, and SQL drivers. Three auto-applied fixes (#61 SSL exception leak, #68 YAML merge dedup, #104 archive overwrite precheck) were dropped: #61 introduced a use-after-free, #68 stored a non-
'staticbyte view in a'staticfield, and #104 added dead gating that did not close the traversal.Memory safety / lifetime
process.envproxy href to an ownedVecbefore reentrant getters can free the env map (Blob.rs)bun bun(broke after threading) #15 — Rollback restores dangling editor name pointer — preserve and restorename_storageondetect_editorfailure (BunObject.rs)github:dependencies #81 — Reentrant reconnect frees live handlers — only free previous handlers whenactive_connections == 0(Listener.rs)node_crypto_binding.rs)ffi::slicewhich tolerates(null, 0)(Crypto.rs)_padding_*fields withoffset_of!proof asserts (npm.rs)self.fail()which clearsoutgoing_websocket(websocket_client.rs)asyncis missing from function #108 — FD-backed fetch body leaks duplicated descriptor — closeopened_fdunconditionally afterread_file(fetch.rs)Untrusted-input bounds / panics
unreachable!()with logged error +Tag::Uninitialized(dependency.rs)globalwithglobalThis#20 — Unchecked lockfile string offsets cause OOB slice — bounds-check non-inlineStringpointers againstctx.buffer(dependency.rs)ResolutionTagdiscriminants on lockfile load (Package.rs)UnexpectedNotModifiedwhen no cached manifest exists (npm.rs)undefinedwhensocketisNone(udp_socket.rs)body_lento 125 and bail on overlong UTF-8 transcode (websocket_client.rs)debug_assert→ real bounds check (Listener.rs)BUF1_LEN/BUF2_U16_LENbeforecopy_nonoverlapping(bun_shim_impl.rs)bun install#101 — Unchecked bin name/entry name copies — bounds-check before slicing intoabs_dest_buf(bin.rs)ifkeyword misclassification causes parser panic — require a delimiter token before classifying (shell_parser/parse.rs)convert_utf8_to_utf16_in_buffer(env_loader.rs)EVP_MD(PBKDF2.rs)DoS / resource caps
// commentafter tagName and before closing tag is broken #17 — Unbounded recursion on deep TOML dotted keys — cap dotted-key segments at 512 (toml.rs)Bun.$andBun.braces(BunObject.rs,Expansion.rs)npm installcommand before running bun #31 — SCRAM PBKDF2 parameters accepted from server — clamp iteration count to[4096, 10M], salt length to[1, 1024](PostgresSQLConnection.rs)Auth / injection / traversal
TLSStatus::SslOk, not justssl_mode != Disable(MySQLConnection.rs)workspace:dependencies #83 — Strict TLS request reuses lax-verified pooled socket — trackestablished_with_reject_unauthorizedand refuse pool reuse for strict callers (HTTPContext.rs,lib.rs,ClientSession.rs)::1instead ofstarts_with(server_body.rs)npm installto Bun's CLI #56 — Unsanitized filename injects response headers — reject\r/\n/NUL/"incontent-dispositionfilenames (RequestContext.rs)region,access_key_id, andhost(s3_signing/credentials.rs)"\0"#34 — Bucket slash enables S3 host confusion — reject buckets containing/(s3_signing/credentials.rs)bun bun#25 — Lexical symlink check permits extraction escape — track created symlinks during extraction and refuse paths that traverse them (libarchive/lib.rs)lstatcached binary; refuse symlinks and other-uid files (bunx_command.rs)Permission hygiene
0o777 & !umaskinstead ofumask | 0o777(bin.rs)ensure_umask(bin.rs)Parser correctness
&&/||precedence transliteration (yaml.rs)