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
60 changes: 60 additions & 0 deletions src/http/HTTPCertError.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,66 @@ impl Default for HTTPCertError {
}
}

/// Owned copy of the uSockets handshake-failure sentinel (`error_no < 0`):
/// `-71`/"EPROTO" for a fatal TLS protocol error (`ssl_dispatch_parked_reason`,
/// `reason` is the `ERR_error_string_n` output) or `-46`/"ECONNRESET" for a
/// mid-handshake close (`ssl_trigger_handshake_econnreset`), both in
/// packages/bun-usockets/src/crypto/openssl.c. The `reason` pointer is a stack
/// buffer in the EPROTO case, so an owned copy is required to outlive
/// `on_handshake`. Carried on `HTTPClientResult` so `fetch()` can report the
/// OpenSSL reason (e.g. `WRONG_VERSION_NUMBER`) instead of a certificate
/// verification error.
#[derive(Clone, Default)]
pub struct TLSHandshakeError {
pub code: Box<[u8]>,
pub reason: Box<[u8]>,
}

impl TLSHandshakeError {
/// Capture the code/reason from a handshake-failure sentinel. Only call
/// when `error_no < 0`; X509 verify errors use [`HTTPCertError`].
pub fn from_verify_error(ssl_error: &bun_uws::us_bun_verify_error_t) -> Self {
Self {
code: Box::from(ssl_error.code_bytes()),
reason: Box::from(ssl_error.reason_bytes()),
}
}

/// Node-style error code for the JS `Error.code` property: for an EPROTO
/// sentinel whose `reason` carries an OpenSSL reason string, derive
/// `ERR_SSL_<REASON>` the way Node's `ThrowCryptoError` does; otherwise
/// fall back to the sentinel's own code (`EPROTO`/`ECONNRESET`). The
/// `reason` may be either the full `ERR_error_string_n` line
/// (`error:<hex>:<lib>:<func>:<REASON>`, direct path via
/// `ssl_dispatch_parked_reason`) or the bare `ERR_reason_error_string`
/// output (`<REASON>` only, inner-TLS path via `SSLWrapper`); both reduce
/// to the last `:`-separated segment, which BoringSSL emits upper-snake.
pub fn node_error_code(&self) -> Box<[u8]> {
if &*self.code == b"EPROTO" {
let reason = self
.reason
.rsplit(|&b| b == b':')
.next()
.unwrap_or(&self.reason);
if !reason.is_empty()
&& reason
.iter()
.all(|&b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_')
{
const PREFIX: &[u8] = b"ERR_SSL_";
let mut code = Vec::with_capacity(PREFIX.len() + reason.len());
code.extend_from_slice(PREFIX);
code.extend_from_slice(reason);
return code.into_boxed_slice();
}
}
if self.code.is_empty() {
return Box::from(&b"EPROTO"[..]);
}
self.code.clone()
}
}

impl HTTPCertError {
/// Build from the uSockets verify-error struct delivered to `on_handshake`.
///
Expand Down
16 changes: 12 additions & 4 deletions src/http/HTTPContext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1187,10 +1187,18 @@ impl<const SSL: bool> Handler<SSL> {
// if we are here is because server rejected us, and the error_no is the cause of this
// if we set reject_unauthorized == false this means the server requires custom CA aka NODE_EXTRA_CA_CERTS
if client.flags.did_have_handshaking_error {
client.close_and_fail::<SSL>(
get_cert_error_from_no(handshake_error.error_no),
socket,
);
// A negative `error_no` is one of the uSockets handshake
// sentinels (-71 EPROTO / -46 ECONNRESET), not an
// `X509_V_ERR_*` code. Capture the OpenSSL reason so the
// JS side can report it instead of a certificate error.
let err = if handshake_error.error_no < 0 {
client.state.tls_handshake_error =
Some(crate::TLSHandshakeError::from_verify_error(&ssl_error));
crate::Error::TLSHandshakeFailed
} else {
get_cert_error_from_no(handshake_error.error_no)
};
client.close_and_fail::<SSL>(err, socket);
return;
}
// if handshake_success it self is false, this means that the connection was rejected
Expand Down
5 changes: 5 additions & 0 deletions src/http/InternalState.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ pub struct InternalState<'a> {
/// the post-redirect target). Captured on the HTTP thread at the failure
/// so the JS side never dereferences the client's borrowed URL buffers.
pub dns_hostname: Option<Box<[u8]>>,
/// Owned copy of the uSockets handshake-failure sentinel when `fail` is
/// `TLSHandshakeFailed`. Carries the OpenSSL reason string so the JS
/// side can report Node's `ERR_SSL_*` code instead of a generic failure.
pub tls_handshake_error: Option<crate::TLSHandshakeError>,
pub request_stage: HTTPStage,
pub response_stage: HTTPStage,
pub certificate_info: Option<CertificateInfo>,
Expand Down Expand Up @@ -140,6 +144,7 @@ impl Default for InternalState<'_> {
fail: None,
dns_error: 0,
dns_hostname: None,
tls_handshake_error: None,
request_stage: HTTPStage::Pending,
response_stage: HTTPStage::Pending,
certificate_info: None,
Expand Down
18 changes: 14 additions & 4 deletions src/http/ProxyTunnel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -378,10 +378,10 @@ fn on_handshake(
this.state.request_stage = HTTPStage::ProxyHeaders;
this.state.request_sent_len = 0;
let handshake_error = HTTPCertError::from_verify_error(ssl_error);
// handshake completed but we may have ssl errors
this.flags.did_have_handshaking_error = handshake_error.error_no != 0;
if handshake_success {
scoped_log!(http_proxy_tunnel, "ProxyTunnel onHandshake success");
// handshake completed but we may have ssl errors
this.flags.did_have_handshaking_error = handshake_error.error_no != 0;
if this.flags.reject_unauthorized {
// only reject the connection if reject_unauthorized == true
if this.flags.did_have_handshaking_error {
Expand Down Expand Up @@ -455,8 +455,18 @@ fn on_handshake(
scoped_log!(http_proxy_tunnel, "ProxyTunnel onHandshake failed");
// if we are here is because server rejected us, and the error_no is the cause of this
// if we set reject_unauthorized == false this means the server requires custom CA aka NODE_EXTRA_CA_CERTS
if this.flags.did_have_handshaking_error && handshake_error.error_no != 0 {
let err = crate::get_cert_error_from_no(handshake_error.error_no);
if this.flags.did_have_handshaking_error {
// A negative `error_no` is one of the uSockets handshake sentinels
// (-71 EPROTO / -46 ECONNRESET), not an `X509_V_ERR_*` code.
// Capture the OpenSSL reason so the JS side can report it instead
// of a certificate error.
let err = if handshake_error.error_no < 0 {
this.state.tls_handshake_error =
Some(crate::TLSHandshakeError::from_verify_error(&ssl_error));
crate::Error::TLSHandshakeFailed
} else {
crate::get_cert_error_from_no(handshake_error.error_no)
};
// SAFETY: `this` dead (NLL); reenter via raw ptr.
ProxyTunnel::close_from_callback(proxy_nn, err);
return;
Expand Down
3 changes: 3 additions & 0 deletions src/http/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ pub enum Error {
DNSResolveFailed,
#[error("ConnectionRefused")]
ConnectionRefused,
#[error("TLSHandshakeFailed")]
TLSHandshakeFailed,
#[error("TooManyRedirects")]
TooManyRedirects,
#[error("HTTP3Unsupported")]
Expand Down Expand Up @@ -282,6 +284,7 @@ impl Error {
Self::ConnectionClosed => "ConnectionClosed",
Self::DNSResolveFailed => "DNSResolveFailed",
Self::ConnectionRefused => "ConnectionRefused",
Self::TLSHandshakeFailed => "TLSHandshakeFailed",
Self::TooManyRedirects => "TooManyRedirects",
Self::HTTP3Unsupported => "HTTP3Unsupported",
Self::ResponseHeadersTooLarge => "ResponseHeadersTooLarge",
Expand Down
15 changes: 14 additions & 1 deletion src/http/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ pub use certificate_info::CertificateInfo;
pub use decompressor::Decompressor;
pub use header_builder::HeaderBuilder;
pub use headers::{Headers, HeadersExt};
pub use http_cert_error::HTTPCertError;
pub use http_cert_error::{HTTPCertError, TLSHandshakeError};
pub use http_context::{HTTPContext, HTTPSocket};
pub use http_request_body::HTTPRequestBody;
pub use http_thread::HttpThread as HTTPThread;
Expand Down Expand Up @@ -439,6 +439,10 @@ pub struct HTTPClientResult<'a> {
/// JS side never dereferences the client's borrowed URL buffers, which
/// the HTTP thread frees after the result callback returns.
pub dns_hostname: Option<Box<[u8]>>,
/// Owned copy of the uSockets handshake-failure sentinel when `fail` is
/// `TLSHandshakeFailed`; carries the OpenSSL reason so the JS side can
/// report Node's `ERR_SSL_*` code instead of a certificate error.
pub tls_handshake_error: Option<TLSHandshakeError>,

/// Owns the response metadata aka headers, url and status code
pub metadata: Option<HTTPResponseMetadata>,
Expand Down Expand Up @@ -501,6 +505,7 @@ impl<'a> HTTPClientResult<'a> {
fail: self.fail,
dns_error: self.dns_error,
dns_hostname: self.dns_hostname,
tls_handshake_error: self.tls_handshake_error,
metadata: self.metadata,
body_size: self.body_size,
certificate_info: self.certificate_info,
Expand Down Expand Up @@ -4229,6 +4234,7 @@ impl<'a> HTTPClient<'a> {
fail,
dns_error,
dns_hostname,
tls_handshake_error,
metadata,
body_size,
certificate_info,
Expand All @@ -4242,6 +4248,7 @@ impl<'a> HTTPClient<'a> {
r.fail,
r.dns_error,
r.dns_hostname,
r.tls_handshake_error,
r.metadata,
r.body_size,
r.certificate_info,
Expand Down Expand Up @@ -4385,6 +4392,7 @@ impl<'a> HTTPClient<'a> {
fail,
dns_error,
dns_hostname,
tls_handshake_error,
metadata,
body_size,
certificate_info,
Expand Down Expand Up @@ -4430,6 +4438,7 @@ impl<'a> HTTPClient<'a> {
fail,
dns_error,
dns_hostname,
tls_handshake_error,
metadata,
body_size,
certificate_info,
Expand All @@ -4443,6 +4452,7 @@ impl<'a> HTTPClient<'a> {
r.fail,
r.dns_error,
r.dns_hostname,
r.tls_handshake_error,
r.metadata,
r.body_size,
r.certificate_info,
Expand Down Expand Up @@ -4472,6 +4482,7 @@ impl<'a> HTTPClient<'a> {
fail,
dns_error,
dns_hostname,
tls_handshake_error,
metadata,
body_size,
certificate_info,
Expand Down Expand Up @@ -4647,6 +4658,7 @@ impl<'a> HTTPClient<'a> {
fail: self.state.fail,
dns_error: self.state.dns_error,
dns_hostname: self.state.dns_hostname.take(),
tls_handshake_error: self.state.tls_handshake_error.take(),
has_more: self.state.fail.is_none() && !self.state.is_done(),
body_size,
certificate_info: None,
Expand All @@ -4664,6 +4676,7 @@ impl<'a> HTTPClient<'a> {
fail: self.state.fail,
dns_error: self.state.dns_error,
dns_hostname: self.state.dns_hostname.take(),
tls_handshake_error: self.state.tls_handshake_error.take(),
// check if we are reporting cert errors, do not have a fail state and we are not done
has_more: certificate_info.is_some()
|| (self.state.fail.is_none() && !self.state.is_done()),
Expand Down
25 changes: 25 additions & 0 deletions src/runtime/webcore/fetch/FetchTasklet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1326,6 +1326,31 @@ impl FetchTasklet {
}
}

// A TLS handshake that failed for a non-certificate reason (server
// sent a fatal alert, peer isn't speaking TLS at all, or the socket
// closed mid-handshake). The HTTP thread captured the OpenSSL reason
// so the error reports Node's `ERR_SSL_*` / `ECONNRESET` identity
// instead of a certificate verification error.
if fail == http::Error::TLSHandshakeFailed {
let tls_err = self.result.tls_handshake_error.take().unwrap_or_default();
let code = tls_err.node_error_code();
let message = if tls_err.reason.is_empty() {
BunString::static_("TLS handshake failed")
} else {
BunString::clone_utf8(&tls_err.reason)
};
return BodyValueError::SystemError(jsc::SystemError {
errno: 0,
code: BunString::clone_utf8(&code),
message,
path,
syscall: BunString::EMPTY,
hostname: BunString::EMPTY,
fd: core::ffi::c_int::MIN,
dest: BunString::EMPTY,
});
}

let code = if fail == http::Error::ConnectionClosed {
BunString::static_("ECONNRESET")
} else {
Expand Down
39 changes: 30 additions & 9 deletions src/uws/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,14 +156,15 @@ pub mod ssl_wrapper {
mod boring_sys {
pub(super) use bun_boringssl::c::{
BIO_ctrl_pending, BIO_free, BIO_new, BIO_read, BIO_s_mem, BIO_set_mem_eof_return,
BIO_write, ERR_clear_error, SSL, SSL_CTX, SSL_CTX_free, SSL_CTX_get_verify_mode,
SSL_ERROR_SSL, SSL_ERROR_SYSCALL, SSL_ERROR_WANT_READ, SSL_ERROR_WANT_RENEGOTIATE,
SSL_ERROR_WANT_WRITE, SSL_ERROR_ZERO_RETURN, SSL_RECEIVED_SHUTDOWN, SSL_VERIFY_NONE,
SSL_VERIFY_PEER, SSL_do_handshake, SSL_free, SSL_get_error, SSL_get_rbio,
SSL_get_shutdown, SSL_get_wbio, SSL_is_init_finished, SSL_new, SSL_pending, SSL_read,
SSL_renegotiate, SSL_set_accept_state, SSL_set_bio, SSL_set_connect_state,
SSL_set_renegotiate_mode, SSL_set_verify, SSL_set0_verify_cert_store, SSL_shutdown,
SSL_write, X509_STORE, X509_STORE_CTX, ssl_renegotiate_explicit, ssl_renegotiate_never,
BIO_write, ERR_clear_error, ERR_peek_last_error, ERR_reason_error_string, SSL, SSL_CTX,
SSL_CTX_free, SSL_CTX_get_verify_mode, SSL_ERROR_SSL, SSL_ERROR_SYSCALL,
SSL_ERROR_WANT_READ, SSL_ERROR_WANT_RENEGOTIATE, SSL_ERROR_WANT_WRITE,
SSL_ERROR_ZERO_RETURN, SSL_RECEIVED_SHUTDOWN, SSL_VERIFY_NONE, SSL_VERIFY_PEER,
SSL_do_handshake, SSL_free, SSL_get_error, SSL_get_rbio, SSL_get_shutdown,
SSL_get_wbio, SSL_is_init_finished, SSL_new, SSL_pending, SSL_read, SSL_renegotiate,
SSL_set_accept_state, SSL_set_bio, SSL_set_connect_state, SSL_set_renegotiate_mode,
SSL_set_verify, SSL_set0_verify_cert_store, SSL_shutdown, SSL_write, X509_STORE,
X509_STORE_CTX, ssl_renegotiate_explicit, ssl_renegotiate_never,
};
}

Expand Down Expand Up @@ -899,6 +900,15 @@ pub mod ssl_wrapper {
if result <= 0 {
// SAFETY: ssl is still valid.
let err = unsafe { boring_sys::SSL_get_error(ssl.as_ptr(), result) };
// Capture the protocol-level reason (WRONG_VERSION_NUMBER,
// fatal alert, ...) before draining the queue so the handshake
// callback can report it. Mirrors `ssl_park_fatal_reason` in
// openssl.c.
let ssl_queue_err = if err == boring_sys::SSL_ERROR_SSL {
boring_sys::ERR_peek_last_error()
} else {
0
};
boring_sys::ERR_clear_error();
if err == boring_sys::SSL_ERROR_ZERO_RETURN {
// Remotely-Initiated Shutdown
Expand All @@ -920,7 +930,18 @@ pub mod ssl_wrapper {
Self::r(this)
.flags
.set_handshake_state(HandshakeState::HandshakeCompleted);
let verify = Self::r(this).get_verify_error();
let verify = if ssl_queue_err != 0 {
// Same shape `ssl_dispatch_parked_reason` (openssl.c)
// uses for a fatal handshake error; `reason` points
// into BoringSSL's static error-string table.
us_bun_verify_error_t {
error_no: -71,
code: c"EPROTO".as_ptr(),
reason: boring_sys::ERR_reason_error_string(ssl_queue_err),
}
} else {
Self::r(this).get_verify_error()
};
Self::r(this).trigger_handshake_callback(false, verify);

if Self::r(this).flags.fatal_error() {
Expand Down
Loading
Loading