Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
45a61b6
Remove dead code from HTTP client, h2, and websocket modules
alii Jun 8, 2026
ee19b76
Merge branch 'main' into claude/split/http
robobun Jun 10, 2026
5ceae99
ci: retrigger
robobun Jun 10, 2026
7545235
Add coverage for h2 header validation and server socket stubs
robobun Jun 10, 2026
601934f
Move dedupe coverage into dedicated test files
robobun Jun 10, 2026
601cceb
Merge branch 'main' into claude/split/http
robobun Jun 17, 2026
abe1037
Merge branch 'main' into claude/split/http
robobun Jun 18, 2026
ecbef75
Merge branch 'main' into claude/split/http
robobun Jun 26, 2026
2597acf
Trim stale buffered-data clause from the finish_init safety doc
robobun Jun 26, 2026
ba36e20
Merge remote-tracking branch 'origin/main' into claude/split/http
robobun Jun 27, 2026
19f7f94
Merge remote-tracking branch 'origin/main' into claude/split/http
robobun Jun 28, 2026
5b596a1
Merge remote-tracking branch 'origin/main' into claude/split/http
robobun Jul 1, 2026
be5db97
Merge remote-tracking branch 'origin/main' into claude/split/http
robobun Jul 8, 2026
5411662
Merge remote-tracking branch 'origin/main' into claude/split/http
robobun Aug 10, 2026
c39cf72
Adapt h2 header-validation test to async value-error delivery
robobun Aug 10, 2026
6cb7abd
[autofix.ci] apply automated fixes
autofix-ci[bot] Aug 10, 2026
8e0f4b4
Add unit tests for the shared h2 field validators
robobun Aug 10, 2026
33089f7
Merge branch 'main' into claude/split/http
alii Aug 11, 2026
4e752b3
Merge branch 'main' into claude/split/http
robobun Aug 11, 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
32 changes: 2 additions & 30 deletions src/http/h2_client/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -735,31 +735,9 @@ fn strip_padding(payload: &[u8]) -> Option<&[u8]> {
/// 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() {
if name.is_empty() || !name.iter().all(|&c| wire::is_lower_tchar(c)) {
return true;
}
for &c in name {
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,
}
}
matches!(
name,
b"connection"
Expand All @@ -771,13 +749,7 @@ pub(crate) fn is_malformed_response_field(name: &[u8]) -> bool {
)
}

/// RFC 9113 §8.2.1: a field value MUST NOT contain NUL (0x00), LF (0x0a), or
/// CR (0x0d). HPACK is length-prefixed so these would otherwise pass through
/// verbatim, breaking the no-CR/LF invariant the HTTP/1.1 parser provides and
/// enabling header injection when values are forwarded downstream.
pub(crate) fn is_malformed_response_value(value: &[u8]) -> bool {
bun_core::strings::contains_any(value, b"\0\r\n")
}
pub(crate) use wire::is_malformed_field_value as is_malformed_response_value;

