Skip to content
Merged
Show file tree
Hide file tree
Changes from 86 commits
Commits
Show all changes
96 commits
Select commit Hold shift + click to select a range
1df973a
install: tighten package name validation
Jarred-Sumner May 24, 2026
7009b08
bunx: tighten cache directory validation
Jarred-Sumner May 24, 2026
6ab45f2
upgrade: tighten staging directory handling
Jarred-Sumner May 24, 2026
cd59a05
install: tighten default trusted dependency matching
Jarred-Sumner May 24, 2026
cc2870f
yaml: bound merge key handling
Jarred-Sumner May 24, 2026
a642d0c
install: tighten trusted dependency matching
Jarred-Sumner May 24, 2026
234753e
install: tighten symlink target validation
Jarred-Sumner May 24, 2026
3fed196
install: tighten manifest URL validation
Jarred-Sumner May 24, 2026
ba9a59d
untar: bound entry path handling
Jarred-Sumner May 24, 2026
6de1701
semver: bound range list traversal
Jarred-Sumner May 24, 2026
fd6769b
valkey: bound protocol line handling
Jarred-Sumner May 24, 2026
9e8af4d
bake: tighten console log forwarding
Jarred-Sumner May 24, 2026
b3e2ebd
transpiler cache: tighten cache directory fallback
Jarred-Sumner May 24, 2026
fc00c9e
url: tighten origin length handling
Jarred-Sumner May 24, 2026
c7ffc9e
bake: tighten dev server host validation
Jarred-Sumner May 24, 2026
9f6ad15
install: tighten bin link permission handling
Jarred-Sumner May 24, 2026
bf8f387
undici: tighten redirect limit handling
Jarred-Sumner May 24, 2026
8b49ba4
install: tighten legacy lockfile trust entries
Jarred-Sumner May 24, 2026
8616141
node:url: tighten search formatting
Jarred-Sumner May 24, 2026
bdd9ff7
test: add regression coverage for input validation changes
Jarred-Sumner May 24, 2026
67ddf8c
sql/postgres: tighten result field handling
Jarred-Sumner May 24, 2026
cfe5805
sql/postgres: tighten startup message handling
Jarred-Sumner May 24, 2026
7410db9
socket: tighten peer identity validation
Jarred-Sumner May 24, 2026
29b81ac
cookie: tighten name parsing
Jarred-Sumner May 24, 2026
f36d9b2
sqlite: tighten statement lifecycle handling
Jarred-Sumner May 24, 2026
15f84d2
node:cluster: tighten ipc message validation
Jarred-Sumner May 24, 2026
c5082ab
sql: tighten tls option handling
Jarred-Sumner May 24, 2026
a72b1f1
node:crypto: tighten digest length validation
Jarred-Sumner May 24, 2026
960c79a
tls: tighten trust store handling
Jarred-Sumner May 24, 2026
37a5323
tls: bound socket state handling
Jarred-Sumner May 24, 2026
fe91ea0
sqlite: tighten array binding handling
Jarred-Sumner May 24, 2026
14a83ba
sqlite: bound file control argument handling
Jarred-Sumner May 24, 2026
03cb3bd
sql: tighten request queue handling
Jarred-Sumner May 24, 2026
12d70fb
webcrypto: bound input length handling
Jarred-Sumner May 24, 2026
07e1df6
node:crypto: tighten cipher output handling
Jarred-Sumner May 24, 2026
21977ef
socket: tighten hostname validation
Jarred-Sumner May 24, 2026
eee777d
sql: tighten connection option validation
Jarred-Sumner May 24, 2026
507faac
dns: tighten lookup coalescing
Jarred-Sumner May 24, 2026
0978baa
test: add regression coverage for input validation changes
Jarred-Sumner May 24, 2026
091cbae
markdown: tighten raw HTML text escaping
Jarred-Sumner May 24, 2026
4627248
http2: reorder frame queue callback handling
Jarred-Sumner May 24, 2026
175d6e1
server: tighten transfer-encoding header validation
Jarred-Sumner May 24, 2026
eb28c00
node:http: normalize request path handling
Jarred-Sumner May 24, 2026
9de3ccc
s3: tighten upload id and signing input validation
Jarred-Sumner May 24, 2026
f305d37
http2: tighten outgoing header value validation
Jarred-Sumner May 24, 2026
85dcde2
http2: tighten inbound header field validation
Jarred-Sumner May 24, 2026
64bed55
http2: bound session stream accounting
Jarred-Sumner May 24, 2026
0b213cc
server: bound file response stream ref handling
Jarred-Sumner May 24, 2026
05ab941
markdown: tighten tag name delimiter handling
Jarred-Sumner May 24, 2026
99d3698
http2: tighten client session reuse keying
Jarred-Sumner May 24, 2026
37d977a
node:http: bound parser input buffer lifetime during execute
Jarred-Sumner May 24, 2026
da2e4b1
http3: tighten response header field validation
Jarred-Sumner May 24, 2026
7838b7c
md: bound autolink suffix trimming
Jarred-Sumner May 24, 2026
b0bee8b
md: tighten block header discriminant handling
Jarred-Sumner May 24, 2026
dec7402
http2: tighten response header name validation
Jarred-Sumner May 24, 2026
307f85c
markdown: tighten terminal text output filtering
Jarred-Sumner May 24, 2026
30b0785
test: add regression coverage for input validation changes
Jarred-Sumner May 24, 2026
012b792
fs: tighten async write buffer handling
Jarred-Sumner May 24, 2026
c790cba
spawn: bound stdin buffer handling
Jarred-Sumner May 24, 2026
68f5152
streams: tighten file reader destination handling
Jarred-Sumner May 24, 2026
cf54239
transpiler: tighten async transform input handling
Jarred-Sumner May 24, 2026
13cd1af
shell: tighten builtin redirect buffer handling
Jarred-Sumner May 24, 2026
b4f4d97
util: tighten parseArgs default value handling
Jarred-Sumner May 24, 2026
74934f8
streams: tighten byte stream chunk allocation
Jarred-Sumner May 24, 2026
e25a192
vm: tighten module link argument validation
Jarred-Sumner May 24, 2026
c1c3c60
stream: tighten buffer list join handling
Jarred-Sumner May 24, 2026
d3a705d
markdown: tighten render input handling
Jarred-Sumner May 24, 2026
60c32be
errors: tighten stack trace formatting
Jarred-Sumner May 24, 2026
761acff
string_decoder: tighten write offset validation
Jarred-Sumner May 24, 2026
e9208bb
fs: tighten owner id handling
Jarred-Sumner May 24, 2026
d77980f
shell: bound brace expansion handling
Jarred-Sumner May 24, 2026
a95ab87
path: tighten absolute path detection
Jarred-Sumner May 24, 2026
f27f887
strings: bound transcoding buffer handling
Jarred-Sumner May 24, 2026
8806c9f
zlib: tighten dictionary handling
Jarred-Sumner May 24, 2026
f5c9a29
fs: bound realpath path handling
Jarred-Sumner May 24, 2026
04ea887
shell: tighten escape handling
Jarred-Sumner May 24, 2026
b5dda7f
test: add regression coverage for input validation changes
Jarred-Sumner May 24, 2026
cedfea7
[autofix.ci] apply automated fixes
autofix-ci[bot] May 24, 2026
741f1c7
Address review feedback: tighten validation paths and improve test re…
Jarred-Sumner May 24, 2026
773932d
fetch: add a maxRedirects option; markdown: pin buffer inputs during …
Jarred-Sumner May 24, 2026
9b25c41
remove redundant comments
Jarred-Sumner May 24, 2026
0047778
install: require the configured registry origin for default script trust
Jarred-Sumner May 24, 2026
c06490a
http2: validate inbound header names as lowercase tokens
Jarred-Sumner May 24, 2026
622c9df
http2: restrict inbound pseudo-header names to the defined set; which…
Jarred-Sumner May 24, 2026
e80a532
test: make the pollable file backpressure fixture independent of the …
Jarred-Sumner May 24, 2026
8ec6890
which: only reserve extension space when probing extensions
Jarred-Sumner May 24, 2026
9362901
Merge remote-tracking branch 'origin/main' into claude/hardening-sweep-4
Jarred-Sumner May 25, 2026
8829d7f
install: use lchmod when setting bin target permissions
Jarred-Sumner May 25, 2026
802304e
install: open the package tag file directly during extraction
Jarred-Sumner May 25, 2026
0d8dc03
remove explanatory comments
Jarred-Sumner May 25, 2026
abae510
install: set bin target permissions with a single fchmodat call
Jarred-Sumner May 25, 2026
d34dbd2
test: assert empty stderr in transpiler detach subprocess test
Jarred-Sumner May 25, 2026
378b48f
sys: invoke fchmodat2 directly when changing modes without following …
Jarred-Sumner May 25, 2026
33f6764
install: write the streaming-extraction github tag through an open fd
Jarred-Sumner May 25, 2026
82e916d
test: assert empty stderr in tls and archive subprocess tests
Jarred-Sumner May 25, 2026
05f502a
install: only pass O_NOFOLLOW to the tag-file open on POSIX
Jarred-Sumner May 25, 2026
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
14 changes: 14 additions & 0 deletions packages/bun-types/globals.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2033,6 +2033,20 @@ interface BunFetchRequestInit extends RequestInit {
* ```
*/
decompress?: boolean;

/**
* The maximum number of redirects to follow when `redirect` is `"follow"`.
* If the response chain redirects more than this many times, the request
* rejects with a "too many redirects" error.
* This is a custom property that is not part of the Fetch API specification.
*
* @default 126
* @example
* ```js
* const response = await fetch("https://example.com/", { maxRedirects: 3 });
* ```
*/
maxRedirects?: number;
}

/**
Expand Down
15 changes: 8 additions & 7 deletions packages/bun-usockets/src/crypto/openssl.c
Original file line number Diff line number Diff line change
Expand Up @@ -517,8 +517,10 @@ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options,
ssl_ctx_drop_passphrase(ssl_context);

if (options.ca_file_name) {
SSL_CTX_set_cert_store(ssl_context, us_get_default_ca_store());

/* An explicit CA replaces the default trust store (Node.js semantics):
* chains must validate exclusively against the supplied CAs. The SSL_CTX
* already owns a fresh, empty X509_STORE from SSL_CTX_new(), so
* SSL_CTX_load_verify_locations below populates only the user's CAs. */
STACK_OF(X509_NAME) *ca_list = SSL_load_client_CA_file(options.ca_file_name);
if (ca_list == NULL) {
*err = CREATE_BUN_SOCKET_ERROR_LOAD_CA_FILE;
Expand All @@ -537,12 +539,11 @@ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options,
us_verify_callback);

} else if (options.ca && options.ca_count > 0) {
X509_STORE *cert_store = NULL;
/* As above: user CAs only, into the SSL_CTX's own initially-empty store —
* otherwise a server doing mTLS with `ca: [internalCA]` would also accept
* any client certificate that chains to a public root. */
X509_STORE *cert_store = SSL_CTX_get_cert_store(ssl_context);
for (unsigned int i = 0; i < options.ca_count; i++) {
if (cert_store == NULL) {
cert_store = us_get_default_ca_store();
SSL_CTX_set_cert_store(ssl_context, cert_store);
}
if (!add_ca_cert_to_ctx_store(ssl_context, options.ca[i], cert_store)) {
*err = CREATE_BUN_SOCKET_ERROR_INVALID_CA;
ssl_ctx_build_fail(ssl_context);
Expand Down
4 changes: 2 additions & 2 deletions packages/bun-usockets/src/crypto/root_certs.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -254,8 +254,8 @@ extern "C" X509_STORE *us_get_default_ca_store() {
}

// Process-wide immutable default store. Safe to share across SSL_CTXs that
// don't add per-config CAs (the user-`ca` path in build_raw still calls
// us_get_default_ca_store() to get a fresh, mutable copy). This makes the
// don't add per-config CAs (the user-`ca` path in build_raw populates the
// SSL_CTX's own private, initially-empty store instead). This makes the
// ~150-root build a once-per-process cost instead of once-per-SSL_CTX, which
// is what kept Bun.connect({tls:true}) under the node-tls-server.test.ts
// 100ms cold-path budget in debug+ASAN.
Expand Down
5 changes: 4 additions & 1 deletion packages/bun-uws/src/HttpParser.h
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,10 @@ namespace uWS
return te;
}

te.has = lastTokenLen > 0;
/* Present even when the value names no transfer coding: treating
* an empty/whitespace-only field as absent would fall back to
* Content-Length framing (request smuggling; RFC 9112 6.3). */
te.has = true;

// Check if the last token is "chunked"
if (lastTokenLen == 7 && strncasecmp(value.data() + lastTokenStart, "chunked", 7) == 0) [[likely]] {
Expand Down
10 changes: 10 additions & 0 deletions src/bun_core/string/immutable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3220,6 +3220,16 @@ pub fn convert_utf8_to_utf16_in_buffer<'a>(buf: &'a mut [u16], input: &[u8]) ->
if input.is_empty() {
return &mut buf[..0];
}
// PORT NOTE: Zig forwards only `buf.ptr` to simdutf (heap overflow if
// undersized); enforce the documented `capacity >= input.len()` precondition
// in release too. On overflow, fall back to the exact post-conversion length
// so multi-byte inputs whose UTF-16 form fits are not over-rejected.
assert!(
input.len() <= buf.len() || element_length_utf8_into_utf16(input) <= buf.len(),
"convert_utf8_to_utf16_in_buffer: buf too small (have {} u16 for {} input bytes)",
buf.len(),
input.len(),
);
let r = simdutf::convert::utf8::to::utf16::with_errors::le(input, buf);
if r.is_successful() {
return &mut buf[..r.count];
Expand Down
8 changes: 8 additions & 0 deletions src/bun_core/string/immutable/unicode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -950,6 +950,14 @@ pub fn convert_utf8_to_utf16_in_buffer_z<'a>(buf: &'a mut [u16], input: &[u8]) -
buf[0] = 0;
return wstr_in_buf(buf, 0);
}
// simdutf writes up to the UTF-16 length of `input` without consulting
// `buf.len()`, and the NUL terminator below needs one more slot.
assert!(
input.len() < buf.len() || element_length_utf8_into_utf16(input) < buf.len(),
"convert_utf8_to_utf16_in_buffer_z: buf too small (have {} u16 for {} input bytes)",
buf.len(),
input.len(),
);
let result = simdutf::convert::utf8::to::utf16::le(input, buf);
buf[result] = 0;
wstr_in_buf(buf, result)
Expand Down
6 changes: 6 additions & 0 deletions src/http/AsyncHTTP.rs
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ pub struct Options<'a> {
pub verbose: Option<HTTPVerboseLevel>,
pub disable_keepalive: Option<bool>,
pub disable_decompression: Option<bool>,
pub max_redirects: Option<u8>,
pub reject_unauthorized: Option<bool>,
pub tls_props: Option<SSLConfigSharedPtr>,
}
Expand Down Expand Up @@ -535,6 +536,11 @@ impl<'a> AsyncHTTP<'a> {
if let Some(val) = options.disable_decompression {
this.client.flags.disable_decompression = val;
}
if let Some(val) = options.max_redirects {
// remaining_redirect_count is decremented on each redirect response
// and the request fails when it reaches 0, so N follows need N + 1.
this.client.remaining_redirect_count = (val.min(126) + 1) as i8;
}
if let Some(val) = options.disable_keepalive {
this.client.flags.disable_keepalive = val;
}
Expand Down
10 changes: 8 additions & 2 deletions src/http/HTTPContext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -898,6 +898,11 @@ impl<const SSL: bool> HTTPContext<SSL> {
if SSL {
if client.can_offer_h2() {
let cfg = SSLConfig::raw_ptr(client.tls_props.as_ref());
// The TLS handshake verifies the peer against get_tls_hostname()
// — which prefers the Host-header override over url.hostname —
// so the override must discriminate the session key, mirroring
// the keep-alive pool's proxy_auth_hash.
let host_header_hash = client.proxy_auth_hash();
for &session in &self.active_h2_sessions {
// Active sessions are kept alive by registry refs; `&mut`
// is unique here (registry is iterated read-only and
Expand All @@ -906,7 +911,7 @@ impl<const SSL: bool> HTTPContext<SSL> {
// strong-ref-held invariant as the pool/found-slot cases.
let s = h2_session_as_mut(NonNull::new(session)).unwrap();
if s.has_headroom()
&& s.matches(hostname, port, cfg)
&& s.matches(hostname, port, cfg, host_header_hash)
// Same guard as the pool path: a session whose TLS
// handshake ran with reject_unauthorized=false never
// validated the peer hostname, so a strict caller
Expand All @@ -924,7 +929,7 @@ impl<const SSL: bool> HTTPContext<SSL> {
// strict caller must not coalesce onto an in-flight connect
// that was initiated with reject_unauthorized=false, since
// the resulting session won't have validated the peer.
if pc.matches(hostname, port, cfg_nn)
if pc.matches(hostname, port, cfg_nn, host_header_hash)
&& (!client.flags.reject_unauthorized || pc.reject_unauthorized)
{
// client outlives the pending connect (resolved before
Expand Down Expand Up @@ -1058,6 +1063,7 @@ impl<const SSL: bool> HTTPContext<SSL> {
port,
ssl_config: cfg,
reject_unauthorized: client.flags.reject_unauthorized,
host_header_hash: client.proxy_auth_hash(),
..Default::default()
});
// `client.pending_h2 = pc` stores a *borrowed* backref into the
Expand Down
14 changes: 12 additions & 2 deletions src/http/h2_client/ClientSession.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ pub struct ClientSession {
/// into the keepalive pool so a strict caller never reuses a session whose
/// hostname was never validated.
pub established_with_reject_unauthorized: bool,
/// Hash of the Host-header SNI override the TLS handshake was verified
/// against (`HTTPClient::proxy_auth_hash()`; 0 when none). Part of the
/// session key so a request expecting validation against `url.hostname`
/// never multiplexes onto a session validated against a different name.
pub host_header_hash: u64,

/// Queued bytes for the socket; whole frames are written here and
/// `flush()` drains as much as the socket accepts.
Expand Down Expand Up @@ -239,6 +244,7 @@ impl ClientSession {
ssl_config: client.tls_props.clone(),
did_have_handshaking_error: client.flags.did_have_handshaking_error,
established_with_reject_unauthorized: client.flags.reject_unauthorized,
host_header_hash: client.proxy_auth_hash(),
write_buffer: bun_io::StreamBuffer::default(),
read_buffer: Vec::new(),
streams: ArrayHashMap::default(),
Expand Down Expand Up @@ -284,12 +290,16 @@ impl ClientSession {
hostname: &[u8],
port: u16,
ssl_config: Option<*const ssl_config::SSLConfig>,
host_header_hash: u64,
) -> bool {
let mine: Option<*const ssl_config::SSLConfig> = self
.ssl_config
.as_ref()
.map(|p| std::ptr::from_ref(p.get()));
self.port == port && mine == ssl_config && strings::eql_long(&self.hostname, hostname, true)
self.port == port
&& mine == ssl_config
&& self.host_header_hash == host_header_hash
&& strings::eql_long(&self.hostname, hostname, true)
}

pub fn adopt(&mut self, client: &mut HTTPClient) {
Expand Down Expand Up @@ -907,7 +917,7 @@ impl ClientSession {
None,
b"",
0,
0,
self.host_header_hash,
Some(self_ptr),
);
} else {
Expand Down
6 changes: 6 additions & 0 deletions src/http/h2_client/PendingConnect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ pub struct PendingConnect {
/// coalescing path apply the same strictness guard *before* the session
/// exists, so a strict caller never waits on a connect started by a lax one.
pub reject_unauthorized: bool,
/// Hash of the leader's Host-header SNI override (`proxy_auth_hash()`),
/// mirrored into the eventual `ClientSession.host_header_hash`. Coalescing
/// must not mix requests whose TLS verification hostname differs.
pub host_header_hash: u64,
// BACKREF: waiters are borrowed HTTP clients owned elsewhere; lifetime-erased.
pub waiters: Vec<NonNull<HTTPClient<'static>>>,
}
Expand Down Expand Up @@ -52,9 +56,11 @@ impl PendingConnect {
hostname: &[u8],
port: u16,
ssl_config: Option<NonNull<SSLConfig>>,
host_header_hash: u64,
) -> bool {
self.port == port
&& self.ssl_config == ssl_config
&& self.host_header_hash == host_header_hash
&& strings::eql_long(&self.hostname, hostname, true)
}

Expand Down
30 changes: 25 additions & 5 deletions src/http/h2_client/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -733,13 +733,33 @@ pub(crate) fn strip_padding(payload: &[u8]) -> Option<&[u8]> {
Some(&payload[1..payload.len() - pad])
}

/// RFC 9113 §8.2.1/§8.2.2 response-side validation: lowercase names, no
/// hop-by-hop fields. Names from lshpack are already lowercase for table
/// hits but a literal can carry anything.
/// RFC 9113 §8.2.1/§8.2.2 response-side validation: names must be lowercase
/// RFC 9110 tokens, no hop-by-hop fields. Names from lshpack are already
/// lowercase for table hits but a literal can carry anything.
pub(crate) fn is_malformed_response_field(name: &[u8]) -> bool {
if name.is_empty() {
return true;
}
for &c in name {
if c >= b'A' && c <= b'Z' {
return true;
match c {
b'a'..=b'z'
| b'0'..=b'9'
| b'!'
| b'#'
| b'$'
| b'%'
| b'&'
| b'\''
| b'*'
| b'+'
| b'-'
| b'.'
| b'^'
| b'_'
| b'`'
| b'|'
| b'~' => {}
_ => return true,
}
}
// PORT NOTE: Zig used a comptime string set; small enough to open-code.
Expand Down
10 changes: 10 additions & 0 deletions src/http/h3_client/callbacks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use super::client_context::ClientContext;
use super::client_session::{ClientSession, session_mut, stream_mut, stream_ref};
use super::encode;
use super::stream::Stream;
use crate::h2_client::dispatch::{is_malformed_response_field, is_malformed_response_value};
use crate::h3_client as H3;
use bun_picohttp as picohttp;