pub(crate) fn error_code_for(err: crate::Error) -> wire::ErrorCode {
match err {
Expand Down
112 changes: 46 additions & 66 deletions src/http/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4782,6 +4782,48 @@ impl<'a> HTTPClient<'a> {
}
}

/// Shared tail of the `Location`-header arms in
/// `handle_response_metadata`: parse the rebuilt absolute href, compare
/// origins against the current URL, then swap the href into
/// `self.redirect`. Returns whether the redirect target is same-origin.
fn apply_redirect_url(&mut self, new_href: Vec<u8>) -> crate::Result<bool> {
let new_url = URL::parse(&new_href);
if !new_url.has_http_like_protocol() {
return Err(crate::Error::UnsupportedRedirectProtocol);
}
// SAFETY: self-borrow — `new_href` is moved into `self.redirect`
// below, which lives as long as `self` (≥ `'a`).
let new_url: URL<'a> = unsafe { new_url.erase_lifetime() };
let is_same_origin = strings::eql_case_insensitive_ascii(
strings::without_trailing_slash(new_url.origin),
strings::without_trailing_slash(self.url.origin),
true,
);
self.url = new_url;
// connected_url still borrows from the previous hop's buffer until
// doRedirect releases the socket, so park it in prev_redirect for
// doRedirect to free instead of leaking it.
debug_assert!(self.prev_redirect.is_empty());
self.prev_redirect = core::mem::replace(&mut self.redirect, new_href);
Ok(is_same_origin)
}

/// Normalize a fully-rebuilt redirect URL through the WHATWG parser and
/// apply it via [`Self::apply_redirect_url`].
fn normalize_and_apply_redirect_url(
&mut self,
mut string_builder: StringBuilder,
) -> crate::Result<bool> {
debug_assert!(string_builder.cap == string_builder.len);
let input = BunString::borrow_utf8(string_builder.allocated_slice());
let normalized_url = OwnedString::new(bun_url::href_from_string(&input));
if normalized_url.tag() == BunStringTag::Dead {
// URL__getHref failed, dont pass dead tagged string to toOwnedSlice.
return Err(crate::Error::RedirectURLInvalid);
}
self.apply_redirect_url(normalized_url.to_owned_slice())
}

pub(crate) fn handle_response_metadata(
&mut self,
response: &mut picohttp::Response,
Expand Down Expand Up @@ -5052,32 +5094,7 @@ impl<'a> HTTPClient<'a> {

let _ = string_builder.append(location);

debug_assert!(string_builder.cap == string_builder.len);

let input = BunString::borrow_utf8(string_builder.allocated_slice());
let normalized_url = OwnedString::new(bun_url::href_from_string(&input));
if normalized_url.tag() == BunStringTag::Dead {
// URL__getHref failed, dont pass dead tagged string to toOwnedSlice.
return Err(crate::Error::RedirectURLInvalid);
}
let normalized_url_str = normalized_url.to_owned_slice();

// SAFETY: self-borrow — `normalized_url_str` is moved into
// `self.redirect` below, which lives as long as `self` (≥ `'a`).
let new_url: URL<'a> =
unsafe { URL::parse(&normalized_url_str).erase_lifetime() };
is_same_origin = strings::eql_case_insensitive_ascii(
strings::without_trailing_slash(new_url.origin),
strings::without_trailing_slash(self.url.origin),
true,
);
self.url = new_url;
// connected_url still borrows from the previous hop's buffer
// until doRedirect releases the socket, so park it in
// prev_redirect for doRedirect to free instead of leaking it.
debug_assert!(self.prev_redirect.is_empty());
self.prev_redirect =
core::mem::replace(&mut self.redirect, normalized_url_str);
is_same_origin = self.normalize_and_apply_redirect_url(string_builder)?;
} else if location.starts_with(b"//") {
let mut string_builder = StringBuilder::default();

Expand Down Expand Up @@ -5108,54 +5125,17 @@ impl<'a> HTTPClient<'a> {

let _ = string_builder.append(location);

debug_assert!(string_builder.cap == string_builder.len);

let input = BunString::borrow_utf8(string_builder.allocated_slice());
let normalized_url = OwnedString::new(bun_url::href_from_string(&input));
if normalized_url.tag() == BunStringTag::Dead {
return Err(crate::Error::RedirectURLInvalid);
}
let normalized_url_str = normalized_url.to_owned_slice();

// SAFETY: self-borrow — `normalized_url_str` is moved into
// `self.redirect` below, which lives as long as `self` (≥ `'a`).
let new_url: URL<'a> =
unsafe { URL::parse(&normalized_url_str).erase_lifetime() };
is_same_origin = strings::eql_case_insensitive_ascii(
strings::without_trailing_slash(new_url.origin),
strings::without_trailing_slash(self.url.origin),
true,
);
self.url = new_url;
debug_assert!(self.prev_redirect.is_empty());
self.prev_redirect =
core::mem::replace(&mut self.redirect, normalized_url_str);
is_same_origin = self.normalize_and_apply_redirect_url(string_builder)?;
} else {
let original_url = self.url.clone();

let base = BunString::borrow_utf8(original_url.href);
let base = BunString::borrow_utf8(self.url.href);
let rel = BunString::borrow_utf8(location);
let new_url_ = OwnedString::new(bun_url::join(&base, &rel));

if new_url_.is_empty() {
return Err(crate::Error::InvalidRedirectURL);
}

let new_url = new_url_.to_owned_slice();
let parsed_url = URL::parse(&new_url);
if !parsed_url.has_http_like_protocol() {
return Err(crate::Error::UnsupportedRedirectProtocol);
}
// SAFETY: self-borrow — `new_url` is moved into `self.redirect`
// below, which lives as long as `self` (≥ `'a`).
self.url = unsafe { parsed_url.erase_lifetime() };
is_same_origin = strings::eql_case_insensitive_ascii(
strings::without_trailing_slash(self.url.origin),
strings::without_trailing_slash(original_url.origin),
true,
);
debug_assert!(self.prev_redirect.is_empty());
self.prev_redirect = core::mem::replace(&mut self.redirect, new_url);
is_same_origin = self.apply_redirect_url(new_url_.to_owned_slice())?;
}
}

Expand Down
101 changes: 101 additions & 0 deletions src/http_types/h2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,3 +217,104 @@ impl SettingsPayloadUnit {
}
}
}

// ─── field validation (RFC 9113 §8.2.1) ─────────

/// RFC 9110 §5.6.2 `tchar`, restricted to lowercase: RFC 9113 §8.2.1 requires
/// HTTP/2 field names to be lowercase, so uppercase tchars are rejected (or
/// normalized) by callers rather than accepted here.
#[inline]
pub const fn is_lower_tchar(c: u8) -> bool {
matches!(
c,
b'a'..=b'z'
| b'0'..=b'9'
| b'!'
| b'#'
| b'$'
| b'%'
| b'&'
| b'\''
| b'*'
| b'+'
| b'-'
| b'.'
| b'^'
| b'_'
| b'`'
| b'|'
| b'~'
)
}

/// RFC 9113 §8.2.1: a field value MUST NOT contain NUL (0x00), LF (0x0a), or
/// CR (0x0d). HPACK is length-prefixed so these would otherwise pass through
/// verbatim, breaking the no-CR/LF invariant the HTTP/1.1 parser provides and
/// enabling header injection when values are forwarded downstream.
#[inline]
pub fn is_malformed_field_value(value: &[u8]) -> bool {
bun_core::strings::contains_any(value, b"\0\r\n")
}

#[cfg(test)]
mod tests {
use super::is_lower_tchar;

/// Exhaustive parity check against the RFC 9110 §5.6.2 `tchar` grammar
/// with the uppercase range removed.
#[test]
fn lower_tchar_matches_grammar() {
// RFC 9110 defines tchar as any VCHAR except the delimiters below;
// deriving the oracle that way keeps it independent of the literal
// table in `is_lower_tchar`.
for c in 0..=u8::MAX {
let is_delimiter = matches!(
c,
b'"' | b'('
| b')'
| b','
| b'/'
| b':'
| b';'
| b'<'
| b'='
| b'>'
| b'?'
| b'@'
| b'['
| b'\\'
| b']'
| b'{'
| b'}'
);
let expected = c.is_ascii_graphic() && !is_delimiter && !c.is_ascii_uppercase();
assert_eq!(is_lower_tchar(c), expected, "byte {c:#04x}");
}
}

/// `contains_any` is backed by the highway objects, which a native
/// `cargo test` of this leaf crate does not link; this crate's unit tests
/// run in CI under Miri, where highway takes its scalar paths.
#[cfg(miri)]
#[test]
fn field_value_rejects_exactly_nul_cr_lf() {
use super::is_malformed_field_value;

assert!(!is_malformed_field_value(b""));
assert!(!is_malformed_field_value(b"text/plain; charset=utf-8"));
// Tabs, spaces, and obs-text are legal in values.
assert!(!is_malformed_field_value(b"\ta b\xff"));
for bad in [b'\0', b'\r', b'\n'] {
assert!(is_malformed_field_value(&[bad]), "lone {bad:#04x}");
assert!(is_malformed_field_value(&[bad, b'x']), "leading {bad:#04x}");
assert!(
is_malformed_field_value(&[b'x', bad]),
"trailing {bad:#04x}"
);
}
let mut long = vec![b'a'; 100];
assert!(!is_malformed_field_value(&long));
long[99] = b'\n';
assert!(is_malformed_field_value(&long));
}
}
83 changes: 83 additions & 0 deletions src/js/internal/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,88 @@ function filterEnvForProxies(env) {
};
}

// Stub members shared by `FakeSocket` (internal/http/FakeSocket.ts) and
// `NodeHTTPServerSocket` (node/_http_server.ts). They are copied onto each
// class's prototype (instead of using a base class) so the prototype chain
// stays `Socket.prototype -> Duplex.prototype`, matching `net.Socket`.
const { constructor: _socketStubConstructor, ...socketStubDescriptors } = Object.getOwnPropertyDescriptors(
class {
declare connecting: boolean;
declare readable: boolean;
declare writable: boolean;
declare writableLength: number;
declare address: () => any;

connect(_port, _host, _connectListener) {
return this;
}

get bufferSize() {
return this.writableLength;
}

get pending() {
return this.connecting;
}

get readyState() {
if (this.connecting) return "opening";
if (this.readable) {
return this.writable ? "open" : "readOnly";
} else {
return this.writable ? "writeOnly" : "closed";
}
}

ref() {
return this;
}

get remoteAddress() {
return this.address()?.address;
}

set remoteAddress(val) {
// initialize the object so that other properties wouldn't be lost
this.address().address = val;
}

get remotePort() {
return this.address()?.port;
}

set remotePort(val) {
// initialize the object so that other properties wouldn't be lost
this.address().port = val;
}

get remoteFamily() {
return this.address()?.family;
}

set remoteFamily(val) {
// initialize the object so that other properties wouldn't be lost
this.address().family = val;
}
Comment thread
robobun marked this conversation as resolved.

resetAndDestroy() {}

setKeepAlive(_enable = false, _initialDelay = 0) {}

setNoDelay(_noDelay = true) {
return this;
}

unref() {
return this;
}
}.prototype,
);

function installSocketStubs(SocketClass: { prototype: object }) {
Object.defineProperties(SocketClass.prototype, socketStubDescriptors);
}

export {
Headers,
METHODS,
Expand Down Expand Up @@ -618,6 +700,7 @@ export {
headerStateSymbol,
headersSymbol,
headersTuple,
installSocketStubs,
isAbortError,
isTlsSymbol,
kAbortController,
Expand Down
Loading