Expand Down Expand Up @@ -225,6 +226,15 @@ extern "C" fn on_stream_headers(s: *mut quic::Stream) {
i += 1;
continue;
}
// RFC 9114 §4.1.2/§4.2: same response-field validation as the
// HTTP/2 path. Trailers (status_code already set) are discarded
// without delivery, so they skip the check like the H2 path does.
if stream.status_code == 0
&& (is_malformed_response_field(name) || is_malformed_response_value(value))
{
session.fail(stream, err!(HTTP3ProtocolError));
return;
}
// PERF(port): was appendAssumeCapacity — Vec::push amortizes.
stream
.decoded_headers
Expand Down
51 changes: 50 additions & 1 deletion src/install/NetworkTask.rs
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,56 @@ impl NetworkTask {
}

// This actually duplicates the string! So we defer deref the WTF managed one above.
break 'blk tmp.to_owned_slice().into_boxed_slice();
let url_bytes = tmp.to_owned_slice().into_boxed_slice();

// The package name is attacker-controlled (e.g. an `npm:` alias
// target) and WHATWG URL joining treats '\' as '/' for special
// schemes, so "\\evil.com\pkg" joins to "https://evil.com/pkg" and
// would receive this scope's Authorization header; "..\\x\\pkg" can
// likewise escape the registry path within the same origin. Reject
// any join that leaves the registry's origin or path directory.
// Protocol/hostname compare case-insensitively because WTF::URL
// lowercases the joined href while `scope.url` keeps the configured
// spelling.
{
let joined = URL::parse(&url_bytes);
let registry = scope.url.url();
let registry_dir_end =
strings::last_index_of_char(registry.pathname, b'/').map_or(0, |i| i + 1);
let registry_dir = &registry.pathname[..registry_dir_end];
if !joined.protocol.eq_ignore_ascii_case(registry.protocol)
|| !joined.hostname.eq_ignore_ascii_case(registry.hostname)
|| joined.get_port_auto() != registry.get_port_auto()
|| !joined.pathname.starts_with(registry_dir)
{
if !is_optional {
log.add_error_fmt(
None,
bun_ast::Loc::EMPTY,
format_args!(
"Invalid package name {}: manifest URL {} is not on registry {}",
quote(name),
quote(&url_bytes),
quote(scope.url.href()),
),
);
} else {
log.add_warning_fmt(
None,
bun_ast::Loc::EMPTY,
format_args!(
"Invalid package name {}: manifest URL {} is not on registry {}",
quote(name),
quote(&url_bytes),
quote(scope.url.href()),
),
);
}
return Err(ForManifestError::InvalidURL);
}
}

break 'blk url_bytes;
};

let mut last_modified: &[u8] = b"";
Expand Down
24 changes: 24 additions & 0 deletions src/install/TarballStream.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1087,6 +1087,10 @@ impl TarballStream {
if self.resolved_github_dirname.is_empty() {
break 'insert_tag;
}
// A malicious tarball can plant a `.bun-tag` symlink;
// remove it so the tag write below cannot follow it out
// of the extraction directory.
let _ = bun_sys::unlinkat(self.dest.unwrap(), bun_core::zstr!(".bun-tag"));
Comment thread
Jarred-Sumner marked this conversation as resolved.
Outdated
if bun_sys::File::write_file(
self.dest.unwrap(),
bun_core::zstr!(".bun-tag"),
Expand Down Expand Up @@ -1404,6 +1408,26 @@ fn make_symlink(
// directory.
let symlink_dir = bun_paths::dirname(path_slice).unwrap_or(b"");
let target_bytes = target.as_bytes();
// A `..` that follows a named component only collapses lexically when
// that component is a real directory. If it is a symlink created by
// another entry of this archive (in any order), the kernel resolves
// the link first and applies `..` to the link target's parent, so a
// chain like `l1 -> .`, `l2 -> l1/..` climbs one directory above the
// extraction root per hop while every target still normalizes to a
// path inside it. Reject non-leading `..` components so the
// normalization below is exact.
let mut seen_named_component = false;
for component in target_bytes.split(|c| *c == b'/') {
match component {
b"" | b"." => {}
b".." => {
if seen_named_component {
return false;
}
}
_ => seen_named_component = true,
}
}
let mut join_buf = PathBuffer::uninit();
if symlink_dir.len() + 1 + target_bytes.len() >= join_buf.len() {
return false;
Expand Down
Loading