diff --git a/packages/bun-usockets/src/bsd.c b/packages/bun-usockets/src/bsd.c index 69ef75395885..37a1abd41ec9 100644 --- a/packages/bun-usockets/src/bsd.c +++ b/packages/bun-usockets/src/bsd.c @@ -1261,6 +1261,10 @@ LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket_unix(const char *path, size_t l struct sockaddr_un server_address; size_t addrlen = 0; if (bsd_create_unix_socket_address(path, len, &dirfd_workaround_for_unix_path_len, &server_address, &addrlen)) { + /* The path could not be expressed as a sockaddr_un (the basename + * exceeds sun_path even with the dirfd workaround); surface the errno + * so the caller can report something better than a codeless failure. */ + if (error && errno) *error = errno; return LIBUS_SOCKET_ERROR; } @@ -1599,12 +1603,31 @@ static int is_loopback(struct sockaddr_storage *sockaddr) { } #endif -LIBUS_SOCKET_DESCRIPTOR bsd_create_connect_socket(struct sockaddr_storage *addr, int options) { +LIBUS_SOCKET_DESCRIPTOR bsd_create_connect_socket(struct sockaddr_storage *addr, struct sockaddr_storage *local_addr, int options) { LIBUS_SOCKET_DESCRIPTOR fd = bsd_create_socket(addr->ss_family, SOCK_STREAM, 0, NULL); if (fd == LIBUS_SOCKET_ERROR) { return LIBUS_SOCKET_ERROR; } + /* Bind to the requested local address/port before connecting (the + * `localAddress`/`localPort` connect options). A failure here - typically + * EADDRINUSE or EADDRNOTAVAIL - fails the connect with that errno. */ + if (local_addr) { + socklen_t local_len = local_addr->ss_family == AF_INET ? sizeof(struct sockaddr_in) : sizeof(struct sockaddr_in6); + if (bind(fd, (struct sockaddr *) local_addr, local_len)) { +#ifdef _WIN32 + int bind_err = WSAGetLastError(); + bsd_close_socket(fd); + WSASetLastError(bind_err); +#else + int bind_err = errno; + bsd_close_socket(fd); + errno = bind_err; +#endif + return LIBUS_SOCKET_ERROR; + } + } + #ifdef _WIN32 win32_set_nonblocking(fd); diff --git a/packages/bun-usockets/src/context.c b/packages/bun-usockets/src/context.c index ba72180749cf..de1fa728ce9b 100644 --- a/packages/bun-usockets/src/context.c +++ b/packages/bun-usockets/src/context.c @@ -475,8 +475,8 @@ static inline void us_internal_init_connect_socket(struct us_socket_t *s, struct us_socket_t *us_socket_group_connect_resolved_dns(struct us_socket_group_t *group, unsigned char kind, struct ssl_ctx_st *ssl_ctx, - struct sockaddr_storage *addr, int options, int socket_ext_size) { - LIBUS_SOCKET_DESCRIPTOR connect_socket_fd = bsd_create_connect_socket(addr, options); + struct sockaddr_storage *addr, struct sockaddr_storage *local_addr, int options, int socket_ext_size) { + LIBUS_SOCKET_DESCRIPTOR connect_socket_fd = bsd_create_connect_socket(addr, local_addr, options); if (connect_socket_fd == LIBUS_SOCKET_ERROR) { return NULL; } @@ -539,14 +539,22 @@ static bool try_parse_ip(const char *ip_str, int port, struct sockaddr_storage * } void *us_socket_group_connect(struct us_socket_group_t *group, unsigned char kind, - struct ssl_ctx_st *ssl_ctx, const char *host, int port, int options, + struct ssl_ctx_st *ssl_ctx, const char *host, int port, + const char *local_host, int local_port, int options, int socket_ext_size, int *has_dns_resolved) { struct us_loop_t *loop = group->loop; + /* The local address is always a literal IP (Node validates it as one). */ + struct sockaddr_storage local_addr_storage; + struct sockaddr_storage *local_addr = NULL; + if (local_host && try_parse_ip(local_host, local_port, &local_addr_storage)) { + local_addr = &local_addr_storage; + } + struct sockaddr_storage addr; if (try_parse_ip(host, port, &addr)) { *has_dns_resolved = 1; - return us_socket_group_connect_resolved_dns(group, kind, ssl_ctx, &addr, options, socket_ext_size); + return us_socket_group_connect_resolved_dns(group, kind, ssl_ctx, &addr, local_addr, options, socket_ext_size); } struct addrinfo_request *ai_req; @@ -563,7 +571,7 @@ void *us_socket_group_connect(struct us_socket_group_t *group, unsigned char kin struct sockaddr_storage a; init_addr_with_port(&entries->info, port, &a); *has_dns_resolved = 1; - struct us_socket_t *s = us_socket_group_connect_resolved_dns(group, kind, ssl_ctx, &a, options, socket_ext_size); + struct us_socket_t *s = us_socket_group_connect_resolved_dns(group, kind, ssl_ctx, &a, local_addr, options, socket_ext_size); Bun__addrinfo_freeRequest(ai_req, s == NULL); return s; } @@ -628,7 +636,8 @@ int start_connections(struct us_connecting_socket_t *c, int count) { for (; c->addrinfo_head != NULL && opened < count; c->addrinfo_head = c->addrinfo_head->ai_next) { struct sockaddr_storage addr; init_addr_with_port(c->addrinfo_head, c->port, &addr); - LIBUS_SOCKET_DESCRIPTOR connect_socket_fd = bsd_create_connect_socket(&addr, c->options); + /* The deferred-DNS path does not carry a local binding. */ + LIBUS_SOCKET_DESCRIPTOR connect_socket_fd = bsd_create_connect_socket(&addr, NULL, c->options); if (connect_socket_fd == LIBUS_SOCKET_ERROR) { continue; } diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index d2d7c8d613e7..faae49e8585b 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -20,6 +20,7 @@ #include "internal/internal.h" #include "libusockets.h" #include +#include #include #include @@ -35,6 +36,7 @@ void *sni_find(void *sni, const char *hostname); #include #include #include +#include #elif LIBUS_USE_WOLFSSL #include #include @@ -69,6 +71,16 @@ struct loop_ssl_data { BIO *shared_rbio; BIO *shared_wbio; BIO_METHOD *shared_biom; + /* The OpenSSL error string of the fatal SSL error that is about to close + * the current socket (set in the SSL_ERROR_SSL branch immediately before + * ssl_close, consumed by the handshake-failure dispatch inside that + * ssl_close, cleared after use). Lets 'wrong version number' and friends + * reach the JS 'tlsClientError' / client error the way Node reports them. */ + char ssl_last_fatal_error[256]; + /* The socket that parked ssl_last_fatal_error. The scratch is per-loop, so + * a reason parked by one socket must never be reported for another (a + * server and a client in the same process share this loop). */ + void *ssl_last_fatal_error_owner; }; enum { @@ -117,8 +129,23 @@ long us_ssl_ctx_live_count(void) { static int us_ctx_ex_idx = -1; static int us_sni_ex_idx = -1; static int us_ctx_cache_ex_idx = -1; +/* Marks an SSL_CTX whose verification store holds user-provided CAs (the + * ca/caFile options or a later addCACert): the per-socket client attach must + * not replace such a store with the process-shared default roots. */ +static int us_ctx_user_ca_ex_idx = -1; static int us_ssl_reneg_state_idx = -1; static int us_ssl_listener_ex_idx = -1; +/* Set (to a non-NULL marker) only on SSLs attached to a real us_socket_t via + * us_internal_ssl_attach. The new-session callback uses it to ignore SSLs + * owned by other engines (the JS-stream SSL wrapper used for TLS-over-duplex) + * whose BIOs do not point at the loop's shared BIO data. */ +static int us_ssl_is_socket_ex_idx = -1; +/* Defined in Rust (src/uws_sys/SocketKind.rs) so the ordinal tracks the enum. */ +extern const unsigned char BUN_SOCKET_KIND_BUN_SOCKET_TLS; +/* Serialized resumable session parked by the new-session callback until the + * SSL stack unwinds; freed with the SSL if never delivered. */ +static int us_ssl_pending_session_idx = -1; +static int us_ssl_pending_keylog_idx = -1; #ifdef _WIN32 static INIT_ONCE us_ex_idx_once = INIT_ONCE_STATIC_INIT; #else @@ -146,6 +173,134 @@ static void us_ssl_reneg_state_free(void *parent, void *ptr, CRYPTO_EX_DATA *ad, us_free(ptr); } +/* A new resumable session is ready (for TLS 1.3, the peer's NewSessionTicket + * was just processed; SSL_get_session() right after the handshake only returns + * an unresumable placeholder). This callback fires from inside + * SSL_read/SSL_do_handshake, where running JS could free the SSL out from + * under the caller - so it only serializes the session and parks it on the + * connection. ssl_flush_pending_session() hands it to the socket's session + * callback once the SSL stack has unwound. */ +struct us_ssl_pending_session_t { + struct us_ssl_pending_session_t *next; + int length; + unsigned char data[]; +}; +static void us_ssl_pending_session_free(void *parent, void *ptr, CRYPTO_EX_DATA *ad, + int index, long argl, void *argp) { + (void)parent; (void)ad; (void)index; (void)argl; (void)argp; + struct us_ssl_pending_session_t *pending = ptr; + while (pending) { + struct us_ssl_pending_session_t *next = pending->next; + free(pending); + pending = next; + } +} +/* NSS key-log lines are produced from inside SSL_do_handshake/SSL_read, so + * they are parked on the SSL the same way new sessions are and delivered once + * the read unwinds. The stored bytes already carry the trailing newline Node + * appends before emitting 'keylog'. */ +static void us_ssl_keylog_cb(const SSL *cssl, const char *line) { + SSL *ssl = (SSL *)cssl; + if (!SSL_get_ex_data(ssl, us_ssl_is_socket_ex_idx)) { + return; + } + size_t line_len = strlen(line); + if (line_len == 0 || line_len > 4096) { + return; + } + struct us_ssl_pending_session_t *pending = + malloc(sizeof(struct us_ssl_pending_session_t) + line_len + 1); + if (!pending) { + return; + } + memcpy(pending->data, line, line_len); + pending->data[line_len] = '\n'; + pending->length = (int)(line_len + 1); + pending->next = NULL; + struct us_ssl_pending_session_t *head = SSL_get_ex_data(ssl, us_ssl_pending_keylog_idx); + if (!head) { + SSL_set_ex_data(ssl, us_ssl_pending_keylog_idx, pending); + } else { + while (head->next) head = head->next; + head->next = pending; + } +} + +static void ssl_flush_pending_keylog(struct us_socket_t *s) { + if (!s->ssl || us_socket_is_closed(s)) { + return; + } + struct us_ssl_pending_session_t *pending = + SSL_get_ex_data(s->ssl, us_ssl_pending_keylog_idx); + if (!pending) { + return; + } + SSL_set_ex_data(s->ssl, us_ssl_pending_keylog_idx, NULL); + while (pending) { + struct us_ssl_pending_session_t *next = pending->next; + if (!us_socket_is_closed(s) && s->ssl) { + us_dispatch_keylog(s, pending->data, pending->length); + } + free(pending); + pending = next; + } +} + +static int us_ssl_new_session_cb(SSL *ssl, SSL_SESSION *session) { + /* Only SSLs attached to a real us_socket_t set this marker; for any other + * owner (the JS-stream SSL wrapper used for TLS-over-duplex) there is no + * dispatch path for the parked session. */ + if (!SSL_get_ex_data(ssl, us_ssl_is_socket_ex_idx)) { + return 0; + } + int length = i2d_SSL_SESSION(session, NULL); + if (length <= 0 || length > 65536) { + return 0; + } + struct us_ssl_pending_session_t *pending = + malloc(sizeof(struct us_ssl_pending_session_t) + (size_t)length); + if (!pending) { + return 0; + } + unsigned char *out = pending->data; + pending->length = i2d_SSL_SESSION(session, &out); + pending->next = NULL; + /* Append: each NewSessionTicket is a distinct resumable session and gets + * its own 'session' event, in arrival order. */ + struct us_ssl_pending_session_t *head = SSL_get_ex_data(ssl, us_ssl_pending_session_idx); + if (!head) { + SSL_set_ex_data(ssl, us_ssl_pending_session_idx, pending); + } else { + while (head->next) head = head->next; + head->next = pending; + } + /* 0: we serialized a copy; the caller keeps ownership of `session`. */ + return 0; +} + +/* Deliver a session parked by the new-session callback. Must only be called + * once the SSL_read/SSL_do_handshake that parked it has returned; the JS it + * runs may close the socket, so callers must check ssl_gone(s) afterwards. */ +static void ssl_flush_pending_session(struct us_socket_t *s) { + if (!s->ssl || us_socket_is_closed(s)) { + return; + } + struct us_ssl_pending_session_t *pending = + SSL_get_ex_data(s->ssl, us_ssl_pending_session_idx); + if (!pending) { + return; + } + SSL_set_ex_data(s->ssl, us_ssl_pending_session_idx, NULL); + while (pending) { + struct us_ssl_pending_session_t *next = pending->next; + if (!us_socket_is_closed(s) && s->ssl) { + us_dispatch_session(s, pending->data, pending->length); + } + free(pending); + pending = next; + } +} + /* Defined in Zig (`SSLContextCache.zig`): tombstones the cache entry on * SSL_CTX refcount→0 so the per-VM weak SSL_CTX cache learns the pointer is * dead without holding a ref of its own. */ @@ -156,8 +311,12 @@ static void us_ex_idx_init(void) { us_ctx_ex_idx = SSL_CTX_get_ex_new_index(0, NULL, NULL, NULL, us_ctx_ex_free); us_sni_ex_idx = SSL_CTX_get_ex_new_index(0, NULL, NULL, NULL, NULL); us_ctx_cache_ex_idx = SSL_CTX_get_ex_new_index(0, NULL, NULL, NULL, bun_ssl_ctx_cache_on_free); + us_ctx_user_ca_ex_idx = SSL_CTX_get_ex_new_index(0, NULL, NULL, NULL, NULL); us_ssl_reneg_state_idx = SSL_get_ex_new_index(0, NULL, NULL, NULL, us_ssl_reneg_state_free); us_ssl_listener_ex_idx = SSL_get_ex_new_index(0, NULL, NULL, NULL, NULL); + us_ssl_is_socket_ex_idx = SSL_get_ex_new_index(0, NULL, NULL, NULL, NULL); + us_ssl_pending_session_idx = SSL_get_ex_new_index(0, NULL, NULL, NULL, us_ssl_pending_session_free); + us_ssl_pending_keylog_idx = SSL_get_ex_new_index(0, NULL, NULL, NULL, us_ssl_pending_session_free); } #ifdef _WIN32 @@ -235,6 +394,30 @@ static long BIO_s_custom_ctrl(BIO *bio, int cmd, long num, void *user) { } } +/* Save/restore the per-loop BIO routing state around a JS callback that runs + * from inside SSL_do_handshake/SSL_read: user JS that writes to or destroys a + * different TLS socket on the same loop re-points loop_ssl_data->ssl_socket + * (and may consume the read-input window), and the interrupted handshake's + * next BIO_write would otherwise land on that other socket's fd. */ +void us_internal_ssl_loop_state_save(void *ssl_ptr, void **out) { + SSL *ssl = (SSL *)ssl_ptr; + struct loop_ssl_data *d = (struct loop_ssl_data *)BIO_get_data(SSL_get_wbio(ssl)); + out[0] = d; + out[1] = d ? (void *)d->ssl_socket : NULL; + out[2] = d ? (void *)d->ssl_read_input : NULL; + out[3] = d ? (void *)(uintptr_t)d->ssl_read_input_length : NULL; + out[4] = d ? (void *)(uintptr_t)d->ssl_read_input_offset : NULL; +} + +void us_internal_ssl_loop_state_restore(void **saved) { + struct loop_ssl_data *d = (struct loop_ssl_data *)saved[0]; + if (!d) return; + d->ssl_socket = (struct us_socket_t *)saved[1]; + d->ssl_read_input = (char *)saved[2]; + d->ssl_read_input_length = (unsigned int)(uintptr_t)saved[3]; + d->ssl_read_input_offset = (unsigned int)(uintptr_t)saved[4]; +} + static int BIO_s_custom_write(BIO *bio, const char *data, int length) { struct loop_ssl_data *loop_ssl_data = (struct loop_ssl_data *)BIO_get_data(bio); @@ -372,7 +555,24 @@ static int add_ca_cert_to_ctx_store(SSL_CTX *ctx, const char *content, X509_STOR } end: BIO_free(in); - return count > 0; + if (count == 0) { + /* The PEM loop terminates with PEM_R_NO_START_LINE once there are no + * (more) CERTIFICATE blocks. A PEM document that contains no + * certificates at all - Node's test suite passes a private key here - is + * ignored the way Node ignores it rather than failing the whole context. + * Content that is not PEM at all, or a malformed certificate block, is + * still an error. */ + unsigned long pem_err = ERR_peek_last_error(); + if ((pem_err == 0 || (ERR_GET_LIB(pem_err) == ERR_LIB_PEM && + ERR_GET_REASON(pem_err) == PEM_R_NO_START_LINE)) && + strstr(content, "-----BEGIN ") != NULL) { + ERR_clear_error(); + return 1; + } + return 0; + } + ERR_clear_error(); + return 1; } static int us_ssl_ctx_use_certificate_chain(SSL_CTX *ctx, const char *content) { @@ -469,7 +669,12 @@ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options, /* Default options we rely on — changing these breaks the BIO logic. */ SSL_CTX_set_read_ahead(ssl_context, 1); SSL_CTX_set_mode(ssl_context, SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER); - SSL_CTX_set_min_proto_version(ssl_context, TLS1_2_VERSION); + /* Honor explicit minVersion/maxVersion (Node's secureProtocol/min/maxVersion); + * default to a TLS1.2 floor when no minimum is requested. */ + SSL_CTX_set_min_proto_version(ssl_context, options.ssl_min_version ? options.ssl_min_version : TLS1_2_VERSION); + if (options.ssl_max_version) { + SSL_CTX_set_max_proto_version(ssl_context, options.ssl_max_version); + } if (options.ssl_prefer_low_memory_usage) { SSL_CTX_set_mode(ssl_context, SSL_MODE_RELEASE_BUFFERS); @@ -484,31 +689,52 @@ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options, SSL_CTX_set_default_passwd_cb(ssl_context, passphrase_cb); } - if (options.cert_file_name) { - if (SSL_CTX_use_certificate_chain_file(ssl_context, options.cert_file_name) != 1) { - ssl_ctx_build_fail(ssl_context); - return NULL; - } - } else if (options.cert && options.cert_count > 0) { + /* Multiple identities (e.g. an RSA and an EC pair, the way Node accepts + * arrays of key/cert or several pfx entries) must be loaded pair-wise: + * loading every certificate first and then every key makes BoringSSL check + * each key against the last certificate loaded and fail with + * KEY_TYPE_MISMATCH on a mixed configuration. With pair-wise loading the + * later identity replaces the earlier one in the legacy slot, which is the + * documented BoringSSL behaviour the adapted tests expect. */ + int interleave_identities = !options.cert_file_name && !options.key_file_name && + options.cert && options.key && + options.cert_count == options.key_count && + options.cert_count > 1; + if (interleave_identities) { for (unsigned int i = 0; i < options.cert_count; i++) { - if (us_ssl_ctx_use_certificate_chain(ssl_context, options.cert[i]) != 1) { + if (us_ssl_ctx_use_certificate_chain(ssl_context, options.cert[i]) != 1 || + us_ssl_ctx_use_privatekey_content(ssl_context, options.key[i], SSL_FILETYPE_PEM) != 1) { ssl_ctx_build_fail(ssl_context); return NULL; } } - } - - if (options.key_file_name) { - if (SSL_CTX_use_PrivateKey_file(ssl_context, options.key_file_name, SSL_FILETYPE_PEM) != 1) { - ssl_ctx_build_fail(ssl_context); - return NULL; + } else { + if (options.cert_file_name) { + if (SSL_CTX_use_certificate_chain_file(ssl_context, options.cert_file_name) != 1) { + ssl_ctx_build_fail(ssl_context); + return NULL; + } + } else if (options.cert && options.cert_count > 0) { + for (unsigned int i = 0; i < options.cert_count; i++) { + if (us_ssl_ctx_use_certificate_chain(ssl_context, options.cert[i]) != 1) { + ssl_ctx_build_fail(ssl_context); + return NULL; + } + } } - } else if (options.key && options.key_count > 0) { - for (unsigned int i = 0; i < options.key_count; i++) { - if (us_ssl_ctx_use_privatekey_content(ssl_context, options.key[i], SSL_FILETYPE_PEM) != 1) { + + if (options.key_file_name) { + if (SSL_CTX_use_PrivateKey_file(ssl_context, options.key_file_name, SSL_FILETYPE_PEM) != 1) { ssl_ctx_build_fail(ssl_context); return NULL; } + } else if (options.key && options.key_count > 0) { + for (unsigned int i = 0; i < options.key_count; i++) { + if (us_ssl_ctx_use_privatekey_content(ssl_context, options.key[i], SSL_FILETYPE_PEM) != 1) { + ssl_ctx_build_fail(ssl_context); + return NULL; + } + } } } /* passwd_cb is only consulted by SSL_CTX_use_PrivateKey* above; the secret @@ -528,6 +754,8 @@ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options, return NULL; } SSL_CTX_set_client_CA_list(ssl_context, ca_list); + us_ex_idx_ensure(); + SSL_CTX_set_ex_data(ssl_context, us_ctx_user_ca_ex_idx, (void *)1); if (SSL_CTX_load_verify_locations(ssl_context, options.ca_file_name, NULL) != 1) { *err = CREATE_BUN_SOCKET_ERROR_INVALID_CA_FILE; ssl_ctx_build_fail(ssl_context); @@ -539,6 +767,8 @@ 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) { + us_ex_idx_ensure(); + SSL_CTX_set_ex_data(ssl_context, us_ctx_user_ca_ex_idx, (void *)1); /* 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. */ @@ -556,7 +786,10 @@ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options, us_verify_callback); } } else if (options.request_cert) { - SSL_CTX_set_cert_store(ssl_context, us_get_default_ca_store()); + /* No per-config CAs are added to this store, so the process-wide shared + * copy (built once) can be used instead of re-parsing the ~150 bundled + * roots for every context - the same approach as Node's root_cert_store. */ + SSL_CTX_set_cert_store(ssl_context, us_get_shared_default_ca_store()); SSL_CTX_set_verify(ssl_context, options.reject_unauthorized ? (SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT) : SSL_VERIFY_PEER, @@ -591,7 +824,9 @@ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options, if (options.ssl_ciphers) { if (!SSL_CTX_set_cipher_list(ssl_context, options.ssl_ciphers)) { - unsigned long ssl_err = ERR_get_error(); + /* Peek, don't consume: the caller decomposes the queued reason + * (NO_CIPHER_MATCH, INVALID_COMMAND) into the JS error. */ + unsigned long ssl_err = ERR_peek_error(); if (!(strlen(options.ssl_ciphers) == 0 && ERR_GET_REASON(ssl_err) == SSL_R_NO_CIPHER_MATCH)) { *err = CREATE_BUN_SOCKET_ERROR_INVALID_CIPHERS; ssl_ctx_build_fail(ssl_context); @@ -605,9 +840,163 @@ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options, SSL_CTX_set_options(ssl_context, options.secure_options); } + /* Surface resumable sessions through the new-session callback the way Node + * does: for TLS 1.3 the resumable session only exists once the peer's + * NewSessionTicket arrives, and BoringSSL only exposes it here. NO_INTERNAL + * keeps BoringSSL from also caching it. */ + SSL_CTX_set_session_cache_mode(ssl_context, SSL_SESS_CACHE_CLIENT | + SSL_SESS_CACHE_SERVER | + SSL_SESS_CACHE_NO_INTERNAL | + SSL_SESS_CACHE_NO_AUTO_CLEAR); + SSL_CTX_sess_set_new_cb(ssl_context, us_ssl_new_session_cb); + SSL_CTX_set_keylog_callback(ssl_context, us_ssl_keylog_cb); return ssl_context; } +/* node:tls `secureContext.context.addCACert(pem)`: append the certificates in + * `content` to this context's trust store. Returns 0 when the content is not + * a PEM document or contains a malformed certificate. */ +int us_ssl_ctx_add_ca_cert(SSL_CTX *ctx, const char *content) { + if (!ctx || !content) { + return 0; + } + X509_STORE *store = SSL_CTX_get_cert_store(ctx); + /* Clone-on-write: a context that shares the process-wide default root + * store must get its own copy before a CA is appended, or the addition + * would be visible to every other context in the process - the same + * root_cert_store check Node's SecureContext::AddCACert performs. + * us_get_shared_default_ca_store() up-refs before returning, so release + * the reference taken just for this comparison. */ + X509_STORE *shared = us_get_shared_default_ca_store(); + int store_is_shared = store && store == shared; + X509_STORE_free(shared); + /* A default context built without ca/requestCert keeps the empty store from + * SSL_CTX_new() (verification for it normally comes from the per-socket + * shared-root override). addCACert must EXTEND the default trust set the + * way Node does, so when the store is the shared one - or still empty - + * replace it with a fresh full default store (bundled roots, NODE_EXTRA_CA + * certificates, system CAs when enabled) before appending the user's CA. */ + int store_is_empty = 0; + if (store && !store_is_shared) { + const STACK_OF(X509_OBJECT) *objs = X509_STORE_get0_objects(store); + store_is_empty = objs == NULL || sk_X509_OBJECT_num(objs) == 0; + } + if (store_is_shared || store_is_empty) { + X509_STORE *own = us_get_default_ca_store(); + if (!own) { + return 0; + } + SSL_CTX_set_cert_store(ctx, own); + store = own; + } + if (!store) { + return 0; + } + us_ex_idx_ensure(); + SSL_CTX_set_ex_data(ctx, us_ctx_user_ca_ex_idx, (void *)1); + return add_ca_cert_to_ctx_store(ctx, content, store); +} + +/* node:tls `pfx` support: parse a PKCS#12 blob and hand back PEM-encoded + * key / certificate / extra-chain strings the regular key/cert/ca options can + * consume. Returns 1 on success; the three out-strings are malloc'd and the + * caller frees them with free(). On failure returns 0 and sets *err_reason to + * a static tag: "parse" (not PKCS#12), "mac" (bad passphrase / corrupt), + * "key" (no private key), "cert" (no certificate). */ +static int pem_from_bio(BIO *bio, char **out, size_t *out_len) { + char *mem = NULL; + long n = BIO_get_mem_data(bio, &mem); + if (n <= 0 || !mem) return 0; + char *copy = (char *)malloc((size_t)n + 1); + if (!copy) return 0; + memcpy(copy, mem, (size_t)n); + copy[n] = 0; + *out = copy; + *out_len = (size_t)n; + return 1; +} + +int us_ssl_parse_pkcs12(const char *data, size_t len, const char *pass, + char **out_key, size_t *out_key_len, + char **out_cert, size_t *out_cert_len, + char **out_ca, size_t *out_ca_len, + const char **err_reason) { + *out_key = *out_cert = *out_ca = NULL; + *out_key_len = *out_cert_len = *out_ca_len = 0; + *err_reason = NULL; + int ok = 0; + EVP_PKEY *pkey = NULL; + X509 *cert = NULL; + STACK_OF(X509) *extra = NULL; + PKCS12 *p12 = NULL; + BIO *kb = NULL, *cb = NULL, *ab = NULL; + if (len > INT_MAX) { + /* BIO_new_mem_buf takes an int; a negative value would mean + * "treat as a NUL-terminated string", silently misparsing the blob. */ + *err_reason = "parse"; + return 0; + } + BIO *in = BIO_new_mem_buf(data, (int)len); + if (!in) { + *err_reason = "parse"; + return 0; + } + p12 = d2i_PKCS12_bio(in, NULL); + BIO_free(in); + if (!p12) { + *err_reason = "parse"; + ERR_clear_error(); + return 0; + } + if (!PKCS12_parse(p12, pass ? pass : "", &pkey, &cert, &extra)) { + *err_reason = "mac"; + ERR_clear_error(); + goto done; + } + if (!pkey) { + *err_reason = "key"; + goto done; + } + if (!cert) { + *err_reason = "cert"; + goto done; + } + kb = BIO_new(BIO_s_mem()); + cb = BIO_new(BIO_s_mem()); + if (!kb || !cb || !PEM_write_bio_PrivateKey(kb, pkey, NULL, NULL, 0, NULL, NULL) || + !PEM_write_bio_X509(cb, cert) || !pem_from_bio(kb, out_key, out_key_len) || + !pem_from_bio(cb, out_cert, out_cert_len)) { + *err_reason = "parse"; + goto done; + } + if (extra && sk_X509_num(extra) > 0) { + ab = BIO_new(BIO_s_mem()); + if (ab) { + for (size_t i = 0; i < sk_X509_num(extra); i++) { + PEM_write_bio_X509(ab, sk_X509_value(extra, i)); + } + pem_from_bio(ab, out_ca, out_ca_len); + } + } + ok = 1; +done: + if (!ok) { + free(*out_key); + free(*out_cert); + free(*out_ca); + *out_key = *out_cert = *out_ca = NULL; + } + if (kb) BIO_free(kb); + if (cb) BIO_free(cb); + if (ab) BIO_free(ab); + if (pkey) EVP_PKEY_free(pkey); + if (cert) X509_free(cert); + if (extra) sk_X509_pop_free(extra, X509_free); + if (p12) PKCS12_free(p12); + ERR_clear_error(); + return ok; +} + SSL_CTX *us_ssl_ctx_from_options(struct us_bun_socket_context_options_t options, enum create_bun_socket_error_t *err) { SSL_CTX *ctx = us_ssl_ctx_build_raw(options, err); @@ -652,6 +1041,22 @@ void us_internal_ssl_attach(struct us_socket_t *s, SSL_CTX *ctx, struct loop_ssl_data *loop_ssl_data = (struct loop_ssl_data *)s->group->loop->data.ssl_data; SSL *ssl = SSL_new(ctx); + /* Only Bun.connect / node:tls sockets surface the 'session' event; tagging + * just those keeps the new-session callback a no-op for every other TLS + * consumer (fetch, Bun.serve, postgres, websockets) instead of serializing + * a session per handshake that the dispatch then discards. */ + /* The listener's own kind is always 0; the kind it assigns to accepted + * sockets lives in accept_kind and may not have been copied onto `s` yet + * when its SSL is initialized. */ + if (ssl && (us_socket_kind(s) == BUN_SOCKET_KIND_BUN_SOCKET_TLS || + (listener && listener->accept_kind == BUN_SOCKET_KIND_BUN_SOCKET_TLS))) { + /* The very first TLS attach in a process can be a client connection, and + * nothing on that path has registered the ex_data indices yet - using the + * still--1 index would make CRYPTO_set_ex_data grow its slot array toward + * (size_t)-1. */ + us_ex_idx_ensure(); + SSL_set_ex_data(ssl, us_ssl_is_socket_ex_idx, (void *)1); + } SSL_set_bio(ssl, loop_ssl_data->shared_rbio, loop_ssl_data->shared_wbio); BIO_up_ref(loop_ssl_data->shared_rbio); BIO_up_ref(loop_ssl_data->shared_wbio); @@ -673,8 +1078,15 @@ void us_internal_ssl_attach(struct us_socket_t *s, SSL_CTX *ctx, * never aborts here — JS reads verify_error and decides. */ if (SSL_CTX_get_verify_mode(ctx) == SSL_VERIFY_NONE) { SSL_set_verify(ssl, SSL_VERIFY_PEER, us_verify_callback); - X509_STORE *roots = us_get_shared_default_ca_store(); - if (roots) SSL_set0_verify_cert_store(ssl, roots); + us_ex_idx_ensure(); + if (!SSL_CTX_get_ex_data(ctx, us_ctx_user_ca_ex_idx)) { + /* Default context: give this socket the process-shared root bundle. + * A context whose store holds user-provided CAs (ca/caFile options or + * addCACert) keeps using its own store - overriding it here would + * hide those CAs from chain verification. */ + X509_STORE *roots = us_get_shared_default_ca_store(); + if (roots) SSL_set0_verify_cert_store(ssl, roots); + } } } else { SSL_set_accept_state(ssl); @@ -690,11 +1102,22 @@ void us_internal_ssl_attach(struct us_socket_t *s, SSL_CTX *ctx, s->ssl_read_wants_write = 0; s->ssl_fatal_error = 0; s->ssl_raw_tap = 0; + s->ssl_in_use = 0; + s->ssl_pending_detach = 0; + s->ssl_pending_close_code = 0; s->ssl_is_server = is_client ? 0 : 1; } void us_internal_ssl_detach(struct us_socket_t *s) { if (s->ssl) { + if (s->ssl_in_use) { + /* SSL_do_handshake/SSL_read is on the stack (a JS callback run from + * inside it destroyed the socket); freeing now would leave BoringSSL + * working on freed memory when control returns. The driver frees it + * when the call unwinds. */ + s->ssl_pending_detach = 1; + return; + } SSL_free(s_ssl(s)); s->ssl = NULL; } @@ -780,14 +1203,49 @@ struct us_bun_verify_error_t us_internal_ssl_verify_error(struct us_socket_t *s) /* The on_handshake callback runs JS which may us_socket_close(s) — that frees * s->ssl. Every caller MUST check ssl_gone(s) immediately after this returns * and bail before touching s->ssl again. */ +/* If a fatal handshake reason was parked by `s`, dispatch it as the EPROTO + * failure for `s` and return 1; the per-loop scratch is copied to the stack + * and cleared before the dispatch runs JS. Returns 0 when nothing was parked + * for this socket. */ +static int ssl_dispatch_parked_reason(struct us_socket_t *s) { + struct loop_ssl_data *loop_ssl_data = + (struct loop_ssl_data *) s->group->loop->data.ssl_data; + if (!loop_ssl_data || !loop_ssl_data->ssl_last_fatal_error[0] || + loop_ssl_data->ssl_last_fatal_error_owner != (void *)s) { + return 0; + } + char reason[sizeof(loop_ssl_data->ssl_last_fatal_error)]; + memcpy(reason, loop_ssl_data->ssl_last_fatal_error, sizeof(reason)); + loop_ssl_data->ssl_last_fatal_error[0] = 0; + loop_ssl_data->ssl_last_fatal_error_owner = NULL; + struct us_bun_verify_error_t verify_error = { + .error = -71, .code = "EPROTO", .reason = reason}; + us_dispatch_handshake(s, 0, verify_error); + return 1; +} + static void ssl_trigger_handshake(struct us_socket_t *s, int success) { s->ssl_handshake_state = HANDSHAKE_COMPLETED; + /* A fatal SSL protocol error (wrong version number, bad record, ...) was + * recorded just before this failure: report it instead of the X509 verify + * result so Node's tlsClientError / client error carries the OpenSSL + * reason string. */ + if (!success && ssl_dispatch_parked_reason(s)) { + return; + } struct us_bun_verify_error_t verify_error = us_internal_ssl_verify_error(s); us_dispatch_handshake(s, success, verify_error); } static void ssl_trigger_handshake_econnreset(struct us_socket_t *s) { s->ssl_handshake_state = HANDSHAKE_COMPLETED; + /* A fatal SSL protocol error (wrong version number, bad record, ...) was + * recorded just before this close: report it instead of the generic + * disconnected-before-established message so Node's tlsClientError / + * client error carries the OpenSSL reason. */ + if (ssl_dispatch_parked_reason(s)) { + return; + } struct us_bun_verify_error_t verify_error = { .error = -46, .code = "ECONNRESET", .reason = "Client network socket disconnected before secure TLS connection was established"}; @@ -882,6 +1340,16 @@ static int ssl_handle_shutdown(struct us_socket_t *s, int force_fast_shutdown) { } struct us_socket_t *us_internal_ssl_close(struct us_socket_t *s, int code, void *reason) { + if (s->ssl && s->ssl_in_use) { + /* A JS callback running from inside SSL_do_handshake/SSL_read (ALPN, SNI, + * keylog, ...) destroyed this socket. Reaching ssl_set_loop_data / + * SSL_do_handshake here would re-enter BoringSSL on the same SSL* while + * the outer ssl_run_handshake is still on the stack; defer to the SSL + * driver's epilogue (the same protocol close_raw and ssl_detach honor). */ + s->ssl_pending_detach = 1; + s->ssl_pending_close_code = (unsigned char) code; + return s; + } /* SEMI_SOCKET never connected — SSL was attached eagerly on the fast-path * connect, but no bytes were ever exchanged. Firing on_handshake(0) here * lands in JS after onConnectError already tore down `this`/its handlers. */ @@ -919,6 +1387,11 @@ struct us_socket_t *us_internal_ssl_close(struct us_socket_t *s, int code, void #define ssl_close us_internal_ssl_close static void ssl_update_handshake(struct us_socket_t *s) { + /* The OpenSSL error queue is per-thread and another socket's failure (a + * server and a client commonly share this thread) may have left entries on + * it; clear it before this socket's handshake step so any reason captured + * below genuinely belongs to this socket's own failure. */ + ERR_clear_error(); if (!s->ssl || s->ssl_handshake_state != HANDSHAKE_PENDING) return; /* SSL_read may have driven the handshake to completion before we got here @@ -938,7 +1411,17 @@ static void ssl_update_handshake(struct us_socket_t *s) { return; } + unsigned char ssl_was_in_use = s->ssl_in_use; + s->ssl_in_use = 1; int result = SSL_do_handshake(s_ssl(s)); + s->ssl_in_use = ssl_was_in_use; + if (!ssl_was_in_use && s->ssl_pending_detach) { + /* A callback run from inside the handshake destroyed this socket; perform + * the deferred close now and do not touch the SSL again. */ + s->ssl_pending_detach = 0; + us_socket_close(s, s->ssl_pending_close_code, NULL); + return; + } if (SSL_get_shutdown(s_ssl(s)) & SSL_RECEIVED_SHUTDOWN) { ssl_close(s, 0, NULL); @@ -949,6 +1432,14 @@ static void ssl_update_handshake(struct us_socket_t *s) { int err = SSL_get_error(s_ssl(s), result); if (err != SSL_ERROR_WANT_READ && err != SSL_ERROR_WANT_WRITE) { if (err == SSL_ERROR_SSL || err == SSL_ERROR_SYSCALL) { + struct loop_ssl_data *loop_ssl_data = + (struct loop_ssl_data *) s->group->loop->data.ssl_data; + unsigned long ssl_queue_err = ERR_peek_last_error(); + if (loop_ssl_data && ssl_queue_err != 0) { + ERR_error_string_n(ssl_queue_err, loop_ssl_data->ssl_last_fatal_error, + sizeof(loop_ssl_data->ssl_last_fatal_error)); + loop_ssl_data->ssl_last_fatal_error_owner = s; + } ERR_clear_error(); s->ssl_fatal_error = 1; } @@ -989,8 +1480,15 @@ struct us_socket_t *us_internal_ssl_on_close(struct us_socket_t *s, int code, vo struct us_socket_t *us_internal_ssl_on_end(struct us_socket_t *s) { ssl_set_loop_data(s); - /* TCP FIN under TLS — send our close_notify (if not already) and raw-close. */ - return ssl_close(s, 0, NULL); + /* TCP FIN under TLS: the peer's write side is gone, so no close_notify reply + * is coming. Send ours best-effort and raw-close now — deferring (the + * code==0 path in ssl_close) would wait forever, and with native + * allowHalfOpen=true the loop.c caller no longer raw-closes for us. */ + s = ssl_close(s, 0, NULL); + if (s && !us_socket_is_closed(s)) { + s = us_internal_socket_close_raw(s, LIBUS_SOCKET_CLOSE_CODE_CLEAN_SHUTDOWN, NULL); + } + return s; } struct us_socket_t *us_internal_ssl_on_writable(struct us_socket_t *s) { @@ -1015,6 +1513,19 @@ struct us_socket_t *us_internal_ssl_on_writable(struct us_socket_t *s) { } struct us_socket_t *us_internal_ssl_on_data(struct us_socket_t *s, char *data, int length) { + /* See ssl_update_handshake: start this socket's SSL processing with a clean + * per-thread error queue so a captured reason cannot belong to another + * socket on the same thread. */ + ERR_clear_error(); + /* An accepted node:tls socket's kind is only assigned after its SSL was + * attached, so the is-a-bun-socket marker the session/keylog callbacks key + * on may still be missing. Set it lazily before the SSL_read that will + * fire those callbacks. */ + if (s->ssl && us_socket_kind(s) == BUN_SOCKET_KIND_BUN_SOCKET_TLS && + !SSL_get_ex_data(s->ssl, us_ssl_is_socket_ex_idx)) { + us_ex_idx_ensure(); + SSL_set_ex_data(s->ssl, us_ssl_is_socket_ex_idx, (void *)1); + } /* upgradeTLS [raw, _] half observes ciphertext before SSL_read consumes it. * Skip the empty-flush call from on_writable (length==0 → no real wire bytes). */ if (s->ssl_raw_tap && length > 0) { @@ -1028,7 +1539,13 @@ struct us_socket_t *us_internal_ssl_on_data(struct us_socket_t *s, char *data, i loop_ssl_data->ssl_read_input_length = length; if (us_socket_is_closed(s)) return NULL; - if (us_internal_ssl_is_shut_down(s)) { + /* SENT_SHUTDOWN alone (TLS half-close from `socket.shutdown()` / node:tls + * `_final`) must NOT skip the read loop — the peer may still have + * application data in flight that has to be delivered before its + * close_notify (handled as ZERO_RETURN below) closes us. Only bail when + * reading is genuinely impossible. */ + if (us_internal_poll_type(&s->p) == POLL_TYPE_SOCKET_SHUT_DOWN || + !s->ssl || !s_ssl(s) || s->ssl_fatal_error) { ssl_close(s, 0, NULL); return NULL; } @@ -1043,9 +1560,18 @@ struct us_socket_t *us_internal_ssl_on_data(struct us_socket_t *s, char *data, i int read = 0; restart: while (1) { + unsigned char ssl_was_in_use = s->ssl_in_use; + s->ssl_in_use = 1; int just_read = SSL_read(s_ssl(s), loop_ssl_data->ssl_read_output + LIBUS_RECV_BUFFER_PADDING + read, LIBUS_RECV_BUFFER_LENGTH - read); + s->ssl_in_use = ssl_was_in_use; + if (!ssl_was_in_use && s->ssl_pending_detach) { + /* A callback run from inside this read destroyed the socket; perform + * the deferred close now and stop processing. */ + s->ssl_pending_detach = 0; + return us_socket_close(s, s->ssl_pending_close_code, NULL); + } if (just_read <= 0) { int err = SSL_get_error(s_ssl(s), just_read); @@ -1060,15 +1586,36 @@ struct us_socket_t *us_internal_ssl_on_data(struct us_socket_t *s, char *data, i s = us_dispatch_data(s, loop_ssl_data->ssl_read_output + LIBUS_RECV_BUFFER_PADDING, read); if (!s || ssl_gone(s)) return NULL; } + /* A NewSessionTicket that rode in with the close_notify was parked + * by the new-session callback; deliver it before the close tears + * the connection down. */ + ssl_flush_pending_session(s); + ssl_flush_pending_keylog(s); + if (ssl_gone(s)) return NULL; ssl_close(s, 0, NULL); return NULL; } if (err == SSL_ERROR_SSL || err == SSL_ERROR_SYSCALL) { + /* Only park the reason while the handshake is still pending - that + * is the only consumer (the close path's EPROTO dispatch). For a + * completed handshake nothing reads it for this socket, and the + * ssl_close below runs JS that could tear down a different + * mid-handshake socket on this loop, which would then pick up this + * socket's reason as its own. */ + if (s->ssl_handshake_state != HANDSHAKE_COMPLETED) { + unsigned long ssl_queue_err = ERR_peek_last_error(); + if (ssl_queue_err != 0) { + ERR_error_string_n(ssl_queue_err, loop_ssl_data->ssl_last_fatal_error, + sizeof(loop_ssl_data->ssl_last_fatal_error)); + loop_ssl_data->ssl_last_fatal_error_owner = s; + } + } ERR_clear_error(); s->ssl_fatal_error = 1; } ssl_close(s, 0, NULL); + loop_ssl_data->ssl_last_fatal_error[0] = 0; return NULL; } else { if (err == SSL_ERROR_WANT_WRITE) s->ssl_read_wants_write = 1; @@ -1144,6 +1691,13 @@ struct us_socket_t *us_internal_ssl_on_data(struct us_socket_t *s, char *data, i if (!s || ssl_gone(s)) return NULL; } + /* The SSL_read loop above is fully unwound; deliver any session the + * new-session callback parked while it ran. The JS this dispatches may + * close the socket. */ + ssl_flush_pending_session(s); + ssl_flush_pending_keylog(s); + if (ssl_gone(s)) return NULL; + return s; } @@ -1237,17 +1791,28 @@ void us_internal_ssl_handshake_abort(struct us_socket_t *s) { /* ── Adopt-TLS (STARTTLS / Bun.connect upgrade) ──────────────────────────── */ +/* Feed bytes that were already read off the wire (e.g. a ClientHello consumed + * by the plain-TCP layer before the socket was adopted into TLS) through the + * same decrypt path as bytes arriving from the kernel. */ +struct us_socket_t *us_socket_tls_feed(struct us_socket_t *s, const char *data, int length) { + if (us_socket_is_closed(s) || !s->ssl || length <= 0) return s; + return us_internal_ssl_on_data(s, (char *)data, length); +} + struct us_socket_t *us_socket_adopt_tls(struct us_socket_t *s, struct us_socket_group_t *group, unsigned char kind, struct ssl_ctx_st *ssl_ctx, - const char *sni, int old_ext_size, + const char *sni, int is_client, int old_ext_size, int ext_size) { if (us_socket_is_closed(s)) return NULL; struct us_socket_t *new_s = us_socket_adopt(s, group, kind, old_ext_size, ext_size); if (!new_s) return NULL; - us_internal_ssl_attach(new_s, ssl_ctx, /*is_client*/1, sni, NULL); + /* is_client=0 puts the SSL in accept state (server-side upgrade, e.g. + * `new tls.TLSSocket(acceptedSocket, { isServer: true })`); there is no + * listener for an adopted socket, so SNI resolves from the single ssl_ctx. */ + us_internal_ssl_attach(new_s, ssl_ctx, is_client, sni, NULL); us_socket_resume(new_s); /* Do NOT kick the handshake or dispatch on_open here — the caller hasn't * repointed the ext slot yet, so any dispatch (open/handshake/close) would @@ -1273,13 +1838,7 @@ static void sni_node_destructor(void *user) { static struct sni_node_t *resolve_listener_ctx(struct us_listen_socket_t *ls, const char *hostname) { if (!ls->sni) return NULL; - struct sni_node_t *node = (struct sni_node_t *)sni_find(ls->sni, hostname); - if (!node) { - if (!ls->on_server_name) return NULL; - ls->on_server_name(ls, hostname); - node = (struct sni_node_t *)sni_find(ls->sni, hostname); - } - return node; + return (struct sni_node_t *)sni_find(ls->sni, hostname); } static int sni_cb(SSL *ssl, int *al, void *arg) { @@ -1293,7 +1852,28 @@ static int sni_cb(SSL *ssl, int *al, void *arg) { const char *hostname = SSL_get_servername(ssl, TLSEXT_NAMETYPE_host_name); if (hostname && hostname[0]) { struct sni_node_t *node = resolve_listener_ctx(ls, hostname); - if (node) SSL_set_SSL_CTX(ssl, node->ctx); + if (node) { + SSL_set_SSL_CTX(ssl, node->ctx); + } else if (ls->on_server_name) { + /* No statically-registered context: ask the dynamic resolver (the JS + * SNICallback) for one. The result applies to this handshake only - + * SSL_set_SSL_CTX takes its own reference - and is deliberately NOT + * cached in the SNI tree, so the callback runs per-connection the way + * Node's does and an attacker-controlled servername cannot grow the + * tree. The callback runs JS and may close this listener; nothing + * below touches ls after it returns. */ + void *saved_loop_state[5]; + us_internal_ssl_loop_state_save(ssl, saved_loop_state); + SSL_CTX *dyn = ls->on_server_name(ls, hostname); + us_internal_ssl_loop_state_restore(saved_loop_state); + if (dyn) { + SSL_set_SSL_CTX(ssl, dyn); + /* The resolver hands back an owned reference and SSL_set_SSL_CTX + * takes its own; release the temporary or every dynamic resolution + * leaks one reference to the selected context. */ + SSL_CTX_free(dyn); + } + } } return SSL_TLSEXT_ERR_OK; } @@ -1343,8 +1923,22 @@ void *us_listen_socket_find_server_name_userdata(struct us_listen_socket_t *ls, return node ? node->user : NULL; } +/* Returns the SSL_CTX registered for `hostname_pattern` via + * us_listen_socket_add_server_name, or NULL. Owned - the caller must release + * the reference. The on_server_name resolvers return owned references (the + * SNI dispatcher frees them after SSL_set_SSL_CTX takes its own), so the + * tree's reference must not be handed out as a borrow. */ +struct ssl_ctx_st *us_listen_socket_find_server_name_ctx(struct us_listen_socket_t *ls, + const char *hostname_pattern) { + if (!ls->sni) return NULL; + struct sni_node_t *node = (struct sni_node_t *)sni_find(ls->sni, hostname_pattern); + if (!node || !node->ctx) return NULL; + SSL_CTX_up_ref(node->ctx); + return node->ctx; +} + void us_listen_socket_on_server_name(struct us_listen_socket_t *ls, - void (*cb)(struct us_listen_socket_t *, const char *)) { + struct ssl_ctx_st *(*cb)(struct us_listen_socket_t *, const char *)) { ls->on_server_name = cb; } diff --git a/packages/bun-usockets/src/eventing/epoll_kqueue.c b/packages/bun-usockets/src/eventing/epoll_kqueue.c index 7507f27a3a4c..79371df7fe13 100644 --- a/packages/bun-usockets/src/eventing/epoll_kqueue.c +++ b/packages/bun-usockets/src/eventing/epoll_kqueue.c @@ -503,8 +503,15 @@ int us_poll_start_rc(struct us_poll_t *p, struct us_loop_t *loop, int events) { #ifdef LIBUS_USE_EPOLL struct epoll_event event; if(!(events & LIBUS_SOCKET_READABLE) && !(events & LIBUS_SOCKET_WRITABLE)) { - // if we are disabling readable, we need to add the other events to detect EOF/HUP/ERR - events |= EPOLLRDHUP | EPOLLHUP | EPOLLERR; + /* Polling neither direction (a half-open socket after the peer's FIN): + * EPOLLHUP and EPOLLERR are always reported even when not requested, + * which is exactly what the dispatcher's eof/error handling needs to + * close the socket once both directions are down. Never add + * EPOLLRDHUP here - the peer's FIN has typically ALREADY arrived, so + * a level-triggered EPOLLRDHUP would fire on every epoll_wait while + * the dispatcher (which derives eof from EPOLLHUP only) ignores it, + * spinning the loop at 100% CPU until the JS side closes the fd. */ + events |= EPOLLHUP | EPOLLERR; } event.events = events; event.data.ptr = p; @@ -531,8 +538,9 @@ void us_poll_change(struct us_poll_t *p, struct us_loop_t *loop, int events) { #ifdef LIBUS_USE_EPOLL struct epoll_event event; if(!(events & LIBUS_SOCKET_READABLE) && !(events & LIBUS_SOCKET_WRITABLE)) { - // if we are disabling readable, we need to add the other events to detect EOF/HUP/ERR - events |= EPOLLRDHUP | EPOLLHUP | EPOLLERR; + /* See us_poll_start_rc: EPOLLHUP/EPOLLERR are implicit; never add + * EPOLLRDHUP for an already-half-closed socket or the loop spins. */ + events |= EPOLLHUP | EPOLLERR; } event.events = events; event.data.ptr = p; diff --git a/packages/bun-usockets/src/internal/internal.h b/packages/bun-usockets/src/internal/internal.h index 014d925fcc55..d6d1a2ff8023 100644 --- a/packages/bun-usockets/src/internal/internal.h +++ b/packages/bun-usockets/src/internal/internal.h @@ -129,6 +129,8 @@ extern struct us_socket_t *us_dispatch_end(us_socket_r s); extern struct us_socket_t *us_dispatch_connect_error(us_socket_r s, int code); extern struct us_connecting_socket_t *us_dispatch_connecting_error(struct us_connecting_socket_t *c, int code); extern void us_dispatch_handshake(us_socket_r s, int success, struct us_bun_verify_error_t err); +extern void us_dispatch_session(us_socket_r s, const unsigned char *data, int length); +extern void us_dispatch_keylog(us_socket_r s, const unsigned char *data, int length); extern struct us_socket_t *us_dispatch_ssl_raw_tap(us_socket_r s, char *data, int length); extern int Bun__addrinfo_get(struct us_loop_t* loop, const char* host, uint16_t port, struct addrinfo_request** ptr); @@ -265,6 +267,16 @@ struct us_socket_t { * Used by Bun's `socket.upgradeTLS()` so the returned [raw, tls] pair's * `raw` half can observe ciphertext (node:net Duplex.ondata semantics). */ unsigned char ssl_raw_tap : 1; + /* Set while SSL_do_handshake/SSL_read is on the stack: JS run from inside + * those calls (ALPN/SNI/keylog callbacks) may destroy the socket, and the + * SSL must not be freed under BoringSSL's feet - the detach is deferred to + * the driver's epilogue via ssl_pending_detach. */ + unsigned char ssl_in_use : 1; + unsigned char ssl_pending_detach : 1; + /* The close code passed to the deferred close (e.g. a reset requested from + * inside a handshake callback must still RST, not FIN, when it is finally + * performed). */ + unsigned char ssl_pending_close_code; struct us_socket_group_t *group; /* NULL for plain TCP. Direct BoringSSL `SSL*`; set by us_internal_ssl_attach @@ -378,7 +390,10 @@ struct us_listen_socket_t { struct ssl_ctx_st *ssl_ctx; /* SNI hostname → {SSL_CTX*, user*} tree. Owned. */ void *sni; - void (*on_server_name)(struct us_listen_socket_t *, const char *hostname); + /* Dynamic SNI resolver: returns the SSL_CTX to serve for `hostname` on the + * in-flight handshake only (the caller does not cache it), or NULL to fall + * through to the default context. */ + struct ssl_ctx_st *(*on_server_name)(struct us_listen_socket_t *, const char *hostname); unsigned int socket_ext_size; /* kind to stamp on accepted sockets. */ unsigned char accept_kind; @@ -391,4 +406,9 @@ void us_internal_socket_group_unlink_connecting_socket(us_socket_group_r group, int us_raw_root_certs(struct us_cert_string_t **out); +/* Save/restore the per-loop BIO routing state around in-handshake JS + * callbacks (SNI / ALPN). Defined in crypto/openssl.c. */ +void us_internal_ssl_loop_state_save(void *ssl, void **out5); +void us_internal_ssl_loop_state_restore(void **saved5); + #endif // INTERNAL_H diff --git a/packages/bun-usockets/src/internal/networking/bsd.h b/packages/bun-usockets/src/internal/networking/bsd.h index 0b57bf11e045..4a69a8ada305 100644 --- a/packages/bun-usockets/src/internal/networking/bsd.h +++ b/packages/bun-usockets/src/internal/networking/bsd.h @@ -230,7 +230,7 @@ LIBUS_SOCKET_DESCRIPTOR bsd_create_udp_socket(const char *host, int port, int op int bsd_connect_udp_socket(LIBUS_SOCKET_DESCRIPTOR fd, const char *host, int port); int bsd_disconnect_udp_socket(LIBUS_SOCKET_DESCRIPTOR fd); -LIBUS_SOCKET_DESCRIPTOR bsd_create_connect_socket(struct sockaddr_storage *addr, int options); +LIBUS_SOCKET_DESCRIPTOR bsd_create_connect_socket(struct sockaddr_storage *addr, struct sockaddr_storage *local_addr, int options); LIBUS_SOCKET_DESCRIPTOR bsd_create_connect_socket_unix(const char *server_path, size_t pathlen, int options); diff --git a/packages/bun-usockets/src/libusockets.h b/packages/bun-usockets/src/libusockets.h index b94041acae7c..8947507a939f 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -322,7 +322,11 @@ struct us_socket_t *us_socket_adopt(us_socket_r s, us_socket_group_r group, * sni may be NULL. */ struct us_socket_t *us_socket_adopt_tls(us_socket_r s, us_socket_group_r group, unsigned char kind, struct ssl_ctx_st *ssl_ctx, const char *sni, - int old_ext_size, int ext_size) __attribute__((nonnull(1, 2, 4))); + int is_client, int old_ext_size, int ext_size) __attribute__((nonnull(1, 2, 4))); +/* Feed bytes that were already read off the wire (e.g. a ClientHello consumed + * by the plain-TCP layer before the socket was adopted into TLS) through the + * same decrypt path as bytes arriving from the kernel. */ +struct us_socket_t *us_socket_tls_feed(us_socket_r s, const char *data, int length) __attribute__((nonnull(1))); /* Send ClientHello after adopt_tls. Separate so the caller can repoint the * ext slot before any dispatch can fire. */ void us_socket_start_tls_handshake(us_socket_r s) nonnull_fn_decl; @@ -350,8 +354,16 @@ void us_listen_socket_remove_server_name(struct us_listen_socket_t *ls, const char *hostname_pattern) nonnull_fn_decl; void *us_listen_socket_find_server_name_userdata(struct us_listen_socket_t *ls, const char *hostname_pattern) nonnull_fn_decl; +/* Returns an owned reference; the caller must release it. */ +struct ssl_ctx_st *us_listen_socket_find_server_name_ctx(struct us_listen_socket_t *ls, + const char *hostname_pattern) nonnull_fn_decl; +/* Parses a PKCS#12 blob into malloc'd PEM key/cert/ca strings (caller frees); + * returns 0 with a static *err_reason tag on failure. */ +int us_ssl_parse_pkcs12(const char *data, size_t len, const char *pass, + char **out_key, size_t *out_key_len, char **out_cert, size_t *out_cert_len, + char **out_ca, size_t *out_ca_len, const char **err_reason); void us_listen_socket_on_server_name(struct us_listen_socket_t *ls, - void (*cb)(struct us_listen_socket_t *, const char *hostname)) nonnull_fn_decl; + struct ssl_ctx_st *(*cb)(struct us_listen_socket_t *, const char *hostname)) nonnull_fn_decl; void *us_socket_server_name_userdata(us_socket_r s); /* ── Connect ────────────────────────────────────────────────────────────── @@ -359,9 +371,10 @@ void *us_socket_server_name_userdata(us_socket_r s); * us_connecting_socket_t* (DNS / happy-eyeballs in flight, *is_connecting=0). * ssl_ctx may be NULL for plain TCP. */ void *us_socket_group_connect(us_socket_group_r group, unsigned char kind, - struct ssl_ctx_st *ssl_ctx, const char *host, int port, int options, + struct ssl_ctx_st *ssl_ctx, const char *host, int port, + const char *local_host, int local_port, int options, int socket_ext_size, int *is_connecting) - __attribute__((nonnull(1, 4, 8))); /* ssl_ctx nullable */ + __attribute__((nonnull(1, 4, 10))); /* ssl_ctx, local_host nullable */ struct us_socket_t *us_socket_group_connect_unix(us_socket_group_r group, unsigned char kind, struct ssl_ctx_st *ssl_ctx, const char *server_path, size_t pathlen, int options, int socket_ext_size) @@ -404,6 +417,9 @@ struct us_bun_socket_context_options_t { const char * const *ca; unsigned int ca_count; unsigned int secure_options; + // Minimum/maximum TLS protocol version (TLS1_VERSION..TLS1_3_VERSION); 0 = unset/default. + int ssl_min_version; + int ssl_max_version; int reject_unauthorized; int request_cert; unsigned int client_renegotiation_limit; @@ -438,6 +454,9 @@ struct ssl_ctx_st *us_ssl_ctx_from_options( void us_internal_ssl_ctx_up_ref(struct ssl_ctx_st *ssl_ctx); void us_internal_ssl_ctx_unref(struct ssl_ctx_st *ssl_ctx); long us_ssl_ctx_live_count(void); +/* Appends the certificates in the PEM `content` to `ctx`'s trust store; + * returns 0 when nothing could be added. */ +int us_ssl_ctx_add_ca_cert(struct ssl_ctx_st *ctx, const char *content); /* Public interfaces for loops */ @@ -507,6 +526,11 @@ int us_socket_write(us_socket_r s, const char *nonnull_arg data, int length) non int us_socket_write2(us_socket_r s, const char *header, int header_length, const char *payload, int payload_length) nonnull_fn_decl; /* Bypass TLS — write raw bytes to the fd even if `s->ssl` is set. */ int us_socket_raw_write(us_socket_r s, const char *data, int length); +/* Like us_socket_write, but additionally reports a fatal (non-would-block) + * send error through *fatal_write_error so opted-in callers can fail the + * write instead of retrying forever. TLS sockets fall back to + * us_socket_write (their errors propagate through the SSL layer). */ +int us_socket_write_check_error(us_socket_r s, const char *data, int length, int *fatal_write_error); void us_socket_timeout(us_socket_r s, unsigned int seconds) nonnull_fn_decl; void us_socket_long_timeout(us_socket_r s, unsigned int minutes) nonnull_fn_decl; diff --git a/packages/bun-usockets/src/loop.c b/packages/bun-usockets/src/loop.c index f237edf57d21..c556ceb6f033 100644 --- a/packages/bun-usockets/src/loop.c +++ b/packages/bun-usockets/src/loop.c @@ -384,7 +384,20 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in /* Both connect and listen sockets are semi-sockets * but they poll for different events */ if (us_poll_events(p) == LIBUS_SOCKET_WRITABLE) { - us_internal_socket_after_open((struct us_socket_t *) p, error || eof); + /* The connecting fd became writable with an error/HUP flag also + * set: the handshake may have completed and then been reset + * before we collected the event. Report the kernel's actual + * SO_ERROR (ECONNRESET for that race) instead of the literal + * boolean, which downstream would misreport as ECONNREFUSED. + * libuv does the same getsockopt in uv__stream_connect. */ + int connect_error = 0; + if (error || eof) { + connect_error = us_socket_get_error((struct us_socket_t *) p); + if (connect_error == 0) { + connect_error = ECONNRESET; + } + } + us_internal_socket_after_open((struct us_socket_t *) p, connect_error); } else { struct us_listen_socket_t *listen_socket = (struct us_listen_socket_t *) p; struct us_socket_group_t *accept_group = listen_socket->accept_group; @@ -662,8 +675,17 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in return; } if(s->flags.allow_half_open) { - /* We got a Error but is EOF and we allow half open so stop polling for readable and keep going*/ - us_poll_change(&s->p, loop, us_poll_events(&s->p) & LIBUS_SOCKET_WRITABLE); + /* EOF with half-open allowed: stop polling readable but KEEP + * polling writable. Masking with the current events dropped + * writable when the EOF landed before the poll had been + * switched to writable for a just-queued write (an end() + * issued in the same tick as connect): the queued bytes + * never flushed, their drain callback never fired, and the + * stream's 'finish' never happened - the FIN-terminated + * http response tests hung on every Linux target. The + * writable dispatch disables writable polling again once + * the buffer is drained, so this does not busy-poll. */ + us_poll_change(&s->p, loop, LIBUS_SOCKET_WRITABLE); s = s->ssl ? us_internal_ssl_on_end(s) : us_dispatch_end(s); } else { /* We dont allow half open just emit end and close the socket */ diff --git a/packages/bun-usockets/src/socket.c b/packages/bun-usockets/src/socket.c index a294fc59fed5..ec8edd713ae6 100644 --- a/packages/bun-usockets/src/socket.c +++ b/packages/bun-usockets/src/socket.c @@ -259,6 +259,16 @@ void us_connecting_socket_close(struct us_connecting_socket_t *c) { * handshake/secureConnection event. openssl.c re-enters here once that * graceful path is done. */ struct us_socket_t *us_internal_socket_close_raw(struct us_socket_t *s, int code, void *reason) { + if (s->ssl && s->ssl_in_use) { + /* A JS callback running from inside SSL_do_handshake/SSL_read (ALPN, SNI, + * keylog, ...) destroyed this socket. Closing now frees the SSL and + * releases context state BoringSSL is still reading on the stack; defer + * the close to the SSL driver's epilogue instead, preserving the close + * code so a requested reset still resets. */ + s->ssl_pending_detach = 1; + s->ssl_pending_close_code = (unsigned char) code; + return s; + } if (!us_socket_is_closed(s)) { struct us_loop_t *loop = s->group->loop; @@ -469,6 +479,38 @@ int us_socket_write(struct us_socket_t *s, const char *data, int length) { return written < 0 ? 0 : written; } +int us_socket_write_check_error(struct us_socket_t *s, const char *data, int length, int *fatal_write_error) { + if (fatal_write_error) *fatal_write_error = 0; + if (us_socket_is_closed(s) || us_socket_is_shut_down(s)) { + return 0; + } + if (s->ssl) { + /* TLS writes have their own error propagation; keep the existing path. */ + return us_socket_write(s, data, length); + } + + int written = bsd_send(us_poll_fd(&s->p), data, length); + if (written < 0) { + /* bsd_send already retries EINTR; bsd_would_block() reads errno on + * POSIX and WSAGetLastError() on Windows. */ + if (bsd_would_block()) { + s->flags.last_write_failed = 1; + us_poll_change(&s->p, s->group->loop, LIBUS_SOCKET_READABLE | LIBUS_SOCKET_WRITABLE); + return 0; + } + /* Fatal send error (EPIPE/ECONNRESET after the peer vanished): report + * it to callers that opt in instead of masking it as would-block, and + * do not keep polling writable - retrying can never succeed. */ + if (fatal_write_error) *fatal_write_error = 1; + return 0; + } + if (written != length) { + s->flags.last_write_failed = 1; + us_poll_change(&s->p, s->group->loop, LIBUS_SOCKET_READABLE | LIBUS_SOCKET_WRITABLE); + } + return written; +} + int us_socket_raw_write(struct us_socket_t *s, const char *data, int length) { /* Bypass-TLS path: openssl.c uses this to flush close_notify *after* * SSL_shutdown() has marked the SSL layer shut down, so checking diff --git a/packages/bun-uws/src/App.h b/packages/bun-uws/src/App.h index 574758c6a2a6..025b81c4fbe9 100644 --- a/packages/bun-uws/src/App.h +++ b/packages/bun-uws/src/App.h @@ -74,6 +74,8 @@ namespace uWS { const char **ca = nullptr; unsigned int ca_count = 0; unsigned int secure_options = 0; + int ssl_min_version = 0; + int ssl_max_version = 0; int reject_unauthorized = 0; int request_cert = 0; unsigned int client_renegotiation_limit = 3; @@ -287,9 +289,15 @@ struct TemplatedApp { TemplatedApp(TemplatedApp &&other) = delete; private: - static void onMissingServerName(struct us_listen_socket_t *ls, const char *hostname) { + static struct ssl_ctx_st *onMissingServerName(struct us_listen_socket_t *ls, const char *hostname) { auto *httpContext = (HttpContext *) us_socket_group_ext(us_listen_socket_group(ls)); httpContext->getSocketContextData()->missingServerNameHandler(hostname); + /* The handler is expected to have registered the name via + * addServerName(); hand the newly-registered context back so the + * in-flight handshake uses it (the resolver no longer re-checks the + * SNI tree after this callback returns). The handler may also have + * closed the listener, freeing the tree. */ + return us_listen_socket_find_server_name_ctx(ls, hostname); } TemplatedApp(SocketContextOptions options) { diff --git a/src/http/HTTPContext.rs b/src/http/HTTPContext.rs index f91ce2a42ae7..846770a52f97 100644 --- a/src/http/HTTPContext.rs +++ b/src/http/HTTPContext.rs @@ -1355,16 +1355,34 @@ impl Handler { // 4. Dead socket: it is already marked as dead let tagged = HTTPContext::::get_tagged(ptr); HTTPContext::::mark_tagged_socket_as_dead(socket, tagged); - socket.close(uws::CloseKind::Failure); - + // An idle (pooled keep-alive) socket's FIN is answered with a graceful + // close so well-behaved servers don't observe ECONNRESET for + // connections we were simply done with, and so is a FIN that + // terminates an EOF-delimited response (the request was fully sent; + // this FIN *is* the end of the response). A FIN that cuts the request + // short while its body is still being sent is answered with a reset + // instead: a graceful close would queue our FIN behind the + // not-yet-delivered body bytes (a server that rejects an upload early + // stops reading them), so the peer would never observe the connection + // closing and it would leak. if let Some(client) = tagged.client_mut() { + if client.has_unsent_request_body() { + socket.close(uws::CloseKind::Failure); + } else { + socket.close(uws::CloseKind::Normal); + } client.on_close::(socket); return; } if let Some(session) = tagged.session_mut() { + // An HTTP/2 session's streams may still be uploading; the same + // undeliverable-bytes reasoning applies, and this matches the + // pre-existing behaviour for this branch. + socket.close(uws::CloseKind::Failure); session.on_close(bun_core::err!("ConnectionClosed")); return; } + socket.close(uws::CloseKind::Normal); } } diff --git a/src/http/lib.rs b/src/http/lib.rs index baf995025487..8c32eb6d4e90 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -1320,6 +1320,22 @@ pub(crate) fn get_cert_error_from_no(error_no: i32) -> bun_core::Error { // Rust struct uses `Option>`. These helpers centralize the unsafe // deref so the state-machine bodies stay readable. impl<'a> HTTPClient<'a> { + #[inline] + /// Whether closing this socket gracefully would queue our FIN behind + /// request-body bytes that have not yet been handed to the kernel - the + /// case where the peer (which may have stopped reading the body) would + /// never observe the connection closing. + pub fn has_unsent_request_body(&self) -> bool { + if self.state.request_stage == RequestStage::Done { + return false; + } + if self.flags.is_streaming_request_body { + // More body chunks may still be produced by JS. + return true; + } + !self.request_body().is_empty() + } + #[inline] fn request_body(&self) -> &[u8] { // `request_body` is a `RawSlice` into `original_request_body` (sibling diff --git a/src/http/ssl_config.rs b/src/http/ssl_config.rs index 9ff070f0d086..c0ff40132021 100644 --- a/src/http/ssl_config.rs +++ b/src/http/ssl_config.rs @@ -35,6 +35,9 @@ pub struct SSLConfig { pub ca: CStrSlice, pub secure_options: u32, + /// Minimum/maximum TLS protocol version (TLS1_VERSION..TLS1_3_VERSION); 0 = unset/default. + pub ssl_min_version: i32, + pub ssl_max_version: i32, pub request_cert: i32, pub reject_unauthorized: i32, pub ssl_ciphers: CStrPtr, @@ -110,6 +113,8 @@ impl SSLConfig { cert: None, ca: None, secure_options: 0, + ssl_min_version: 0, + ssl_max_version: 0, request_cert: 0, reject_unauthorized: 0, ssl_ciphers: core::ptr::null(), @@ -207,6 +212,8 @@ impl SSLConfig { } ctx_opts.request_cert = self.request_cert; ctx_opts.reject_unauthorized = self.reject_unauthorized; + ctx_opts.ssl_min_version = self.ssl_min_version; + ctx_opts.ssl_max_version = self.ssl_max_version; ctx_opts } @@ -271,6 +278,12 @@ impl SSLConfig { if self.secure_options != other.secure_options { return false; } + if self.ssl_min_version != other.ssl_min_version { + return false; + } + if self.ssl_max_version != other.ssl_max_version { + return false; + } if self.request_cert != other.request_cert { return false; } @@ -335,6 +348,8 @@ impl SSLConfig { hash_slice!(cert); hash_slice!(ca); hasher.update(&self.secure_options.to_ne_bytes()); + hasher.update(&self.ssl_min_version.to_ne_bytes()); + hasher.update(&self.ssl_max_version.to_ne_bytes()); hasher.update(&self.request_cert.to_ne_bytes()); hasher.update(&self.reject_unauthorized.to_ne_bytes()); hash_cstr!(ssl_ciphers); @@ -423,6 +438,8 @@ impl Clone for SSLConfig { cert: clone_strings(&self.cert), ca: clone_strings(&self.ca), secure_options: self.secure_options, + ssl_min_version: self.ssl_min_version, + ssl_max_version: self.ssl_max_version, request_cert: self.request_cert, reject_unauthorized: self.reject_unauthorized, ssl_ciphers: clone_string(self.ssl_ciphers), diff --git a/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs b/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs index 55d3def55e6d..001c1e696719 100644 --- a/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs +++ b/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs @@ -675,11 +675,12 @@ impl HTTPClient { // SAFETY: forwards `this` with root provenance; no `&mut Self` is live. unsafe { Self::dispatch_abrupt_close(this.as_ptr(), code) }; - if SSL { - tcp.close(uws::CloseCode::Normal); - } else { - tcp.close(uws::CloseCode::Failure); - } + // A failed upgrade (bad status line, mismatched subprotocol, invalid + // headers, ...) is an application-level rejection of a healthy TCP + // connection — close it gracefully (FIN) like Node's ws client does. + // A Failure close arms SO_LINGER{1,0} and sends an RST, which the + // server observes as ECONNRESET on a connection it served correctly. + tcp.close(uws::CloseCode::Normal); } /// # Safety diff --git a/src/js/internal/shared.ts b/src/js/internal/shared.ts index defd073927c0..b00bf9667056 100644 --- a/src/js/internal/shared.ts +++ b/src/js/internal/shared.ts @@ -151,6 +151,121 @@ function getLazy(initializer: () => T) { }; } +// ─── Node-style performance-entry observation ──────────────────────────────── +// For entry types the native (WebCore) PerformanceObserver does not implement +// ('net', 'dns', ...). Mirrors lib/internal/perf/observe.js: producers check +// hasObserver() before doing any work, startPerf() stashes a context on the +// producing object, and stopPerf() builds a plain entry and dispatches it to +// the registered observers on a fresh tick. +// https://github.com/nodejs/node/blob/v25.2.1/lib/internal/perf/observe.js + +const observerCounts = new Map(); +const kObservers = new Set(); + +/** Entry types routed through this JS-side registry instead of the native observer. */ +const kNodeEntryTypes = new Set(["net", "dns"]); + +function hasObserver(type) { + return (observerCounts.get(type) ?? 0) > 0; +} + +function startPerf(target, key, context) { + context.startTime = performance.now(); + target[key] = context; +} + +function stopPerf(target, key, context) { + const ctx = target[key]; + if (!ctx) { + return; + } + target[key] = undefined; + const startTime = ctx.startTime; + const entry = { + name: ctx.name, + entryType: ctx.type, + startTime, + duration: performance.now() - startTime, + detail: context?.detail !== undefined ? context.detail : ctx.detail, + }; + for (const observer of kObservers) { + observer.bufferEntry(entry); + } +} + +/** + * One registered observer of node-only entry types. The PerformanceObserver + * wrapper in node:perf_hooks owns one of these when it observes such a type. + */ +class NodeEntryObserver { + callback; + owner; + types = new Set(); + buffer = []; + scheduled = false; + + constructor(callback, owner) { + this.callback = callback; + this.owner = owner; + } + + observe(types) { + for (const type of this.types) { + observerCounts.set(type, (observerCounts.get(type) ?? 1) - 1); + } + this.types = new Set(types); + for (const type of this.types) { + observerCounts.set(type, (observerCounts.get(type) ?? 0) + 1); + } + kObservers.add(this); + } + + disconnect() { + for (const type of this.types) { + observerCounts.set(type, (observerCounts.get(type) ?? 1) - 1); + } + this.types.clear(); + this.buffer = []; + kObservers.delete(this); + } + + bufferEntry(entry) { + if (!this.types.has(entry.entryType)) { + return; + } + this.buffer.push(entry); + if (!this.scheduled) { + this.scheduled = true; + setImmediate(() => { + this.scheduled = false; + const entries = this.buffer; + if (entries.length === 0) { + return; + } + this.buffer = []; + this.callback.$call(undefined, makeNodeEntryList(entries), this.owner); + }); + } + } +} + +function makeNodeEntryList(entries) { + // Node's PerformanceObserverEntryList hands entries out in chronological + // (startTime) order and getEntriesByName takes an optional type filter. + const sorted = entries.slice().sort((a, b) => a.startTime - b.startTime); + return { + getEntries() { + return sorted.slice(); + }, + getEntriesByType(type) { + return sorted.filter(entry => entry.entryType === type); + }, + getEntriesByName(name, type) { + return sorted.filter(entry => entry.name === name && (type === undefined || entry.entryType === type)); + }, + }; +} + // export default { @@ -165,6 +280,12 @@ export default { once, getLazy, + hasObserver, + startPerf, + stopPerf, + kNodeEntryTypes, + NodeEntryObserver, + kHandle: Symbol("kHandle"), kAutoDestroyed: Symbol("kAutoDestroyed"), kResistStopPropagation: Symbol("kResistStopPropagation"), diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index 19afd513bb63..712c782c8d99 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -3043,6 +3043,14 @@ class ServerHttp2Session extends Http2Session { this.close(); } #onError(error: Error) { + if (this.listenerCount("error") === 0) { + // An unobserved transport teardown (the peer dropped a connection + // nobody is listening to anymore): destroy quietly - the destroy still + // errors any remaining streams - instead of re-emitting on a session + // with no 'error' listener and crashing the process. + this.destroy(); + return; + } this.destroy(error); } #onTimeout() { @@ -3614,6 +3622,13 @@ class ClientHttp2Session extends Http2Session { this.destroy(); return; } + if (this.listenerCount("error") === 0) { + // Same as the server-session case: a transport teardown on a session + // nobody observes (an idle pooled connection) shuts down quietly; the + // destroy still errors any remaining streams. + this.destroy(); + return; + } this.destroy(error); } #onTimeout() { diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 5cb14482ea86..aa8fcbb50195 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -26,7 +26,15 @@ const EventEmitter = require("node:events"); let dns: typeof import("node:dns"); const normalizedArgsSymbol = Symbol("normalizedArgs"); -const { ExceptionWithHostPort, ConnResetException, NodeAggregateError, ErrnoException } = require("internal/shared"); +const { + ExceptionWithHostPort, + ConnResetException, + NodeAggregateError, + ErrnoException, + hasObserver, + startPerf, + stopPerf, +} = require("internal/shared"); import type { Socket, SocketHandler, SocketListener } from "bun"; import type { Server as NetServer, Socket as NetSocket, ServerOpts } from "node:net"; import type { TLSSocket } from "node:tls"; @@ -45,6 +53,55 @@ const getDefaultAutoSelectFamily = $zig("node_net_binding.zig", "getDefaultAutoS const setDefaultAutoSelectFamily = $zig("node_net_binding.zig", "setDefaultAutoSelectFamily"); const getDefaultAutoSelectFamilyAttemptTimeout = $zig("node_net_binding.zig", "getDefaultAutoSelectFamilyAttemptTimeout"); // prettier-ignore const setDefaultAutoSelectFamilyAttemptTimeout = $zig("node_net_binding.zig", "setDefaultAutoSelectFamilyAttemptTimeout"); // prettier-ignore + +/** + * `--tls-keylog=`: every TLS socket appends its NSS key-log lines here, + * the way Node's CLI option store seeds an implicit 'keylog' listener. + */ +let tlsKeylogPath: string | undefined; +let tlsKeylogWarned = false; +function appendTlsKeylog(line: Buffer) { + if (!tlsKeylogWarned) { + tlsKeylogWarned = true; + process.emitWarning( + "Using --tls-keylog makes TLS connections insecure by writing secret key material to file " + tlsKeylogPath, + ); + } + try { + // The keylog contains TLS master secrets; create it owner-readable only. + // The mode is only applied when the file is created. + require("node:fs").appendFileSync(tlsKeylogPath, line, { mode: 0o600 }); + } catch { + // Node ignores keylog write failures. + } +} + +// Node seeds the family-autoselection defaults from its CLI option store. +// The equivalent flags reach us through process.execArgv; apply them once at +// module load so getDefaultAutoSelectFamily*() reflect the command line. +{ + const execArgv = process.execArgv; + for (let i = 0; i < execArgv.length; i++) { + const arg = execArgv[i]; + if (arg === "--no-network-family-autoselection" || arg === "--no-enable-network-family-autoselection") { + setDefaultAutoSelectFamily(false); + } else if (arg === "--network-family-autoselection" || arg === "--enable-network-family-autoselection") { + setDefaultAutoSelectFamily(true); + } else if (arg.startsWith("--network-family-autoselection-attempt-timeout=")) { + const value = Number(arg.slice(arg.indexOf("=") + 1)); + // The setter validates >= 1 and clamps < 10 to 10, like Node's; ignore + // degenerate CLI values rather than throwing at module load. + if (Number.isFinite(value) && value >= 1) setDefaultAutoSelectFamilyAttemptTimeout(value); + } else if (arg === "--network-family-autoselection-attempt-timeout" && i + 1 < execArgv.length) { + const value = Number(execArgv[i + 1]); + if (Number.isFinite(value) && value >= 1) setDefaultAutoSelectFamilyAttemptTimeout(value); + } else if (arg.startsWith("--tls-keylog=")) { + tlsKeylogPath = arg.slice("--tls-keylog=".length); + } else if (arg === "--tls-keylog" && i + 1 < execArgv.length) { + tlsKeylogPath = execArgv[i + 1]; + } + } +} const SocketAddress = $zig("node_net_binding.zig", "SocketAddress"); const BlockList = $zig("node_net_binding.zig", "BlockList"); const newDetachedSocket = $newZigFunction("node_net_binding.zig", "newDetachedSocket", 1); @@ -66,6 +123,7 @@ const kReinitializeHandle = Symbol("kReinitializeHandle"); const kRealListen = Symbol("kRealListen"); const kSetNoDelay = Symbol("kSetNoDelay"); +const kSetTOS = Symbol("kSetTOS"); const kSetKeepAlive = Symbol("kSetKeepAlive"); const kSetKeepAliveInitialDelay = Symbol("kSetKeepAliveInitialDelay"); const kConnectOptions = Symbol("connect-options"); @@ -77,11 +135,22 @@ const ksocket = Symbol("ksocket"); const khandlers = Symbol("khandlers"); const kclosed = Symbol("closed"); const kended = Symbol("ended"); +const kpendingSession = Symbol("pendingSession"); +const kPerfHooksNetConnectContext = Symbol("kPerfHooksNetConnectContext"); +const khandshakeTimer = Symbol("khandshakeTimer"); +const kUserUnrefed = Symbol("kUserUnrefed"); +// Set when pause() dropped the handle's hold on the loop, so the read paths +// only restore a hold they actually removed - re-refing a handle that never +// held the loop (a wrapped duplex with no fd) would pin the process. +const kPausedUnref = Symbol("kPausedUnref"); const kwriteCallback = Symbol("writeCallback"); const kSocketClass = Symbol("kSocketClass"); function endNT(socket, callback, err) { - socket.$end(); + // Node's _final half-closes the writable side (sends FIN) and leaves the + // readable side open; the Duplex's allowHalfOpen drives the eventual destroy. + // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/net.js#L500 + socket.shutdown(); callback(err); } function emitCloseNT(self, hasError) { @@ -142,6 +211,39 @@ function onConnectEnd() { } } +/** + * Build the Error for a handshake that failed before completing. A fatal SSL + * protocol error (wrong version number, bad record, ...) carries the OpenSSL + * error string in `verifyError.reason`; everything else is the peer + * disconnecting mid-handshake, which Node reports as ECONNRESET. + */ +function tlsHandshakeError(verifyError) { + if (verifyError && verifyError.code && verifyError.code !== "ECONNRESET") { + const reason = verifyError.reason || verifyError.message || "TLS handshake failed"; + const err = new Error(reason) as Error & { + code?: string; + library?: string; + function?: string; + reason?: string; + }; + // A fatal SSL-library error carries the full OpenSSL error string + // ("error:0a00042e:SSL routines:OPENSSL_internal:TLSV1_ALERT_PROTOCOL_VERSION"). + // Decompose it into Node's library/function/reason properties and the + // ERR_SSL_ code the way ThrowCryptoError does. + const match = /^error:[0-9a-f]+:SSL routines:([^:]*):(.+)$/.exec(reason); + if (match) { + err.library = "SSL routines"; + err.function = match[1]; + err.reason = match[2]; + err.code = `ERR_SSL_${match[2]}`; + } else { + err.code = verifyError.code; + } + return err; + } + return new ConnResetException("socket hang up"); +} + const SocketHandlers: SocketHandler = { close(socket, err) { const self = socket.data; @@ -186,6 +288,25 @@ const SocketHandlers: SocketHandler = { // we just reuse the same code but we can push null or enqueue right away SocketEmitEndNT(self); }, + // A new resumable TLS session arrived (the peer's NewSessionTicket was just + // processed). Mirrors Node's onnewsessionclient: emit once the handshake has + // been verified, otherwise park it and emit from the handshake handler. + session(socket, session) { + const self = socket.data; + if (!self) return; + if (self._secureEstablished) { + self.emit("session", session); + } else { + self[kpendingSession] = session; + } + }, + keylog(socket, line) { + const self = socket.data; + if (!self) return; + self.emit("keylog", line); + if (tlsKeylogPath !== undefined) appendTlsKeylog(line); + self.server?.emit?.("keylog", line, self); + }, error(socket, error) { const self = socket.data; if (!self) return; @@ -244,10 +365,34 @@ const SocketHandlers: SocketHandler = { // will be handled in onConnectEnd return; } + // The second argument is "authorized" (handshake + verification + + // hostname), matching the public Bun.connect handshake callback. node:tls + // decides what to do with verification results in JS via the + // rejectUnauthorized / checkServerIdentity handling below, so a + // verification-class result (an X509 code such as + // UNABLE_TO_VERIFY_LEAF_SIGNATURE, or the native hostname verdict) still + // means the TLS session itself was established. Only a fatal TLS protocol + // failure tears the socket down here: those arrive as EPROTO carrying the + // OpenSSL "error:...:SSL routines:..." reason (or an already decomposed + // ERR_SSL_* / ERR_OSSL_* code). + const isProtocolFailure = + !success && + verifyError?.code != null && + (verifyError.code === "EPROTO" || /^ERR_(SSL|OSSL)_/.test(verifyError.code)); + if (isProtocolFailure) { + // Surface the OpenSSL reason instead of letting the close path report a + // generic disconnect. + self.destroy(tlsHandshakeError(verifyError)); + return; + } self._securePending = false; self.secureConnecting = false; - self._secureEstablished = !!success; + // ECONNRESET and protocol-level failures returned above, so reaching here + // means the TLS session itself was established - even when `success` + // (authorized) is false purely because of the native hostname verdict, + // which arrives with no error object. + self._secureEstablished = true; self.emit("secure", self); self.alpnProtocol = socket.alpnProtocol; @@ -275,6 +420,15 @@ const SocketHandlers: SocketHandler = { } self.emit("secureConnect", verifyError); self.removeListener("end", onConnectEnd); + // For TLS 1.2 the NewSessionTicket is part of the handshake, so the + // new-session callback fired before the handshake completed and the + // session was parked; deliver it now that 'secureConnect' has been + // emitted, the way Node flushes its kPendingSession. + const pendingSession = self[kpendingSession]; + if (pendingSession) { + self[kpendingSession] = null; + self.emit("session", pendingSession); + } }, timeout(socket) { const self = socket.data; @@ -286,17 +440,84 @@ const SocketHandlers: SocketHandler = { } as const; function SocketEmitEndNT(self, _err?) { + // A read error delivered with the close (e.g. a received RST surfacing as + // ECONNRESET) is not a clean EOF — Node destroys the socket with the error + // ("read ECONNRESET") instead of emitting a graceful 'end'. Guard on + // !destroyed so an already-torn-down socket isn't re-destroyed, and on an + // 'error' listener so callers that opted into error handling get Node's + // behavior while those that did not keep the previous silent EOF (a server + // hard-closing after a clean response would otherwise surface here as an + // unhandled error across the proxy/http2/fetch suites under ASAN/baseline + // timing). + // A reset that lands after the exchange already finished in BOTH + // directions (clean EOF delivered and nothing left being written) is + // teardown noise - a peer hard-closing once the exchange completed - not + // data loss; Node would have destroyed the socket on 'end' for these + // non-keepalive flows before the RST could ever be observed. Surfacing it + // produced unhandled errors between tests across the fetch/http2 suites on + // Windows, where loopback RSTs at teardown are routine. A reset while the + // socket is still writing (the peer aborted mid-transfer) is real and is + // surfaced (test-net-error-twice). + // writableFinished (everything actually flushed) - NOT writableEnded (end() + // merely called): a peer reset while queued data is still unflushed is the + // peer aborting mid-transfer and must surface (test-net-error-twice). + const teardownNoise = self[kended] && self.writableFinished; + if (_err && !self.destroyed && !teardownNoise && self.listenerCount("error") > 0) { + // The consumer can detach its 'error' listener between this close + // callback and destroy()'s deferred 'error' emission (a request that + // finished just as the reset arrived); a last-resort no-op listener keeps + // that race from surfacing as an uncaught exception - the no-listener + // case is already a documented silent close. + self.once("error", () => {}); + if (_err.code === undefined && typeof _err.errno === "number" && _err.errno !== 0) { + // A codeless close error that still carries the errno (Windows IOCP + // delivers some this way): derive the proper code from it, like Node's + // errnoException(nread, 'read'). Raw WSA values (-10054, ...) that the + // errno table cannot name fall through to the reset shape below instead + // of surfacing "Unknown system error N". + const er = new ErrnoException(_err.errno, "read") as Error & { code?: string }; + if (typeof er.code === "string" && /^E[A-Z0-9]+$/.test(er.code)) { + self.destroy(er); + return; + } + } + if (_err.code === undefined || _err.code === "ECONNRESET") { + // Shape a reset (or a fully bare close error) like Node's + // errnoException(UV_ECONNRESET, 'read'). + const er = new ConnResetException("read ECONNRESET") as Error & { + code: string; + errno?: number; + syscall?: string; + }; + er.errno = _err.errno ?? (process.platform === "win32" ? -4077 : process.platform === "linux" ? -104 : -54); + er.syscall = "read"; + self.destroy(er); + } else { + // Any other coded error (ETIMEDOUT, EPIPE, ...) keeps its identity. + self.destroy(_err); + } + return; + } if (!self[kended]) { if (!self.allowHalfOpen) { self.write = writeAfterFIN; } self[kended] = true; self.push(null); + } else if (_err && !self.destroyed) { + // An error excluded from the synthesis above (teardown noise, or no + // listener attached): nothing more is coming, but the socket still has to + // finish its lifecycle - close it quietly instead of leaving it open with + // no further events. + self.destroy(); + } + // A write that was waiting on the native drain can never complete once the + // socket is gone - fail it so 'finish'/destroy are not stuck behind it. + const pendingWrite = self[kwriteCallback]; + if (pendingWrite && (self.destroyed || _err)) { + self[kwriteCallback] = null; + pendingWrite(_err ?? $ERR_SOCKET_CLOSED()); } - // TODO: check how the best way to handle this - // if (err) { - // self.destroy(err); - // } } const ServerHandlers: SocketHandler = { @@ -310,6 +531,52 @@ const ServerHandlers: SocketHandler = { socket.pause(); } }, + keylog(socket, line) { + const { data: self } = socket; + if (!self) return; + self.emit("keylog", line); + if (tlsKeylogPath !== undefined) appendTlsKeylog(line); + self.server?.emit?.("keylog", line, self); + }, + alpnCallback(socket, servername, protocolsWire) { + // Returns false when this server has no ALPNCallback (the native side + // falls through to the static ALPNProtocols list), the selected protocol + // string, or undefined to refuse the connection - Node's contract. + const self = socket.data; + const server = self?.server ?? self; + const cb = server?._ALPNCallback; + if (typeof cb !== "function") return false; + const wire = Buffer.isBuffer(protocolsWire) ? protocolsWire : Buffer.from(protocolsWire); + const protocols = []; + for (let i = 0; i + 1 <= wire.length; ) { + const n = wire[i]; + protocols.push(wire.toString("latin1", i + 1, i + 1 + n)); + i += 1 + n; + } + return cb.$call(self, { servername, protocols }); + }, + serverName(server, servername) { + // Returns the native SecureContext a synchronous SNICallback selects for + // this handshake, or undefined to fall through to the default context. + // The native side installs it on the in-flight SSL only - nothing is + // cached, so the callback runs per-connection the way Node's does. An + // asynchronous callback resolves after the native resolver has already + // returned and therefore falls through to the default context. The + // native dispatch passes the listener's `data` (the owning tls.Server) + // directly. + const cb = server?._SNICallback; + if (typeof cb !== "function" || !servername) return undefined; + let selected; + cb.$call(server, servername, (err, context) => { + // Node assigns `sni_context = context.context || context` and its + // native side ignores anything that is not a real SecureContext, so a + // bare `{}` (or undefined) falls through to the default context. + if (!err && context && typeof context === "object" && context.context) { + selected = context.context; + } + }); + return selected; + }, close(socket, err) { $debug("Bun.Server close"); const data = this.data; @@ -332,75 +599,45 @@ const ServerHandlers: SocketHandler = { open(socket) { $debug("Bun.Server open"); const self = socket.data as any as NetServer; - socket[kServerSocket] = self._handle; - const options = self[bunSocketServerOptions]; - const { pauseOnConnect, connectionListener, [kSocketClass]: SClass, requestCert, rejectUnauthorized } = options; - const _socket = new SClass({}) as NetSocket | TLSSocket; - _socket.isServer = true; - _socket._requestCert = requestCert; - _socket._rejectUnauthorized = rejectUnauthorized; - - _socket[kAttach](this.localPort, socket); - - if (self.blockList) { - const addressType = isIP(socket.remoteAddress); - if (addressType && self.blockList.check(socket.remoteAddress, `ipv${addressType}`)) { - const data = { - localAddress: _socket.localAddress, - localPort: _socket.localPort || this.localPort, - localFamily: _socket.localFamily, - remoteAddress: _socket.remoteAddress, - remotePort: _socket.remotePort, - remoteFamily: _socket.remoteFamily || "IPv4", - }; - socket.end(); - self.emit("drop", data); - return; - } - } - if (self.maxConnections != null && self._connections >= self.maxConnections) { - const data = { - localAddress: _socket.localAddress, - localPort: _socket.localPort || this.localPort, - localFamily: _socket.localFamily, - remoteAddress: _socket.remoteAddress, - remotePort: _socket.remotePort, - remoteFamily: _socket.remoteFamily || "IPv4", - }; - - socket.end(); - self.emit("drop", data); - return; - } - - const bunTLS = _socket[bunTlsSymbol]; - const isTLS = typeof bunTLS === "function"; - - self._connections++; - _socket.server = self; - - if (pauseOnConnect) { - _socket.pause(); - } - - if (typeof connectionListener === "function") { - this.pauseOnConnect = pauseOnConnect; - if (!isTLS) { - self.prependOnceListener("connection", connectionListener); - } - } - self.emit("connection", _socket); - // the duplex implementation start paused, so we resume when pauseOnConnect is falsy - if (!pauseOnConnect && !isTLS) { - _socket.resume(); + if (!self) return; + // Dispatch through the listener handle's onconnection hook so user code + // (and node:cluster RoundRobinHandle) can intercept accepted sockets the + // same way Node.js exposes TCP/Pipe wrap onconnection. + // For a standalone server-side wrap (new TLSSocket(duplex, { isServer })), + // `self` is the wrapping socket - not a Server - and its handle has no + // onconnection; throwing here would tear the brand-new TLS engine down + // before the ClientHello ever arrives. + const handle = self._handle || socket.listener; + if (handle && typeof handle.onconnection === "function") { + handle.onconnection(0, socket); } }, handshake(socket, success, verifyError) { const self = socket.data; - if (!success && verifyError?.code === "ECONNRESET") { - const err = new ConnResetException("socket hang up"); + // `server` is null for a standalone `new tls.TLSSocket(socket, { isServer: true })` + // (no listening server owns it) — guard every server.emit / server option read. + const server = self.server; + if (self[khandshakeTimer]) { + clearTimeout(self[khandshakeTimer]); + self[khandshakeTimer] = undefined; + } + // On the server side the second argument is the raw handshake result + // (client-certificate verification is reported separately through + // `verifyError` and handled below), so !success always means the TLS + // session was never established. + if (!success) { + // The handshake never completed: there is no TLS session, so there is + // no secureConnection. Report the failure through tlsClientError the + // way Node does and tear the connection down. A connection that was + // already reported (handshake timeout, explicit destroy) is not + // reported a second time when its teardown unwinds the handshake. + if (self._hadError || self.destroyed) { + if (!self.destroyed) self.destroy(); + return; + } + const err = tlsHandshakeError(verifyError); self.emit("_tlsError", err); - self.server.emit("tlsClientError", err, self); + server?.emit("tlsClientError", err, self); self._hadError = true; // error before handshake on the server side will only be emitted using tlsClientError self.destroy(); @@ -410,14 +647,16 @@ const ServerHandlers: SocketHandler = { self.secureConnecting = false; self._secureEstablished = !!success; self.servername = socket.getServername(); - const server = self.server!; self.alpnProtocol = socket.alpnProtocol; if (self._requestCert || self._rejectUnauthorized) { if (verifyError) { self.authorized = false; self.authorizationError = verifyError.code || verifyError.message; - server.emit("tlsClientError", verifyError, self); - if (self._rejectUnauthorized) { + server?.emit("tlsClientError", verifyError, self); + // Node only enforces client-cert verification (and the resulting destroy) + // when the server actually requested a cert; a server without requestCert + // leaves `authorized` false but keeps the connection open. + if (self._rejectUnauthorized && self._requestCert) { // if we reject we still need to emit secure self.emit("secure", self); self.destroy(verifyError); @@ -427,15 +666,17 @@ const ServerHandlers: SocketHandler = { self.authorized = true; } } - const connectionListener = server[bunSocketServerOptions]?.connectionListener; - if (typeof connectionListener === "function") { - server.prependOnceListener("secureConnection", connectionListener); + if (server) { + const connectionListener = server[bunSocketServerOptions]?.connectionListener; + if (typeof connectionListener === "function") { + server.prependOnceListener("secureConnection", connectionListener); + } + server.emit("secureConnection", self); } - server.emit("secureConnection", self); // after secureConnection event we emmit secure and secureConnect self.emit("secure", self); self.emit("secureConnect", verifyError); - if (server.pauseOnConnect) { + if (server?.pauseOnConnect) { self.pause(); } else { self.resume(); @@ -481,6 +722,125 @@ const ServerHandlers: SocketHandler = { binaryType: "buffer", } as const; +// Node.js-compatible onconnection: assigned to server._handle.onconnection in +// kRealListen and invoked from ServerHandlers.open with `this` bound to the +// listener handle. Kept as a standalone function so tests/cluster can wrap it. +function onconnection(err, clientHandle) { + const handle = this; + const self = handle[owner_symbol] as NetServer; + if (err) { + self.emit("error", err); + return; + } + clientHandle[kServerSocket] = handle; + const options = self[bunSocketServerOptions]; + const { pauseOnConnect, connectionListener, [kSocketClass]: SClass, requestCert, rejectUnauthorized } = options; + // Propagate the server's half-open/highWaterMark settings to the accepted + // socket so the Duplex's allowHalfOpen matches what the native layer was + // configured with in kRealListen; without this, net.createServer({ + // allowHalfOpen: true }) would be ignored on accepted connections. + // Matches Node's onconnection: + // https://github.com/nodejs/node/blob/843dc5f0d5ad/lib/net.js#L2349 + const _socket = new SClass({ + allowHalfOpen: self.allowHalfOpen, + highWaterMark: self.highWaterMark, + }) as NetSocket | TLSSocket; + _socket.isServer = true; + _socket._requestCert = requestCert; + _socket._rejectUnauthorized = rejectUnauthorized; + + _socket[kAttach](clientHandle.localPort, clientHandle); + + if (self.blockList) { + const addressType = isIP(clientHandle.remoteAddress); + if (addressType && self.blockList.check(clientHandle.remoteAddress, `ipv${addressType}`)) { + const data = { + localAddress: _socket.localAddress, + localPort: _socket.localPort || clientHandle.localPort, + localFamily: _socket.localFamily, + remoteAddress: _socket.remoteAddress, + remotePort: _socket.remotePort, + remoteFamily: _socket.remoteFamily || "IPv4", + }; + clientHandle.end(); + self.emit("drop", data); + return; + } + } + if (self.maxConnections != null && self._connections >= self.maxConnections) { + const data = { + localAddress: _socket.localAddress, + localPort: _socket.localPort || clientHandle.localPort, + localFamily: _socket.localFamily, + remoteAddress: _socket.remoteAddress, + remotePort: _socket.remotePort, + remoteFamily: _socket.remoteFamily || "IPv4", + }; + + clientHandle.end(); + self.emit("drop", data); + return; + } + + const bunTLS = _socket[bunTlsSymbol]; + const isTLS = typeof bunTLS === "function"; + + if (self.noDelay && clientHandle.setNoDelay) { + _socket[kSetNoDelay] = true; + clientHandle.setNoDelay(true); + } + if (self.keepAlive && clientHandle.setKeepAlive) { + _socket[kSetKeepAlive] = true; + _socket[kSetKeepAliveInitialDelay] = self.keepAliveInitialDelay; + clientHandle.setKeepAlive(true, self.keepAliveInitialDelay); + } + + self._connections++; + _socket.server = self; + _socket._server = self; + + if (pauseOnConnect) { + _socket.pause(); + } + + if (typeof connectionListener === "function") { + clientHandle.pauseOnConnect = pauseOnConnect; + if (!isTLS) { + self.prependOnceListener("connection", connectionListener); + } + } + // A client that never completes the TLS handshake must not hold the + // accepted socket open forever: report it through tlsClientError after + // handshakeTimeout the way Node does. The timer is cleared when the + // handshake settles (either way) or the socket closes first. + if (isTLS && self._handshakeTimeout > 0) { + const timer = setTimeout(() => { + _socket[khandshakeTimer] = undefined; + const err = $ERR_TLS_HANDSHAKE_TIMEOUT(); + _socket._hadError = true; + self.emit("tlsClientError", err, _socket); + if (!_socket.destroyed) _socket.destroy(); + }, self._handshakeTimeout); + // Node's handshake timer is unref'd: a fully-unref'd server (the + // graceful-shutdown pattern) must not be held open by a client that + // stalls mid-handshake. + timer.unref?.(); + _socket[khandshakeTimer] = timer; + _socket.once("close", () => { + if (_socket[khandshakeTimer]) { + clearTimeout(_socket[khandshakeTimer]); + _socket[khandshakeTimer] = undefined; + } + }); + } + + self.emit("connection", _socket); + // the duplex implementation start paused, so we resume when pauseOnConnect is falsy + if (!pauseOnConnect && !isTLS) { + _socket.resume(); + } +} + // TODO: SocketHandlers2 is a bad name but its temporary. reworking the Server in a followup PR const SocketHandlers2: SocketHandler["data"]> = { open(socket) { @@ -488,13 +848,11 @@ const SocketHandlers2: SocketHandler 0 + ) { + // Shape it like Node's errnoException(UV_ECONNRESET, 'read'): message, + // code, errno and syscall all populated. + // Same late-detach guard as SocketEmitEndNT: the listener seen at + // close-time can be gone by the deferred 'error' emission. + self.once("error", () => {}); + const er = new ConnResetException("read ECONNRESET") as Error & { errno?: number; syscall?: string }; + er.errno = err.errno; + er.syscall = "read"; + self.destroy(er); + return; + } self[kended] = true; if (!self.allowHalfOpen) self.write = writeAfterFIN; self.push(null); @@ -557,10 +963,34 @@ const SocketHandlers2: SocketHandler { if (!this.destroyed) { this.emit("error", error); @@ -902,7 +1373,11 @@ Socket.prototype.connect = function connect(...args) { this.pause(); } else { process.nextTick(() => { - this.resume(); + // Honor pause()/resume() calls made while connecting — only start + // flowing if the user hasn't explicitly paused the stream. Matches + // Node's afterConnect, which calls socket.read(0) only when not paused: + // https://github.com/nodejs/node/blob/843dc5f0d5ad/lib/net.js#L1649 + if (!this.isPaused()) this.resume(); }); this.connecting = true; } @@ -965,6 +1440,14 @@ Socket.prototype.connect = function connect(...args) { } // start using existing connection if (connection) { + // A generic duplex transport is already established, so this socket is + // not "connecting" - only the TLS layer is pending, which + // secureConnecting tracks. Node reports false here. A provided + // net.Socket keeps its existing accounting (its own connect lifecycle + // drives this flag). + if (!(connection instanceof Socket)) { + this.connecting = false; + } if (connectListener != null) this.once("secureConnect", connectListener); try { // reset the underlying writable object when establishing a new connection @@ -990,12 +1473,16 @@ Socket.prototype.connect = function connect(...args) { connection.on("close", events[3]); this._handle = result; } else { - if (socket) { + // upgradeTLS requires an established socket; a socket that is still + // connecting (e.g. tls.connect({ socket: net.connect(port) })) must be + // upgraded once it emits 'connect'. + if (socket && !connection.connecting) { this[kupgraded] = connection; const result = socket.upgradeTLS({ data: { self: this, req: { oncomplete: afterConnect } }, tls, socket: this[khandlers], + isServer: false, }); if (result) { const [raw, tls] = result; @@ -1011,6 +1498,13 @@ Socket.prototype.connect = function connect(...args) { } else { // wait to be connected connection.once("connect", () => { + // The TLS socket may have been destroyed before the underlying + // socket connected (e.g. tls.connect({ socket }).destroy()); don't + // start a handshake on a dead socket. + if (this.destroyed) { + connection.destroy(); + return; + } const socket = connection._handle; if (!upgradeDuplex && socket) { // if is named pipe socket we can upgrade it using the same wrapper than we use for duplex @@ -1034,6 +1528,7 @@ Socket.prototype.connect = function connect(...args) { data: { self: this, req: { oncomplete: afterConnect } }, tls, socket: this[khandlers], + isServer: false, }); if (result) { const [raw, tls] = result; @@ -1114,6 +1609,14 @@ Socket.prototype._destroy = function _destroy(err, callback) { $debug("Socket.prototype._destroy"); this.connecting = false; + // Tear down a wrapped generic duplex with this socket: the native handle's + // close only flushes close_notify and lets the wrapper drain; without an + // explicit destroy here a late RST on the underlying transport can surface + // as an unhandled error after this socket is gone. + const upgraded = this[kupgraded]; + if (upgraded && !(upgraded instanceof Socket) && !upgraded.destroyed) { + upgraded.destroy?.(); + } for (let s = this; s !== null; s = s._parent) { clearTimeout(s[kTimeout]); @@ -1129,7 +1632,11 @@ Socket.prototype._destroy = function _destroy(err, callback) { if (this.resetAndClosing) { this.resetAndClosing = false; - const err = this._handle.close(); + // resetAndDestroy() must send an RST (not a graceful FIN) so the peer sees + // ECONNRESET. `close()` does a fast shutdown (clean close) which only + // happens to surface as RST on some platforms; `terminate()` arms + // SO_LINGER{1,0} for a real reset on all platforms. + const err = this._handle.terminate(); setImmediate(() => { $debug("emit close"); this.emit("close", isException); @@ -1151,7 +1658,7 @@ Socket.prototype._destroy = function _destroy(err, callback) { callback(err); } else { callback(err); - process.nextTick(emitCloseNT, this, false); + process.nextTick(emitCloseNT, this, !!err); } if (this.server) { @@ -1166,7 +1673,7 @@ Socket.prototype._destroy = function _destroy(err, callback) { Socket.prototype._final = function _final(callback) { $debug("Socket.prototype._final"); if (this.connecting) { - return this.once("connect", () => this._final(callback)); + return this.once("connect", this._final.bind(this, callback)); } const socket = this._handle; @@ -1211,19 +1718,92 @@ Socket.prototype.resume = function resume() { if (!this.connecting) { this._handle?.resume(); } + // Restore the hold pause() removed - even while still connecting, so the + // pause-then-resume sequence is symmetric. Gated on the pause flag so a + // socket that was never paused (e.g. a wrapped duplex with no fd) is not + // newly pinned to the loop. + if (this[kPausedUnref] && !this[kUserUnrefed]) { + this._handle?.ref?.(); + this[kPausedUnref] = false; + } return Duplex.prototype.resume.$call(this); }; Socket.prototype.pause = function pause() { if (!this.destroyed) { this._handle?.pause(); + // libuv only counts a stream handle as active - and therefore as keeping + // the event loop alive - while it is reading. A paused socket lets the + // process exit; resume() re-refs it unless the user explicitly unref'd. + this._handle?.unref?.(); + // Only remember the unref when this handle can actually hold the loop: a + // TLS socket wrapped over a generic duplex has no fd, so re-refing it + // later would newly pin the process. + if (!this[kupgraded] || this[kupgraded] instanceof Socket) { + this[kPausedUnref] = true; + } } return Duplex.prototype.pause.$call(this); }; +// Server-side TLS upgrade over an accepted socket, for +// `new tls.TLSSocket(socket, { isServer: true })`. Adopts the connection's fd +// into an accept-state TLS socket (us_socket_adopt_tls with is_client=0) so the +// native read path drives the handshake. Lives here, not tls.ts, to reach +// ServerHandlers — the shared accepted-socket handler table, with per-socket +// state carried via `data` (mirrors tls.createServer's one-handler-for-all model). +Socket.prototype[Symbol.for("::bunUpgradeServerTLS::")] = function (connection, tls) { + const socket = connection._handle; + if (!socket) { + // A generic Duplex (or a not-yet-connected net.Socket) has no native fd + // to adopt into a TLS socket; run the TLS engine over the stream itself. + // The returned events feed the stream's bytes into the engine and back. + const [result, events] = upgradeDuplexToTLS(connection, { + data: this, + tls, + socket: serverHandlersFor(this), + isServer: true, + }); + connection.on("data", events[0]); + connection.on("end", events[1]); + connection.on("drain", events[2]); + connection.on("close", events[3]); + this._handle = result; + return; + } + this[kupgraded] = connection; + // Bytes that already arrived before the wrap (e.g. the ClientHello) were + // pulled off the fd into the connection's readable buffer; hand them to the + // TLS engine so the handshake doesn't stall. + const pending = connection.read(); + const result = socket.upgradeTLS({ + data: this, + tls, + socket: serverHandlersFor(this), + isServer: true, + initialData: pending || undefined, + }); + if (!result) { + this._handle = null; + throw new Error("Invalid socket"); + } + const [raw, tlsHandle] = result; + connection._handle = raw; + this.once("end", this[kCloseRawConnection]); + raw.connecting = false; + this._handle = tlsHandle; +}; + Socket.prototype.read = function read(size) { if (!this.connecting) { this._handle?.resume(); + // Restarting kernel reads makes the handle hold the loop open again; + // mirror resume()'s re-ref or a paused-then-read() socket waits for + // data without keeping the process alive. + if (this[kPausedUnref] && !this[kUserUnrefed]) { + this._handle?.ref?.(); + this[kPausedUnref] = false; + } } return Duplex.prototype.read.$call(this, size); }; @@ -1234,6 +1814,12 @@ Socket.prototype._read = function _read(size) { this.once("connect", () => this._read(size)); } else { socket?.resume(); + // See read() above - the Readable machinery's pull path must also + // restore the handle's hold on the loop. + if (this[kPausedUnref] && !this[kUserUnrefed]) { + socket?.ref?.(); + this[kPausedUnref] = false; + } } }; @@ -1284,6 +1870,7 @@ Object.defineProperty(Socket.prototype, "readyState", { }); Socket.prototype.ref = function ref() { + this[kUserUnrefed] = false; const socket = this._handle; if (!socket) { this.once("connect", this.ref); @@ -1359,6 +1946,35 @@ Socket.prototype.setNoDelay = function setNoDelay(enable = true) { return this; }; +// Matches Node's setTypeOfService/getTypeOfService. Bun's native socket handle +// has no setTypeOfService/getTypeOfService, so these fall through to caching the +// value, mirroring Node's no-native-handle fallback. +// https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/net.js#L661 +Socket.prototype.setTypeOfService = function setTypeOfService(tos) { + if (Number.isNaN(tos)) { + throw $ERR_INVALID_ARG_TYPE("tos", "number", tos); + } + validateInt32(tos, "tos", 0, 255); + + if (!this._handle || !this._handle.setTypeOfService) { + this[kSetTOS] = tos; + return this; + } + + if (tos !== this[kSetTOS]) { + this[kSetTOS] = tos; + this._handle.setTypeOfService(tos); + } + return this; +}; + +Socket.prototype.getTypeOfService = function getTypeOfService() { + if (!this._handle || !this._handle.getTypeOfService) { + return this[kSetTOS] !== undefined ? this[kSetTOS] : 0; + } + return this._handle.getTypeOfService(); +}; + Socket.prototype.setTimeout = { setTimeout(msecs, callback) { if (this.destroyed) return this; @@ -1393,6 +2009,7 @@ Socket.prototype._unrefTimer = function _unrefTimer() { }; Socket.prototype.unref = function unref() { + this[kUserUnrefed] = true; const socket = this._handle; if (!socket) { this.once("connect", this.unref); @@ -1466,10 +2083,34 @@ Socket.prototype._write = function _write(chunk, encoding, callback) { return false; } this._unrefTimer(); + if (socket.readyState < 0) { + // The handle's native socket was already closed (e.g. handle.close() was + // called directly): fail the write the way a write(2) on a closed fd does + // in Node instead of waiting forever for a drain that never comes. + // Node reports this as errnoException(UV_EBADF/UV_EPIPE, 'write'), with + // message, code, errno and syscall all populated. + const er = new ErrnoException(process.platform === "win32" ? -4047 /* UV_EPIPE */ : -9 /* UV_EBADF */, "write"); + process.nextTick(callback, er); + return false; + } const success = socket.$write(chunk, encoding); this[kBytesWritten] = socket.bytesWritten; if (success) { - callback(); + if (this.encrypted) { + // TLS batches writes through the SSL engine, so the bytes stay buffered + // after $write returns. Defer the callback so writableLength/bufferSize + // reflects the queued bytes until they are flushed (test-tls-buffersize.js). + // Node's bufferSize getter is just writableLength: + // https://github.com/nodejs/node/blob/843dc5f0d5ad/lib/net.js#L752 + process.nextTick(callback); + } else { + // A plain TCP write completes synchronously once $write reports success. + // Calling the callback synchronously lets writableLength drain so a tight + // write() loop backpressures at the kernel rather than the JS + // highWaterMark, matching Node's _write (test-net-throttle.js): + // https://github.com/nodejs/node/blob/843dc5f0d5ad/lib/net.js#L1036 + callback(); + } } else if (this[kwriteCallback]) { callback(new Error("overlapping _write()")); } else { @@ -1774,6 +2415,18 @@ function internalConnect(self, options, address, port, addressType, localAddress req.tls = tls; err = kConnectTcp(self, addressType, req, address, port); + // kConnectTcp returns 0 (not undefined) on the async-connect path, so the + // perf context must be established whenever the attempt was dispatched + // without a synchronous error — matching the `if (err)` failure check + // below. Guarding on `err === undefined` never fired, so the 'net' entry + // was never produced for the TCP path. + if (!err && hasObserver("net")) { + startPerf(self, kPerfHooksNetConnectContext, { + type: "net", + name: "connect", + detail: { host: address, port }, + }); + } } else { const req: any = {}; req.address = address; @@ -1978,6 +2631,10 @@ function afterConnect(status, handle, req, readable, writable) { self.emit("connect"); self.emit("ready"); + if (self[kPerfHooksNetConnectContext] && hasObserver("net")) { + stopPerf(self, kPerfHooksNetConnectContext); + } + // Start the first read, or get an immediate EOF. // this doesn't actually consume any bytes, because len=0. if (readable && !self.isPaused()) self.read(0); @@ -2028,6 +2685,14 @@ function afterConnectMultiple(context, current, status, handle, req, readable, w return; } + if (hasObserver("net")) { + startPerf(self, kPerfHooksNetConnectContext, { + type: "net", + name: "connect", + detail: { host: req.address, port: req.port }, + }); + } + afterConnect(status, self._handle, req, readable, writable); } @@ -2070,15 +2735,22 @@ function Server(options?, connectionListener?) { } // https://nodejs.org/api/net.html#netcreateserveroptions-connectionlistener - const { + let { allowHalfOpen = false, keepAlive = false, - keepAliveInitialDelay = 0, + keepAliveInitialDelay, highWaterMark = getDefaultHighWaterMark(), pauseOnConnect = false, noDelay = false, } = options; + if (keepAliveInitialDelay !== undefined) { + validateNumber(keepAliveInitialDelay, "options.keepAliveInitialDelay"); + if (keepAliveInitialDelay < 0) keepAliveInitialDelay = 0; + } else { + keepAliveInitialDelay = 0; + } + this._connections = 0; this._handle = null as MaybeListener; @@ -2088,12 +2760,14 @@ function Server(options?, connectionListener?) { this.listeningId = 1; this[bunSocketServerOptions] = undefined; + // Server option coercion matches Node's Server constructor: + // https://github.com/nodejs/node/blob/843dc5f0d5ad/lib/net.js#L1880 this.allowHalfOpen = allowHalfOpen; - this.keepAlive = keepAlive; - this.keepAliveInitialDelay = keepAliveInitialDelay; + this.keepAlive = Boolean(keepAlive); + this.keepAliveInitialDelay = ~~(keepAliveInitialDelay / 1000); this.highWaterMark = highWaterMark; this.pauseOnConnect = Boolean(pauseOnConnect); - this.noDelay = noDelay; + this.noDelay = Boolean(noDelay); options.connectionListener = connectionListener; this[bunSocketServerOptions] = options; @@ -2202,6 +2876,8 @@ Server.prototype.listen = function listen(port, hostname, onListen) { let allowHalfOpen = false; let reusePort = false; let ipv6Only = false; + let readableAll = false; + let writableAll = false; let fd; //port is actually path if (typeof port === "string") { @@ -2233,7 +2909,7 @@ Server.prototype.listen = function listen(port, hostname, onListen) { if (typeof port === "function") { onListen = port; port = 0; - } else if (typeof port === "object") { + } else if (port !== null && typeof port === "object") { const options = port; addServerAbortSignalOption(this, options); @@ -2245,6 +2921,10 @@ Server.prototype.listen = function listen(port, hostname, onListen) { allowHalfOpen = options.allowHalfOpen; reusePort = options.reusePort; backlog = options.backlog; + // For a unix-socket listen, readableAll/writableAll chmod the socket file + // in kRealListen; threaded through as locals (not stashed on the instance). + readableAll = options.readableAll; + writableAll = options.writableAll; if (typeof options.fd === "number" && options.fd >= 0) { fd = options.fd; @@ -2253,31 +2933,49 @@ Server.prototype.listen = function listen(port, hostname, onListen) { const isLinux = process.platform === "linux" || process.platform === "android"; - if (!Number.isSafeInteger(port) || port < 0) { - if (path) { - const isAbstractPath = path.startsWith("\0"); - if (isLinux && isAbstractPath && (options.writableAll || options.readableAll)) { - const message = `The argument 'options' can not set readableAll or writableAll to true when path is abstract unix socket. Received ${JSON.stringify(options)}`; + // Match Node's listen() option normalization + validation. + // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/net.js#L2145 + if ((port === undefined && "port" in options) || port === null) { + port = 0; + } - const error = new TypeError(message); - error.code = "ERR_INVALID_ARG_VALUE"; - throw error; - } - - hostname = path; - port = undefined; - } else { - let message = 'The argument \'options\' must have the property "port" or "path"'; - try { - message = `${message}. Received ${JSON.stringify(options)}`; - } catch {} + if (typeof port === "number" || typeof port === "string") { + // validatePort coerces "0" -> 0 and throws ERR_SOCKET_BAD_PORT for + // out-of-range/non-numeric values; a valid port takes precedence over path. + validatePort(port, "options.port"); + port = port | 0; + // A valid port takes precedence over `path` (Node listens on TCP when both are given). + path = undefined; + } else if (isPipeName(path)) { + const isAbstractPath = path.startsWith("\0"); + if (isLinux && isAbstractPath && (options.writableAll || options.readableAll)) { + const message = `The argument 'options' can not set readableAll or writableAll to true when path is abstract unix socket. Received ${JSON.stringify(options)}`; const error = new TypeError(message); error.code = "ERR_INVALID_ARG_VALUE"; throw error; } - } else if (port === undefined) { - port = 0; + + hostname = path; + port = undefined; + } else if (!("port" in options) && !("path" in options)) { + let message = 'The argument \'options\' must have the property "port" or "path"'; + try { + message = `${message}. Received ${JSON.stringify(options)}`; + } catch {} + + const error = new TypeError(message); + error.code = "ERR_INVALID_ARG_VALUE"; + throw error; + } else { + let message = "The argument 'options' is invalid"; + try { + message = `${message}. Received ${JSON.stringify(options)}`; + } catch {} + + const error = new TypeError(message); + error.code = "ERR_INVALID_ARG_VALUE"; + throw error; } // port @@ -2291,8 +2989,21 @@ Server.prototype.listen = function listen(port, hostname, onListen) { // signal An AbortSignal that may be used to close a listening server. if (typeof options.callback === "function") onListen = options?.callback; - } else if (!Number.isSafeInteger(port) || port < 0) { + } else if (port === undefined || port === null) { port = 0; + } else if (typeof port === "number" || typeof port === "string") { + // Positional port: validatePort coerces and throws ERR_SOCKET_BAD_PORT for + // out-of-range/non-numeric values, matching Node's normalizeArgs + validatePort. + validatePort(port, "options.port"); + port = port | 0; + } else { + let message = "The argument 'options' is invalid"; + try { + message = `${message}. Received ${JSON.stringify(port)}`; + } catch {} + const error = new TypeError(message); + error.code = "ERR_INVALID_ARG_VALUE"; + throw error; } hostname = hostname || "::"; } @@ -2338,6 +3049,8 @@ Server.prototype.listen = function listen(port, hostname, onListen) { ipv6Only, allowHalfOpen, reusePort, + readableAll, + writableAll, undefined, undefined, path, @@ -2347,7 +3060,8 @@ Server.prototype.listen = function listen(port, hostname, onListen) { onListen, ); } catch (err) { - setTimeout(emitErrorNextTick, 1, this, err); + const isUnix = path != null; + setTimeout(emitErrorNextTick, 1, this, formatListenError(err, isUnix ? path : hostname, isUnix ? undefined : port)); } return this; }; @@ -2360,6 +3074,8 @@ Server.prototype[kRealListen] = function ( ipv6Only, allowHalfOpen, reusePort, + readableAll, + writableAll, tls, contexts, _onListen, @@ -2369,23 +3085,45 @@ Server.prototype[kRealListen] = function ( this._handle = Bun.listen({ unix: path, tls, - allowHalfOpen: allowHalfOpen || this[bunSocketServerOptions]?.allowHalfOpen || false, + // Accepted sockets are always half-open natively; the stream layer + // implements allowHalfOpen=false (see kConnect / onSocketEnd). + allowHalfOpen: true, reusePort: reusePort || this[bunSocketServerOptions]?.reusePort || false, ipv6Only: ipv6Only || this[bunSocketServerOptions]?.ipv6Only || false, exclusive: exclusive || this[bunSocketServerOptions]?.exclusive || false, - socket: ServerHandlers, + socket: serverHandlersFor(this), data: this, }); + // Mirror libuv uv_pipe_chmod: readableAll/writableAll relax the unix socket + // file's group/other permission bits. Skipped on Windows and abstract + // sockets (no filesystem entry). uSockets binds synchronously, so the file + // exists by the time Bun.listen returns. + // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/net.js#L1899 + if ((readableAll || writableAll) && process.platform !== "win32" && path.charCodeAt(0) !== 0) { + let desired = 0; + if (readableAll) desired |= 0o44; // S_IRGRP | S_IROTH + if (writableAll) desired |= 0o22; // S_IWGRP | S_IWOTH + try { + const fs = require("node:fs"); + const cur = fs.statSync(path).mode; + if ((cur & desired) !== desired) fs.chmodSync(path, cur | desired); + } catch (e) { + // _handle is a Bun.listen SocketListener: it exposes stop(), not close(). + this._handle?.stop?.(true); + this._handle = null; + throw e; + } + } } else if (fd != null) { this._handle = Bun.listen({ fd, hostname, tls, - allowHalfOpen: allowHalfOpen || this[bunSocketServerOptions]?.allowHalfOpen || false, + allowHalfOpen: true, reusePort: reusePort || this[bunSocketServerOptions]?.reusePort || false, ipv6Only: ipv6Only || this[bunSocketServerOptions]?.ipv6Only || false, exclusive: exclusive || this[bunSocketServerOptions]?.exclusive || false, - socket: ServerHandlers, + socket: serverHandlersFor(this), data: this, }); } else { @@ -2393,15 +3131,18 @@ Server.prototype[kRealListen] = function ( port, hostname, tls, - allowHalfOpen: allowHalfOpen || this[bunSocketServerOptions]?.allowHalfOpen || false, + allowHalfOpen: true, reusePort: reusePort || this[bunSocketServerOptions]?.reusePort || false, ipv6Only: ipv6Only || this[bunSocketServerOptions]?.ipv6Only || false, exclusive: exclusive || this[bunSocketServerOptions]?.exclusive || false, - socket: ServerHandlers, + socket: serverHandlersFor(this), data: this, }); } + this._handle[owner_symbol] = this; + this._handle.onconnection = onconnection; + const addr = this.address(); if (addr && typeof addr === "object") { const familyLast = String(addr.family).slice(-1); @@ -2432,6 +3173,7 @@ Server.prototype[kRealListen] = function ( Server.prototype[EventEmitter.captureRejectionSymbol] = function (err, event, sock) { switch (event) { case "connection": + case "secureConnection": sock.destroy(err); break; default: @@ -2484,6 +3226,8 @@ function listenInCluster( ipv6Only, allowHalfOpen, reusePort, + readableAll, + writableAll, flags, options, path, @@ -2505,6 +3249,8 @@ function listenInCluster( ipv6Only, allowHalfOpen, reusePort, + readableAll, + writableAll, tls, contexts, onListen, @@ -2535,6 +3281,8 @@ function listenInCluster( ipv6Only, allowHalfOpen, reusePort, + readableAll, + writableAll, tls, contexts, onListen, @@ -2607,6 +3355,38 @@ function closeSocketHandle(self, isException, isCleanupPending = false) { } } +// Reformat a native listen error to Node's "listen : " +// (Node uses exceptionWithHostPort). Only rewrites known uv codes; the code is +// already set natively. +// https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/net.js#L1899 +function uvListenErrorDescription(code) { + switch (code) { + case "EADDRINUSE": + return "address already in use"; + case "EACCES": + return "permission denied"; + case "EADDRNOTAVAIL": + return "address not available"; + case "EINVAL": + return "invalid argument"; + default: + return undefined; + } +} +function formatListenError(err, address, port) { + const desc = err && typeof err.code === "string" ? uvListenErrorDescription(err.code) : undefined; + if (desc) { + err.syscall = "listen"; + // Node's exceptionWithHostPort also exposes the failing address/port as + // own properties; user code commonly reads them off listen errors. + err.address = address; + if (port) err.port = port; + const where = port ? `${address}:${port}` : address; + err.message = `listen ${err.code}: ${desc}${where ? ` ${where}` : ""}`; + } + return err; +} + function checkBindError(err, port, handle) { // EADDRINUSE may not be reported until we call listen() or connect(). // To complicate matters, a failed bind() followed by listen() or connect() diff --git a/src/js/node/perf_hooks.ts b/src/js/node/perf_hooks.ts index f5b05ed542fd..de20bfc411d6 100644 --- a/src/js/node/perf_hooks.ts +++ b/src/js/node/perf_hooks.ts @@ -1,5 +1,5 @@ // Hardcoded module "node:perf_hooks" -const { throwNotImplemented } = require("internal/shared"); +const { throwNotImplemented, kNodeEntryTypes, NodeEntryObserver } = require("internal/shared"); const cppCreateHistogram = $newCppFunction("JSNodePerformanceHooksHistogram.cpp", "jsFunction_createHistogram", 3) as ( min: number, @@ -12,7 +12,7 @@ var { PerformanceEntry, PerformanceMark, PerformanceMeasure, - PerformanceObserver, + PerformanceObserver: NodePerformanceObserver, PerformanceObserverEntryList, } = globalThis; @@ -113,6 +113,91 @@ class PerformanceResourceTiming { } $toClass(PerformanceResourceTiming, "PerformanceResourceTiming", PerformanceEntry); +const kNodeObserver = Symbol("kNodeObserver"); +const kObserverCallback = Symbol("kObserverCallback"); + +/** + * The native (WebCore) observer only understands mark/measure/resource. + * Node-only entry types ('net', 'dns', ...) are routed to the JS-side + * registry in internal/shared; everything else is delegated to the native + * observer unchanged. (`NodePerformanceObserver` is the existing alias for + * the native class destructured from globalThis above.) + */ +class PerformanceObserverForNodeTypes extends NodePerformanceObserver { + constructor(callback) { + super(callback); + this[kObserverCallback] = callback; + } + + /** The native list plus the Node-only types routed through the JS registry. */ + static get supportedEntryTypes() { + return [...new Set([...(NodePerformanceObserver.supportedEntryTypes ?? []), ...kNodeEntryTypes])].sort(); + } + + observe(options) { + let requested; + let isTypeMode = false; + if (options != null && typeof options === "object") { + if (options.entryTypes !== undefined && Array.isArray(options.entryTypes)) { + requested = options.entryTypes; + } else if (options.type !== undefined) { + requested = [options.type]; + isTypeMode = true; + } + } + if (requested) { + const nodeTypes = requested.filter(type => kNodeEntryTypes.has(type)); + let registration = this[kNodeObserver]; + if (nodeTypes.length > 0 && !registration) { + registration = this[kNodeObserver] = new NodeEntryObserver(this[kObserverCallback], this); + } + if (registration) { + if (isTypeMode) { + // observe({type}) appends to the observed set per the spec. + registration.observe([...registration.types, ...nodeTypes]); + } else { + // observe({entryTypes}) replaces the observed set, including + // dropping a previously-observed node type when the new set has + // none. + registration.observe(nodeTypes); + } + } + if (nodeTypes.length > 0) { + const webTypes = requested.filter(type => !kNodeEntryTypes.has(type)); + if (webTypes.length === 0) { + // observe({entryTypes}) replaces the whole observed set: a + // previously-subscribed web type must stop firing when the new set + // is node-only. The native impl rejects an empty entryTypes array, + // so drop the subscription instead of re-observing with []. + if (!isTypeMode) { + try { + super.disconnect(); + } catch {} + } + return; + } + // A non-empty webTypes set alongside a node type is only possible in + // entryTypes mode (observe({type}) requests exactly one type), so the + // forwarded subscription is always an entryTypes one. + return super.observe({ ...options, entryTypes: webTypes }); + } + } + return super.observe(options); + } + + disconnect() { + this[kNodeObserver]?.disconnect(); + this[kNodeObserver] = undefined; + return super.disconnect(); + } +} +// Not $toClass: that resets the prototype object and would drop the +// observe/disconnect overrides above. Only the public name needs fixing. +Object.defineProperty(PerformanceObserverForNodeTypes, "name", { + value: "PerformanceObserver", + configurable: true, +}); + export default { performance: { mark(_) { @@ -169,7 +254,7 @@ export default { PerformanceEntry, PerformanceMark, PerformanceMeasure, - PerformanceObserver, + PerformanceObserver: PerformanceObserverForNodeTypes, PerformanceObserverEntryList, PerformanceNodeTiming, monitorEventLoopDelay: function monitorEventLoopDelay(options?: { resolution?: number }) { diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index c684f4f00a7e..835b1d8e5ff7 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -1,11 +1,18 @@ // Hardcoded module "node:tls" -const { isArrayBufferView, isTypedArray } = require("node:util/types"); +const { isArrayBufferView } = require("node:util/types"); const net = require("node:net"); const Duplex = require("internal/streams/duplex"); +const EventEmitter = require("node:events"); const addServerName = $newZigFunction("Listener.zig", "jsAddServerName", 3); const { throwNotImplemented } = require("internal/shared"); const { throwOnInvalidTLSArray } = require("internal/tls"); -const { validateString, validateFunction } = require("internal/validators"); +const { + validateString, + validateNumber, + validateUint32, + validateBuffer, + validateFunction, +} = require("internal/validators"); const { Server: NetServer, Socket: NetSocket } = net; @@ -189,6 +196,61 @@ function getValidCiphersSet() { return _VALID_CIPHERS_SET; } +// OpenSSL cipher-list selector keywords that are not literal suite names. +const CIPHER_LIST_SELECTORS = new Set([ + "DEFAULT", + "ALL", + "COMPLEMENTOFDEFAULT", + "COMPLEMENTOFALL", + "HIGH", + "MEDIUM", + "LOW", + "PSK", + "aNULL", + "eNULL", + "NULL", + "EXPORT", + "EXP", + "kRSA", + "aRSA", + "RSA", + "kDHE", + "kEDH", + "DH", + "DHE", + "EDH", + "kECDHE", + "kEECDH", + "ECDHE", + "EECDH", + "ECDH", + "aECDSA", + "ECDSA", + "aDSS", + "DSS", + "AES", + "AESGCM", + "AESCCM", + "CHACHA20", + "3DES", + "DES", + "RC4", + "RC2", + "MD5", + "SHA", + "SHA1", + "SHA256", + "SHA384", + "CAMELLIA", + "ARIA", + "SRP", + "TLSv1", + "TLSv1.0", + "TLSv1.2", + "TLSv1.3", + "SSLv3", +]); + function validateCiphers(ciphers: string, name: string = "options") { // Set the cipher list and cipher suite before anything else because // @SECLEVEL= changes the security level and that affects subsequent @@ -202,12 +264,156 @@ function validateCiphers(ciphers: string, name: string = "options") { const requested = ciphers.split(":"); for (const r of requested) { if (r && !ciphersSet.has(r)) { + // OpenSSL cipher-list grammar: `!X`/`-X`/`+X` operators, `A+B` + // intersections, `@SECLEVEL=n`/`@STRENGTH` directives and selector + // keywords (HIGH, PSK, aNULL, ...) are not literal cipher names - + // leave their evaluation to BoringSSL. Only an unrecognized literal + // suite name is rejected here. + // BoringSSL has no security levels: its cipher parser rejects + // @SECLEVEL with INVALID_COMMAND. Report that the way the native + // parser would, with Node's decomposed error shape. + if (r.includes("@SECLEVEL")) { + const err = new Error("error:0f000076:SSL routines:OPENSSL_internal:INVALID_COMMAND") as Error & { + code: string; + library: string; + function: string; + reason: string; + }; + err.code = "ERR_SSL_INVALID_COMMAND"; + err.library = "SSL routines"; + err.function = "OPENSSL_internal"; + err.reason = "INVALID_COMMAND"; + throw err; + } + const first = r.charCodeAt(0); + if ( + first === 0x21 /* ! */ || + first === 0x2d /* - */ || + first === 0x2b /* + */ || + first === 0x40 /* @ */ || + r.includes("+") || + CIPHER_LIST_SELECTORS.has(r) + ) { + continue; + } throw $ERR_SSL_NO_CIPHER_MATCH(); } } } } +const VALID_TLS_VERSIONS = new Set(["TLSv1", "TLSv1.1", "TLSv1.2", "TLSv1.3"]); + +// Subset of Node's configSecureContext() validations: +// https://github.com/nodejs/node/blob/843dc5f0d5ad/lib/internal/tls/secure-context.js#L318 +// Valid OpenSSL/BoringSSL secureProtocol method names (legacy API). Built lazily +// so the Set is only allocated when a secureProtocol option is actually used. +let _SECURE_PROTOCOL_METHODS: Set | undefined; +function getSecureProtocolMethods() { + if (!_SECURE_PROTOCOL_METHODS) { + _SECURE_PROTOCOL_METHODS = new Set([ + "TLS_method", + "TLS_client_method", + "TLS_server_method", + "SSLv23_method", + "SSLv23_client_method", + "SSLv23_server_method", + "TLSv1_method", + "TLSv1_client_method", + "TLSv1_server_method", + "TLSv1_1_method", + "TLSv1_1_client_method", + "TLSv1_1_server_method", + "TLSv1_2_method", + "TLSv1_2_client_method", + "TLSv1_2_server_method", + ]); + } + return _SECURE_PROTOCOL_METHODS; +} +// Matches Node: SSLv2/SSLv3 methods are disabled, anything unrecognized is an +// unknown method. +// https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/internal/tls/secure-context.js#L100 +function invalidProtocolMethod(message) { + // Node throws all secureProtocol failures (SSLv2/SSLv3 disabled + unknown + // method) via THROW_ERR_TLS_INVALID_PROTOCOL_METHOD: a TypeError carrying the + // ERR_TLS_INVALID_PROTOCOL_METHOD code, varying only the message. + const error = new TypeError(message); + error.code = "ERR_TLS_INVALID_PROTOCOL_METHOD"; + return error; +} +function validateSecureProtocol(secureProtocol) { + if (secureProtocol === undefined || secureProtocol === null) return; + validateString(secureProtocol, "options.secureProtocol"); + if (secureProtocol.startsWith("SSLv2_")) throw invalidProtocolMethod("SSLv2 methods disabled"); + if (secureProtocol.startsWith("SSLv3_")) throw invalidProtocolMethod("SSLv3 methods disabled"); + if (!getSecureProtocolMethods().has(secureProtocol)) { + throw invalidProtocolMethod(`Unknown method: ${secureProtocol}`); + } +} + +function validateSecureContextOptions(options) { + const { + ciphers, + passphrase, + ecdhCurve, + minVersion, + maxVersion, + sessionTimeout, + ticketKeys, + clientCertEngine, + dhparam, + secureProtocol, + } = options; + validateSecureProtocol(secureProtocol); + if (ciphers !== undefined && ciphers !== null) validateString(ciphers, "options.ciphers"); + if (passphrase !== undefined && passphrase !== null) validateString(passphrase, "options.passphrase"); + if (ecdhCurve !== undefined && ecdhCurve !== null) validateString(ecdhCurve, "options.ecdhCurve"); + // clientCertEngine must be a string (engine name); a provided engine then + // fails because BoringSSL (which Bun always uses) has no OpenSSL ENGINE + // support, matching Node's setClientCertEngine. Node: + // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/internal/tls/secure-context.js#L296 + if (clientCertEngine !== undefined && clientCertEngine !== null) { + if (typeof clientCertEngine !== "string") { + throw $ERR_INVALID_ARG_TYPE("options.clientCertEngine", ["string", "null", "undefined"], clientCertEngine); + } + throw $ERR_CRYPTO_CUSTOM_ENGINE_NOT_SUPPORTED("Custom engines not supported by this OpenSSL"); + } + // BoringSSL (always used by Bun) has no automatic DH parameter selection. + // Matches Node's setDHParam('auto') throwing ERR_CRYPTO_UNSUPPORTED_OPERATION. + // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/internal/tls/secure-context.js#L254 + if (dhparam === "auto") { + throw $ERR_CRYPTO_UNSUPPORTED_OPERATION("Automatic DH parameter selection is not supported"); + } + if (minVersion != null && !VALID_TLS_VERSIONS.has(minVersion)) + throw $ERR_TLS_INVALID_PROTOCOL_VERSION(String(minVersion), "minimum"); + if (maxVersion != null && !VALID_TLS_VERSIONS.has(maxVersion)) + throw $ERR_TLS_INVALID_PROTOCOL_VERSION(String(maxVersion), "maximum"); + if (ticketKeys !== undefined && ticketKeys !== null) { + validateBuffer(ticketKeys, "options.ticketKeys"); + if (ticketKeys.byteLength !== 48) { + throw $ERR_INVALID_ARG_VALUE("options.ticketKeys", ticketKeys.byteLength, "must be exactly 48 bytes"); + } + } + // Negative session timeouts are rejected (min 0), matching Node — newer + // OpenSSL/BoringSSL do not handle negative values as users expect. + // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/internal/tls/secure-context.js#L319 + if (sessionTimeout !== undefined && sessionTimeout !== null) { + // Node validates this with validateInt32(..., 0), whose range message + // reads ">= 0 && <= 2147483647"; the shared validator here words it + // differently, so spell the check out to match. + if (typeof sessionTimeout !== "number") { + throw $ERR_INVALID_ARG_TYPE("options.sessionTimeout", "number", sessionTimeout); + } + if (!Number.isInteger(sessionTimeout)) { + throw $ERR_OUT_OF_RANGE("options.sessionTimeout", "an integer", sessionTimeout); + } + if (sessionTimeout < 0 || sessionTimeout > 2147483647) { + throw $ERR_OUT_OF_RANGE("options.sessionTimeout", ">= 0 && <= 2147483647", sessionTimeout); + } + } +} + const SymbolReplace = Symbol.replace; const RegExpPrototypeSymbolReplace = RegExp.prototype[SymbolReplace]; const RegExpPrototypeExec = RegExp.prototype.exec; @@ -407,7 +613,115 @@ const NativeSecureContext = $zig("SecureContext.zig", "js.getConstructor"); // accepts null|string|ArrayBuffer|Blob|array, so coerce falsy → null before // crossing into native so `{ key: false }` etc. doesn't throw // ERR_INVALID_ARG_TYPE from the bindgen layer. -function newNativeSecureContext(options) { +// BoringSSL TLS1_x_VERSION constants (from openssl/tls1.h). The native context +// applies these via SSL_CTX_set_min/max_proto_version. +const TLS1_VERSION = 0x0301; +const TLS1_1_VERSION = 0x0302; +const TLS1_2_VERSION = 0x0303; +const TLS1_3_VERSION = 0x0304; +function tlsStringToProtocolVersion(v) { + switch (v) { + case "TLSv1": + return TLS1_VERSION; + case "TLSv1.1": + return TLS1_1_VERSION; + case "TLSv1.2": + return TLS1_2_VERSION; + case "TLSv1.3": + return TLS1_3_VERSION; + default: + return 0; + } +} +// Node's legacy secureProtocol string pins both bounds to a single version +// (e.g. 'TLSv1_2_method'); 'TLS_method'/'SSLv23_method' leave the range open. +// https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/internal/tls/secure-context.js#L120 +function secureProtocolToVersionRange(secureProtocol) { + if (typeof secureProtocol !== "string") return null; + if ( + secureProtocol === "TLSv1_method" || + secureProtocol === "TLSv1_client_method" || + secureProtocol === "TLSv1_server_method" + ) + return [TLS1_VERSION, TLS1_VERSION]; + if ( + secureProtocol === "TLSv1_1_method" || + secureProtocol === "TLSv1_1_client_method" || + secureProtocol === "TLSv1_1_server_method" + ) + return [TLS1_1_VERSION, TLS1_1_VERSION]; + if ( + secureProtocol === "TLSv1_2_method" || + secureProtocol === "TLSv1_2_client_method" || + secureProtocol === "TLSv1_2_server_method" + ) + return [TLS1_2_VERSION, TLS1_2_VERSION]; + return null; +} + +/** + * Node's `pfx` option: parse each PKCS#12 blob into PEM key/cert/ca and fold + * them into the regular options so every downstream consumer (the native + * config, the multi-identity check, the CA store) sees plain key/cert/ca. + * Returns the original object untouched when no pfx is present. + */ +function processPfxOptions(options) { + if (options == null || options.pfx == null) return options; + const out = { ...options }; + const keys = out.key == null ? [] : Array.isArray(out.key) ? [...out.key] : [out.key]; + const certs = out.cert == null ? [] : Array.isArray(out.cert) ? [...out.cert] : [out.cert]; + const cas = out.ca == null ? [] : Array.isArray(out.ca) ? [...out.ca] : [out.ca]; + const pfxCAs = []; + const entries = Array.isArray(out.pfx) ? out.pfx : [out.pfx]; + for (const entry of entries) { + let buf = entry; + let passphrase = out.passphrase; + if ( + entry != null && + typeof entry === "object" && + !Buffer.isBuffer(entry) && + !$isTypedArrayView(entry) && + entry.buf !== undefined + ) { + buf = entry.buf; + if (entry.passphrase !== undefined) passphrase = entry.passphrase; + } + const parsed = NativeSecureContext.parsePkcs12(buf, passphrase); + keys.push(parsed.key); + certs.push(parsed.cert); + // A CA bundled inside the PKCS#12 EXTENDS the trust set (Node loads it + // via addCACert on top of the default roots); folding it into the `ca` + // option would instead REPLACE the trust store and break verification + // against the default/NODE_EXTRA_CA_CERTS roots for pfx-only clients. + if (parsed.ca) pfxCAs.push(parsed.ca); + } + out.key = keys.length === 1 ? keys[0] : keys; + out.cert = certs.length === 1 ? certs[0] : certs; + if (cas.length) out.ca = cas.length === 1 ? cas[0] : cas; + if (pfxCAs.length) out._pfxExtraCACerts = pfxCAs; + out.pfx = undefined; + return out; +} + +function newNativeSecureContext(options, cached = true) { + maybeWarnAboutExtraCACerts(); + if (options == null) { + // tls.createSecureContext() with no options builds the default context. + return (cached ? NativeSecureContext.intern : NativeSecureContext.createPrivate)({}); + } + options = processPfxOptions(options); + // PKCS#12-embedded CAs extend the trust set after the context is built; a + // mutated context must not be the shared cached one. + const pfxExtraCAs = options._pfxExtraCACerts; + if (pfxExtraCAs) cached = false; + // ALPN protocols given as an array of strings are converted to the + // length-prefixed wire format before crossing into native, the way Node's + // convertALPNProtocols normalizes them on the socket options. + if (Array.isArray(options.ALPNProtocols)) { + const normalized = {}; + convertALPNProtocols(options.ALPNProtocols, normalized); + options = { ...options, ALPNProtocols: normalized.ALPNProtocols }; + } if (options && (!options.key || !options.cert || !options.ca)) { options = { ...options, @@ -416,20 +730,50 @@ function newNativeSecureContext(options) { ca: options.ca || null, }; } - return NativeSecureContext.intern(options); + if (options) { + // Read each option once. Translate minVersion/maxVersion/secureProtocol to + // the integer protocol range the native layer applies, so the bindings + // receive numbers, not the user-facing strings. Note: the module-level + // DEFAULT_MIN/MAX_VERSION are intentionally not applied here as a fallback — + // doing so surfaces a pre-existing TLS1.2-specific hang in + // test-tls-net-socket-keepalive. + const { minVersion: optMinVersion, maxVersion: optMaxVersion, secureProtocol: optSecureProtocol } = options; + if (optMinVersion !== undefined || optMaxVersion !== undefined || optSecureProtocol !== undefined) { + let minVersion = tlsStringToProtocolVersion(optMinVersion); + let maxVersion = tlsStringToProtocolVersion(optMaxVersion); + const range = secureProtocolToVersionRange(optSecureProtocol); + if (range) { + minVersion = range[0]; + maxVersion = range[1]; + } + options = { ...options, minVersion, maxVersion }; + } + } + const ctx = (cached ? NativeSecureContext.intern : NativeSecureContext.createPrivate)(options); + if (pfxExtraCAs) { + for (const pem of pfxExtraCAs) ctx.addCACert(pem); + } + return ctx; } var InternalSecureContext = class SecureContext { context; servername; - constructor(options) { + constructor(options, cached = true) { + // When tls.setDefaultCACertificates() has installed an override and no + // explicit `ca` was given, use the override as the default CA set so the + // process-wide default applies on every construction path (the public + // createSecureContext(), the connect/TLSSocket path, addContext and + // setSecureContext), matching Node's secure-context default. + if (_defaultCACertificatesOverride !== undefined && (options == null || options.ca == null)) { + options = { ...options, ca: _defaultCACertificatesOverride }; + } if (options) { + validateSecureContextOptions(options); if (options.cert) throwOnInvalidTLSArray("options.cert", options.cert); if (options.key) throwOnInvalidTLSArray("options.key", options.key); if (options.ca) throwOnInvalidTLSArray("options.ca", options.ca); - if (options.passphrase != null && typeof options.passphrase !== "string") - throw new TypeError("passphrase argument must be an string"); if (options.servername != null && typeof options.servername !== "string") throw new TypeError("servername argument must be an string"); if (options.secureOptions != null && typeof options.secureOptions !== "number") @@ -454,7 +798,7 @@ var InternalSecureContext = class SecureContext { // The native handle (SSL_CTX wrapper) is what's memoised — not this JS // object — so per-call fields like `servername` come from THIS call's // options while the expensive SSL_CTX is shared. - this.context = newNativeSecureContext(options); + this.context = newNativeSecureContext(options, cached); this.servername = options?.servername; } }; @@ -465,10 +809,14 @@ function SecureContext(options): void { function createSecureContext(options) { if (options instanceof InternalSecureContext) return options; + // The setDefaultCACertificates() override is applied inside the + // InternalSecureContext constructor so every construction path honors it. // The native handle (SSL_CTX) is memoised inside `NativeSecureContext.intern` // by the per-VM `SSLContextCache`, so no JS-side hashing here. The JS wrapper // is built fresh because it carries the per-call `servername`. - return new InternalSecureContext(options); + // The user-facing constructor owns its SSL_CTX exclusively so addCACert + // cannot leak across contexts; internal connect/listen paths stay cached. + return new InternalSecureContext(options, false); } // Translate some fields from the handle's C-friendly format into more idiomatic @@ -506,10 +854,43 @@ function TLSSocket(socket?, options?) { const isNetSocketOrDuplex = socket instanceof Duplex; + // A provided underlying socket must be a Duplex/net.Socket. An event emitter + // that isn't a stream (e.g. a bare EventEmitter) is not a valid socket — Node + // throws when wrapping it. Distinguished from a TLS options object, which is + // not an EventEmitter. + if (socket != null && !isNetSocketOrDuplex && socket instanceof EventEmitter) { + throw $ERR_INVALID_ARG_TYPE("socket", "Duplex", socket); + } + options = isNetSocketOrDuplex ? { ...options, allowHalfOpen: false } : options || socket || {}; NetSocket.$call(this, options); + // A server-side TLSSocket is created with { isServer: true }; track it so + // server-only guards (e.g. setServername throwing ERR_TLS_SNI_FROM_SERVER) + // behave like Node. Accepted sockets set this again in onconnection. + const isServer = !!options.isServer; + this.isServer = isServer; + + // A custom SNICallback must be a function — but Node only validates it on the + // server side (it is meaningless for a client), inside the isServer branch. + // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/internal/tls/wrap.js#L929 + if (isServer) { + const sniCallback = options.SNICallback; + if (sniCallback != null) { + validateFunction(sniCallback, "options.SNICallback"); + this._SNICallback = sniCallback; + } + const alpnCallback = options.ALPNCallback; + if (alpnCallback != null) { + validateFunction(alpnCallback, "options.ALPNCallback"); + if (options.ALPNProtocols) { + throw $ERR_TLS_ALPN_CALLBACK_WITH_PROTOCOLS(); + } + this._ALPNCallback = alpnCallback; + } + } + this.ciphers = options.ciphers; if (this.ciphers) { validateCiphers(options.ciphers); @@ -521,13 +902,18 @@ function TLSSocket(socket?, options?) { convertALPNProtocols(ALPNProtocols, this); } - if (isNetSocketOrDuplex) { + if (isNetSocketOrDuplex && !this.isServer) { this._handle = socket; // keep compatibility with http2-wrapper or other places that try to grab JSStreamSocket in node.js, with here is just the TLSSocket this._handle._parentWrap = this; } + // For the server wrap, _handle is assigned the upgraded TLS handle by the + // server-upgrade method below; leaving it unset until then means a synchronous + // teardown during upgradeTLS won't call close() on the bare net.Socket. } - this[ksecureContext] = options.secureContext || createSecureContext(options); + // Internal path: keep the per-digest cache (only the user-facing + // tls.createSecureContext() owns its SSL_CTX exclusively). + this[ksecureContext] = options.secureContext || new InternalSecureContext(options); this.authorized = false; this.secureConnecting = true; this._secureEstablished = false; @@ -537,20 +923,60 @@ function TLSSocket(socket?, options?) { } this[kcheckServerIdentity] = options.checkServerIdentity || checkServerIdentity; this[ksession] = options.session || null; + + // `new tls.TLSSocket(socket, { isServer: true })`: drive the server-side TLS + // handshake over the provided socket via net.ts's native upgrade path (reaches + // the module-private kupgraded + the shared ServerHandlers). Client-side wraps + // go through the connect path elsewhere. + if (isNetSocketOrDuplex && this.isServer) { + this[Symbol.for("::bunUpgradeServerTLS::")](socket, this[buntls](null, null)); + } } $toClass(TLSSocket, "TLSSocket", NetSocket); +TLSSocket.prototype._destroySSL = function _destroySSL() { + // Releases the TLS state for this socket; the connection itself is torn + // down by the caller (Node's callers always destroy() right after). The + // native socket frees its SSL when it closes, so there is nothing to free + // separately here. + this.secureConnecting = false; + this._secureEstablished = false; +}; + TLSSocket.prototype._start = function _start() { // some frameworks uses this _start internal implementation is suposed to start TLS handshake/connect this.connect(); }; +TLSSocket.prototype._final = function _final(callback) { + // Defer the FIN until the TLS handshake completes. net.Socket._final calls + // socket.shutdown(), which while SSL is still in init half-closes the write + // side before the client's TLS Finished is flushed — the peer then sees a + // bare FIN and reports ECONNRESET (e.g. socket.end('') right after + // tls.connect()). Node's native TLSWrap.DoShutdown likewise flushes the + // handshake output before the underlying stream's FIN. + // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/src/crypto/crypto_tls.cc#L1203 + // A never-connected TLSSocket (e.g. new tls.TLSSocket().end(cb)) has no handle + // and no handshake to wait for; finish immediately like NetSocket._final's + // no-handle fast path, otherwise the deferred callback would never fire. + if (!this._handle) return callback(); + if (this.secureConnecting) { + return this.once("secureConnect", NetSocket.prototype._final.bind(this, callback)); + } + return NetSocket.prototype._final.$call(this, callback); +}; + TLSSocket.prototype.getSession = function getSession() { return this._handle?.getSession?.(); }; TLSSocket.prototype.getEphemeralKeyInfo = function getEphemeralKeyInfo() { - return this._handle?.getEphemeralKeyInfo?.(); + const info = this._handle?.getEphemeralKeyInfo?.(); + if (info == null) return info; + // Node always returns an object shaped { type, name, size } (each undefined + // when there is no ephemeral key, e.g. a non-(EC)DHE key exchange). + // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/_tls_wrap.js#L1437 + return { type: info.type, name: info.name, size: info.size }; }; TLSSocket.prototype.getCipher = function getCipher() { @@ -562,7 +988,10 @@ TLSSocket.prototype.getSharedSigalgs = function getSharedSigalgs() { }; TLSSocket.prototype.getProtocol = function getProtocol() { - return this._handle?.getTLSVersion?.(); + // Node returns the negotiated protocol string, or null once the socket is no + // longer connected (e.g. after 'close'). + // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/_tls_wrap.js#L1455 + return this._handle?.getTLSVersion?.() ?? null; }; TLSSocket.prototype.getFinished = function getFinished() { @@ -578,6 +1007,18 @@ TLSSocket.prototype.isSessionReused = function isSessionReused() { }; TLSSocket.prototype.renegotiate = function renegotiate(options, callback) { + // https://github.com/nodejs/node/blob/v25.2.1/lib/_tls_wrap.js#L878 + if (options === null || typeof options !== "object") { + throw $ERR_INVALID_ARG_TYPE("options", "object", options); + } + if (callback !== undefined) { + validateFunction(callback, "callback"); + } + + if (this.destroyed) { + return; + } + if (this[krenegotiationDisabled]) { // if renegotiation is disabled should emit error event in nextTick for nodejs compatibility const error = $ERR_TLS_RENEGOTIATION_DISABLED(); @@ -589,29 +1030,22 @@ TLSSocket.prototype.renegotiate = function renegotiate(options, callback) { // if the socket is detached we can't renegotiate, nodejs do a noop too (we should not return false or true here) if (!socket) return; - if (options) { - let requestCert = !!this._requestCert; - let rejectUnauthorized = !!this._rejectUnauthorized; - - if (options.requestCert !== undefined) requestCert = !!options.requestCert; - if (options.rejectUnauthorized !== undefined) rejectUnauthorized = !!options.rejectUnauthorized; - - if (requestCert !== this._requestCert || rejectUnauthorized !== this._rejectUnauthorized) { - socket.setVerifyMode?.(requestCert, rejectUnauthorized); - this._requestCert = requestCert; - this._rejectUnauthorized = rejectUnauthorized; - } - } - try { - socket.renegotiate?.(); - // if renegotiate is successful should emit secure event when done - if (typeof callback === "function") this.once("secure", () => callback(null)); - return true; - } catch (err) { - // if renegotiate fails should emit error event in nextTick for nodejs compatibility - if (typeof callback === "function") process.nextTick(callback, err); - return false; + let requestCert = !!this._requestCert; + let rejectUnauthorized = !!this._rejectUnauthorized; + if (options.requestCert !== undefined) requestCert = !!options.requestCert; + if (options.rejectUnauthorized !== undefined) rejectUnauthorized = !!options.rejectUnauthorized; + if (requestCert !== this._requestCert || rejectUnauthorized !== this._rejectUnauthorized) { + socket.setVerifyMode?.(requestCert, rejectUnauthorized); + this._requestCert = requestCert; + this._rejectUnauthorized = rejectUnauthorized; } + + // BoringSSL does not implement TLS renegotiation; Node built against + // BoringSSL reports exactly this from renegotiate() regardless of the + // protocol version, and so do we. + const error = $ERR_TLS_RENEGOTIATION_UNSUPPORTED(); + if (typeof callback === "function") process.nextTick(callback, error); + return false; }; TLSSocket.prototype.disableRenegotiation = function disableRenegotiation() { @@ -624,7 +1058,24 @@ TLSSocket.prototype.getTLSTicket = function getTLSTicket() { return this._handle?.getTLSTicket?.(); }; +TLSSocket.prototype.setKeyCert = function setKeyCert(context) { + // Serve this connection's identity from the given context (Node calls this + // from ALPNCallback/SNICallback before the certificate is sent). Accepts a + // SecureContext or the same options object createSecureContext takes. + const ctx = context?.context ? context : new InternalSecureContext(context); + this._handle?.setKeyCert?.(ctx.context); +}; + TLSSocket.prototype.exportKeyingMaterial = function exportKeyingMaterial(length, label, context) { + // https://github.com/nodejs/node/blob/v25.2.1/lib/internal/tls/wrap.js#L1039 + validateUint32(length, "length", true); + validateString(label, "label"); + if (context !== undefined) validateBuffer(context, "context"); + + if (!this._secureEstablished) { + throw $ERR_TLS_INVALID_STATE(); + } + if (context) { return this._handle?.exportKeyingMaterial?.(length, label, context); } @@ -640,6 +1091,7 @@ TLSSocket.prototype.enableTrace = function enableTrace() { }; TLSSocket.prototype.setServername = function setServername(name) { + validateString(name, "name"); if (this.isServer) { throw $ERR_TLS_SNI_FROM_SERVER(); } @@ -654,10 +1106,13 @@ TLSSocket.prototype.setSession = function setSession(session) { return this._handle?.setSession?.(session); }; -TLSSocket.prototype.getPeerCertificate = function getPeerCertificate(abbreviated) { +TLSSocket.prototype.getPeerCertificate = function getPeerCertificate(detailed) { if (this._handle) { + // The native parameter means "abbreviated" - the inverse of Node's + // `detailed`. Detailed requests get the whole chain with + // issuerCertificate links; everything else gets just the leaf. const cert = - arguments.length < 1 ? this._handle.getPeerCertificate?.() : this._handle.getPeerCertificate?.(abbreviated); + arguments.length < 1 ? this._handle.getPeerCertificate?.() : this._handle.getPeerCertificate?.(!detailed); if (cert) { return translatePeerCertificate(cert); } @@ -676,7 +1131,36 @@ TLSSocket.prototype.getCertificate = function getCertificate() { }; TLSSocket.prototype.getPeerX509Certificate = function getPeerX509Certificate() { - return this._handle?.getPeerX509Certificate?.(); + // Build the X509Certificate chain from the detailed peer-certificate + // objects, linking each to its issuer the way Node does. The + // `issuerCertificate` own property shadows the prototype getter (which is + // always undefined for certificates parsed outside a TLS connection). + const cert = this.getPeerCertificate(true); + if (!cert || !cert.raw) { + return this._handle?.getPeerX509Certificate?.(); + } + const { X509Certificate } = require("node:crypto"); + const seen = new Map(); + const toX509 = chainCert => { + if (!chainCert || !chainCert.raw) return undefined; + const cached = seen.get(chainCert); + if (cached) return cached; + const x509 = new X509Certificate(chainCert.raw); + seen.set(chainCert, x509); + if (chainCert.issuerCertificate && chainCert.issuerCertificate !== chainCert) { + const issuer = toX509(chainCert.issuerCertificate); + if (issuer) { + Object.defineProperty(x509, "issuerCertificate", { + __proto__: null, + value: issuer, + configurable: true, + enumerable: false, + }); + } + } + return x509; + }; + return toX509(cert); }; TLSSocket.prototype.getX509Certificate = function getX509Certificate() { @@ -715,6 +1199,30 @@ function Server(options, secureConnectionListener): void { return new Server(options, secureConnectionListener); } + // tls.createServer(options) requires an object (a function is the connection + // listener); matches Node throwing ERR_INVALID_ARG_TYPE for e.g. a string. + if (options != null && typeof options !== "object" && typeof options !== "function") { + throw $ERR_INVALID_ARG_TYPE("options", "object", options); + } + // A custom SNICallback must be a function. + // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/internal/tls/wrap.js#L929 + if (options != null && typeof options === "object") { + const sniCallback = options.SNICallback; + if (sniCallback != null) { + validateFunction(sniCallback, "options.SNICallback"); + this._SNICallback = sniCallback; + } + const alpnCallback = options.ALPNCallback; + if (alpnCallback != null) { + validateFunction(alpnCallback, "options.ALPNCallback"); + // Node forbids combining the dynamic callback with a static list. + if (options.ALPNProtocols) { + throw $ERR_TLS_ALPN_CALLBACK_WITH_PROTOCOLS(); + } + this._ALPNCallback = alpnCallback; + } + } + NetServer.$apply(this, [options, secureConnectionListener]); this.key = undefined; @@ -734,7 +1242,7 @@ function Server(options, secureConnectionListener): void { throw new TypeError("hostname must be a string"); } if (!(context instanceof InternalSecureContext)) { - context = createSecureContext(context); + context = new InternalSecureContext(context); } if (this._handle) { // Pass the native SSL_CTX wrapper, not the JS InternalSecureContext — @@ -751,6 +1259,8 @@ function Server(options, secureConnectionListener): void { options = options.context; } if (options) { + validateSecureContextOptions(options); + options = processPfxOptions(options); const { ALPNProtocols } = options; if (ALPNProtocols) { @@ -769,7 +1279,49 @@ function Server(options, secureConnectionListener): void { this.key = key; } + // BoringSSL rejects a mixed EC/RSA multi-identity configuration while + // loading the chain. The native context is built lazily at listen time, + // so surface the most common mismatch synchronously here: a key whose + // type differs from its own index-paired certificate. This is a + // best-effort check - the native loader at listen time remains the + // authority and still rejects configurations that pass it. + if (Array.isArray(key) && key.length > 1 && cert) { + const certs = Array.isArray(cert) ? cert : [cert]; + try { + const { createPrivateKey, X509Certificate } = require("node:crypto"); + for (let i = 0; i < key.length; i++) { + const k = key[i]; + if (typeof k !== "string" && !$isTypedArrayView(k)) continue; + const pairedCert = certs[i < certs.length ? i : certs.length - 1]; + const certType = new X509Certificate(pairedCert).publicKey.asymmetricKeyType; + if (createPrivateKey(k).asymmetricKeyType !== certType) { + const err = new Error( + "error:0b000074:X.509 certificate routines:OPENSSL_internal:KEY_TYPE_MISMATCH", + ) as Error & { code: string; library: string; function: string; reason: string }; + err.code = "ERR_OSSL_X509_KEY_TYPE_MISMATCH"; + err.library = "X.509 certificate routines"; + err.function = "OPENSSL_internal"; + err.reason = "KEY_TYPE_MISMATCH"; + throw err; + } + } + } catch (e: any) { + if (e?.code === "ERR_OSSL_X509_KEY_TYPE_MISMATCH") throw e; + // An unparseable key or certificate falls through to the native + // load, which produces its own error. + } + } + let ca = options.ca; + // PKCS#12-embedded CAs are stashed separately so createSecureContext can + // extend (not replace) the default trust set via addCACert. The server + // path hands raw {key, cert, ca} to the native listener and has no + // addCACert hook, so fold them into `ca` here - an mTLS server should + // verify client certificates against the bundle's own CA chain. + const pfxExtraCAs = options._pfxExtraCACerts; + if (pfxExtraCAs?.length) { + ca = ca == null ? pfxExtraCAs : Array.isArray(ca) ? [...ca, ...pfxExtraCAs] : [ca, ...pfxExtraCAs]; + } if (ca) { throwOnInvalidTLSArray("options.ca", ca); this.ca = ca; @@ -813,6 +1365,15 @@ function Server(options, secureConnectionListener): void { this.ciphers = options.ciphers; } + + // Pin the protocol version range the server will negotiate. + // validateSecureContextOptions already rejected unknown method names. + // Assign unconditionally so a later setSecureContext() without these + // options clears the previous call's version constraints instead of + // re-applying them on the next listen. + this.secureProtocol = options.secureProtocol; + this.minVersion = options.minVersion; + this.maxVersion = options.maxVersion; } }; @@ -820,7 +1381,13 @@ function Server(options, secureConnectionListener): void { throw Error("Not implented in Bun yet"); }; - Server.prototype.setTicketKeys = function () { + Server.prototype.setTicketKeys = function (keys) { + if (!ArrayBuffer.isView(keys)) { + throw $ERR_INVALID_ARG_TYPE("buffer", ["Buffer", "TypedArray", "DataView"], keys); + } + if (keys.byteLength !== 48) { + throw $ERR_INVALID_ARG_VALUE("buffer", keys, "Session ticket keys must be a 48-byte buffer"); + } throw Error("Not implented in Bun yet"); }; @@ -840,23 +1407,59 @@ function Server(options, secureConnectionListener): void { clientRenegotiationWindow: CLIENT_RENEG_WINDOW, contexts: contexts, ciphers: this.ciphers, + // Translate minVersion/maxVersion/secureProtocol to the integer + // protocol range the native layer applies (secureProtocol wins, like + // Node's SecureContext::Init). + ...(this.secureProtocol !== undefined || this.minVersion !== undefined || this.maxVersion !== undefined + ? (() => { + let minVersion = tlsStringToProtocolVersion(this.minVersion); + let maxVersion = tlsStringToProtocolVersion(this.maxVersion); + const range = secureProtocolToVersionRange(this.secureProtocol); + if (range) { + minVersion = range[0]; + maxVersion = range[1]; + } + return { minVersion, maxVersion }; + })() + : {}), }, TLSSocket, ]; }; this.setSecureContext(options); + maybeWarnAboutExtraCACerts(); + // Matches Node's tls.Server handshakeTimeout default + validation: + // https://github.com/nodejs/node/blob/843dc5f0d5ad/lib/internal/tls/wrap.js#L1386 + const handshakeTimeout = (options && options.handshakeTimeout) || 120 * 1000; + validateNumber(handshakeTimeout, "options.handshakeTimeout"); + this._handshakeTimeout = handshakeTimeout; } $toClass(Server, "Server", NetServer); function createServer(options, connectionListener) { return new Server(options, connectionListener); } -const DEFAULT_ECDH_CURVE = "auto", - // https://github.com/Jarred-Sumner/uSockets/blob/fafc241e8664243fc0c51d69684d5d02b9805134/src/crypto/openssl.c#L519-L523 - DEFAULT_MIN_VERSION = "TLSv1.2", +const DEFAULT_ECDH_CURVE = "auto"; +// https://github.com/Jarred-Sumner/uSockets/blob/fafc241e8664243fc0c51d69684d5d02b9805134/src/crypto/openssl.c#L519-L523 +let DEFAULT_MIN_VERSION = "TLSv1.2", DEFAULT_MAX_VERSION = "TLSv1.3"; +// Node seeds the protocol-version defaults from its --tls-min-vX.Y / +// --tls-max-vX.Y CLI flags; the equivalent flags reach us through +// process.execArgv. The lowest requested minimum and the highest requested +// maximum win when several are passed, matching node_options precedence. +{ + const execArgv = process.execArgv; + const hasFlag = (flag: string) => execArgv.includes(flag); + if (hasFlag("--tls-min-v1.0")) DEFAULT_MIN_VERSION = "TLSv1"; + else if (hasFlag("--tls-min-v1.1")) DEFAULT_MIN_VERSION = "TLSv1.1"; + else if (hasFlag("--tls-min-v1.2")) DEFAULT_MIN_VERSION = "TLSv1.2"; + else if (hasFlag("--tls-min-v1.3")) DEFAULT_MIN_VERSION = "TLSv1.3"; + if (hasFlag("--tls-max-v1.3")) DEFAULT_MAX_VERSION = "TLSv1.3"; + else if (hasFlag("--tls-max-v1.2")) DEFAULT_MAX_VERSION = "TLSv1.2"; +} + function normalizeConnectArgs(listArgs) { const args = net._normalizeArgs(listArgs); $assert($isObject(args[0])); @@ -883,6 +1486,10 @@ function connect(...args) { const options = normal[0]; const { ALPNProtocols, servername } = options as { ALPNProtocols?: unknown; servername?: unknown }; + if ("checkServerIdentity" in options) { + validateFunction(options.checkServerIdentity, "options.checkServerIdentity"); + } + if (servername && net.isIP(servername)) { throw $ERR_INVALID_ARG_VALUE( "options.servername", @@ -895,7 +1502,15 @@ function connect(...args) { convertALPNProtocols(ALPNProtocols, options); } - return new TLSSocket(options).connect(normal); + const tlssock = new TLSSocket(options); + // Honor the `timeout` option here: Socket.prototype.connect does not (only + // the net.createConnection factory does), so tls.connect applies it + // explicitly, exactly like Node's tls connect. + // https://github.com/nodejs/node/blob/614050b657e9757c1097aa85f92f2cb51149dc0d/lib/internal/tls/wrap.js#L1791 + if (options.timeout) { + tlssock.setTimeout(options.timeout); + } + return tlssock.connect(normal); } function getCiphers() { @@ -912,9 +1527,11 @@ function convertProtocols(protocols) { (p, c, i) => { const len = Buffer.byteLength(c); if (len > 255) { - throw new RangeError( + const err = new RangeError( `The byte length of the protocol at index ${i} exceeds the maximum length. It must be <= 255. Received ${len}`, ); + (err as any).code = "ERR_OUT_OF_RANGE"; + throw err; } lens[i] = len; return p + 1 + len; @@ -933,19 +1550,17 @@ function convertProtocols(protocols) { return buff; } +// Matches Node's convertALPNProtocols: +// https://github.com/nodejs/node/blob/843dc5f0d5ad/lib/tls.js#L268 function convertALPNProtocols(protocols, out) { // If protocols is Array - translate it into buffer if (Array.isArray(protocols)) { out.ALPNProtocols = convertProtocols(protocols); - } else if (isTypedArray(protocols)) { - // Copy new buffer not to be modified by user. - out.ALPNProtocols = Buffer.from(protocols); } else if (isArrayBufferView(protocols)) { + // Copy new buffer not to be modified by user. out.ALPNProtocols = Buffer.from( protocols.buffer.slice(protocols.byteOffset, protocols.byteOffset + protocols.byteLength), ); - } else if (Buffer.isBuffer(protocols)) { - out.ALPNProtocols = protocols; } } @@ -997,11 +1612,112 @@ function cacheExtraCACertificates(): string[] { return extraCACertificates; } +let warnedAboutExtraCACerts = false; +/** + * Match Node's crypto_context.cc: a NODE_EXTRA_CA_CERTS file that cannot be + * loaded is ignored with a one-time warning on stderr - emitted when the + * first secure context is created, not at startup - rather than failing the + * process. The reason text mirrors the strerror()-derived string Node prints. + */ +function maybeWarnAboutExtraCACerts() { + if (warnedAboutExtraCACerts) return; + warnedAboutExtraCACerts = true; + const extraPath = process.env.NODE_EXTRA_CA_CERTS; + if (!extraPath) return; + try { + require("node:fs").accessSync(extraPath); + } catch (err: any) { + // Node prints this with a raw fprintf(stderr, ...) from + // crypto_context.cc, not through process.emitWarning - no pid prefix and + // no colorization. + process.stderr.write( + `Warning: Ignoring extra certs from \`${extraPath}\`, load failed: ${ + err?.code === "ENOENT" ? "No such file or directory" : err?.message + }\n`, + ); + } +} + +// Runtime override for the "default" CA certificate set, installed by +// tls.setDefaultCACertificates(). undefined = no override (use the real +// bundled/system default). Only affects type "default"/implicit — "bundled", +// "system" and "extra" are unchanged. +// https://github.com/nodejs/node/blob/main/lib/internal/tls/secure-context.js +let _defaultCACertificatesOverride: Array | undefined; + +type CACertInput = string | NodeJS.ArrayBufferView; +interface X509CertificateLike { + readonly fingerprint256: string; + toString(): string; +} +type X509CertificateCtor = new (cert: CACertInput) => X509CertificateLike; +let _X509CertificateClass: X509CertificateCtor | undefined; + +// tls.setDefaultCACertificates(certs) +// https://github.com/nodejs/node/blob/v25.2.1/lib/tls.js#L202 +// Node validates `certs` as an Array (its ERR_INVALID_ARG_TYPE renders the +// 'Array' name as "an instance of Array"; Bun's validateArray renders the same +// name as "of type Array", so build the error directly to match Node here), +// then hands the certs to the native root store. Bun has no equivalent native +// store override, so keep a JS-side override that getCACertificates('default') +// and createSecureContext() read. +function setDefaultCACertificates(certs: ReadonlyArray): void { + if (!$isArray(certs)) { + let received: string; + if (certs === null) received = "null"; + else if (typeof certs === "object") received = `an instance of ${(certs as object).constructor?.name ?? "Object"}`; + else if (typeof certs === "string") received = `type string ('${certs}')`; + else received = `type ${typeof certs} (${String(certs)})`; + const error = new TypeError(`The "certs" argument must be an instance of Array. Received ${received}`) as Error & { + code: string; + }; + error.code = "ERR_INVALID_ARG_TYPE"; + throw error; + } + _X509CertificateClass ??= require("node:crypto").X509Certificate as X509CertificateCtor; + // Parse each cert and de-duplicate by fingerprint so getCACertificates() + // returns a normalized, unique PEM set (matching Node, whose native store + // collapses duplicates). Build into a temp array and only commit on success, + // so an invalid element leaves the previous default untouched. + const seen = new Set(); + const normalized: Array = []; + for (let i = 0; i < certs.length; i++) { + const cert = certs[i]; + if (typeof cert !== "string" && !isArrayBufferView(cert)) { + throw $ERR_INVALID_ARG_TYPE(`certs[${i}]`, "string or an instance of ArrayBufferView", cert); + } + // An element may be a concatenated PEM bundle; Node adds every certificate + // it contains, so split on certificate boundaries before parsing (a single + // X509Certificate parse only consumes the first block). + const text = + typeof cert === "string" ? cert : Buffer.from(cert.buffer, cert.byteOffset, cert.byteLength).toString("latin1"); + const blocks = text.includes("-----BEGIN") + ? // Keep only the blocks that actually start a PEM certificate: bundle + // files routinely begin with comment headers (curl's cacert.pem, + // RHEL's ca-bundle.crt) that the lookahead split leaves as a leading + // non-PEM element. + text.split(/(?=-----BEGIN [A-Z0-9 ]*CERTIFICATE-----)/).filter(block => block.includes("CERTIFICATE-----")) + : [cert]; + for (const block of blocks) { + const x509 = new _X509CertificateClass(block as CACertInput); + const fingerprint = x509.fingerprint256; + if (!seen.has(fingerprint)) { + seen.add(fingerprint); + normalized.push(x509.toString()); + } + } + } + _defaultCACertificatesOverride = normalized; +} + function getCACertificates(type = "default") { validateString(type, "type"); switch (type) { case "default": + if (_defaultCACertificatesOverride !== undefined) { + return _defaultCACertificatesOverride.slice(); + } return cacheDefaultCACertificates(); case "bundled": return cacheBundledRootCertificates(); @@ -1047,6 +1763,7 @@ export default { DEFAULT_MAX_VERSION, DEFAULT_MIN_VERSION, getCiphers, + setDefaultCACertificates, parseCertString, SecureContext, Server, diff --git a/src/jsc/ErrorCode.rs b/src/jsc/ErrorCode.rs index e0a745a8eeec..15e9fdb1ff05 100644 --- a/src/jsc/ErrorCode.rs +++ b/src/jsc/ErrorCode.rs @@ -551,139 +551,143 @@ impl ErrorCode { pub const TLS_PSK_SET_IDENTITY_HINT_FAILED: ErrorCode = ErrorCode(245); /// `ERR_TLS_RENEGOTIATION_DISABLED` (instanceof Error) pub const TLS_RENEGOTIATION_DISABLED: ErrorCode = ErrorCode(246); + /// `ERR_TLS_RENEGOTIATION_UNSUPPORTED` (instanceof Error) + pub const TLS_RENEGOTIATION_UNSUPPORTED: ErrorCode = ErrorCode(247); /// `ERR_TLS_SNI_FROM_SERVER` (instanceof Error) - pub const TLS_SNI_FROM_SERVER: ErrorCode = ErrorCode(247); + pub const TLS_SNI_FROM_SERVER: ErrorCode = ErrorCode(248); + /// `ERR_TLS_INVALID_STATE` (instanceof Error) + pub const TLS_INVALID_STATE: ErrorCode = ErrorCode(249); /// `ERR_TLS_ALPN_CALLBACK_WITH_PROTOCOLS` (instanceof TypeError) - pub const TLS_ALPN_CALLBACK_WITH_PROTOCOLS: ErrorCode = ErrorCode(248); + pub const TLS_ALPN_CALLBACK_WITH_PROTOCOLS: ErrorCode = ErrorCode(250); /// `ERR_SSL_NO_CIPHER_MATCH` (instanceof Error) - pub const SSL_NO_CIPHER_MATCH: ErrorCode = ErrorCode(249); + pub const SSL_NO_CIPHER_MATCH: ErrorCode = ErrorCode(251); /// `ERR_UNAVAILABLE_DURING_EXIT` (instanceof Error) - pub const UNAVAILABLE_DURING_EXIT: ErrorCode = ErrorCode(250); + pub const UNAVAILABLE_DURING_EXIT: ErrorCode = ErrorCode(252); /// `ERR_UNCAUGHT_EXCEPTION_CAPTURE_ALREADY_SET` (instanceof Error) - pub const UNCAUGHT_EXCEPTION_CAPTURE_ALREADY_SET: ErrorCode = ErrorCode(251); + pub const UNCAUGHT_EXCEPTION_CAPTURE_ALREADY_SET: ErrorCode = ErrorCode(253); /// `ERR_UNESCAPED_CHARACTERS` (instanceof TypeError) - pub const UNESCAPED_CHARACTERS: ErrorCode = ErrorCode(252); + pub const UNESCAPED_CHARACTERS: ErrorCode = ErrorCode(254); /// `ERR_UNHANDLED_ERROR` (instanceof Error) - pub const UNHANDLED_ERROR: ErrorCode = ErrorCode(253); + pub const UNHANDLED_ERROR: ErrorCode = ErrorCode(255); /// `ERR_UNKNOWN_CREDENTIAL` (instanceof Error) - pub const UNKNOWN_CREDENTIAL: ErrorCode = ErrorCode(254); + pub const UNKNOWN_CREDENTIAL: ErrorCode = ErrorCode(256); /// `ERR_UNKNOWN_ENCODING` (instanceof TypeError) - pub const UNKNOWN_ENCODING: ErrorCode = ErrorCode(255); + pub const UNKNOWN_ENCODING: ErrorCode = ErrorCode(257); /// `ERR_UNKNOWN_SIGNAL` (instanceof TypeError) - pub const UNKNOWN_SIGNAL: ErrorCode = ErrorCode(256); + pub const UNKNOWN_SIGNAL: ErrorCode = ErrorCode(258); /// `ERR_ZSTD_INVALID_PARAM` (instanceof RangeError) - pub const ZSTD_INVALID_PARAM: ErrorCode = ErrorCode(257); + pub const ZSTD_INVALID_PARAM: ErrorCode = ErrorCode(259); /// `ERR_USE_AFTER_CLOSE` (instanceof Error) - pub const USE_AFTER_CLOSE: ErrorCode = ErrorCode(258); + pub const USE_AFTER_CLOSE: ErrorCode = ErrorCode(260); /// `ERR_WASI_NOT_STARTED` (instanceof Error) - pub const WASI_NOT_STARTED: ErrorCode = ErrorCode(259); + pub const WASI_NOT_STARTED: ErrorCode = ErrorCode(261); /// `ERR_WEBASSEMBLY_RESPONSE` (instanceof TypeError) - pub const WEBASSEMBLY_RESPONSE: ErrorCode = ErrorCode(260); + pub const WEBASSEMBLY_RESPONSE: ErrorCode = ErrorCode(262); /// `ERR_WORKER_INIT_FAILED` (instanceof Error) - pub const WORKER_INIT_FAILED: ErrorCode = ErrorCode(261); + pub const WORKER_INIT_FAILED: ErrorCode = ErrorCode(263); /// `ERR_WORKER_NOT_RUNNING` (instanceof Error) - pub const WORKER_NOT_RUNNING: ErrorCode = ErrorCode(262); + pub const WORKER_NOT_RUNNING: ErrorCode = ErrorCode(264); /// `ERR_WORKER_UNSUPPORTED_OPERATION` (instanceof TypeError) - pub const WORKER_UNSUPPORTED_OPERATION: ErrorCode = ErrorCode(263); + pub const WORKER_UNSUPPORTED_OPERATION: ErrorCode = ErrorCode(265); /// `ERR_ZLIB_INITIALIZATION_FAILED` (instanceof Error) - pub const ZLIB_INITIALIZATION_FAILED: ErrorCode = ErrorCode(264); + pub const ZLIB_INITIALIZATION_FAILED: ErrorCode = ErrorCode(266); /// `MODULE_NOT_FOUND` (instanceof Error) - pub const MODULE_NOT_FOUND: ErrorCode = ErrorCode(265); + pub const MODULE_NOT_FOUND: ErrorCode = ErrorCode(267); /// `ERR_INTERNAL_ASSERTION` (instanceof Error) - pub const INTERNAL_ASSERTION: ErrorCode = ErrorCode(266); + pub const INTERNAL_ASSERTION: ErrorCode = ErrorCode(268); /// `ERR_OSSL_EVP_INVALID_DIGEST` (instanceof Error) - pub const OSSL_EVP_INVALID_DIGEST: ErrorCode = ErrorCode(267); + pub const OSSL_EVP_INVALID_DIGEST: ErrorCode = ErrorCode(269); /// `ERR_KEY_GENERATION_JOB_FAILED` (instanceof Error) - pub const KEY_GENERATION_JOB_FAILED: ErrorCode = ErrorCode(268); + pub const KEY_GENERATION_JOB_FAILED: ErrorCode = ErrorCode(270); /// `ERR_MISSING_OPTION` (instanceof TypeError) - pub const MISSING_OPTION: ErrorCode = ErrorCode(269); + pub const MISSING_OPTION: ErrorCode = ErrorCode(271); /// `ERR_REDIS_AUTHENTICATION_FAILED` (instanceof Error) - pub const REDIS_AUTHENTICATION_FAILED: ErrorCode = ErrorCode(270); + pub const REDIS_AUTHENTICATION_FAILED: ErrorCode = ErrorCode(272); /// `ERR_REDIS_CONNECTION_CLOSED` (instanceof Error) - pub const REDIS_CONNECTION_CLOSED: ErrorCode = ErrorCode(271); + pub const REDIS_CONNECTION_CLOSED: ErrorCode = ErrorCode(273); /// `ERR_REDIS_CONNECTION_TIMEOUT` (instanceof Error) - pub const REDIS_CONNECTION_TIMEOUT: ErrorCode = ErrorCode(272); + pub const REDIS_CONNECTION_TIMEOUT: ErrorCode = ErrorCode(274); /// `ERR_REDIS_IDLE_TIMEOUT` (instanceof Error) - pub const REDIS_IDLE_TIMEOUT: ErrorCode = ErrorCode(273); + pub const REDIS_IDLE_TIMEOUT: ErrorCode = ErrorCode(275); /// `ERR_REDIS_INVALID_ARGUMENT` (instanceof Error) - pub const REDIS_INVALID_ARGUMENT: ErrorCode = ErrorCode(274); + pub const REDIS_INVALID_ARGUMENT: ErrorCode = ErrorCode(276); /// `ERR_REDIS_INVALID_ARRAY` (instanceof Error) - pub const REDIS_INVALID_ARRAY: ErrorCode = ErrorCode(275); + pub const REDIS_INVALID_ARRAY: ErrorCode = ErrorCode(277); /// `ERR_REDIS_INVALID_BULK_STRING` (instanceof Error) - pub const REDIS_INVALID_BULK_STRING: ErrorCode = ErrorCode(276); + pub const REDIS_INVALID_BULK_STRING: ErrorCode = ErrorCode(278); /// `ERR_REDIS_INVALID_COMMAND` (instanceof Error) - pub const REDIS_INVALID_COMMAND: ErrorCode = ErrorCode(277); + pub const REDIS_INVALID_COMMAND: ErrorCode = ErrorCode(279); /// `ERR_REDIS_INVALID_DATABASE` (instanceof Error) - pub const REDIS_INVALID_DATABASE: ErrorCode = ErrorCode(278); + pub const REDIS_INVALID_DATABASE: ErrorCode = ErrorCode(280); /// `ERR_REDIS_INVALID_ERROR_STRING` (instanceof Error) - pub const REDIS_INVALID_ERROR_STRING: ErrorCode = ErrorCode(279); + pub const REDIS_INVALID_ERROR_STRING: ErrorCode = ErrorCode(281); /// `ERR_REDIS_INVALID_INTEGER` (instanceof Error) - pub const REDIS_INVALID_INTEGER: ErrorCode = ErrorCode(280); + pub const REDIS_INVALID_INTEGER: ErrorCode = ErrorCode(282); /// `ERR_REDIS_INVALID_PASSWORD` (instanceof Error) - pub const REDIS_INVALID_PASSWORD: ErrorCode = ErrorCode(281); + pub const REDIS_INVALID_PASSWORD: ErrorCode = ErrorCode(283); /// `ERR_REDIS_INVALID_RESPONSE` (instanceof Error) - pub const REDIS_INVALID_RESPONSE: ErrorCode = ErrorCode(282); + pub const REDIS_INVALID_RESPONSE: ErrorCode = ErrorCode(284); /// `ERR_REDIS_INVALID_RESPONSE_TYPE` (instanceof Error) - pub const REDIS_INVALID_RESPONSE_TYPE: ErrorCode = ErrorCode(283); + pub const REDIS_INVALID_RESPONSE_TYPE: ErrorCode = ErrorCode(285); /// `ERR_REDIS_INVALID_SIMPLE_STRING` (instanceof Error) - pub const REDIS_INVALID_SIMPLE_STRING: ErrorCode = ErrorCode(284); + pub const REDIS_INVALID_SIMPLE_STRING: ErrorCode = ErrorCode(286); /// `ERR_REDIS_INVALID_STATE` (instanceof Error) - pub const REDIS_INVALID_STATE: ErrorCode = ErrorCode(285); + pub const REDIS_INVALID_STATE: ErrorCode = ErrorCode(287); /// `ERR_REDIS_INVALID_USERNAME` (instanceof Error) - pub const REDIS_INVALID_USERNAME: ErrorCode = ErrorCode(286); + pub const REDIS_INVALID_USERNAME: ErrorCode = ErrorCode(288); /// `ERR_REDIS_TLS_NOT_AVAILABLE` (instanceof Error) - pub const REDIS_TLS_NOT_AVAILABLE: ErrorCode = ErrorCode(287); + pub const REDIS_TLS_NOT_AVAILABLE: ErrorCode = ErrorCode(289); /// `ERR_REDIS_TLS_UPGRADE_FAILED` (instanceof Error) - pub const REDIS_TLS_UPGRADE_FAILED: ErrorCode = ErrorCode(288); + pub const REDIS_TLS_UPGRADE_FAILED: ErrorCode = ErrorCode(290); /// `HPE_UNEXPECTED_CONTENT_LENGTH` (instanceof Error) - pub const HPE_UNEXPECTED_CONTENT_LENGTH: ErrorCode = ErrorCode(289); + pub const HPE_UNEXPECTED_CONTENT_LENGTH: ErrorCode = ErrorCode(291); /// `HPE_INVALID_TRANSFER_ENCODING` (instanceof Error) - pub const HPE_INVALID_TRANSFER_ENCODING: ErrorCode = ErrorCode(290); + pub const HPE_INVALID_TRANSFER_ENCODING: ErrorCode = ErrorCode(292); /// `HPE_INVALID_EOF_STATE` (instanceof Error) - pub const HPE_INVALID_EOF_STATE: ErrorCode = ErrorCode(291); + pub const HPE_INVALID_EOF_STATE: ErrorCode = ErrorCode(293); /// `HPE_INVALID_METHOD` (instanceof Error) - pub const HPE_INVALID_METHOD: ErrorCode = ErrorCode(292); + pub const HPE_INVALID_METHOD: ErrorCode = ErrorCode(294); /// `HPE_INTERNAL` (instanceof Error) - pub const HPE_INTERNAL: ErrorCode = ErrorCode(293); + pub const HPE_INTERNAL: ErrorCode = ErrorCode(295); /// `ERR_VM_MODULE_STATUS` (instanceof Error) - pub const VM_MODULE_STATUS: ErrorCode = ErrorCode(294); + pub const VM_MODULE_STATUS: ErrorCode = ErrorCode(296); /// `ERR_VM_MODULE_ALREADY_LINKED` (instanceof Error) - pub const VM_MODULE_ALREADY_LINKED: ErrorCode = ErrorCode(295); + pub const VM_MODULE_ALREADY_LINKED: ErrorCode = ErrorCode(297); /// `ERR_VM_MODULE_CANNOT_CREATE_CACHED_DATA` (instanceof Error) - pub const VM_MODULE_CANNOT_CREATE_CACHED_DATA: ErrorCode = ErrorCode(296); + pub const VM_MODULE_CANNOT_CREATE_CACHED_DATA: ErrorCode = ErrorCode(298); /// `ERR_VM_MODULE_NOT_MODULE` (instanceof Error) - pub const VM_MODULE_NOT_MODULE: ErrorCode = ErrorCode(297); + pub const VM_MODULE_NOT_MODULE: ErrorCode = ErrorCode(299); /// `ERR_VM_MODULE_DIFFERENT_CONTEXT` (instanceof Error) - pub const VM_MODULE_DIFFERENT_CONTEXT: ErrorCode = ErrorCode(298); + pub const VM_MODULE_DIFFERENT_CONTEXT: ErrorCode = ErrorCode(300); /// `ERR_VM_MODULE_LINK_FAILURE` (instanceof Error) - pub const VM_MODULE_LINK_FAILURE: ErrorCode = ErrorCode(299); + pub const VM_MODULE_LINK_FAILURE: ErrorCode = ErrorCode(301); /// `ERR_VM_MODULE_CACHED_DATA_REJECTED` (instanceof Error) - pub const VM_MODULE_CACHED_DATA_REJECTED: ErrorCode = ErrorCode(300); + pub const VM_MODULE_CACHED_DATA_REJECTED: ErrorCode = ErrorCode(302); /// `ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING` (instanceof TypeError) - pub const VM_DYNAMIC_IMPORT_CALLBACK_MISSING: ErrorCode = ErrorCode(301); + pub const VM_DYNAMIC_IMPORT_CALLBACK_MISSING: ErrorCode = ErrorCode(303); /// `HPE_INVALID_HEADER_TOKEN` (instanceof Error) - pub const HPE_INVALID_HEADER_TOKEN: ErrorCode = ErrorCode(302); + pub const HPE_INVALID_HEADER_TOKEN: ErrorCode = ErrorCode(304); /// `HPE_HEADER_OVERFLOW` (instanceof Error) - pub const HPE_HEADER_OVERFLOW: ErrorCode = ErrorCode(303); + pub const HPE_HEADER_OVERFLOW: ErrorCode = ErrorCode(305); /// `ERR_SECRETS_NOT_AVAILABLE` (instanceof Error) - pub const SECRETS_NOT_AVAILABLE: ErrorCode = ErrorCode(304); + pub const SECRETS_NOT_AVAILABLE: ErrorCode = ErrorCode(306); /// `ERR_SECRETS_NOT_FOUND` (instanceof Error) - pub const SECRETS_NOT_FOUND: ErrorCode = ErrorCode(305); + pub const SECRETS_NOT_FOUND: ErrorCode = ErrorCode(307); /// `ERR_SECRETS_ACCESS_DENIED` (instanceof Error) - pub const SECRETS_ACCESS_DENIED: ErrorCode = ErrorCode(306); + pub const SECRETS_ACCESS_DENIED: ErrorCode = ErrorCode(308); /// `ERR_SECRETS_PLATFORM_ERROR` (instanceof Error) - pub const SECRETS_PLATFORM_ERROR: ErrorCode = ErrorCode(307); + pub const SECRETS_PLATFORM_ERROR: ErrorCode = ErrorCode(309); /// `ERR_SECRETS_USER_CANCELED` (instanceof Error) - pub const SECRETS_USER_CANCELED: ErrorCode = ErrorCode(308); + pub const SECRETS_USER_CANCELED: ErrorCode = ErrorCode(310); /// `ERR_SECRETS_INTERACTION_NOT_ALLOWED` (instanceof Error) - pub const SECRETS_INTERACTION_NOT_ALLOWED: ErrorCode = ErrorCode(309); + pub const SECRETS_INTERACTION_NOT_ALLOWED: ErrorCode = ErrorCode(311); /// `ERR_SECRETS_AUTH_FAILED` (instanceof Error) - pub const SECRETS_AUTH_FAILED: ErrorCode = ErrorCode(310); + pub const SECRETS_AUTH_FAILED: ErrorCode = ErrorCode(312); /// `ERR_SECRETS_INTERACTION_REQUIRED` (instanceof Error) - pub const SECRETS_INTERACTION_REQUIRED: ErrorCode = ErrorCode(311); + pub const SECRETS_INTERACTION_REQUIRED: ErrorCode = ErrorCode(313); /// == C++ `NODE_ERROR_COUNT`. - pub const COUNT: u16 = 312; + pub const COUNT: u16 = 314; } // ────────────────────────────────────────────────────────────────────────── @@ -970,11 +974,14 @@ impl ErrorCode { pub const ERR_TLS_HANDSHAKE_TIMEOUT: ErrorCode = ErrorCode::TLS_HANDSHAKE_TIMEOUT; pub const ERR_TLS_INVALID_PROTOCOL_METHOD: ErrorCode = ErrorCode::TLS_INVALID_PROTOCOL_METHOD; pub const ERR_TLS_INVALID_PROTOCOL_VERSION: ErrorCode = ErrorCode::TLS_INVALID_PROTOCOL_VERSION; + pub const ERR_TLS_INVALID_STATE: ErrorCode = ErrorCode::TLS_INVALID_STATE; pub const ERR_TLS_PROTOCOL_VERSION_CONFLICT: ErrorCode = ErrorCode::TLS_PROTOCOL_VERSION_CONFLICT; pub const ERR_TLS_PSK_SET_IDENTITY_HINT_FAILED: ErrorCode = ErrorCode::TLS_PSK_SET_IDENTITY_HINT_FAILED; pub const ERR_TLS_RENEGOTIATION_DISABLED: ErrorCode = ErrorCode::TLS_RENEGOTIATION_DISABLED; + pub const ERR_TLS_RENEGOTIATION_UNSUPPORTED: ErrorCode = + ErrorCode::TLS_RENEGOTIATION_UNSUPPORTED; pub const ERR_TLS_SNI_FROM_SERVER: ErrorCode = ErrorCode::TLS_SNI_FROM_SERVER; pub const ERR_TLS_ALPN_CALLBACK_WITH_PROTOCOLS: ErrorCode = ErrorCode::TLS_ALPN_CALLBACK_WITH_PROTOCOLS; @@ -1303,7 +1310,9 @@ static CODE_STR: [&str; ErrorCode::COUNT as usize] = [ "ERR_TLS_PROTOCOL_VERSION_CONFLICT", "ERR_TLS_PSK_SET_IDENTITY_HINT_FAILED", "ERR_TLS_RENEGOTIATION_DISABLED", + "ERR_TLS_RENEGOTIATION_UNSUPPORTED", "ERR_TLS_SNI_FROM_SERVER", + "ERR_TLS_INVALID_STATE", "ERR_TLS_ALPN_CALLBACK_WITH_PROTOCOLS", "ERR_SSL_NO_CIPHER_MATCH", "ERR_UNAVAILABLE_DURING_EXIT", diff --git a/src/jsc/bindings/ErrorCode.cpp b/src/jsc/bindings/ErrorCode.cpp index 8aef2ccca1b1..0506ed83d53c 100644 --- a/src/jsc/bindings/ErrorCode.cpp +++ b/src/jsc/bindings/ErrorCode.cpp @@ -2435,12 +2435,16 @@ JSC_DEFINE_HOST_FUNCTION(Bun::jsFunctionMakeErrorWithCode, (JSC::JSGlobalObject return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_SOCKET_CLOSED_BEFORE_CONNECTION, "Socket closed before the connection was established"_s)); case ErrorCode::ERR_TLS_RENEGOTIATION_DISABLED: return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_TLS_RENEGOTIATION_DISABLED, "TLS session renegotiation disabled for this socket"_s)); + case ErrorCode::ERR_TLS_RENEGOTIATION_UNSUPPORTED: + return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_TLS_RENEGOTIATION_UNSUPPORTED, "TLS session renegotiation is unsupported by this TLS implementation"_s)); case ErrorCode::ERR_UNAVAILABLE_DURING_EXIT: return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_UNAVAILABLE_DURING_EXIT, "Cannot call function in process exit handler"_s)); case ErrorCode::ERR_TLS_CERT_ALTNAME_FORMAT: return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_TLS_CERT_ALTNAME_FORMAT, "Invalid subject alternative name string"_s)); case ErrorCode::ERR_TLS_SNI_FROM_SERVER: return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_TLS_SNI_FROM_SERVER, "Cannot issue SNI from a TLS server-side socket"_s)); + case ErrorCode::ERR_TLS_INVALID_STATE: + return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_TLS_INVALID_STATE, "TLS socket connection must be securely established"_s)); case ErrorCode::ERR_INVALID_URI: return JSC::JSValue::encode(createError(globalObject, ErrorCode::ERR_INVALID_URI, "URI malformed"_s)); case ErrorCode::ERR_HTTP2_PSEUDOHEADER_NOT_ALLOWED: diff --git a/src/jsc/bindings/ErrorCode.ts b/src/jsc/bindings/ErrorCode.ts index d721b2a4494e..fedd44dab794 100644 --- a/src/jsc/bindings/ErrorCode.ts +++ b/src/jsc/bindings/ErrorCode.ts @@ -257,7 +257,9 @@ const errors: ErrorCodeMapping = [ ["ERR_TLS_PROTOCOL_VERSION_CONFLICT", TypeError], ["ERR_TLS_PSK_SET_IDENTITY_HINT_FAILED", Error], ["ERR_TLS_RENEGOTIATION_DISABLED", Error], + ["ERR_TLS_RENEGOTIATION_UNSUPPORTED", Error], ["ERR_TLS_SNI_FROM_SERVER", Error], + ["ERR_TLS_INVALID_STATE", Error], ["ERR_TLS_ALPN_CALLBACK_WITH_PROTOCOLS", TypeError], ["ERR_SSL_NO_CIPHER_MATCH", Error], ["ERR_UNAVAILABLE_DURING_EXIT", Error], diff --git a/src/jsc/bindings/NodeValidator.cpp b/src/jsc/bindings/NodeValidator.cpp index b647beb090f7..9e6aa3b6d41c 100644 --- a/src/jsc/bindings/NodeValidator.cpp +++ b/src/jsc/bindings/NodeValidator.cpp @@ -607,6 +607,9 @@ JSC_DEFINE_HOST_FUNCTION(jsFunction_validateUndefined, (JSC::JSGlobalObject * gl return JSValue::encode(jsUndefined()); } +// Matches Node's validateBuffer, which throws ERR_INVALID_ARG_TYPE with the +// "must be an instance of Buffer, TypedArray, or DataView" message: +// https://github.com/nodejs/node/blob/843dc5f0d5ad/lib/internal/validators.js#L396 JSC_DEFINE_HOST_FUNCTION(jsFunction_validateBuffer, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { auto& vm = JSC::getVM(globalObject); @@ -616,12 +619,10 @@ JSC_DEFINE_HOST_FUNCTION(jsFunction_validateBuffer, (JSC::JSGlobalObject * globa auto name = callFrame->argument(1); if (!buffer.isUndefined()) { - if (!buffer.isCell()) return Bun::ERR::INVALID_ARG_TYPE(scope, globalObject, name, "Buffer, TypedArray, or DataView"_s, buffer); - - auto ty = buffer.asCell()->type(); - - if (JSC::typedArrayType(ty) == NotTypedArray) { - return Bun::ERR::INVALID_ARG_TYPE(scope, globalObject, name, "Buffer, TypedArray, or DataView"_s, buffer); + if (!buffer.isCell() || JSC::typedArrayType(buffer.asCell()->type()) == NotTypedArray) { + auto nameStr = name.isUndefined() ? String("buffer"_s) : name.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + return Bun::ERR::INVALID_ARG_INSTANCE(scope, globalObject, nameStr, "Buffer, TypedArray, or DataView"_s, buffer); } } return JSValue::encode(jsUndefined()); diff --git a/src/jsc/generated.rs b/src/jsc/generated.rs index f5b75aa1b12c..84a4947d106a 100644 --- a/src/jsc/generated.rs +++ b/src/jsc/generated.rs @@ -197,6 +197,10 @@ pub struct SocketConfigHandlers { pub on_end: JSValue, pub on_error: JSValue, pub on_handshake: JSValue, + pub on_session: JSValue, + pub on_keylog: JSValue, + pub on_server_name: JSValue, + pub on_alpn_callback: JSValue, pub binary_type: SocketConfigHandlersBinaryType, } @@ -212,6 +216,10 @@ struct ExternSocketConfigHandlers { onEnd: JSValue, onConnectError: JSValue, onTimeout: JSValue, + onSession: JSValue, + onKeylog: JSValue, + onServerName: JSValue, + onALPNCallback: JSValue, binary_type: SocketConfigHandlersBinaryType, } @@ -239,6 +247,10 @@ impl SocketConfigHandlers { on_end: ext.onEnd, on_connect_error: ext.onConnectError, on_timeout: ext.onTimeout, + on_session: ext.onSession, + on_keylog: ext.onKeylog, + on_server_name: ext.onServerName, + on_alpn_callback: ext.onALPNCallback, binary_type: ext.binary_type, } } @@ -291,6 +303,8 @@ pub struct SSLConfig { pub reject_unauthorized: Option, pub request_cert: bool, pub secure_options: u32, + pub ssl_min_version: i32, + pub ssl_max_version: i32, pub ca: SSLConfigFile, pub cert: SSLConfigFile, pub key: SSLConfigFile, @@ -543,6 +557,8 @@ struct ExternSSLConfig { cert: ExternSSLConfigFile, key: ExternSSLConfigFile, secure_options: u32, + ssl_min_version: i32, + ssl_max_version: i32, key_file: RawWTFStringImpl, cert_file: RawWTFStringImpl, ca_file: RawWTFStringImpl, @@ -575,6 +591,8 @@ impl SSLConfig { cert: SSLConfigFile::convert_from_extern(ext.cert), key: SSLConfigFile::convert_from_extern(ext.key), secure_options: ext.secure_options, + ssl_min_version: ext.ssl_min_version, + ssl_max_version: ext.ssl_max_version, key_file: adopt_opt_string(ext.key_file), cert_file: adopt_opt_string(ext.cert_file), ca_file: adopt_opt_string(ext.ca_file), diff --git a/src/runtime/api/SecureContext.classes.ts b/src/runtime/api/SecureContext.classes.ts index dd23aaa6ec72..31cafc1610d7 100644 --- a/src/runtime/api/SecureContext.classes.ts +++ b/src/runtime/api/SecureContext.classes.ts @@ -12,6 +12,13 @@ export default [ // digest so identical configs return the same JS cell. Replaces the // old SHA-256/WeakRef cache that lived in `tls.ts`. intern: { fn: "intern", length: 1 }, + // `tls.createSecureContext()` — exclusive-ownership variant: no digest + // memoisation at either cache level, so addCACert on one context can + // never affect another. The connect/listen paths keep using `intern`. + createPrivate: { fn: "create_private", length: 1 }, + // Parses a PKCS#12 (`pfx`) blob into { key, cert, ca } PEM strings so + // the regular key/cert/ca option plumbing can consume it. + parsePkcs12: { fn: "parse_pkcs12", length: 2 }, }, // No prototype surface — node:tls hands out the SecureContext object // itself as `.context`. We deliberately do NOT expose the underlying @@ -19,6 +26,13 @@ export default [ // `context._external` is a V8 External (opaque) used only by N-API // addons that link OpenSSL directly, which Bun's BoringSSL build can't // satisfy anyway. - proto: {}, + proto: { + // `secureContext.context.addCACert(pem)` — Node's SecureContext exposes + // this so extra CAs can be appended to an existing context's store. + addCACert: { + fn: "add_ca_cert", + length: 1, + }, + }, }), ]; diff --git a/src/runtime/api/bun/SecureContext.rs b/src/runtime/api/bun/SecureContext.rs index deb91602e377..7e457dd8963d 100644 --- a/src/runtime/api/bun/SecureContext.rs +++ b/src/runtime/api/bun/SecureContext.rs @@ -85,6 +85,149 @@ impl SecureContext { // wraps this in `host_fn_result` and exports the C-ABI shim, so no // `#[bun_jsc::host_fn]` here — that macro's Free shim calls by bare name // and cannot resolve an associated fn. + /// `SecureContext.parsePkcs12(pfx, passphrase)` - parses a PKCS#12 blob + /// into `{ key, cert, ca? }` PEM strings so the regular key/cert/ca + /// option plumbing can consume Node's `pfx` option. Same codegen shim + /// arrangement as `intern` (no `#[host_fn]` attribute here). + pub fn parse_pkcs12(global: &JSGlobalObject, callframe: &CallFrame) -> JsResult { + let args = callframe.arguments(); + if args.is_empty() { + return Err(global.throw(format_args!("PFX certificate argument is mandatory"))); + } + // The pfx arrives as a Buffer/TypedArray (binary DER) or a string; + // a string-conversion would mangle the DER bytes, so read the raw + // view when one exists. + let pfx_string; + let pfx_bytes: &[u8] = if let Some(ab) = args[0].as_array_buffer(global) { + // SAFETY: the ArrayBuffer view is alive for the duration of the + // call (the argument is rooted by the call frame). + unsafe { core::slice::from_raw_parts(ab.ptr, ab.len) } + } else { + pfx_string = args[0].to_slice(global)?; + pfx_string.slice() + }; + if pfx_bytes.is_empty() { + return Err(global.throw(format_args!("PFX certificate argument is mandatory"))); + } + // The passphrase is optional; the C side treats NULL as "". + let pass_owned: Option> = if args.len() > 1 && !args[1].is_undefined_or_null() { + let p = args[1].to_slice(global)?; + let mut v = p.slice().to_vec(); + v.push(0); + Some(v) + } else { + None + }; + let mut out_key: *mut core::ffi::c_char = core::ptr::null_mut(); + let mut out_cert: *mut core::ffi::c_char = core::ptr::null_mut(); + let mut out_ca: *mut core::ffi::c_char = core::ptr::null_mut(); + let mut key_len = 0usize; + let mut cert_len = 0usize; + let mut ca_len = 0usize; + let mut err_reason: *const core::ffi::c_char = core::ptr::null(); + // SAFETY: the buffers are live for the call; the out-pointers are + // freed below with libc free per the helper's contract. + let ok = unsafe { + c::us_ssl_parse_pkcs12( + pfx_bytes.as_ptr().cast(), + pfx_bytes.len(), + pass_owned + .as_ref() + .map_or(core::ptr::null(), |v| v.as_ptr().cast()), + &raw mut out_key, + &raw mut key_len, + &raw mut out_cert, + &raw mut cert_len, + &raw mut out_ca, + &raw mut ca_len, + &raw mut err_reason, + ) + }; + unsafe extern "C" { + fn free(ptr: *mut core::ffi::c_void); + } + if ok == 0 { + let reason = if err_reason.is_null() { + "" + } else { + // SAFETY: the helper sets a static NUL-terminated tag on failure. + unsafe { core::ffi::CStr::from_ptr(err_reason) } + .to_str() + .unwrap_or("") + }; + let message = match reason { + "key" => "Unable to load private key from PFX data", + "cert" => "Unable to load certificate from PFX data", + _ => "Unable to load PFX certificate", + }; + return Err(global.throw(format_args!("{message}"))); + } + let result = JSValue::create_empty_object(global, 0); + // SAFETY: the helper returned NUL-terminated PEM strings of the given + // lengths; ZigString::to_js copies into the JS heap before `free`. + unsafe { + let key_slice = core::slice::from_raw_parts(out_key.cast::(), key_len); + result.put(global, b"key", ZigString::init(key_slice).to_js(global)); + let cert_slice = core::slice::from_raw_parts(out_cert.cast::(), cert_len); + result.put(global, b"cert", ZigString::init(cert_slice).to_js(global)); + if !out_ca.is_null() && ca_len > 0 { + let ca_slice = core::slice::from_raw_parts(out_ca.cast::(), ca_len); + result.put(global, b"ca", ZigString::init(ca_slice).to_js(global)); + } + free(out_key.cast()); + free(out_cert.cast()); + if !out_ca.is_null() { + free(out_ca.cast()); + } + } + Ok(result) + } + + /// `tls.createSecureContext()` entry - builds a context that owns its + /// SSL_CTX exclusively: no digest memoisation at either the JS-wrapper + /// cache or the native SSLContextCache level, so prototype mutators like + /// `addCACert` can never affect another context (or the cached + /// connect/listen contexts). The internal connect/listen paths keep using + /// `intern` for the per-digest cache. + pub fn create_private(global: &JSGlobalObject, callframe: &CallFrame) -> JsResult { + let args = callframe.arguments(); + let opts = if args.len() > 0 { + args[0] + } else { + JSValue::UNDEFINED + }; + + // SAFETY: `bun_vm()` returns the live per-global VM pointer; valid for the call. + let vm = global.bun_vm().as_mut(); + let config = SSLConfig::from_js(vm, global, opts)?.unwrap_or_else(SSLConfig::zero); + // `defer config.deinit()` — handled by Drop. + + let ctx_opts = config.as_usockets(); + let d = ctx_opts.digest(); + + let mut err = uws::create_bun_socket_error_t::none; + let Some(ctx) = ctx_opts.create_ssl_context(&mut err) else { + if err == uws::create_bun_socket_error_t::none + || err == uws::create_bun_socket_error_t::invalid_ciphers + { + let code = boringssl::ERR_get_error(); + if code != 0 { + return Err(global.throw_value(err_to_js(global, code))); + } + if err == uws::create_bun_socket_error_t::none { + return Err(global.throw(format_args!("Failed to create SSL context"))); + } + } + return Err(global.throw_value(create_bun_socket_error_to_js(err, global))); + }; + let sc = Box::new(SecureContext { + ctx, + digest: d, + extra_memory: ctx_opts.approx_cert_bytes() + SSL_CTX_BASE_COST, + }); + Ok(Self::to_js_boxed(sc, global)) + } + pub fn intern(global: &JSGlobalObject, callframe: &CallFrame) -> JsResult { let args = callframe.arguments(); let opts = if args.len() > 0 { @@ -156,15 +299,22 @@ impl SecureContext { // `err` is only set for the input-validation paths (bad PEM, missing // file, …). When BoringSSL itself fails (e.g. unsupported curve) the // enum is still `.none`; surface the library error stack instead of - // throwing an empty placeholder. - if err == uws::create_bun_socket_error_t::none { + // throwing an empty placeholder. A rejected cipher list also keeps + // its specific reason (NO_CIPHER_MATCH, INVALID_COMMAND) on the + // queue - Node reports that decomposed error rather than a generic + // "invalid ciphers". + if err == uws::create_bun_socket_error_t::none + || err == uws::create_bun_socket_error_t::invalid_ciphers + { // `ERR_get_error` is declared `safe fn` in `boringssl_sys` (no // preconditions; reads the thread-local error queue). let code = boringssl::ERR_get_error(); if code != 0 { return Err(global.throw_value(err_to_js(global, code))); } - return Err(global.throw(format_args!("Failed to create SSL context"))); + if err == uws::create_bun_socket_error_t::none { + return Err(global.throw(format_args!("Failed to create SSL context"))); + } } return Err(global.throw_value(create_bun_socket_error_to_js(err, global))); }; @@ -186,6 +336,42 @@ impl SecureContext { self.ctx } + /// `secureContext.context.addCACert(pem)` — appends the certificates in + /// the given PEM string or buffer to this context's trust store, the way + /// Node's SecureContext exposes it. + #[bun_jsc::host_fn(method)] + pub fn add_ca_cert( + this: &Self, + global: &JSGlobalObject, + frame: &CallFrame, + ) -> JsResult { + let args = frame.arguments(); + if args.is_empty() { + return Err( + global.throw_invalid_arguments(format_args!("addCACert requires a certificate")) + ); + } + let pem = args[0].to_slice(global)?; + let bytes = pem.slice(); + if bytes.is_empty() { + return Err( + global.throw_invalid_arguments(format_args!("addCACert requires a certificate")) + ); + } + // The C side wants a NUL-terminated PEM document. + let mut owned = bytes.to_vec(); + owned.push(0); + // SAFETY: `this.ctx` is the live SSL_CTX this object owns a reference + // to, and `owned` is a NUL-terminated buffer valid for the call. + let ok = unsafe { + c::us_ssl_ctx_add_ca_cert(this.ctx, owned.as_ptr().cast::()) + }; + if ok == 0 { + return Err(global.throw(format_args!("Invalid CA certificate"))); + } + Ok(JSValue::UNDEFINED) + } + // Codegen's `host_fn_finalize` calls this via `|b| SecureContext::finalize(b)` // and requires `fn finalize(self: Box)`; clippy::boxed_local is a // false positive on that contract. @@ -202,6 +388,8 @@ impl SecureContext { const SSL_CTX_BASE_COST: usize = 50 * 1024; +use bun_jsc::ZigStringJsc as _; +use bun_jsc::zig_string::ZigString; use bun_uws_sys::socket_context::c; mod cpp { diff --git a/src/runtime/crypto/boringssl_jsc.rs b/src/runtime/crypto/boringssl_jsc.rs index cfb63ba2cb99..3708dd75bb55 100644 --- a/src/runtime/crypto/boringssl_jsc.rs +++ b/src/runtime/crypto/boringssl_jsc.rs @@ -1,14 +1,63 @@ //! JSC bridge for BoringSSL error formatting. Keeps `src/boringssl/` free of JSC types. use bun_boringssl_sys as boring; -use bun_jsc::{JSGlobalObject, JSValue}; +use bun_core::{String as BunString, ZigString}; +use bun_jsc::{JSGlobalObject, JSValue, StringJsc as _, ZigStringJsc as _}; -const PREFIX: &[u8] = b"BoringSSL "; +/// Node's `ERR_LIB_*` → macro-prefix map from `crypto_util.cc` +/// (`OSSL_ERROR_CODES_MAP`). Libraries Node does not map get an empty prefix +/// and compose to `ERR_OSSL_`. +fn lib_short_name(lib: u32) -> &'static str { + // The numeric values are BoringSSL's `ERR_LIB_*` enum (err.h). + match lib { + 2 => "SYS_", + 3 => "BN_", + 4 => "RSA_", + 5 => "DH_", + 6 => "EVP_", + 7 => "BUF_", + 8 => "OBJ_", + 9 => "PEM_", + 10 => "DSA_", + 11 => "X509_", + 12 => "ASN1_", + 13 => "CONF_", + 14 => "CRYPTO_", + 15 => "EC_", + 16 => "SSL_", + 17 => "BIO_", + 18 => "PKCS7_", + 20 => "X509V3_", + 21 => "RAND_", + 22 => "ENGINE_", + 23 => "OCSP_", + 24 => "UI_", + 25 => "COMP_", + 26 => "ECDSA_", + 27 => "ECDH_", + 28 => "HMAC_", + 33 => "USER_", + _ => "", + } +} + +/// SAFETY: `ptr` is a NUL-terminated static string returned by BoringSSL's +/// error-string tables (or null). +fn static_cstr<'a>(ptr: *const core::ffi::c_char) -> Option<&'a [u8]> { + if ptr.is_null() { + return None; + } + // SAFETY: see above - the pointer is a 'static NUL-terminated table entry. + let bytes = unsafe { core::ffi::CStr::from_ptr(ptr) }.to_bytes(); + if bytes.is_empty() { None } else { Some(bytes) } +} pub fn err_to_js(global: &JSGlobalObject, err_code: u32) -> JSValue { - let mut outbuf = [0u8; 128 + 1 + PREFIX.len()]; - outbuf[..PREFIX.len()].copy_from_slice(PREFIX); - let message_buf = &mut outbuf[PREFIX.len()..]; + // The message is the raw ERR_error_string output + // ("error:0b000074:X.509 certificate routines:OPENSSL_internal:..."), + // exactly what Node built against BoringSSL produces - no prefix. + let mut outbuf = [0u8; 128 + 1]; + let message_buf = &mut outbuf[..]; // SAFETY: message_buf is a valid writable buffer of message_buf.len() bytes. unsafe { @@ -20,8 +69,7 @@ pub fn err_to_js(global: &JSGlobalObject, err_code: u32) -> JSValue { } let error_message: &[u8] = bun_core::slice_to_nul(&outbuf[..]); - if error_message.len() == PREFIX.len() { - // TODO(port): globalThis.ERR(.BORINGSSL, ...) builder — confirm bun_jsc API shape + if error_message.is_empty() { return global .err( bun_jsc::ErrorCode::BORINGSSL, @@ -30,13 +78,34 @@ pub fn err_to_js(global: &JSGlobalObject, err_code: u32) -> JSValue { .to_js(); } - // TODO(port): globalThis.ERR(.BORINGSSL, ...) builder — confirm bun_jsc API shape - global - .err( - bun_jsc::ErrorCode::BORINGSSL, - format_args!("{}", bstr::BStr::new(error_message)), - ) - .to_js() + // A plain Error carrying Node's library/function/reason/code decomposition + // of the OpenSSL error, the way ThrowCryptoError builds it: the code is + // ERR_OSSL__ (or ERR_SSL_ for the SSL library). + // The message must own its bytes - `outbuf` is a stack buffer and the + // error instance outlives this frame. + let err = BunString::clone_utf8(error_message).to_error_instance(global); + + if let Some(library) = static_cstr(boring::ERR_lib_error_string(err_code)) { + err.put(global, b"library", ZigString::init(library).to_js(global)); + } + if let Some(function) = static_cstr(boring::ERR_func_error_string(err_code)) { + err.put(global, b"function", ZigString::init(function).to_js(global)); + } + if let Some(reason) = static_cstr(boring::ERR_reason_error_string(err_code)) { + err.put(global, b"reason", ZigString::init(reason).to_js(global)); + + let lib = lib_short_name((err_code >> 24) & 0xff); + // Don't generate codes like "ERR_OSSL_SSL_". + let prefix = if lib == "SSL_" { "" } else { "OSSL_" }; + let mut code = Vec::with_capacity(4 + prefix.len() + lib.len() + reason.len()); + code.extend_from_slice(b"ERR_"); + code.extend_from_slice(prefix.as_bytes()); + code.extend_from_slice(lib.as_bytes()); + code.extend_from_slice(reason); + err.put(global, b"code", ZigString::init(&code).to_js(global)); + } + + err } // ported from: src/runtime/crypto/boringssl_jsc.zig diff --git a/src/runtime/node/node_net_binding.rs b/src/runtime/node/node_net_binding.rs index 4e6349004d36..a63514f9d1a9 100644 --- a/src/runtime/node/node_net_binding.rs +++ b/src/runtime/node/node_net_binding.rs @@ -23,7 +23,10 @@ pub(crate) static AUTO_SELECT_FAMILY_DEFAULT: AtomicBool = AtomicBool::new(true) // If this becomes used in more places, and especially if it can be read by other threads, we may // need to store it as a field in the VirtualMachine instead of in a `threadlocal`. thread_local! { - pub(crate) static AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_DEFAULT: Cell = const { Cell::new(250) }; + // Node's default is 250ms with a documented floor of 10ms, but the CLI + // default in node_options.h is 500ms; the vendored test/common multiplies + // the default by 5 (upstream) assuming 500. + pub(crate) static AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_DEFAULT: Cell = const { Cell::new(500) }; } pub(crate) fn get_default_auto_select_family(global: &JSGlobalObject) -> JSValue { @@ -142,6 +145,7 @@ pub(crate) fn new_detached_socket(global: &JSGlobalObject, frame: &CallFrame) -> ref_count: bun_ptr::RefCount::init(), protos: JsCell::new(None), handlers: Cell::new(None), + local_binding: JsCell::new(None), // — defaults — owned_ssl_ctx: Cell::new(None), flags: Cell::new(SocketFlags::default()), diff --git a/src/runtime/socket/Handlers.rs b/src/runtime/socket/Handlers.rs index 464e7d9ba6c5..05858371e0c6 100644 --- a/src/runtime/socket/Handlers.rs +++ b/src/runtime/socket/Handlers.rs @@ -35,6 +35,10 @@ pub struct Handlers { pub on_end: JSValue, pub on_error: JSValue, pub on_handshake: JSValue, + pub on_session: JSValue, + pub on_keylog: JSValue, + pub on_server_name: JSValue, + pub on_alpn_callback: JSValue, pub binary_type: BinaryType, @@ -100,6 +104,22 @@ macro_rules! for_each_callback_field { let $f = &mut $self.on_handshake; $body } + { + let $f = &mut $self.on_session; + $body + } + { + let $f = &mut $self.on_keylog; + $body + } + { + let $f = &mut $self.on_server_name; + $body + } + { + let $f = &mut $self.on_alpn_callback; + $body + } }}; } @@ -298,6 +318,10 @@ impl Handlers { on_end: JSValue::ZERO, on_error: JSValue::ZERO, on_handshake: JSValue::ZERO, + on_session: JSValue::ZERO, + on_keylog: JSValue::ZERO, + on_server_name: JSValue::ZERO, + on_alpn_callback: JSValue::ZERO, binary_type: match generated.binary_type { GeneratedBinaryType::Arraybuffer => BinaryType::ArrayBuffer, GeneratedBinaryType::Buffer => BinaryType::Buffer, @@ -342,6 +366,10 @@ impl Handlers { assign_callback!(on_end, "onEnd"); assign_callback!(on_error, "onError"); assign_callback!(on_handshake, "onHandshake"); + assign_callback!(on_session, "onSession"); + assign_callback!(on_keylog, "onKeylog"); + assign_callback!(on_server_name, "onServerName"); + assign_callback!(on_alpn_callback, "onALPNCallback"); if result.on_data.is_empty() && result.on_writable.is_empty() { return Err(global_object.throw_invalid_arguments(format_args!( @@ -372,6 +400,10 @@ impl Handlers { self.on_end.unprotect(); self.on_error.unprotect(); self.on_handshake.unprotect(); + self.on_session.unprotect(); + self.on_keylog.unprotect(); + self.on_server_name.unprotect(); + self.on_alpn_callback.unprotect(); } fn with_async_context_if_needed(&mut self, global_object: &JSGlobalObject) { @@ -398,6 +430,10 @@ impl Handlers { self.on_end.protect(); self.on_error.protect(); self.on_handshake.protect(); + self.on_session.protect(); + self.on_keylog.protect(); + self.on_server_name.protect(); + self.on_alpn_callback.protect(); } } diff --git a/src/runtime/socket/Listener.rs b/src/runtime/socket/Listener.rs index 205eea54f6e7..c777a6df7f8e 100644 --- a/src/runtime/socket/Listener.rs +++ b/src/runtime/socket/Listener.rs @@ -460,6 +460,13 @@ impl Listener { bstr::BStr::new(hostname_bytes) )); log!("Failed to listen {}", errno); + // libuv reports UV_EINVAL for a pipe path it cannot express in a + // sockaddr_un, which is what Node surfaces for an over-long path. + let errno = if errno == bun_sys::SystemErrno::ENAMETOOLONG as c_int { + bun_sys::SystemErrno::EINVAL as c_int + } else { + errno + }; if errno != 0 { err.put( global, @@ -510,6 +517,18 @@ impl Listener { ); } } + // Register the dynamic SNI dispatch when the JS config provided a + // `serverName` handler - `sni_cb` invokes it on an SNI-map miss + // and installs whichever context it returns on the in-flight SSL, + // falling back to the default context when it returns null. + // SAFETY: `handlers` is embedded in the live Listener. + if !unsafe { &*this_ref.handlers.as_ptr() } + .on_server_name + .is_empty() + { + // S008: `ListenSocket` is an `opaque_ffi!` ZST - safe deref. + bun_opaque::opaque_deref_mut(listen_socket).on_server_name(us_dispatch_server_name); + } } let this = scopeguard::ScopeGuard::into_inner(cleanup); // ownership transfers to JS wrapper @@ -539,6 +558,7 @@ impl Listener { poll_ref: JsCell::new(KeepAlive::init()), ref_pollref_on_connect: Cell::new(true), connection: JsCell::new(None), + local_binding: JsCell::new(None), server_name: JsCell::new(None), buffered_data_for_node_net: Default::default(), bytes_written: Cell::new(0), @@ -581,6 +601,7 @@ impl Listener { poll_ref: JsCell::new(KeepAlive::init()), ref_pollref_on_connect: Cell::new(true), connection: JsCell::new(None), + local_binding: JsCell::new(None), server_name: JsCell::new(None), buffered_data_for_node_net: Default::default(), bytes_written: Cell::new(0), @@ -972,6 +993,28 @@ impl Listener { }; // errdefer connection.deinit() — Box drops on error path + // `localAddress`/`localPort`: bind the socket to this address before + // connecting. node:net validates localAddress as a literal IP and + // localPort as a number before they reach us. + let local_binding: Option<(Box<[u8]>, u16)> = 'lb: { + let Some(local_addr_js) = opts.get_truthy(global, "localAddress")? else { + break 'lb None; + }; + if !local_addr_js.is_string() { + break 'lb None; + } + let local_addr_slice = local_addr_js.to_slice(global)?; + let local_addr_bytes = local_addr_slice.slice(); + if local_addr_bytes.is_empty() { + break 'lb None; + } + let local_port: u16 = match opts.get_truthy(global, "localPort")? { + Some(p) if p.is_number() => p.to_int32().clamp(0, 65535) as u16, + _ => 0, + }; + Some((local_addr_bytes.to_vec().into_boxed_slice(), local_port)) + }; + // Resolve the prebuilt SSL_CTX before the platform branches so the Windows // named-pipe path can adopt it. node:tls passes the native SecureContext as // `tls.secureContext` so we share its already-built SSL_CTX. @@ -1094,6 +1137,7 @@ impl Listener { // Free old resources before reassignment to prevent memory leaks // when sockets are reused for reconnection (common with MongoDB driver) prev.connection.set(Some(connection)); + prev.local_binding.set(local_binding.clone()); if prev.flags.get().contains(SocketFlags::OWNED_PROTOS) { prev.protos.set(None); } @@ -1108,6 +1152,7 @@ impl Listener { handlers: Cell::new(NonNull::new(handlers_ptr)), socket: Cell::new(uws::NewSocketHandler::::DETACHED), connection: JsCell::new(Some(connection)), + local_binding: JsCell::new(local_binding.clone()), protos: JsCell::new(ssl_taken.as_mut().and_then(|s| s.take_protos())), server_name: JsCell::new( ssl_taken.as_mut().and_then(|s| s.take_server_name()), @@ -1195,6 +1240,7 @@ impl Listener { // non-pipe arm below. Previously `.connection = null` // dropped the duped pipe-path bytes on the floor. prev.connection.set(Some(connection)); + prev.local_binding.set(local_binding.clone()); debug_assert!(prev.protos.get().is_none()); debug_assert!(prev.server_name.get().is_none()); prev_ptr @@ -1204,6 +1250,7 @@ impl Listener { handlers: Cell::new(NonNull::new(handlers_ptr)), socket: Cell::new(uws::NewSocketHandler::::DETACHED), connection: JsCell::new(Some(connection)), + local_binding: JsCell::new(local_binding.clone()), protos: JsCell::new(None), server_name: JsCell::new(None), owned_ssl_ctx: Cell::new(None), @@ -1320,6 +1367,7 @@ impl Listener { prev_maybe_tls, handlers_ptr, connection, + local_binding, ssl_taken.as_mut(), owned_ssl_ctx, default_data, @@ -1333,6 +1381,7 @@ impl Listener { prev_maybe_tcp, handlers_ptr, connection, + local_binding, ssl_taken.as_mut(), owned_ssl_ctx, default_data, @@ -1412,6 +1461,7 @@ fn connect_finish( maybe_previous: Option<*mut NewSocket>, handlers_ptr: *mut Handlers, connection: UnixOrHost, + local_binding: Option<(Box<[u8]>, u16)>, mut ssl: Option<&mut SSLConfig>, owned_ssl_ctx: Option>, default_data: JSValue, @@ -1449,6 +1499,7 @@ fn connect_finish( // Free old resources before reassignment to prevent memory leaks // when sockets are reused for reconnection (common with MongoDB driver) prev.connection.set(Some(connection)); + prev.local_binding.set(local_binding); if prev.flags.get().contains(SocketFlags::OWNED_PROTOS) { prev.protos.set(None); // drop old Box } @@ -1467,6 +1518,7 @@ fn connect_finish( handlers: Cell::new(NonNull::new(handlers_ptr)), socket: Cell::new(uws::NewSocketHandler::::DETACHED), connection: JsCell::new(Some(connection)), + local_binding: JsCell::new(local_binding), protos: JsCell::new(ssl.as_mut().and_then(|s| s.take_protos())), server_name: JsCell::new(ssl.as_mut().and_then(|s| s.take_server_name())), owned_ssl_ctx: Cell::new(owned_ssl_ctx.map(|p| p.as_ptr())), @@ -1512,9 +1564,30 @@ fn connect_finish( // reads `self.connection` directly so no second borrow is needed here. if socket_ref.do_connect().is_err() { let errno = if port.is_none() { - bun_sys::SystemErrno::ENOENT as c_int + // Preserve the real errno from the failed connect(2) on a unix path: + // connecting to an existing non-socket file is ENOTSOCK, a + // permission-denied path is EACCES, a missing one is ENOENT. + let os_errno = bun_sys::last_errno(); + if os_errno == bun_sys::SystemErrno::ENAMETOOLONG as c_int { + // libuv reports UV_EINVAL for a pipe path it cannot express. + bun_sys::SystemErrno::EINVAL as c_int + } else if os_errno != 0 { + os_errno + } else { + bun_sys::SystemErrno::ENOENT as c_int + } } else { - bun_sys::SystemErrno::ECONNREFUSED as c_int + // A synchronous TCP connect failure is almost always the local + // bind() (localAddress/localPort) failing - preserve EADDRINUSE + // and EADDRNOTAVAIL; everything else stays ECONNREFUSED. + let os_errno = bun_sys::last_errno(); + if os_errno == bun_sys::SystemErrno::EADDRINUSE as c_int + || os_errno == bun_sys::SystemErrno::EADDRNOTAVAIL as c_int + { + os_errno + } else { + bun_sys::SystemErrno::ECONNREFUSED as c_int + } }; // SAFETY: `socket` is the live heap pointer; `socket_ref`'s `&mut` is no // longer used on this branch. `handle_connect_error` takes `*mut Self` @@ -1783,3 +1856,78 @@ impl WindowsNamedPipeListeningContext { } // ported from: src/runtime/socket/Listener.zig + +/// `openssl.c`'s `sni_cb` calls this on an SNI-map miss so the JS +/// `SNICallback` can pick a context for the requested hostname. The returned +/// `SSL_CTX*` (or null for "use the default") applies to the in-flight +/// handshake only - the caller installs it with `SSL_set_SSL_CTX`, which takes +/// its own reference, and nothing is cached in the SNI tree, so the callback +/// runs per-connection the way Node's does. The contract is synchronous: a +/// context provided after this returns has no effect on this handshake. +/// +/// # Safety +/// `ls` is a live listen socket whose accept-group ext holds a `*mut Listener` +/// and `hostname` is a NUL-terminated string valid for the call. JS-thread +/// only. +pub(crate) extern "C" fn us_dispatch_server_name( + ls: *mut uws_sys::ListenSocket, + hostname: *const core::ffi::c_char, +) -> *mut c_void { + jsc::mark_binding!(); + if ls.is_null() || hostname.is_null() { + return core::ptr::null_mut(); + } + // SAFETY: `ls` is live per the fn contract; the accept group's ext holds + // the owning `*mut Listener` for the lifetime of the listen socket. + let listener_ptr: *mut Listener = unsafe { (*ls).group().owner::() }; + if listener_ptr.is_null() { + return core::ptr::null_mut(); + } + // SAFETY: see above. + let listener: &Listener = unsafe { &*listener_ptr }; + // SAFETY: `handlers` is embedded in the live Listener. + let handlers = unsafe { &*listener.handlers.as_ptr() }; + if handlers.vm.is_shutting_down() { + return core::ptr::null_mut(); + } + let callback = handlers.on_server_name; + if callback.is_empty() { + return core::ptr::null_mut(); + } + // No `Handlers::enter`/`exit` scope here: that protocol tracks the + // accepted-socket callback lifecycle (an exit returning true means "the + // socket died during the callback, free the handlers"), and running it + // against the listener's own handlers from inside the handshake corrupts + // their refcount for every subsequent accept. The listener and its + // embedded handlers are structurally alive for the duration of this + // synchronous dispatch - the listen socket cannot be freed mid-handshake. + let global = handlers.global_object; + // Pass the listener's `data` (the owning net.Server) rather than minting a + // JS wrapper for the Listener itself - `to_js` here would create a second + // cell owning the same Rust struct and whichever is collected first frees + // it out from under the other. + let this_value = listener + .strong_data + .get() + .get() + .unwrap_or(JSValue::UNDEFINED); + // SAFETY: `hostname` is NUL-terminated per the fn contract. + let name = unsafe { core::ffi::CStr::from_ptr(hostname) }; + let js_name = ZigString::init(name.to_bytes()).to_js(&global); + let result = match callback.call(&global, this_value, &[this_value, js_name]) { + Ok(v) => v, + Err(err) => global.take_exception(err), + }; + if let Some(err_value) = result.to_error() { + let _ = handlers.call_error_handler(this_value, &[this_value, err_value]); + return core::ptr::null_mut(); + } + // The JS handler returns the native SecureContext selected by a + // synchronous SNICallback, or undefined to fall through to the default. + if let Some(sc) = SecureContext::from_js(result) { + // SAFETY: from_js returned non-null; the SecureContext is live for the + // call and SSL_set_SSL_CTX takes its own reference to the SSL_CTX. + return unsafe { (*sc).borrow() }.cast(); + } + core::ptr::null_mut() +} diff --git a/src/runtime/socket/SSLConfig.bindv2.ts b/src/runtime/socket/SSLConfig.bindv2.ts index 04a3f0b0f1e1..2309bf9a616f 100644 --- a/src/runtime/socket/SSLConfig.bindv2.ts +++ b/src/runtime/socket/SSLConfig.bindv2.ts @@ -59,6 +59,16 @@ export const SSLConfig = b.dictionary( default: 0, internalName: "secure_options", }, + minVersion: { + type: b.i32, + default: 0, + internalName: "ssl_min_version", + }, + maxVersion: { + type: b.i32, + default: 0, + internalName: "ssl_max_version", + }, keyFile: { type: b.String.nullable, internalName: "key_file", diff --git a/src/runtime/socket/SSLConfig.rs b/src/runtime/socket/SSLConfig.rs index 0566ebeab74a..c9befe6cf4dc 100644 --- a/src/runtime/socket/SSLConfig.rs +++ b/src/runtime/socket/SSLConfig.rs @@ -176,11 +176,15 @@ impl SSLConfigFromJs for SSLConfig { as i32; result.request_cert = generated.request_cert as i32; result.secure_options = generated.secure_options; + result.ssl_min_version = generated.ssl_min_version; + result.ssl_max_version = generated.ssl_max_version; any = any || result.low_memory_mode || generated.reject_unauthorized.is_some() || generated.request_cert - || result.secure_options != 0; + || result.secure_options != 0 + || result.ssl_min_version != 0 + || result.ssl_max_version != 0; result.ca = handle_file_for_field(global, "ca", &generated.ca)?; result.cert = handle_file_for_field(global, "cert", &generated.cert)?; diff --git a/src/runtime/socket/SocketConfig.bindv2.ts b/src/runtime/socket/SocketConfig.bindv2.ts index 242366aa29cc..bb320349ee5e 100644 --- a/src/runtime/socket/SocketConfig.bindv2.ts +++ b/src/runtime/socket/SocketConfig.bindv2.ts @@ -23,6 +23,10 @@ export const Handlers = b.dictionary( end: { type: b.RawAny, internalName: "onEnd" }, connectError: { type: b.RawAny, internalName: "onConnectError" }, timeout: { type: b.RawAny, internalName: "onTimeout" }, + session: { type: b.RawAny, internalName: "onSession" }, + keylog: { type: b.RawAny, internalName: "onKeylog" }, + serverName: { type: b.RawAny, internalName: "onServerName" }, + alpnCallback: { type: b.RawAny, internalName: "onALPNCallback" }, binaryType: { type: BinaryType, default: "buffer", diff --git a/src/runtime/socket/WindowsNamedPipe.rs b/src/runtime/socket/WindowsNamedPipe.rs index 9dab8f4247bc..0e2cbb0a5494 100644 --- a/src/runtime/socket/WindowsNamedPipe.rs +++ b/src/runtime/socket/WindowsNamedPipe.rs @@ -1219,6 +1219,14 @@ impl WindowsNamedPipe { unsafe { (*this).wrapper = None }; } } + } else { + // Plain (non-TLS) named pipe: half-close the write side so the peer + // observes EOF. Without this, Socket.prototype.end() over a Windows + // named pipe (endNT → shutdown()) never signals the peer, and an + // allowHalfOpen peer waiting on 'end' hangs. `writer.end()` is + // idempotent and mirrors `close`'s unconditional writer teardown. + // SAFETY: `this` aliases the live `&mut self`; single JS thread. + unsafe { (*this).writer.end() }; } } diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index 1dda99cdacfb..8313fdff26fb 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -8,6 +8,8 @@ use core::ptr::{self, NonNull}; use bun_io::KeepAlive; use bun_jsc::JsCell; +use bun_jsc::ZigStringJsc as _; +use bun_jsc::zig_string::ZigString; use bun_ptr::IntrusiveRc; // PORT NOTE: do NOT `use bun_boringssl_sys::SSL` here — it shadows the // `const SSL: bool` generic param in `NewSocket` below, making rustc @@ -92,6 +94,99 @@ extern "C" fn select_alpn_callback( } // SAFETY: ex_data slot 0 holds a `*mut TLSSocket` (set in on_open). let this: &TLSSocket = unsafe { &*this_ptr.cast::() }; + // Dynamic per-connection ALPN: when the listener's config carries an + // `alpnCallback` handler, consult it with the client's protocol list (and + // the SNI name) before the static ALPNProtocols list. The JS handler + // returns `false` when the server has no ALPNCallback (fall through to + // the static list), the selected protocol string, or anything else to + // refuse the connection with a fatal no_application_protocol alert - the + // same contract as Node's ALPNCallback. + { + let handlers = this.get_handlers(); + let callback = handlers.on_alpn_callback; + if !callback.is_empty() && !handlers.vm.is_shutting_down() && !in_.is_null() && inlen > 0 { + let scope = Handlers::enter_ref(handlers); + let global = handlers.global_object; + let this_value = this.get_this_value(&global); + let wire_len = inlen as usize; + let buffer = match JSValue::create_buffer_from_length(&global, wire_len) { + Ok(b) => b, + Err(_) => { + if scope.exit() { + this.handlers.set(None); + } + return boringssl_sys::SSL_TLSEXT_ERR_ALERT_FATAL; + } + }; + if let Some(ab) = buffer.as_array_buffer(&global) { + // SAFETY: `ab.ptr` points at a fresh `wire_len`-byte JS buffer + // and `in_` is valid for `inlen` per the callback contract. + unsafe { core::ptr::copy_nonoverlapping(in_, ab.ptr, wire_len) }; + } + // SAFETY: `ssl` is the live SSL handle passed into this ALPN + // callback; SSL_get_servername reads the negotiated SNI name and + // returns NULL or a NUL-terminated string owned by the SSL. + let servername_ptr = unsafe { boringssl_sys::SSL_get_servername(ssl.cast_const(), 0) }; + let servername_js = if servername_ptr.is_null() { + JSValue::UNDEFINED + } else { + // SAFETY: BoringSSL hands back a NUL-terminated name. + let name = unsafe { core::ffi::CStr::from_ptr(servername_ptr) }; + ZigString::init(name.to_bytes()).to_js(&global) + }; + // The user callback (and the error handler below) run from inside + // SSL_do_handshake on this socket: JS that writes to or destroys a + // different TLS socket on the same loop re-points the per-loop BIO + // routing state, and this handshake's next flight would land on + // that other socket's fd. Snapshot and restore it around every + // JS-running region. + let mut saved_loop_state: [*mut c_void; 5] = [core::ptr::null_mut(); 5]; + tls_socket_functions::ffi::us_internal_ssl_loop_state_save( + boringssl_sys::SSL::opaque_ref(ssl), + saved_loop_state.as_mut_ptr(), + ); + let result = + match callback.call(&global, this_value, &[this_value, servername_js, buffer]) { + Ok(v) => v, + Err(err) => global.take_exception(err), + }; + if let Some(err_value) = result.to_error() { + let _ = handlers.call_error_handler(this_value, &[this_value, err_value]); + tls_socket_functions::ffi::us_internal_ssl_loop_state_restore( + saved_loop_state.as_mut_ptr(), + ); + if scope.exit() { + this.handlers.set(None); + } + return boringssl_sys::SSL_TLSEXT_ERR_ALERT_FATAL; + } + tls_socket_functions::ffi::us_internal_ssl_loop_state_restore( + saved_loop_state.as_mut_ptr(), + ); + if scope.exit() { + this.handlers.set(None); + } + if !result.is_boolean() || result.to_boolean() { + // The server has an ALPNCallback and it answered: a string + // selects that protocol for this connection; anything else + // refuses it. + let Ok(chosen) = result.to_slice(&global) else { + return boringssl_sys::SSL_TLSEXT_ERR_ALERT_FATAL; + }; + let chosen_bytes = chosen.slice(); + if !result.is_string() || chosen_bytes.is_empty() || chosen_bytes.len() > 255 { + return boringssl_sys::SSL_TLSEXT_ERR_ALERT_FATAL; + } + let mut wire = Vec::with_capacity(chosen_bytes.len() + 1); + wire.push(chosen_bytes.len() as u8); + wire.extend_from_slice(chosen_bytes); + this.protos.set(Some(wire.into_boxed_slice())); + // Fall through to the standard selection below, which now + // negotiates against the single chosen protocol (and sends the + // fatal alert if the client did not actually offer it). + } + } + } if let Some(protos) = this.protos.get() { if protos.is_empty() { return boringssl_sys::SSL_TLSEXT_ERR_NOACK; @@ -179,6 +274,9 @@ pub struct NewSocket { pub poll_ref: JsCell, pub ref_pollref_on_connect: Cell, pub connection: JsCell>, + /// `localAddress`/`localPort` from the connect options: the socket is + /// bound to this address before connecting. Always a literal IP. + pub local_binding: JsCell, u16)>>, pub protos: JsCell>>, pub server_name: JsCell>>, pub buffered_data_for_node_net: JsCell>, @@ -403,12 +501,18 @@ impl NewSocket { // `ZBox` guarantees a trailing NUL; host bytes contain no interior NUL. let host_c = hostz.as_zstr().as_cstr(); + // Bind to the requested local address before connecting, if any. + let local = self.local_binding.get(); + let local_z = local + .as_ref() + .map(|(h, p)| (bun_core::ZBox::from_bytes(h), *p)); self.socket.set( match group.connect( kind, ssl_ctx, host_c, c_int::from(port), + local_z.as_ref().map(|(z, p)| (z.as_zstr().as_cstr(), *p)), flags, core::mem::size_of::<*mut c_void>() as c_int, ) { @@ -828,13 +932,36 @@ impl NewSocket { } debug_assert!(errno >= 0); - let errno_: c_int = if errno == sys::SystemErrno::ENOENT as c_int { - sys::SystemErrno::ENOENT as c_int + // Unix-path connect errors keep their real code (a non-socket file is + // ENOTSOCK, a permission-denied path is EACCES, a missing one is + // ENOENT, an inexpressible path is EINVAL); everything else stays + // ECONNREFUSED. + let errno_: c_int = if errno == sys::SystemErrno::ENOENT as c_int + || errno == sys::SystemErrno::ENOTSOCK as c_int + || errno == sys::SystemErrno::EACCES as c_int + || errno == sys::SystemErrno::EINVAL as c_int + || errno == sys::SystemErrno::ECONNRESET as c_int + || errno == sys::SystemErrno::EADDRINUSE as c_int + || errno == sys::SystemErrno::EADDRNOTAVAIL as c_int + { + errno } else { sys::SystemErrno::ECONNREFUSED as c_int }; let code_ = if errno == sys::SystemErrno::ENOENT as c_int { BunString::static_("ENOENT") + } else if errno == sys::SystemErrno::ENOTSOCK as c_int { + BunString::static_("ENOTSOCK") + } else if errno == sys::SystemErrno::EACCES as c_int { + BunString::static_("EACCES") + } else if errno == sys::SystemErrno::EINVAL as c_int { + BunString::static_("EINVAL") + } else if errno == sys::SystemErrno::ECONNRESET as c_int { + BunString::static_("ECONNRESET") + } else if errno == sys::SystemErrno::EADDRINUSE as c_int { + BunString::static_("EADDRINUSE") + } else if errno == sys::SystemErrno::EADDRNOTAVAIL as c_int { + BunString::static_("EADDRNOTAVAIL") } else { BunString::static_("ECONNREFUSED") }; @@ -1121,26 +1248,36 @@ impl NewSocket { } } } + // A server needs the per-connection ALPN selector when it + // has static ALPNProtocols OR a dynamic ALPNCallback (the + // selector consults the callback first and falls back to + // the static list). The callback reads `this` from the SSL, + // not the CTX-level arg (shared across the listener). + // ffi-safe-fn: opaque-ZST `&SSL`/`&SSL_CTX` redecls; + // `ssl_ptr` non-null in this branch and `SSL_get_SSL_CTX` + // never returns null for a live SSL. + if this.is_server() + && (this.protos.get().is_some() + || !this.get_handlers().on_alpn_callback.is_empty()) + { + let ssl_ref = boringssl_sys::SSL::opaque_ref(ssl_ptr); + tls_socket_functions::ffi::SSL_set_ex_data( + ssl_ref, + 0, + this_ptr.cast::(), + ); + tls_socket_functions::ffi::SSL_CTX_set_alpn_select_cb( + SSL_CTX::opaque_ref(tls_socket_functions::ffi::SSL_get_SSL_CTX( + ssl_ref, + )), + Some(select_alpn_callback), + ptr::null_mut(), + ); + } if let Some(protos) = this.protos.get() { if this.is_server() { - // Per-connection: callback reads `this` from the SSL, - // not the CTX-level arg (shared across the listener). - // ffi-safe-fn: opaque-ZST `&SSL`/`&SSL_CTX` redecls; - // `ssl_ptr` non-null in this branch and - // `SSL_get_SSL_CTX` never returns null for a live SSL. - let ssl_ref = boringssl_sys::SSL::opaque_ref(ssl_ptr); - tls_socket_functions::ffi::SSL_set_ex_data( - ssl_ref, - 0, - this_ptr.cast::(), - ); - tls_socket_functions::ffi::SSL_CTX_set_alpn_select_cb( - SSL_CTX::opaque_ref(tls_socket_functions::ffi::SSL_get_SSL_CTX( - ssl_ref, - )), - Some(select_alpn_callback), - ptr::null_mut(), - ); + // Registered above (selector + ex_data); nothing + // further to do for the static server list here. } else { // SAFETY: `ssl_ptr` non-null in this branch; // `protos.as_ptr()` is readable for `protos.len()` @@ -1213,6 +1350,32 @@ impl NewSocket { } this.mark_inactive(); } + if !SSL + && !this.socket.get().is_detached() + && this.buffered_data_for_node_net.get().len() > 0 + { + // A write issued from inside the open/'connection' callback (a + // server answering the moment a connection arrives) can be + // deferred into `buffered_data_for_node_net` before the socket has + // any usockets-level backpressure, so no writable event would ever + // flush it and its JS write callback would never run - the socket + // then never finishes and holds the event loop (the FIN-terminated + // http response tests hung on every Linux target). Deliver it now + // that the open dispatch is done; if it fully drains, complete the + // pending JS write the same way on_writable's tail does, otherwise + // the do_socket_write backpressure arms the normal writable + // subscription. + this.internal_flush(); + if this.buffered_data_for_node_net.get().len() == 0 { + let drain_callback = handlers.on_writable; + if !drain_callback.is_empty() { + if let Err(err) = drain_callback.call(&global, this_value, &[this_value]) { + let _ = handlers + .call_error_handler(this_value, &[this_value, global.take_error(err)]); + } + } + } + } if scope.exit() { this.handlers.set(None); } @@ -1425,6 +1588,112 @@ impl NewSocket { Ok(()) } + /// A new resumable TLS session arrived (the peer's NewSessionTicket was + /// processed during an earlier `SSL_read`). Hands the serialized session + /// to the JS `session` handler, mirroring Node's `onnewsession` callback. + /// Dispatched from `ssl_flush_pending_session()` after the SSL stack has + /// unwound, so the JS handler may safely destroy the socket. + /// + /// # Safety + /// `this` points at a live `NewSocket`; JS-thread only. + pub unsafe fn on_session(this: *mut Self, session: &[u8]) -> JsResult<()> { + jsc::mark_binding!(); + // SAFETY: per fn contract; shared reborrow only. + let this: &Self = unsafe { &*this }; + if this.socket.get().is_detached() { + return Ok(()); + } + let handlers = this.get_handlers(); + if handlers.vm.is_shutting_down() { + return Ok(()); + } + let callback = handlers.on_session; + if callback.is_empty() { + return Ok(()); + } + let scope = Handlers::enter_ref(handlers); + let global = handlers.global_object; + let this_value = this.get_this_value(&global); + let buffer = match JSValue::create_buffer_from_length(&global, session.len()) { + Ok(b) => b, + Err(e) => { + if scope.exit() { + this.handlers.set(None); + } + return Err(e); + } + }; + if let Some(ab) = buffer.as_array_buffer(&global) { + // SAFETY: `ab.ptr` points to a freshly-created `session.len()`-byte + // JS buffer kept alive on the stack; `session` is valid for its length. + unsafe { + core::ptr::copy_nonoverlapping(session.as_ptr(), ab.ptr, session.len()); + } + } + let result = match callback.call(&global, this_value, &[this_value, buffer]) { + Ok(v) => v, + Err(err) => global.take_exception(err), + }; + if let Some(err_value) = result.to_error() { + let _ = handlers.call_error_handler(this_value, &[this_value, err_value]); + } + if scope.exit() { + this.handlers.set(None); + } + Ok(()) + } + + /// `*mut Self` for the same noalias-reentry reason as `on_session`. + /// + /// # Safety + /// `this` points at a live `NewSocket`; JS-thread only. + pub unsafe fn on_keylog(this: *mut Self, line: &[u8]) -> JsResult<()> { + jsc::mark_binding!(); + // SAFETY: per fn contract; shared reborrow only. + let this: &Self = unsafe { &*this }; + if this.socket.get().is_detached() { + return Ok(()); + } + let handlers = this.get_handlers(); + if handlers.vm.is_shutting_down() { + return Ok(()); + } + let callback = handlers.on_keylog; + if callback.is_empty() { + return Ok(()); + } + let scope = Handlers::enter_ref(handlers); + let global = handlers.global_object; + let this_value = this.get_this_value(&global); + let buffer = match JSValue::create_buffer_from_length(&global, line.len()) { + Ok(b) => b, + Err(e) => { + if scope.exit() { + this.handlers.set(None); + } + return Err(e); + } + }; + if let Some(ab) = buffer.as_array_buffer(&global) { + // SAFETY: `ab.ptr` points to a freshly-created `line.len()`-byte + // JS buffer kept alive on the stack; `line` is valid for its length. + unsafe { + core::ptr::copy_nonoverlapping(line.as_ptr(), ab.ptr, line.len()); + } + } + let result = match callback.call(&global, this_value, &[this_value, buffer]) { + Ok(v) => v, + Err(err) => global.take_exception(err), + }; + if let Some(err_value) = result.to_error() { + let _ = handlers.call_error_handler(this_value, &[this_value, err_value]); + } + if scope.exit() { + this.handlers.set(None); + } + Ok(()) + } + /// `*mut Self` for the same noalias-reentry reason as `on_writable`. /// /// # Safety @@ -1907,7 +2176,31 @@ impl NewSocket { return -1; } - let res = self.do_socket_write(buffer); + // The raw [raw, tls] upgrade twin shares the TLS half's us_socket_t + // (`s->ssl` is set) but must write raw bytes: write_check_error would + // route it through the SSL-encrypting us_socket_write, and its fatal + // signal is never set for TLS sockets anyway. + if self.flags.get().contains(Flags::BYPASS_TLS) { + let res = self.do_socket_write(buffer); + let uwrote: usize = usize::try_from(res.max(0)).expect("int cast"); + self.bytes_written + .set(self.bytes_written.get() + uwrote as u64); + log!("write({}) = {}", buffer.len(), res); + return res; + } + + let (res, fatal) = socket.write_check_error(buffer); + if fatal { + // The kernel rejected the write outright (EPIPE/ECONNRESET after + // the peer vanished). Nothing buffered here can ever be delivered: + // drop it and close through the normal native close path so the JS + // side observes 'close' and completes any pending write callback, + // instead of waiting forever on a drain that cannot come. + self.buffered_data_for_node_net + .with_mut(|b| b.clear_and_free()); + self.socket.get().close(uws::CloseCode::Failure); + return -1; + } let uwrote: usize = usize::try_from(res.max(0)).expect("int cast"); self.bytes_written .set(self.bytes_written.get() + uwrote as u64); @@ -2372,13 +2665,31 @@ impl NewSocket { // `bytes_written`/`flags`/`buffered_data_for_node_net` across the // re-entrant `do_socket_write`) is no longer needed. if self.buffered_data_for_node_net.get().len() > 0 { - // `do_socket_write` does not touch `buffered_data_for_node_net`, so a + // Neither write call touches `buffered_data_for_node_net`, so a // `JsCell::get()` projection is valid for the duration of the call. - let written: usize = usize::try_from( + // + // The drain-driven retry must detect a fatal send error the same way + // the initial write does: once the peer is gone the kernel rejects + // every retry (EPIPE/ECONNRESET), and treating that as would-block + // kept this buffer parked forever (the FIN-terminated-response hang). + // BYPASS_TLS twins keep the raw write path; TLS errors propagate + // through the SSL layer. + let res: i32 = if self.flags.get().contains(Flags::BYPASS_TLS) { self.do_socket_write(self.buffered_data_for_node_net.get().slice()) - .max(0), - ) - .unwrap(); + } else { + let (res, fatal) = self + .socket + .get() + .write_check_error(self.buffered_data_for_node_net.get().slice()); + if fatal { + self.buffered_data_for_node_net + .with_mut(|b| b.clear_and_free()); + self.socket.get().close(uws::CloseCode::Failure); + return; + } + res + }; + let written: usize = usize::try_from(res.max(0)).unwrap(); self.bytes_written .set(self.bytes_written.get() + written as u64); if written > 0 { @@ -2428,7 +2739,26 @@ impl NewSocket { _frame: &CallFrame, ) -> JsResult { jsc::mark_binding!(); + // Capture the in-flight-connect state before close_and_detach() sets + // DETACHED. Resetting a SEMI_SOCKET (Connected arm, handshake not yet + // established) dispatches no terminal callback in us_socket_close, so + // on_close/mark_inactive never runs — balance connect_finish's ref_(), + // downgrade the Strong this_value, and release the event-loop ref here, + // exactly as close() does. Without it those refs leak (LSan-caught). + let socket = this.socket.get(); + let is_semi_connect = socket.socket.get().is_some() && !socket.is_established(); this.close_and_detach(uws::CloseCode::Failure); + if is_semi_connect { + this.poll_ref.with_mut(|p| { + p.unref(bun_io::posix_event_loop::get_vm_ctx( + bun_io::AllocatorType::Js, + )) + }); + if !matches!(this.this_value.get(), JsRef::Finalized) { + this.this_value.with_mut(|r| r.downgrade()); + } + this.deref(); + } Ok(JSValue::UNDEFINED) } @@ -2731,10 +3061,6 @@ impl NewSocket { "upgradeTLS requires an established socket" ))); }; - if this.is_server() { - return Err(global.throw(format_args!("Server-side upgradeTLS is not supported. Use upgradeDuplexToTLS with isServer: true instead."))); - } - let args = callframe.arguments_old::<1>(); if args.len < 1 { return Err(global.throw(format_args!("Expected 1 arguments"))); @@ -2744,12 +3070,35 @@ impl NewSocket { return Err(global.throw(format_args!("Expected options object"))); } + // Server-side upgrade (`new tls.TLSSocket(socket, { isServer: true })`): + // adopt the fd into an accept-state SSL so the native read path drives the + // handshake — same code path as the client upgrade, only `is_client` flips. + // An explicit `isServer` option wins over the underlying socket's mode so + // an outgoing connection can still be wrapped as the server side, the way + // Node honors the option regardless of how the socket was created. + let is_server = match opts.get_truthy(global, "isServer")? { + Some(value) => value.to_boolean(), + None => this.is_server(), + }; + let socket_obj = opts .get(global, "socket")? .ok_or_else(|| global.throw(format_args!("Expected \"socket\" option")))?; if global.has_exception() { return Ok(JSValue::ZERO); } + // Bytes already consumed from the wire before the upgrade (e.g. the + // ClientHello sitting in the readable buffer of the socket being + // wrapped); fed into the TLS engine once the upgrade is wired up. + let initial_data: StringOrBuffer = match opts.get_truthy(global, "initialData")? { + Some(v) => StringOrBuffer::from_js(global, v)?.unwrap_or(StringOrBuffer::EMPTY), + None => StringOrBuffer::EMPTY, + }; + // Handlers lifecycle is always client-mode (heap-per-connection) here: a + // standalone `new TLSSocket(socket, { isServer })` is NOT a SocketListener, + // and server-mode Handlers::mark_inactive assumes its `this` is a Listener's + // embedded `handlers` field. The server-ness lives in the SSL accept state + // (adopt_tls is_client=!is_server) + the ServerHandlers JS table, not here. let handlers = Handlers::from_js(global, socket_obj, false)?; if global.has_exception() { return Ok(JSValue::ZERO); @@ -2899,6 +3248,7 @@ impl NewSocket { socket: Cell::new(SocketHandler::::DETACHED), owned_ssl_ctx: Cell::new(owned_ctx_taken), connection: JsCell::new(this.connection.get().clone()), + local_binding: JsCell::new(None), protos: JsCell::new(cfg.and_then(|c| c.protos_bytes().map(Box::<[u8]>::from))), server_name: JsCell::new( cfg.and_then(|c| c.server_name_bytes().map(Box::<[u8]>::from)), @@ -2935,6 +3285,7 @@ impl NewSocket { uws::SocketKind::BunSocketTls, &mut *((*tls_ptr).owned_ssl_ctx.get().unwrap()), sni, + !is_server, core::mem::size_of::<*mut c_void>() as i32, core::mem::size_of::<*mut c_void>() as i32, ) @@ -3032,14 +3383,25 @@ impl NewSocket { socket: Cell::new(SocketHandler::::from(new_raw.as_ptr())), owned_ssl_ctx: Cell::new(None), connection: JsCell::new(None), + local_binding: JsCell::new(None), protos: JsCell::new(None), server_name: JsCell::new(None), // is_active so the chained `raw.onClose` → `markInactive` path // tears down `raw_handlers` (client-mode handlers free // themselves there). No poll_ref — `tls` keeps the loop alive. // active_connections=1 was already on raw_handlers from `this`. + // OWNS_HANDLERS transfers from the retired wrapper rather than + // being asserted: a client socket's Handlers are its own + // heap::alloc root and the twin must free them, but an accepted + // server socket only borrows an interior pointer into its + // listener's embedded Handlers - claiming ownership of that + // would bad-free the listener's allocation when the twin is + // finalized. flags: Cell::new( - Flags::BYPASS_TLS | Flags::IS_ACTIVE | Flags::OWNED_PROTOS | Flags::OWNS_HANDLERS, + Flags::BYPASS_TLS + | Flags::IS_ACTIVE + | Flags::OWNED_PROTOS + | (this.flags.get() & Flags::OWNS_HANDLERS), ), this_value: JsCell::new(JsRef::empty()), poll_ref: JsCell::new(KeepAlive::init()), @@ -3092,6 +3454,21 @@ impl NewSocket { }; // SAFETY: `new_raw` is the live adopted `us_socket_t`. unsafe { (*new_raw.as_ptr()).start_tls_handshake() }; + // The socket being wrapped may have had its readable interest off (an + // accepted socket nobody was reading yet — its ClientHello is still in + // the kernel buffer); make sure the adopted TLS socket is reading so + // the handshake can be driven. A no-op when it was already reading. + // SAFETY: `new_raw` is the live adopted `us_socket_t`. + unsafe { (*new_raw.as_ptr()).resume() }; + // Feed bytes that arrived before the upgrade (already pulled off the fd + // by the plain-TCP layer) into the TLS engine exactly as if they had + // just been received — for a server-side wrap this is the ClientHello. + let initial_slice = initial_data.slice(); + if !initial_slice.is_empty() { + // SAFETY: `new_raw` is live; the slice borrows a JS-owned buffer kept + // alive by the options object for the duration of this call. + unsafe { (*new_raw.as_ptr()).tls_feed(initial_slice) }; + } let array = JSValue::create_empty_array(global, 2)?; array.put_index(global, 0, raw_js_value)?; @@ -3189,6 +3566,14 @@ impl NewSocket { } } #[bun_jsc::host_fn(method)] + pub fn set_key_cert(this: &Self, g: &JSGlobalObject, f: &CallFrame) -> JsResult { + if SSL { + tls_socket_functions::set_key_cert(Self::as_tls(this), g, f) + } else { + Ok(JSValue::UNDEFINED) + } + } + #[bun_jsc::host_fn(method)] pub fn export_keying_material( this: &Self, g: &JSGlobalObject, @@ -3889,6 +4274,7 @@ pub fn js_upgrade_duplex_to_tls( socket: Cell::new(SocketHandler::::DETACHED), owned_ssl_ctx: Cell::new(None), connection: JsCell::new(None), + local_binding: JsCell::new(None), protos: JsCell::new( socket_config.and_then(|cfg| cfg.protos_bytes().map(Box::<[u8]>::from)), ), @@ -4016,11 +4402,12 @@ pub fn js_upgrade_duplex_to_tls( tls_ref.socket.set(from_duplex::(&mut dc.upgrade)); tls_ref.mark_active(); - tls_ref.poll_ref.with_mut(|p| { - p.ref_(bun_io::posix_event_loop::get_vm_ctx( - bun_io::posix_event_loop::AllocatorType::Js, - )) - }); + // Unlike a real socket, a TLS engine over a JS stream has no I/O of its + // own to wait for - it is driven entirely by the stream's events - so it + // must not hold the event loop open. Node's TLSWrap over a JS stream + // behaves the same way: a script that leaves a duplexPair-backed TLS pair + // dangling still exits. If the underlying stream is a real socket, that + // socket's own handle keeps the loop alive. dc.start_tls(); diff --git a/src/runtime/socket/sockets.classes.ts b/src/runtime/socket/sockets.classes.ts index 2b2980ffb0e5..cde25136193b 100644 --- a/src/runtime/socket/sockets.classes.ts +++ b/src/runtime/socket/sockets.classes.ts @@ -65,6 +65,10 @@ function generate(ssl) { fn: "getTLSTicket", length: 0, }, + setKeyCert: { + fn: "setKeyCert", + length: 1, + }, exportKeyingMaterial: { fn: "exportKeyingMaterial", length: 3, diff --git a/src/runtime/socket/tls_socket_functions.rs b/src/runtime/socket/tls_socket_functions.rs index 6444685bc76d..426913520bf1 100644 --- a/src/runtime/socket/tls_socket_functions.rs +++ b/src/runtime/socket/tls_socket_functions.rs @@ -1,7 +1,9 @@ use core::ffi::{c_char, c_int, c_long, c_void}; +use crate::api::bun_secure_context::SecureContext; use bun_boringssl_sys as boringssl; use bun_core::{String as BunString, ZigString, strings}; +use bun_jsc::JsClass as _; use bun_jsc::{ self as jsc, CallFrame, JSGlobalObject, JSValue, JsResult, StringJsc as _, ZigStringJsc as _, }; @@ -15,7 +17,7 @@ use crate::api::bun_x509 as X509; // ────────────────────────────────────────────────────────────────────────── #[allow(non_camel_case_types, non_upper_case_globals, dead_code)] pub(super) mod ffi { - use super::boringssl::{SSL, SSL_CTX, X509, struct_stack_st_X509}; + use super::boringssl::{SSL, SSL_CTX, X509, X509_STORE, X509_STORE_CTX, struct_stack_st_X509}; use core::ffi::{c_char, c_int, c_long, c_uint, c_void}; // Re-export the one decl whose `*const c_char` NUL-terminated arg keeps a @@ -177,6 +179,10 @@ pub(super) mod ffi { out_len: &mut c_uint, ); pub(crate) safe fn SSL_get_ex_data(ssl: &SSL, idx: c_int) -> *mut c_void; + /// Save/restore the per-loop BIO routing state around in-handshake JS + /// callbacks (defined in usockets' openssl.c). + pub(crate) safe fn us_internal_ssl_loop_state_save(ssl: &SSL, out5: *mut *mut c_void); + pub(crate) safe fn us_internal_ssl_loop_state_restore(saved5: *mut *mut c_void); pub(crate) safe fn SSL_renegotiate(ssl: &SSL) -> c_int; pub(crate) safe fn SSL_set_renegotiate_mode( ssl: &SSL, @@ -192,6 +198,30 @@ pub(super) mod ffi { pub(crate) safe fn SSL_set_ex_data(ssl: &SSL, idx: c_int, data: *mut c_void) -> c_int; // Returns the borrowed parent CTX (always non-null for a live `SSL*`). pub(crate) safe fn SSL_get_SSL_CTX(ssl: &SSL) -> *mut SSL_CTX; + // Swaps the cert/key/chain (and session-related state) this connection + // serves to those of `ctx`; takes its own reference to `ctx`. + pub(crate) fn SSL_set_SSL_CTX(ssl: *mut SSL, ctx: *mut SSL_CTX) -> *mut SSL_CTX; + // Apply `ctx`'s leaf certificate / private key / extra chain directly + // to the connection - SSL_set_SSL_CTX alone does not retarget the + // certificate once ClientHello processing has reached ALPN selection. + pub(crate) fn SSL_CTX_get0_certificate(ctx: *const SSL_CTX) -> *mut core::ffi::c_void; + pub(crate) fn SSL_CTX_get0_privatekey(ctx: *const SSL_CTX) -> *mut core::ffi::c_void; + pub(crate) fn SSL_use_certificate( + ssl: *mut SSL, + x509: *mut core::ffi::c_void, + ) -> core::ffi::c_int; + pub(crate) fn SSL_use_PrivateKey( + ssl: *mut SSL, + pkey: *mut core::ffi::c_void, + ) -> core::ffi::c_int; + pub(crate) fn SSL_CTX_get0_chain_certs( + ctx: *const SSL_CTX, + out_chain: *mut *mut core::ffi::c_void, + ) -> core::ffi::c_int; + pub(crate) fn SSL_set1_chain( + ssl: *mut SSL, + chain: *mut core::ffi::c_void, + ) -> core::ffi::c_int; // Atomic refcount bump on a live `SSL_CTX*`; opaque-ZST ref ⇒ no // caller-side precondition (route via `SSL_CTX::opaque_ref`). pub(crate) safe fn SSL_CTX_up_ref(ctx: &SSL_CTX) -> c_int; @@ -212,6 +242,35 @@ pub(super) mod ffi { >, arg: *mut c_void, ); + // Returns the borrowed cert store of a live `SSL_CTX*`. + pub(crate) safe fn SSL_CTX_get_cert_store(ctx: &SSL_CTX) -> *mut X509_STORE; + // Emptiness probe for a cert store: `get0_objects` borrows the + // object stack and `OPENSSL_sk_num(NULL)` returns 0. + pub(crate) fn X509_STORE_get0_objects(store: *mut X509_STORE) -> *mut c_void; + pub(crate) fn OPENSSL_sk_num(sk: *const c_void) -> usize; + // The process-wide default root store; up-refs before returning, so + // the caller owns a reference it must release with X509_STORE_free. + pub(crate) fn us_get_shared_default_ca_store() -> *mut X509_STORE; + pub(crate) fn X509_STORE_free(store: *mut X509_STORE); + // X509_STORE_CTX lifecycle for issuer lookups; `new` allocates, + // `init` borrows the store, `free` releases. Used to extend the peer + // certificate chain through the local trust store. + pub(crate) fn X509_STORE_CTX_new() -> *mut X509_STORE_CTX; + pub(crate) fn X509_STORE_CTX_init( + ctx: *mut X509_STORE_CTX, + store: *mut X509_STORE, + x509: *mut X509, + chain: *mut struct_stack_st_X509, + ) -> c_int; + pub(crate) fn X509_STORE_CTX_free(ctx: *mut X509_STORE_CTX); + // Writes a +1 X509 reference to `*issuer` on success (> 0). + pub(crate) fn X509_STORE_CTX_get1_issuer( + issuer: *mut *mut X509, + ctx: *mut X509_STORE_CTX, + x: *mut X509, + ) -> c_int; + // Returns X509_V_OK (0) when `issuer` could have issued `subject`. + pub(crate) fn X509_check_issued(issuer: *mut X509, subject: *mut X509) -> c_int; } } use crate::node::StringOrBuffer; @@ -452,8 +511,117 @@ pub(super) fn get_peer_certificate( return Ok(JSValue::UNDEFINED); } - // TODO: we need to support the non abbreviated version of this - Ok(JSValue::UNDEFINED) + // The detailed form returns the whole chain the peer presented, each + // certificate linking to its issuer through `issuerCertificate`, the way + // Node's getPeerCertificate(true) does. SSL_get_peer_cert_chain includes + // the leaf on the client side but not on the server side, where the +1 + // peer certificate above is the leaf instead. + let first_obj = X509::to_js(boringssl::X509::opaque_mut(first_cert), global)?; + // Link each certificate to its predecessor immediately so every object in + // the chain is reachable from the stack-rooted `first_obj` before the next + // `X509::to_js` allocation can trigger a GC - a heap-backed Vec + // is not stack-scanned. + let mut prev_obj: JSValue = first_obj; + let mut last_cert: *mut boringssl::X509 = first_cert; + if !cert_chain.is_null() { + let mut i: usize = if cert.is_null() { 1 } else { 0 }; + loop { + let next = + ffi::sk_X509_value(boringssl::struct_stack_st_X509::opaque_ref(cert_chain), i); + if next.is_null() { + break; + } + let obj = X509::to_js(boringssl::X509::opaque_mut(next), global)?; + prev_obj.put(global, b"issuerCertificate", obj); + prev_obj = obj; + last_cert = next; + i += 1; + } + } + + // Extend the chain through the local trust store until a self-issued + // certificate is reached, the way Node's getPeerCertificate(true) walks + // X509_STORE_CTX_get1_issuer to surface the root that completed + // verification even though the peer never sent it. + let mut last_is_self_issued = false; + // SAFETY: the store ctx is created, initialized against the live SSL_CTX's + // store, used only within this scope and freed before returning; every + // issuer returned by get1_issuer is a +1 reference collected in `extras` + // and released after its fields have been copied into JS values and the + // terminal self-issued check has run. + unsafe { + let mut store = ffi::SSL_CTX_get_cert_store(boringssl::SSL_CTX::opaque_ref( + ffi::SSL_get_SSL_CTX(boringssl::SSL::opaque_ref(ssl_ptr)), + )); + // A context built without an explicit `ca` (and without requestCert, + // which installs the shared roots) carries an empty store and the + // issuer walk would stop at whatever the peer sent. Fall back to the + // process-wide default roots the way Node's per-context store always + // contains the bundled roots. The getter up-refs, so the temporary + // reference is released after the walk. + let mut shared_store: *mut boringssl::X509_STORE = core::ptr::null_mut(); + if store.is_null() || ffi::OPENSSL_sk_num(ffi::X509_STORE_get0_objects(store)) == 0 { + shared_store = ffi::us_get_shared_default_ca_store(); + if !shared_store.is_null() { + store = shared_store; + } + } + let store_ctx = ffi::X509_STORE_CTX_new(); + if !store_ctx.is_null() { + if !store.is_null() + && ffi::X509_STORE_CTX_init( + store_ctx, + store, + core::ptr::null_mut(), + core::ptr::null_mut(), + ) == 1 + { + let mut extras: Vec<*mut boringssl::X509> = Vec::new(); + // Cap the walk so a cyclic store cannot loop forever. + while extras.len() < 16 && ffi::X509_check_issued(last_cert, last_cert) != 0 { + let mut issuer: *mut boringssl::X509 = core::ptr::null_mut(); + if ffi::X509_STORE_CTX_get1_issuer(&raw mut issuer, store_ctx, last_cert) <= 0 + || issuer.is_null() + { + break; + } + match X509::to_js(boringssl::X509::opaque_mut(issuer), global) { + Ok(obj) => { + prev_obj.put(global, b"issuerCertificate", obj); + prev_obj = obj; + } + Err(e) => { + boringssl::X509_free(issuer); + for extra in extras { + boringssl::X509_free(extra); + } + ffi::X509_STORE_CTX_free(store_ctx); + if !shared_store.is_null() { + ffi::X509_STORE_free(shared_store); + } + return Err(e); + } + } + extras.push(issuer); + last_cert = issuer; + } + last_is_self_issued = ffi::X509_check_issued(last_cert, last_cert) == 0; + for extra in extras { + boringssl::X509_free(extra); + } + } + ffi::X509_STORE_CTX_free(store_ctx); + } + if !shared_store.is_null() { + ffi::X509_STORE_free(shared_store); + } + } + + // A self-issued terminal certificate references itself, like Node. + if last_is_self_issued { + prev_obj.put(global, b"issuerCertificate", prev_obj); + } + Ok(first_obj) } pub(super) fn get_certificate( @@ -700,7 +868,56 @@ pub(super) fn get_tls_peer_finished_message( Ok(buffer) } -pub(super) fn export_keying_material( +/// `tlsSocket.setKeyCert(secureContext)` - serve this connection's identity +/// from the given context: SSL_set_SSL_CTX swaps the cert/key/chain used for +/// the rest of the handshake (Node calls it from ALPNCallback / SNICallback). +pub(crate) fn set_key_cert( + this: &This, + global: &JSGlobalObject, + frame: &CallFrame, +) -> JsResult { + if this.socket.get().is_detached() { + return Ok(JSValue::UNDEFINED); + } + let args = frame.arguments_old::<1>(); + if args.len < 1 { + return Err(global.throw(format_args!("setKeyCert requires a SecureContext"))); + } + let Some(sc) = SecureContext::from_js(args.ptr[0]) else { + return Err(global.throw(format_args!("setKeyCert requires a SecureContext"))); + }; + let Some(ssl_ptr) = this.socket.get().ssl() else { + return Ok(JSValue::UNDEFINED); + }; + // SAFETY: `sc` is a live SecureContext; borrow() hands back an owned + // reference and SSL_set_SSL_CTX takes its own, so release the temporary. + unsafe { + let ctx = (*sc).borrow(); + ffi::SSL_set_SSL_CTX(ssl_ptr.cast(), ctx.cast()); + // SSL_set_SSL_CTX stops retargeting the certificate once ClientHello + // processing has reached ALPN selection, and Node supports calling + // setKeyCert from ALPNCallback - apply the identity directly. + let leaf = ffi::SSL_CTX_get0_certificate(ctx.cast()); + let pkey = ffi::SSL_CTX_get0_privatekey(ctx.cast()); + if !leaf.is_null() && !pkey.is_null() { + let ok_cert = ffi::SSL_use_certificate(ssl_ptr.cast(), leaf); + let ok_key = ffi::SSL_use_PrivateKey(ssl_ptr.cast(), pkey); + let mut ok_chain = 1; + let mut chain: *mut core::ffi::c_void = core::ptr::null_mut(); + if ffi::SSL_CTX_get0_chain_certs(ctx.cast(), &raw mut chain) == 1 && !chain.is_null() { + ok_chain = ffi::SSL_set1_chain(ssl_ptr.cast(), chain); + } + if ok_cert != 1 || ok_key != 1 || ok_chain != 1 { + boringssl::SSL_CTX_free(ctx.cast()); + return Err(global.throw(format_args!("setKeyCert failed to apply the context"))); + } + } + boringssl::SSL_CTX_free(ctx.cast()); + } + Ok(JSValue::UNDEFINED) +} + +pub(crate) fn export_keying_material( this: &This, global: &JSGlobalObject, frame: &CallFrame, diff --git a/src/runtime/socket/uws_dispatch.rs b/src/runtime/socket/uws_dispatch.rs index b0a026fa044f..cc9b26214acb 100644 --- a/src/runtime/socket/uws_dispatch.rs +++ b/src/runtime/socket/uws_dispatch.rs @@ -207,10 +207,14 @@ pub(crate) unsafe extern "C" fn us_dispatch_ssl_raw_tap( // `twin` is `IntrusiveRc` (intrusive ref-counted heap pointer); // grab the raw `*mut` without consuming the ref so the +1 stays put. let raw: *mut TLSSocket = raw.as_ptr(); + // A negative length from the C side means there is nothing to deliver; + // never panic across the `extern "C"` boundary. + let Ok(len) = usize::try_from(len) else { + return s; + }; // SAFETY: `data` points to `len` readable bytes from the TLS BIO; loop.c // guarantees the buffer outlives this call. - let slice = - unsafe { core::slice::from_raw_parts(data, usize::try_from(len).expect("len >= 0")) }; + let slice = unsafe { core::slice::from_raw_parts(data, len) }; // Zig: `raw.onData(TLSSocket.Socket.from(s), data[..])` where // `Socket = uws.NewSocketHandler(ssl)`. SAFETY: `twin` holds a live +1 // ref to the `[raw, _]` half; dispatch is single-threaded so no aliasing @@ -220,4 +224,67 @@ pub(crate) unsafe extern "C" fn us_dispatch_ssl_raw_tap( s } +/// A new (resumable) TLS session is ready. BoringSSL's new-session callback +/// parks the serialized session while `SSL_read`/`SSL_do_handshake` runs; +/// `ssl_flush_pending_session()` dispatches it here once that stack has +/// unwound. Mirrors Node's `NewSessionCallback` → `onnewsession` flow. Only +/// `bun_socket_tls` sockets reach this. +/// +/// # Safety +/// `openssl.c` must pass a live, non-null `s` whose ext slot holds a valid +/// `*mut TLSSocket`, and `data` must point to `len` readable bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn us_dispatch_session(s: *mut us_socket_t, data: *const u8, len: c_int) { + let s_ref = us_socket_t::opaque_mut(s); + if s_ref.kind() != SocketKind::BunSocketTls { + return; + } + type TLSSocket = super::NewSocket; + let tls_ptr: *mut TLSSocket = *s_ref.ext::<*mut TLSSocket>(); + if tls_ptr.is_null() { + return; + } + // A negative length from the C side means there is nothing to deliver; + // never panic across the `extern "C"` boundary. + let Ok(len) = usize::try_from(len) else { + return; + }; + // SAFETY: `data` points to `len` readable bytes owned by the caller for the + // duration of this call. + let slice = unsafe { core::slice::from_raw_parts(data, len) }; + // SAFETY: ext slot for BunSocketTls holds a live *mut TLSSocket; dispatch is + // single-threaded. `on_session` takes `*mut Self` (noalias re-entrancy). + let _ = unsafe { TLSSocket::on_session(tls_ptr, slice) }; +} + +/// Hands an NSS key-log line parked by the keylog callback to the JS +/// `keylog` handler. +/// +/// # Safety +/// `openssl.c` must pass a live, non-null `s` whose ext slot holds a valid +/// `*mut TLSSocket`, and `data` must point to `len` readable bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn us_dispatch_keylog(s: *mut us_socket_t, data: *const u8, len: c_int) { + let s_ref = us_socket_t::opaque_mut(s); + if s_ref.kind() != SocketKind::BunSocketTls { + return; + } + type TLSSocket = super::NewSocket; + let tls_ptr: *mut TLSSocket = *s_ref.ext::<*mut TLSSocket>(); + if tls_ptr.is_null() { + return; + } + // A negative length from the C side means there is nothing to deliver; + // never panic across the `extern "C"` boundary. + let Ok(len) = usize::try_from(len) else { + return; + }; + // SAFETY: `data` points to `len` readable bytes owned by the caller for the + // duration of this call. + let slice = unsafe { core::slice::from_raw_parts(data, len) }; + // SAFETY: ext slot for BunSocketTls holds a live *mut TLSSocket; dispatch is + // single-threaded. `on_keylog` takes `*mut Self` (noalias re-entrancy). + let _ = unsafe { TLSSocket::on_keylog(tls_ptr, slice) }; +} + // ported from: src/runtime/socket/uws_dispatch.zig diff --git a/src/sql_jsc/mysql/MySQLConnection.rs b/src/sql_jsc/mysql/MySQLConnection.rs index 2f3a1b94f24b..12ec58d002c0 100644 --- a/src/sql_jsc/mysql/MySQLConnection.rs +++ b/src/sql_jsc/mysql/MySQLConnection.rs @@ -364,6 +364,7 @@ impl MySQLConnection { bun_uws::SocketKind::MysqlTls, ssl_ctx, sni, + true, // is_client ext_size, ext_size, ) else { diff --git a/src/sql_jsc/postgres/PostgresSQLConnection.rs b/src/sql_jsc/postgres/PostgresSQLConnection.rs index 1326ed303fb4..186c46f344ac 100644 --- a/src/sql_jsc/postgres/PostgresSQLConnection.rs +++ b/src/sql_jsc/postgres/PostgresSQLConnection.rs @@ -474,6 +474,7 @@ impl PostgresSQLConnection { bun_uws::SocketKind::PostgresTls, ssl_ctx, sni, + true, // is_client ext_size, ext_size, ) else { diff --git a/src/uws_sys/ListenSocket.rs b/src/uws_sys/ListenSocket.rs index 4fa67b02f912..e2a2e845625d 100644 --- a/src/uws_sys/ListenSocket.rs +++ b/src/uws_sys/ListenSocket.rs @@ -112,7 +112,10 @@ impl ListenSocket { NonNull::new(p.cast::()) } - pub fn on_server_name(&mut self, cb: extern "C" fn(*mut ListenSocket, *const c_char)) { + pub fn on_server_name( + &mut self, + cb: extern "C" fn(*mut ListenSocket, *const c_char) -> *mut c_void, + ) { us_listen_socket_on_server_name(self, cb) } } @@ -139,7 +142,7 @@ unsafe extern "C" { ) -> *mut c_void; safe fn us_listen_socket_on_server_name( ls: &mut ListenSocket, - cb: extern "C" fn(*mut ListenSocket, *const c_char), + cb: extern "C" fn(*mut ListenSocket, *const c_char) -> *mut c_void, ); } diff --git a/src/uws_sys/SocketContext.rs b/src/uws_sys/SocketContext.rs index 9878da7d98db..aa100579f10b 100644 --- a/src/uws_sys/SocketContext.rs +++ b/src/uws_sys/SocketContext.rs @@ -113,6 +113,8 @@ pub struct BunSocketContextOptions { pub ca: *const *const c_char, pub ca_count: u32, pub secure_options: u32, + pub ssl_min_version: i32, + pub ssl_max_version: i32, pub reject_unauthorized: i32, pub request_cert: i32, pub client_renegotiation_limit: u32, @@ -136,6 +138,8 @@ impl Default for BunSocketContextOptions { ca: ptr::null(), ca_count: 0, secure_options: 0, + ssl_min_version: 0, + ssl_max_version: 0, reject_unauthorized: 0, request_cert: 0, client_renegotiation_limit: 3, @@ -234,6 +238,8 @@ impl BunSocketContextOptions { feed_arr(&mut h, self.cert, self.cert_count); feed_arr(&mut h, self.ca, self.ca_count); h.update(bun_core::bytes_of(&self.secure_options)); + h.update(bun_core::bytes_of(&self.ssl_min_version)); + h.update(bun_core::bytes_of(&self.ssl_max_version)); h.update(bun_core::bytes_of(&self.reject_unauthorized)); h.update(bun_core::bytes_of(&self.request_cert)); h.update(bun_core::bytes_of(&self.client_renegotiation_limit)); @@ -306,6 +312,27 @@ pub mod c { ) -> *mut SSL_CTX; // safe: no args; reads a process-global counter — no preconditions. pub safe fn us_ssl_ctx_live_count() -> c_long; + /// Appends the certificates in the NUL-terminated PEM `content` to + /// `ctx`'s trust store; returns 0 when nothing could be added. + pub fn us_ssl_ctx_add_ca_cert( + ctx: *mut SSL_CTX, + content: *const core::ffi::c_char, + ) -> core::ffi::c_int; + /// Parses a PKCS#12 blob into malloc'd PEM key/cert/ca strings (the + /// caller frees them with libc free); returns 0 with a static + /// `err_reason` tag on failure. + pub fn us_ssl_parse_pkcs12( + data: *const core::ffi::c_char, + len: usize, + pass: *const core::ffi::c_char, + out_key: *mut *mut core::ffi::c_char, + out_key_len: *mut usize, + out_cert: *mut *mut core::ffi::c_char, + out_cert_len: *mut usize, + out_ca: *mut *mut core::ffi::c_char, + out_ca_len: *mut usize, + err_reason: *mut *const core::ffi::c_char, + ) -> core::ffi::c_int; } } diff --git a/src/uws_sys/SocketGroup.rs b/src/uws_sys/SocketGroup.rs index c597800defaf..f5bbaf006f77 100644 --- a/src/uws_sys/SocketGroup.rs +++ b/src/uws_sys/SocketGroup.rs @@ -219,6 +219,7 @@ impl SocketGroup { ssl_ctx: Option<*mut SslCtx>, host: &core::ffi::CStr, port: c_int, + local_binding: Option<(&core::ffi::CStr, u16)>, options: c_int, socket_ext_size: c_int, ) -> ConnectResult { @@ -235,6 +236,8 @@ impl SocketGroup { ssl_ctx.unwrap_or(ptr::null_mut()), host.as_ptr(), port, + local_binding.map_or(ptr::null(), |(h, _)| h.as_ptr()), + local_binding.map_or(0, |(_, p)| c_int::from(p)), options, socket_ext_size, &raw mut has_dns_resolved, @@ -352,6 +355,8 @@ unsafe extern "C" { ssl_ctx: *mut SslCtx, host: *const c_char, port: c_int, + local_host: *const c_char, + local_port: c_int, options: c_int, socket_ext_size: c_int, is_connecting: *mut c_int, diff --git a/src/uws_sys/SocketKind.rs b/src/uws_sys/SocketKind.rs index 8ef12d9cc603..0881d168da1e 100644 --- a/src/uws_sys/SocketKind.rs +++ b/src/uws_sys/SocketKind.rs @@ -136,5 +136,9 @@ pub(crate) static BUN_SOCKET_KIND_UWS_HTTP_TLS: u8 = SocketKind::UwsHttpTls as u pub(crate) static BUN_SOCKET_KIND_UWS_WS: u8 = SocketKind::UwsWs as u8; #[unsafe(no_mangle)] pub(crate) static BUN_SOCKET_KIND_UWS_WS_TLS: u8 = SocketKind::UwsWsTls as u8; +/// Referenced from `openssl.c` so the new-session callback's per-SSL marker is +/// only set for the sockets that actually surface the `'session'` event. +#[unsafe(no_mangle)] +pub(crate) static BUN_SOCKET_KIND_BUN_SOCKET_TLS: u8 = SocketKind::BunSocketTls as u8; // ported from: src/uws_sys/SocketKind.zig diff --git a/src/uws_sys/socket.rs b/src/uws_sys/socket.rs index 9ad6723baab1..e07801b37f31 100644 --- a/src/uws_sys/socket.rs +++ b/src/uws_sys/socket.rs @@ -259,6 +259,17 @@ impl NewSocketHandler { // ── state queries ─────────────────────────────────────────────────────── + /// Raw-TCP write that also reports a fatal send error; non-Connected and + /// TLS-wrapped sockets fall back to the plain write (no fatal signal). + pub fn write_check_error(&self, data: &[u8]) -> (i32, bool) { + on_socket!(self.socket; + connected s => s.write_check_error(data), + duplex d => (d.encode_and_write(data), false), + pipe p => (p.encode_and_write(data), false), + else => (0, false), + ) + } + pub fn is_closed(&self) -> bool { on_socket!(self.socket; connected s => s.is_closed(), @@ -713,7 +724,7 @@ impl NewSocketHandler { // discriminant-first), which would hand the trampoline `1` instead of // the owner pointer. let ext_size = size_of::>>() as c_int; - match g.connect(kind, ssl_ctx, host_z, port, opts, ext_size) { + match g.connect(kind, ssl_ctx, host_z, port, None, opts, ext_size) { ConnectResult::Failed => Err(ConnectError::FailedToOpenSocket), ConnectResult::Socket(s) => { *sock(s).ext::>>() = NonNull::new(owner); diff --git a/src/uws_sys/us_socket_t.rs b/src/uws_sys/us_socket_t.rs index b248e911e732..6a8fc86b460b 100644 --- a/src/uws_sys/us_socket_t.rs +++ b/src/uws_sys/us_socket_t.rs @@ -90,6 +90,23 @@ impl us_socket_t { c::us_socket_is_closed(self) > 0 } + /// Write that also reports a fatal (non-would-block) send error so the + /// node:net path can fail the pending write instead of waiting forever. + pub fn write_check_error(&self, data: &[u8]) -> (i32, bool) { + let mut fatal: i32 = 0; + // SAFETY: `self` is a live `us_socket_t`; `data` is valid for its length + // (clamped to i32) and `fatal` outlives the call as the out-parameter. + let written = unsafe { + c::us_socket_write_check_error( + self, + data.as_ptr().cast(), + i32::try_from(data.len().min(MAX_I32)).expect("int cast"), + &raw mut fatal, + ) + }; + (written, fatal != 0) + } + pub fn is_shutdown(&self) -> bool { c::us_socket_is_shut_down(self) > 0 } @@ -243,6 +260,7 @@ impl us_socket_t { k: SocketKind, ssl_ctx: &mut SslCtx, sni: Option<&core::ffi::CStr>, + is_client: bool, old_ext: i32, new_ext: i32, ) -> Option> { @@ -255,6 +273,7 @@ impl us_socket_t { k as u8, ssl_ctx, sni.map_or(ptr::null(), |s| s.as_ptr()), + is_client as i32, old_ext, new_ext, )) @@ -267,6 +286,29 @@ impl us_socket_t { c::us_socket_start_tls_handshake(self); } + /// Feed bytes that were already read off the wire (e.g. a ClientHello the + /// plain-TCP layer consumed before the upgrade) through the same decrypt + /// path as bytes arriving from the kernel. + pub fn tls_feed(&mut self, data: &[u8]) { + if data.is_empty() { + return; + } + // The C side takes an `int` length: feed in i32-sized chunks instead of + // truncating the cast (a clamp would silently drop the tail and there is + // no return value to report a partial feed). Each chunk can re-enter the + // data dispatch, which may close the socket — stop feeding once it does. + for chunk in data.chunks(MAX_I32) { + if self.is_closed() { + return; + } + // SAFETY: `self` is a live TLS `us_socket_t`; `chunk` is valid for its + // length, which fits in an i32 by construction. + unsafe { + c::us_socket_tls_feed(self, chunk.as_ptr().cast(), chunk.len() as i32); + } + } + } + /// Tee inbound ciphertext to `us_dispatch_ssl_raw_tap` before `SSL_read` /// consumes it, so the `[raw, tls]` pair from `upgradeTLS` can surface /// encrypted bytes to the original net.Socket `data` listener. @@ -466,6 +508,12 @@ mod c { ) -> *mut us_socket_t; pub(super) safe fn us_socket_shutdown(s: &mut us_socket_t); pub(super) safe fn us_socket_is_closed(s: &us_socket_t) -> i32; + pub(super) fn us_socket_write_check_error( + s: &us_socket_t, + data: *const core::ffi::c_char, + length: i32, + fatal_write_error: *mut i32, + ) -> i32; pub(super) safe fn us_socket_shutdown_read(s: &mut us_socket_t); pub(super) safe fn us_socket_is_shut_down(s: &us_socket_t) -> i32; pub(super) safe fn us_socket_sendfile_needs_more(socket: &mut us_socket_t); @@ -488,9 +536,16 @@ mod c { kind: u8, ssl_ctx: *mut SslCtx, sni: *const c_char, + is_client: i32, old_ext_size: i32, ext_size: i32, ) -> *mut us_socket_t; + /// Feed already-read bytes through the TLS decrypt path. + pub(super) fn us_socket_tls_feed( + s: *mut us_socket_t, + data: *const c_char, + length: i32, + ) -> *mut us_socket_t; pub(super) safe fn us_socket_start_tls_handshake(s: &mut us_socket_t); } } diff --git a/test/expectations.txt b/test/expectations.txt index 3dffda28abe6..e19cbbb11d15 100644 --- a/test/expectations.txt +++ b/test/expectations.txt @@ -53,3 +53,38 @@ test/js/bun/spawn/spawn-maxbuf.test.ts [ FLAKY ] [ ASAN ] test/cli/run/require-cache.test.ts [ LEAK ] # files transpiled and loaded don't leak file paths > via require() [ ASAN ] test/js/bun/http/req-url-leak.test.ts [ LEAK ] # req.url doesn't leak memory [ ASAN ] test/js/bun/io/bun-write-leak.test.ts [ LEAK ] # Bun.write should not leak the output data + +# Windows-only gaps in named-pipe / socket teardown for ported Node net tests +# (these pass on Linux and macOS): half-close (FIN) handling on named pipes, +# RST delivery surfacing as ECONNRESET, and EADDRINUSE on a second listen on +# the same pipe path. +[ WINDOWS ] test/js/node/test/parallel/test-net-pingpong.js [ FAIL ] # named-pipe half-close (FIN) handling +[ WINDOWS ] test/js/node/test/parallel/test-net-socket-reset-send.js [ FAIL ] # reset not surfaced as ECONNRESET on Windows +[ WINDOWS ] test/js/node/test/parallel/test-net-connect-reset-after-destroy.js [ FAIL ] # reset not surfaced as ECONNRESET on Windows +[ WINDOWS ] test/js/node/test/parallel/test-net-connect-reset-until-connected.js [ FAIL ] # reset not surfaced as ECONNRESET on Windows +[ WINDOWS ] test/js/node/test/parallel/test-net-pipe-connect-errors.js [ FAIL ] # named-pipe connect errors are not mapped to ENOENT/EACCES on Windows yet +[ WINDOWS ] test/js/node/test/parallel/test-net-server-listen-path.js [ FAIL ] # EADDRINUSE not reported for a second listen on the same pipe path + +# Same Windows half-close + client-RST gap over a node:http server: the server +# half-closes (res.socket.end()) mid-upload, the fetch client RSTs the cut-short +# 10 MB POST body, and that RST is not surfaced to the server's still-open +# readable side on Windows, so the accepted connection never ends and +# server.close() (via `await using`) waits forever. The assertion itself passes; +# only the teardown hangs. Passes on Linux and macOS. +[ WINDOWS ] test/js/bun/test/parallel/test-http-should-not-emit-or-throw-error-when-writing-after-socket.end.ts [ FAIL ] # half-close + client RST not surfaced on Windows; server.close() waits forever + +# The 10 MB socket write in this test is an order of magnitude slower under +# AddressSanitizer and exceeds the per-test timeout; it passes on regular builds. +[ ASAN ] test/js/node/test/parallel/test-net-error-twice.js [ SKIP ] # ASAN-instrumented 10 MB write exceeds the timeout + +# The localAddress/localPort bind-before-connect and the SO_ERROR read on a +# connecting socket that was reset during establishment are implemented in the +# POSIX (kqueue/epoll + BSD socket) connect path; Windows connects through +# libuv and needs its own implementation of both. +[ WINDOWS ] test/js/node/test/parallel/test-net-client-bind-twice.js [ FAIL ] # localAddress/localPort binding not implemented for the libuv connect path +[ WINDOWS ] test/js/node/test/parallel/test-net-server-reset.js [ FAIL ] # connect-time RST reports ECONNREFUSED instead of ECONNRESET on the libuv path +# Cluster workers sharing a listen port behave differently on Linux, where +# SO_REUSEPORT load-balances across the workers' own listeners instead of the +# primary distributing accepted connections; the upstream test's expectations +# only hold on the distributing model. Passes on macOS, Windows and FreeBSD. +[ LINUX ] test/js/node/test/sequential/test-net-listen-shared-ports.js [ FAIL ] # SO_REUSEPORT shared-listener semantics on Linux diff --git a/test/integration/bun-types/fixture/serve-types.test.ts b/test/integration/bun-types/fixture/serve-types.test.ts index 90af82d39291..358eeeaa1e5c 100644 --- a/test/integration/bun-types/fixture/serve-types.test.ts +++ b/test/integration/bun-types/fixture/serve-types.test.ts @@ -101,7 +101,7 @@ test( }, { onConstructorFailure: error => { - expect(error.message).toContain("BoringSSL error:0900006e:PEM routines:OPENSSL_internal:NO_START_LINE"); + expect(error.message).toContain("error:0900006e:PEM routines:OPENSSL_internal:NO_START_LINE"); }, }, ); diff --git a/test/js/bun/net/socket.test.ts b/test/js/bun/net/socket.test.ts index ead9e8d856e9..6181b9cea90b 100644 --- a/test/js/bun/net/socket.test.ts +++ b/test/js/bun/net/socket.test.ts @@ -637,7 +637,7 @@ describe.concurrent("socket", () => { }), ).toThrow( expect.objectContaining({ - code: "ERR_BORINGSSL", + code: "ERR_OSSL_PEM_NO_START_LINE", }), ); @@ -651,7 +651,7 @@ describe.concurrent("socket", () => { }), ).toThrow( expect.objectContaining({ - code: "ERR_BORINGSSL", + code: "ERR_OSSL_PEM_NO_START_LINE", }), ); @@ -666,7 +666,7 @@ describe.concurrent("socket", () => { }), ).toThrow( expect.objectContaining({ - code: "ERR_BORINGSSL", + code: "ERR_OSSL_PEM_NO_START_LINE", }), ); diff --git a/test/js/node/http2/node-http2.test.js b/test/js/node/http2/node-http2.test.js index 84b0ee3cd448..2137ad095d20 100644 --- a/test/js/node/http2/node-http2.test.js +++ b/test/js/node/http2/node-http2.test.js @@ -1,4 +1,4 @@ -import { bunEnv, bunExe, isASAN, isCI, nodeExe } from "harness"; +import { bunEnv, bunExe, isASAN, isCI, isDebug, nodeExe } from "harness"; import { createTest } from "node-harness"; import fs from "node:fs"; import http2 from "node:http2"; @@ -10,7 +10,10 @@ import { Duplex } from "stream"; import http2utils from "./helpers"; import { nodeEchoServer, TLS_CERT, TLS_OPTIONS } from "./http2-helpers"; const { describe, expect, it, beforeAll, afterAll, createCallCheckCtx } = createTest(import.meta.path); -const ASAN_MULTIPLIER = isASAN ? 3 : 1; +// bun-debug ships with ASAN but isn't named bun-asan, so isASAN is false +// there; the 10k-request maxSessionMemory stress test takes ~90s under +// debug+ASAN vs ~2s release, so scale for either. +const ASAN_MULTIPLIER = isDebug ? 10 : isASAN ? 3 : 1; function invalidArgTypeHelper(input) { if (input === null) return " Received null"; @@ -1594,7 +1597,7 @@ it("http2 session.goaway() validates input types", async done => { // Test opaqueData argument expect(() => session.goaway(0, 0, input)).toThrow( - 'The "opaqueData" argument must be of type Buffer, ' + `TypedArray, or DataView.${received}`, + 'The "opaqueData" argument must be an instance of Buffer, ' + `TypedArray, or DataView.${received}`, ); } diff --git a/test/js/node/net/node-net.test.ts b/test/js/node/net/node-net.test.ts index 27a15e38ab22..ae14db8e7b44 100644 --- a/test/js/node/net/node-net.test.ts +++ b/test/js/node/net/node-net.test.ts @@ -671,6 +671,9 @@ it("should not hang after destroy", async () => { const net = require("node:net"); const { promise: listening, resolve: resolveListening, reject } = Promise.withResolvers(); const server = net.createServer(c => { + // The client destroys without reading; the resulting RST surfaces as + // ECONNRESET here (Node behaves identically) — handle it. + c.on("error", () => {}); c.write("Hello client"); }); try { diff --git a/test/js/node/test/common/boringssl.js b/test/js/node/test/common/boringssl.js new file mode 100644 index 000000000000..46e0738d596b --- /dev/null +++ b/test/js/node/test/common/boringssl.js @@ -0,0 +1,346 @@ +/* eslint-disable node-core/crypto-check */ + +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const fixtures = require('../common/fixtures'); +const tls = require('tls'); + +// This module is for BoringSSL-specific branches in tests whose original +// OpenSSL coverage cannot run unchanged. Each helper should assert the +// observable BoringSSL behavior that explains why the OpenSSL-specific +// assertions are bypassed. + +/** + * BoringSSL exposes many removed or disabled TLS cipher suites as "no match" + * at secure-context creation time. This is used for suites such as + * finite-field DHE and anonymous ECDH that OpenSSL builds may still negotiate + * in tests. + * @param {Function} fn + */ +function assertNoCipherMatch(fn) { + // Only the code is asserted: the OpenSSL-style decomposition (library/ + // function/reason casing) differs between the native handshake path and the + // JS cipher validation path that produces this error. + assert.throws(fn, { + code: 'ERR_SSL_NO_CIPHER_MATCH', + }); +} + +/** + * BoringSSL does not parse OpenSSL cipher-string commands such as `@SECLEVEL`. + * Those are OpenSSL policy directives, not cipher names. + * @param {Function} fn + */ +function assertInvalidCommand(fn) { + assert.throws(fn, { + code: 'ERR_SSL_INVALID_COMMAND', + library: 'SSL routines', + function: 'OPENSSL_internal', + reason: 'INVALID_COMMAND', + }); +} + +/** + * Node's DHE tests exercise OpenSSL's finite-field DHE cipher support and DH + * parameter-size policy. BoringSSL does not offer these DHE cipher suites on + * this surface, so creating a server context with a DHE-only cipher list fails + * before a handshake can test DH parameter behavior. + */ +function assertFiniteFieldDheUnsupported() { + assertNoCipherMatch(() => { + tls.createServer({ + key: fixtures.readKey('agent2-key.pem'), + cert: fixtures.readKey('agent2-cert.pem'), + ciphers: 'DHE-RSA-AES128-GCM-SHA256', + }); + }); +} + +/** + * OpenSSL security levels reject small keys by policy and can be adjusted with + * `@SECLEVEL` in the cipher string. BoringSSL does not implement those security + * levels: the small-key server context is accepted, while the OpenSSL-specific + * `@SECLEVEL` command is rejected as invalid cipher-string syntax. + */ +function assertOpenSSLSecurityLevelsUnsupported() { + const options = { + key: fixtures.readKey('agent11-key.pem'), + cert: fixtures.readKey('agent11-cert.pem'), + ciphers: 'DEFAULT', + }; + + tls.createServer(options).close(); + + options.ciphers = 'DEFAULT:@SECLEVEL=0'; + assertInvalidCommand(() => tls.createServer(options)); +} + +/** + * Node's multi-key tests rely on OpenSSL accepting an array of private keys and + * matching them with an array of certificates. BoringSSL rejects this mixed + * EC/RSA identity configuration while configuring the certificate chain, before + * a client can negotiate either identity. + */ +function assertMultiKeyUnsupported() { + assert.throws(() => { + tls.createServer({ + key: [ + fixtures.readKey('ec10-key.pem'), + fixtures.readKey('agent1-key.pem'), + ], + cert: [ + fixtures.readKey('agent1-cert.pem'), + fixtures.readKey('ec10-cert.pem'), + ], + }); + }, { + code: 'ERR_OSSL_X509_KEY_TYPE_MISMATCH', + library: 'X.509 certificate routines', + function: 'OPENSSL_internal', + reason: 'KEY_TYPE_MISMATCH', + }); +} + +/** + * BoringSSL does not support caller-initiated renegotiation. Even on a TLS 1.2 + * connection, TLSSocket#renegotiate() returns false and the callback receives + * Node's BoringSSL-specific unsupported-renegotiation error instead of + * entering the native binding or exercising Node's renegotiation-limit logic. + */ +function testRenegotiationUnsupported() { + const server = tls.createServer({ + key: fixtures.readKey('rsa_private.pem'), + cert: fixtures.readKey('rsa_cert.crt'), + maxVersion: 'TLSv1.2', + }, (socket) => socket.resume()); + + server.listen(0, common.mustCall(() => { + const client = tls.connect({ + port: server.address().port, + rejectUnauthorized: false, + maxVersion: 'TLSv1.2', + }, common.mustCall(() => { + const ok = client.renegotiate({}, common.mustCall((err) => { + assert.throws(() => { throw err; }, { + code: 'ERR_TLS_RENEGOTIATION_UNSUPPORTED', + message: 'TLS session renegotiation is unsupported by this TLS ' + + 'implementation', + }); + client.destroy(); + server.close(); + })); + assert.strictEqual(ok, false); + })); + client.on('error', common.mustNotCall()); + })); +} + +/** + * OpenSSL exposes the negotiated ephemeral key type, name, and size for TLS + * clients. With BoringSSL the same ECDHE TLS 1.2 handshake succeeds, but + * getEphemeralKeyInfo() returns null on the server side and an object whose + * fields are undefined on the client side. + */ +function testEphemeralKeyInfoUnsupported() { + const server = tls.createServer({ + key: fixtures.readKey('agent2-key.pem'), + cert: fixtures.readKey('agent2-cert.pem'), + ciphers: 'ECDHE-RSA-AES256-GCM-SHA384', + ecdhCurve: 'prime256v1', + maxVersion: 'TLSv1.2', + }, common.mustCall((socket) => { + assert.strictEqual(socket.getEphemeralKeyInfo(), null); + socket.end(); + })); + + server.listen(0, common.mustCall(() => { + const client = tls.connect({ + port: server.address().port, + rejectUnauthorized: false, + maxVersion: 'TLSv1.2', + }, common.mustCall(() => { + assert.deepStrictEqual(client.getEphemeralKeyInfo(), { + type: undefined, + name: undefined, + size: undefined, + }); + server.close(); + })); + })); +} + +/** + * The protocol matrix tests cover OpenSSL behavior for legacy TLS protocols. + * For BoringSSL we only need to exhibit that a TLSv1-only client cannot connect + * to a server whose minimum protocol is TLS 1.2; the client receives the + * protocol-version alert instead of the OpenSSL version-specific matrix. + */ +function testLegacyProtocolUnsupported() { + const server = tls.createServer({ + key: fixtures.readKey('agent2-key.pem'), + cert: fixtures.readKey('agent2-cert.pem'), + minVersion: 'TLSv1.2', + }, common.mustNotCall()); + + server.on('tlsClientError', common.mustCall()); + server.listen(0, common.mustCall(() => { + const client = tls.connect({ + port: server.address().port, + rejectUnauthorized: false, + secureProtocol: 'TLSv1_method', + }, common.mustNotCall()); + client.on('error', common.mustCall((err) => { + assert.strictEqual(err.code, 'ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION'); + server.close(); + })); + })); +} + +/** + * BoringSSL can load a multi-PFX option well enough to serve the ECDSA + * identity, but it does not provide the same OpenSSL multi-identity selection + * behavior. After the ECDSA handshake succeeds, an RSA-only client fails with + * no shared cipher instead of selecting the RSA identity from the same PFX list. + */ +function testMultiPfxSelectionDifference() { + const server = tls.createServer({ + pfx: [ + { + buf: fixtures.readKey('agent1.pfx'), + passphrase: 'sample', + }, + fixtures.readKey('ec.pfx'), + ], + }, common.mustCallAtLeast((socket) => socket.end(), 1)); + + server.listen(0, common.mustCall(() => { + const ecdsa = tls.connect(server.address().port, { + ciphers: 'ECDHE-ECDSA-AES256-GCM-SHA384', + maxVersion: 'TLSv1.2', + rejectUnauthorized: false, + }, common.mustCall(() => { + assert.strictEqual(ecdsa.getCipher().name, + 'ECDHE-ECDSA-AES256-GCM-SHA384'); + ecdsa.end(); + + server.once('tlsClientError', common.mustCall((err) => { + assert.strictEqual(err.code, 'ERR_SSL_NO_SHARED_CIPHER'); + })); + const rsa = tls.connect(server.address().port, { + ciphers: 'ECDHE-RSA-AES256-GCM-SHA384', + maxVersion: 'TLSv1.2', + rejectUnauthorized: false, + }, common.mustNotCall()); + rsa.on('error', common.mustCall((err) => { + assert.strictEqual(err.code, 'ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE'); + server.close(); + })); + })); + })); +} + +/** + * PSK works for TLS 1.2 in BoringSSL, but Node's PSK tests also cover the + * default TLS 1.3 path. In that path BoringSSL does not complete a certificate- + * less PSK-only handshake through Node's current server setup: the server + * reports NO_CERTIFICATE_SET and the client receives an internal-error alert. + */ +function testPskTls13Unsupported() { + const key = Buffer.from('d731ef57be09e5204f0b205b60627028', 'hex'); + let gotClientError = false; + let gotServerError = false; + function maybeClose(server) { + if (gotClientError && gotServerError) + server.close(); + } + + const server = tls.createServer({ + ciphers: 'PSK+HIGH', + pskCallback() { return key; }, + }, common.mustNotCall()); + + server.once('tlsClientError', common.mustCall((err) => { + assert.strictEqual(err.code, 'ERR_SSL_NO_CERTIFICATE_SET'); + gotServerError = true; + maybeClose(server); + })); + + server.listen(0, common.mustCall(() => { + const client = tls.connect({ + port: server.address().port, + ciphers: 'PSK+HIGH', + checkServerIdentity() {}, + pskCallback() { + return { psk: key, identity: 'TestUser' }; + }, + }, common.mustNotCall()); + client.on('error', common.mustCall((err) => { + assert.strictEqual(err.code, 'ERR_SSL_TLSV1_ALERT_INTERNAL_ERROR'); + gotClientError = true; + maybeClose(server); + })); + })); +} + +/** + * The OpenSSL ticket tests assume that once a TLS 1.3 session is reused, the + * client will not necessarily receive a replacement session event before close. + * BoringSSL emits new session tickets on both the initial and resumed TLS 1.3 + * connections, so the resumed connection still emits at least one 'session' + * event while isSessionReused() is true. + */ +function testTls13SessionTicketSemanticsDiffer() { + const server = tls.createServer({ + key: fixtures.readKey('agent1-key.pem'), + cert: fixtures.readKey('agent1-cert.pem'), + }, (socket) => socket.end()); + + let session; + let secondSessionEvents = 0; + + server.listen(0, common.mustCall(() => { + const first = tls.connect({ + port: server.address().port, + rejectUnauthorized: false, + }, common.mustCall(() => { + assert.strictEqual(first.isSessionReused(), false); + })); + first.on('session', common.mustCallAtLeast((sess) => { + session = sess; + }, 1)); + first.on('close', common.mustCall(() => { + assert(Buffer.isBuffer(session)); + + const second = tls.connect({ + port: server.address().port, + rejectUnauthorized: false, + session, + }, common.mustCall(() => { + assert.strictEqual(second.isSessionReused(), true); + })); + second.on('session', common.mustCallAtLeast(() => { + secondSessionEvents++; + }, 1)); + second.on('close', common.mustCall(() => { + assert(secondSessionEvents > 0); + server.close(); + })); + second.resume(); + })); + first.resume(); + })); +} + +module.exports = { + assertFiniteFieldDheUnsupported, + assertMultiKeyUnsupported, + assertNoCipherMatch, + assertOpenSSLSecurityLevelsUnsupported, + testEphemeralKeyInfoUnsupported, + testLegacyProtocolUnsupported, + testMultiPfxSelectionDifference, + testPskTls13Unsupported, + testRenegotiationUnsupported, + testTls13SessionTicketSemanticsDiffer, +}; diff --git a/test/js/node/test/common/index.js b/test/js/node/test/common/index.js index 0106c03453a1..213f8a802f7b 100644 --- a/test/js/node/test/common/index.js +++ b/test/js/node/test/common/index.js @@ -185,7 +185,7 @@ const isPi = (() => { const isDumbTerminal = process.env.TERM === 'dumb'; // When using high concurrency or in the CI we need much more time for each connection attempt -net.setDefaultAutoSelectFamilyAttemptTimeout(platformTimeout(net.getDefaultAutoSelectFamilyAttemptTimeout() * 10)); +net.setDefaultAutoSelectFamilyAttemptTimeout(platformTimeout(net.getDefaultAutoSelectFamilyAttemptTimeout() * 5)); const defaultAutoSelectFamilyAttemptTimeout = net.getDefaultAutoSelectFamilyAttemptTimeout(); const buildType = process.config.target_defaults ? diff --git a/test/js/node/test/common/tls.js b/test/js/node/test/common/tls.js index bbc37df19c55..8568659d13cc 100644 --- a/test/js/node/test/common/tls.js +++ b/test/js/node/test/common/tls.js @@ -186,4 +186,46 @@ exports.assertIsCAArray = function assertIsCAArray(certs) { } }; +function extractMetadata(cert) { + const x509 = new crypto.X509Certificate(cert); + return { + serialNumber: x509.serialNumber, + issuer: x509.issuer, + subject: x509.subject, + }; +} +exports.extractMetadata = extractMetadata; + +// To compare two certificates, we can just compare serialNumber, issuer, +// and subject like X509_comp(). We can't just compare two strings because +// the line endings or order of the fields may differ after PEM serdes by +// OpenSSL. +exports.assertEqualCerts = function assertEqualCerts(a, b) { + const setA = new Set(a.map(extractMetadata)); + const setB = new Set(b.map(extractMetadata)); + assert.deepStrictEqual(setA, setB); +}; + +exports.includesCert = function includesCert(certs, cert) { + const metadata = extractMetadata(cert); + for (const c of certs) { + const cMetadata = extractMetadata(c); + if (cMetadata.serialNumber === metadata.serialNumber && + cMetadata.issuer === metadata.issuer && + cMetadata.subject === metadata.subject) { + return true; + } + } + return false; +}; + exports.TestTLSSocket = TestTLSSocket; + +// Dumps certs into a file to pass safely into test/fixtures/list-certs.js +exports.writeCerts = function writeCerts(certs, filename) { + const fs = require('fs'); + for (const cert of certs) { + const x509 = new crypto.X509Certificate(cert); + fs.appendFileSync(filename, x509.toString()); + } +}; diff --git a/test/js/node/test/fixtures/list-certs.js b/test/js/node/test/fixtures/list-certs.js new file mode 100644 index 000000000000..cebb9eb5627d --- /dev/null +++ b/test/js/node/test/fixtures/list-certs.js @@ -0,0 +1,19 @@ +const assert = require('assert'); +const EXPECTED_CERTS_PATH = process.env.EXPECTED_CERTS_PATH; +let expectedCerts = []; +if (EXPECTED_CERTS_PATH) { + const fs = require('fs'); + const file = fs.readFileSync(EXPECTED_CERTS_PATH, 'utf-8'); + expectedCerts = file.split('-----END CERTIFICATE-----\n') + .filter(line => line.trim() !== '') + .map(line => line + '-----END CERTIFICATE-----\n'); +} + +const tls = require('tls'); +const { includesCert, extractMetadata } = require('../common/tls'); + +const CERTS_TYPE = process.env.CERTS_TYPE || 'default'; +const actualCerts = tls.getCACertificates(CERTS_TYPE); +for (const cert of expectedCerts) { + assert(includesCert(actualCerts, cert), 'Expected certificate not found: ' + JSON.stringify(extractMetadata(cert))); +} diff --git a/test/js/node/test/fixtures/tls-extra-ca-override.js b/test/js/node/test/fixtures/tls-extra-ca-override.js new file mode 100644 index 000000000000..9d7065ba4f24 --- /dev/null +++ b/test/js/node/test/fixtures/tls-extra-ca-override.js @@ -0,0 +1,50 @@ +'use strict'; + +// Test script for overidding NODE_EXTRA_CA_CERTS with tls.setDefaultCACertificates(). + +const tls = require('tls'); +const assert = require('assert'); +const { assertEqualCerts, includesCert } = require('../common/tls'); + +// Assert that NODE_EXTRA_CA_CERTS is set +assert(process.env.NODE_EXTRA_CA_CERTS, 'NODE_EXTRA_CA_CERTS environment variable should be set'); + +// Get initial state with extra CA +const initialDefaults = tls.getCACertificates('default'); +const systemCerts = tls.getCACertificates('system'); +const bundledCerts = tls.getCACertificates('bundled'); +const extraCerts = tls.getCACertificates('extra'); + +// For this test to work the extra certs must not be in bundled certs +assert.notStrictEqual(bundledCerts.length, 0); +for (const cert of extraCerts) { + assert(!includesCert(bundledCerts, cert)); +} + +// Test setting it to initial defaults. +tls.setDefaultCACertificates(initialDefaults); +assertEqualCerts(tls.getCACertificates('default'), initialDefaults); +assertEqualCerts(tls.getCACertificates('default'), initialDefaults); + +// Test setting it to the bundled certificates. +tls.setDefaultCACertificates(bundledCerts); +assertEqualCerts(tls.getCACertificates('default'), bundledCerts); +assertEqualCerts(tls.getCACertificates('default'), bundledCerts); + +// Test setting it to just the extra certificates. +tls.setDefaultCACertificates(extraCerts); +assertEqualCerts(tls.getCACertificates('default'), extraCerts); +assertEqualCerts(tls.getCACertificates('default'), extraCerts); + +// Test setting it to an empty array. +tls.setDefaultCACertificates([]); +assert.deepStrictEqual(tls.getCACertificates('default'), []); + +// Test bundled and extra certs are unaffected +assertEqualCerts(tls.getCACertificates('bundled'), bundledCerts); +assertEqualCerts(tls.getCACertificates('extra'), extraCerts); + +if (systemCerts.length > 0) { + // Test system certs are unaffected. + assertEqualCerts(tls.getCACertificates('system'), systemCerts); +} diff --git a/test/js/node/test/fixtures/tls-get-ca-certificates-worker.js b/test/js/node/test/fixtures/tls-get-ca-certificates-worker.js new file mode 100644 index 000000000000..1d05fcacac54 --- /dev/null +++ b/test/js/node/test/fixtures/tls-get-ca-certificates-worker.js @@ -0,0 +1,10 @@ +'use strict'; + +const tls = require('tls'); +const { parentPort } = require('worker_threads'); + +parentPort.postMessage({ + bundledLen: tls.getCACertificates('bundled').length, + systemLen: tls.getCACertificates('system').length, + defaultLen: tls.getCACertificates('default').length, +}); diff --git a/test/js/node/test/parallel/test-http-server-reject-chunked-with-content-length.js b/test/js/node/test/parallel/test-http-server-reject-chunked-with-content-length.js deleted file mode 100644 index d7e2e7df88ee..000000000000 --- a/test/js/node/test/parallel/test-http-server-reject-chunked-with-content-length.js +++ /dev/null @@ -1,30 +0,0 @@ -'use strict'; - -const common = require('../common'); -const http = require('http'); -const net = require('net'); -const assert = require('assert'); - -const reqstr = 'POST / HTTP/1.1\r\n' + - 'Host: localhost\r\n' + - 'Content-Length: 1\r\n' + - 'Transfer-Encoding: chunked\r\n\r\n'; - -const server = http.createServer(common.mustNotCall()); -server.on('clientError', common.mustCall((err) => { - assert.match(err.message, /^Parse Error/); - assert.strictEqual(err.code, 'HPE_INVALID_TRANSFER_ENCODING'); - server.close(); -})); -server.listen(0, () => { - const client = net.connect({ port: server.address().port }, () => { - client.write(reqstr); - client.end(); - }); - client.on('data', (data) => { - // Should not get to this point because the server should simply - // close the connection without returning any data. - assert.fail('no data should be returned by the server'); - }); - client.on('end', common.mustCall()); -}); diff --git a/test/js/node/test/parallel/test-http2-server-shutdown-options-errors.js b/test/js/node/test/parallel/test-http2-server-shutdown-options-errors.js index 364b43b36b1e..5a2ca62a6c8e 100644 --- a/test/js/node/test/parallel/test-http2-server-shutdown-options-errors.js +++ b/test/js/node/test/parallel/test-http2-server-shutdown-options-errors.js @@ -44,11 +44,12 @@ server.on('stream', common.mustCall((stream) => { { code: 'ERR_INVALID_ARG_TYPE', name: 'TypeError', - message: 'The "opaqueData" argument must be of type Buffer, ' + + message: 'The "opaqueData" argument must be an instance of Buffer, ' + `TypedArray, or DataView.${received}` } ); } + stream.session.destroy(); })); diff --git a/test/js/node/test/parallel/test-net-allow-half-open.js b/test/js/node/test/parallel/test-net-allow-half-open.js new file mode 100644 index 000000000000..c7f829a986e4 --- /dev/null +++ b/test/js/node/test/parallel/test-net-allow-half-open.js @@ -0,0 +1,47 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); + +{ + const server = net.createServer(common.mustCall((socket) => { + socket.end(Buffer.alloc(1024)); + })).listen(0, common.mustCall(() => { + const socket = net.connect(server.address().port); + assert.strictEqual(socket.allowHalfOpen, false); + socket.resume(); + socket.on('end', common.mustCall(() => { + process.nextTick(() => { + // Ensure socket is not destroyed straight away + // without proper shutdown. + assert(!socket.destroyed); + server.close(); + }); + })); + socket.on('finish', common.mustCall(() => { + assert(!socket.destroyed); + })); + socket.on('close', common.mustCall()); + })); +} + +{ + const server = net.createServer(common.mustCall((socket) => { + socket.end(Buffer.alloc(1024)); + })).listen(0, common.mustCall(() => { + const socket = net.connect(server.address().port); + assert.strictEqual(socket.allowHalfOpen, false); + socket.resume(); + socket.on('end', common.mustCall(() => { + assert(!socket.destroyed); + })); + socket.end('asd'); + socket.on('finish', common.mustCall(() => { + assert(!socket.destroyed); + })); + socket.on('close', common.mustCall(() => { + server.close(); + })); + })); +} diff --git a/test/js/node/test/parallel/test-net-autoselectfamily-attempt-timeout-cli-option.js b/test/js/node/test/parallel/test-net-autoselectfamily-attempt-timeout-cli-option.js new file mode 100644 index 000000000000..cf177e968ce7 --- /dev/null +++ b/test/js/node/test/parallel/test-net-autoselectfamily-attempt-timeout-cli-option.js @@ -0,0 +1,10 @@ +'use strict'; + +// Flags: --network-family-autoselection-attempt-timeout=123 + +const { platformTimeout } = require('../common'); + +const assert = require('assert'); +const { getDefaultAutoSelectFamilyAttemptTimeout } = require('net'); + +assert.strictEqual(getDefaultAutoSelectFamilyAttemptTimeout(), platformTimeout(123 * 5)); diff --git a/test/js/node/test/parallel/test-net-autoselectfamily-commandline-option.js b/test/js/node/test/parallel/test-net-autoselectfamily-commandline-option.js new file mode 100644 index 000000000000..54627a1266ed --- /dev/null +++ b/test/js/node/test/parallel/test-net-autoselectfamily-commandline-option.js @@ -0,0 +1,48 @@ +'use strict'; + +// Flags: --no-network-family-autoselection + +const common = require('../common'); +const { createMockedLookup } = require('../common/dns'); + +const assert = require('assert'); +const { createConnection, createServer } = require('net'); + +// Test that IPV4 is NOT reached if IPV6 is not reachable and the option has been disabled via command line +{ + const ipv4Server = createServer(common.mustCallAtLeast((socket) => { + socket.on('data', common.mustCall(() => { + socket.write('response-ipv4'); + socket.end(); + })); + }, 0)); + + ipv4Server.listen(0, '127.0.0.1', common.mustCall(() => { + const port = ipv4Server.address().port; + + const connection = createConnection({ + host: 'example.org', + port, + lookup: createMockedLookup('::1', '127.0.0.1'), + }); + + connection.on('ready', common.mustNotCall()); + connection.on('error', common.mustCall((error) => { + assert.strictEqual(connection.autoSelectFamilyAttemptedAddresses, undefined); + + if (common.hasIPv6) { + assert.strictEqual(error.code, 'ECONNREFUSED'); + assert.strictEqual(error.message, `connect ECONNREFUSED ::1:${port}`); + } else if (error.code === 'EAFNOSUPPORT') { + assert.strictEqual(error.message, `connect EAFNOSUPPORT ::1:${port} - Local (undefined:undefined)`); + } else if (error.code === 'EUNATCH') { + assert.strictEqual(error.message, `connect EUNATCH ::1:${port} - Local (:::0)`); + } else { + assert.strictEqual(error.code, 'EADDRNOTAVAIL'); + assert.strictEqual(error.message, `connect EADDRNOTAVAIL ::1:${port} - Local (:::0)`); + } + + ipv4Server.close(); + })); + })); +} diff --git a/test/js/node/test/parallel/test-net-autoselectfamily.js b/test/js/node/test/parallel/test-net-autoselectfamily.js new file mode 100644 index 000000000000..0fdac23a6f6e --- /dev/null +++ b/test/js/node/test/parallel/test-net-autoselectfamily.js @@ -0,0 +1,223 @@ +'use strict'; + +const common = require('../common'); +const { createMockedLookup } = require('../common/dns'); + +const assert = require('assert'); +const { createConnection, createServer } = require('net'); + +// Test that happy eyeballs algorithm is properly implemented. + +// Purposely not using setDefaultAutoSelectFamilyAttemptTimeout here to test the +// parameter is correctly used in options. + +// Some of the machines in the CI need more time to establish connection +const autoSelectFamilyAttemptTimeout = common.defaultAutoSelectFamilyAttemptTimeout; + +// Test that IPV4 is reached if IPV6 is not reachable +{ + const ipv4Server = createServer(common.mustCall((socket) => { + socket.on('data', common.mustCall(() => { + socket.write('response-ipv4'); + socket.end(); + })); + })); + + ipv4Server.listen(0, '127.0.0.1', common.mustCall(() => { + const port = ipv4Server.address().port; + + const connection = createConnection({ + host: 'example.org', + port: port, + lookup: createMockedLookup('::1', '127.0.0.1'), + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout, + }); + + let response = ''; + connection.setEncoding('utf-8'); + + connection.on('ready', common.mustCall(() => { + assert.deepStrictEqual(connection.autoSelectFamilyAttemptedAddresses, [`::1:${port}`, `127.0.0.1:${port}`]); + })); + + connection.on('data', (chunk) => { + response += chunk; + }); + + connection.on('end', common.mustCall(() => { + assert.strictEqual(response, 'response-ipv4'); + ipv4Server.close(); + })); + + connection.write('request'); + })); +} + +// Test that only the last successful connection is established. +{ + const ipv4Server = createServer(common.mustCall((socket) => { + socket.on('data', common.mustCall(() => { + socket.write('response-ipv4'); + socket.end(); + })); + })); + + ipv4Server.listen(0, '127.0.0.1', common.mustCall(() => { + const port = ipv4Server.address().port; + + const connection = createConnection({ + host: 'example.org', + port: port, + lookup: createMockedLookup( + '2606:4700::6810:85e5', '2606:4700::6810:84e5', '::1', + '104.20.22.46', '104.20.23.46', '127.0.0.1', + ), + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout, + }); + + let response = ''; + connection.setEncoding('utf-8'); + + connection.on('ready', common.mustCall(() => { + assert.deepStrictEqual( + connection.autoSelectFamilyAttemptedAddresses, + [ + `2606:4700::6810:85e5:${port}`, + `104.20.22.46:${port}`, + `2606:4700::6810:84e5:${port}`, + `104.20.23.46:${port}`, + `::1:${port}`, + `127.0.0.1:${port}`, + ] + ); + })); + + connection.on('data', (chunk) => { + response += chunk; + }); + + connection.on('end', common.mustCall(() => { + assert.strictEqual(response, 'response-ipv4'); + ipv4Server.close(); + })); + + connection.write('request'); + })); +} + +// Test that IPV4 is NOT reached if IPV6 is reachable +if (common.hasIPv6) { + const ipv4Server = createServer((socket) => { + socket.on('data', common.mustNotCall(() => { + socket.write('response-ipv4'); + socket.end(); + })); + }); + + const ipv6Server = createServer(common.mustCall((socket) => { + socket.on('data', common.mustCall(() => { + socket.write('response-ipv6'); + socket.end(); + })); + })); + + ipv4Server.listen(0, '127.0.0.1', common.mustCall(() => { + const port = ipv4Server.address().port; + + ipv6Server.listen(port, '::1', common.mustCall(() => { + const connection = createConnection({ + host: 'example.org', + port, + lookup: createMockedLookup('::1', '127.0.0.1'), + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout, + }); + + let response = ''; + connection.setEncoding('utf-8'); + + connection.on('ready', common.mustCall(() => { + assert.deepStrictEqual(connection.autoSelectFamilyAttemptedAddresses, [`::1:${port}`]); + })); + + connection.on('data', (chunk) => { + response += chunk; + }); + + connection.on('end', common.mustCall(() => { + assert.strictEqual(response, 'response-ipv6'); + ipv4Server.close(); + ipv6Server.close(); + })); + + connection.write('request'); + })); + })); +} + +// Test that when all errors are returned when no connections succeeded +{ + const connection = createConnection({ + host: 'example.org', + port: 10, + lookup: createMockedLookup('::1', '127.0.0.1'), + autoSelectFamily: true, + autoSelectFamilyAttemptTimeout, + }); + + connection.on('ready', common.mustNotCall()); + connection.on('error', common.mustCall((error) => { + assert.deepStrictEqual(connection.autoSelectFamilyAttemptedAddresses, ['::1:10', '127.0.0.1:10']); + assert.strictEqual(error.constructor.name, 'AggregateError'); + assert.strictEqual(error.errors.length, 2); + + const errors = error.errors.map((e) => e.message); + assert.ok(errors.includes('connect ECONNREFUSED 127.0.0.1:10')); + + if (common.hasIPv6) { + assert.ok(errors.includes('connect ECONNREFUSED ::1:10')); + } + })); +} + +// Test that the option can be disabled +{ + const ipv4Server = createServer(common.mustCallAtLeast((socket) => { + socket.on('data', common.mustCall(() => { + socket.write('response-ipv4'); + socket.end(); + })); + }, 0)); + + ipv4Server.listen(0, '127.0.0.1', common.mustCall(() => { + const port = ipv4Server.address().port; + + const connection = createConnection({ + host: 'example.org', + port, + lookup: createMockedLookup('::1', '127.0.0.1'), + autoSelectFamily: false, + }); + + connection.on('ready', common.mustNotCall()); + connection.on('error', common.mustCall((error) => { + assert.strictEqual(connection.autoSelectFamilyAttemptedAddresses, undefined); + + if (common.hasIPv6) { + assert.strictEqual(error.code, 'ECONNREFUSED'); + assert.strictEqual(error.message, `connect ECONNREFUSED ::1:${port}`); + } else if (error.code === 'EAFNOSUPPORT') { + assert.strictEqual(error.message, `connect EAFNOSUPPORT ::1:${port} - Local (undefined:undefined)`); + } else if (error.code === 'EUNATCH') { + assert.strictEqual(error.message, `connect EUNATCH ::1:${port} - Local (:::0)`); + } else { + assert.strictEqual(error.code, 'EADDRNOTAVAIL'); + assert.strictEqual(error.message, `connect EADDRNOTAVAIL ::1:${port} - Local (:::0)`); + } + + ipv4Server.close(); + })); + })); +} diff --git a/test/js/node/test/parallel/test-net-binary.js b/test/js/node/test/parallel/test-net-binary.js new file mode 100644 index 000000000000..cf8715411d0f --- /dev/null +++ b/test/js/node/test/parallel/test-net-binary.js @@ -0,0 +1,88 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +/* eslint-disable strict */ +require('../common'); +const assert = require('assert'); +const net = require('net'); + +let binaryString = ''; +for (let i = 255; i >= 0; i--) { + const s = `'\\${i.toString(8)}'`; + const S = eval(s); + assert.strictEqual(S.charCodeAt(0), i); + assert.strictEqual(S, String.fromCharCode(i)); + binaryString += S; +} + +// safe constructor +const echoServer = net.Server(function(connection) { + connection.setEncoding('latin1'); + connection.on('data', function(chunk) { + connection.write(chunk, 'latin1'); + }); + connection.on('end', function() { + connection.end(); + }); +}); +echoServer.listen(0); + +let recv = ''; + +echoServer.on('listening', function() { + let j = 0; + const c = net.createConnection({ + port: this.address().port + }); + + c.setEncoding('latin1'); + c.on('data', function(chunk) { + const n = j + chunk.length; + while (j < n && j < 256) { + c.write(String.fromCharCode(j), 'latin1'); + j++; + } + if (j === 256) { + c.end(); + } + recv += chunk; + }); + + c.on('connect', function() { + c.write(binaryString, 'binary'); + }); + + c.on('close', function() { + echoServer.close(); + }); +}); + +process.on('exit', function() { + assert.strictEqual(recv.length, 2 * 256); + + const a = recv.split(''); + + const first = a.slice(0, 256).reverse().join(''); + + const second = a.slice(256, 2 * 256).join(''); + + assert.strictEqual(first, second); +}); diff --git a/test/js/node/test/parallel/test-net-bytes-read.js b/test/js/node/test/parallel/test-net-bytes-read.js new file mode 100644 index 000000000000..d569d78403e1 --- /dev/null +++ b/test/js/node/test/parallel/test-net-bytes-read.js @@ -0,0 +1,47 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); + +const big = Buffer.alloc(1024 * 1024); + +const handler = common.mustCall((socket) => { + socket.end(big); + server.close(); +}); + +const onListen = common.mustCall(() => { + let prev = 0; + + function checkRaise(value) { + assert(value > prev); + prev = value; + } + + const onData = common.mustCallAtLeast((chunk) => { + checkRaise(socket.bytesRead); + }); + + const onEnd = common.mustCall(() => { + assert.strictEqual(socket.bytesRead, prev); + assert.strictEqual(big.length, prev); + }); + + const onClose = common.mustCall(() => { + assert(!socket._handle); + assert.strictEqual(socket.bytesRead, prev); + assert.strictEqual(big.length, prev); + }); + + const onConnect = common.mustCall(() => { + socket.on('data', onData); + socket.on('end', onEnd); + socket.on('close', onClose); + socket.end(); + }); + + const socket = net.connect(server.address().port, onConnect); +}); + +const server = net.createServer(handler).listen(0, onListen); diff --git a/test/js/node/test/parallel/test-net-bytes-stats.js b/test/js/node/test/parallel/test-net-bytes-stats.js new file mode 100644 index 000000000000..40fa13d415fe --- /dev/null +++ b/test/js/node/test/parallel/test-net-bytes-stats.js @@ -0,0 +1,78 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +require('../common'); +const assert = require('assert'); +const net = require('net'); + +let bytesRead = 0; +let bytesWritten = 0; +let count = 0; + +const tcp = net.Server(function(s) { + console.log('tcp server connection'); + + // trigger old mode. + s.resume(); + + s.on('end', function() { + bytesRead += s.bytesRead; + console.log(`tcp socket disconnect #${count}`); + }); +}); + +tcp.listen(0, function doTest() { + console.error('listening'); + const socket = net.createConnection(this.address().port); + + socket.on('connect', function() { + count++; + console.error('CLIENT connect #%d', count); + + socket.write('foo', function() { + console.error('CLIENT: write cb'); + socket.end('bar'); + }); + }); + + socket.on('finish', function() { + bytesWritten += socket.bytesWritten; + console.error('CLIENT end event #%d', count); + }); + + socket.on('close', function() { + console.error('CLIENT close event #%d', count); + console.log(`Bytes read: ${bytesRead}`); + console.log(`Bytes written: ${bytesWritten}`); + if (count < 2) { + console.error('RECONNECTING'); + socket.connect(tcp.address().port); + } else { + tcp.close(); + } + }); +}); + +process.on('exit', function() { + assert.strictEqual(bytesRead, 12); + assert.strictEqual(bytesWritten, 12); +}); diff --git a/test/js/node/test/parallel/test-net-client-bind-twice.js b/test/js/node/test/parallel/test-net-client-bind-twice.js new file mode 100644 index 000000000000..ca7eb502d85b --- /dev/null +++ b/test/js/node/test/parallel/test-net-client-bind-twice.js @@ -0,0 +1,26 @@ +'use strict'; + +// This tests that net.connect() from a used local port throws EADDRINUSE. + +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); + +const server1 = net.createServer(common.mustNotCall()); +server1.listen(0, common.localhostIPv4, common.mustCall(() => { + const server2 = net.createServer(common.mustNotCall()); + server2.listen(0, common.localhostIPv4, common.mustCall(() => { + const client = net.connect({ + host: common.localhostIPv4, + port: server1.address().port, + localAddress: common.localhostIPv4, + localPort: server2.address().port + }, common.mustNotCall()); + + client.on('error', common.mustCall((err) => { + assert.strictEqual(err.code, 'EADDRINUSE'); + server1.close(); + server2.close(); + })); + })); +})); diff --git a/test/js/node/test/parallel/test-net-connect-memleak.js b/test/js/node/test/parallel/test-net-connect-memleak.js new file mode 100644 index 000000000000..de925f5d08c4 --- /dev/null +++ b/test/js/node/test/parallel/test-net-connect-memleak.js @@ -0,0 +1,58 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +// Flags: --expose-gc + +const common = require('../common'); +const { onGC } = require('../common/gc'); +const assert = require('assert'); +const net = require('net'); + +// Test that the implicit listener for an 'connect' event on net.Sockets is +// added using `once()`, i.e. can be gc'ed once that event has occurred. + +const server = net.createServer(common.mustCall()).listen(0); + +let collected = false; +const gcListener = { ongc() { collected = true; } }; + +{ + const gcObject = {}; + onGC(gcObject, gcListener); + + const sock = net.createConnection( + server.address().port, + common.mustCall(() => { + assert.strictEqual(gcObject, gcObject); // Keep reference alive + assert.strictEqual(collected, false); + setImmediate(done, sock); + })); +} + +function done(sock) { + globalThis.gc(); + setImmediate(common.mustCall(() => { + assert.strictEqual(collected, true); + sock.end(); + server.close(); + })); +} diff --git a/test/js/node/test/parallel/test-net-connect-options-allowhalfopen.js b/test/js/node/test/parallel/test-net-connect-options-allowhalfopen.js new file mode 100644 index 000000000000..26f1ace52e09 --- /dev/null +++ b/test/js/node/test/parallel/test-net-connect-options-allowhalfopen.js @@ -0,0 +1,118 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); + +// Test allowHalfOpen +{ + let clientReceivedFIN = 0; + let serverConnections = 0; + let clientSentFIN = 0; + let serverReceivedFIN = 0; + const host = common.localhostIPv4; + + function serverOnConnection(socket) { + console.log(`'connection' ${++serverConnections} emitted on server`); + const srvConn = serverConnections; + socket.resume(); + socket.on('data', common.mustCall(function socketOnData(data) { + this.clientId = data.toString(); + console.log( + `server connection ${srvConn} is started by client ${this.clientId}`); + })); + // 'end' on each socket must not be emitted twice + socket.on('end', common.mustCall(function socketOnEnd() { + console.log(`Server received FIN sent by client ${this.clientId}`); + if (++serverReceivedFIN < CLIENT_VARIANTS) return; + setTimeout(() => { + server.close(); + console.log(`connection ${this.clientId} is closing the server: + FIN ${serverReceivedFIN} received by server, + FIN ${clientReceivedFIN} received by client + FIN ${clientSentFIN} sent by client, + FIN ${serverConnections} sent by server`.replace(/ {3,}/g, '')); + }, 50); + }, 1)); + socket.end(); + console.log(`Server has sent ${serverConnections} FIN`); + } + + // These two levels of functions (and not arrows) are necessary in order to + // bind the `index`, and the calling socket (`this`) + function clientOnConnect(index) { + return common.mustCall(function clientOnConnectInner() { + const client = this; + console.log(`'connect' emitted on Client ${index}`); + client.resume(); + client.on('end', common.mustCall(function clientOnEnd() { + setTimeout(common.mustCall(() => { + // When allowHalfOpen is true, client must still be writable + // after the server closes the connections, but not readable + console.log(`client ${index} received FIN`); + assert(!client.readable); + assert(client.writable); + assert(client.write(String(index))); + client.end(); + clientSentFIN++; + console.log( + `client ${index} sent FIN, ${clientSentFIN} have been sent`); + }), 50); + })); + client.on('close', common.mustCall(function clientOnClose() { + clientReceivedFIN++; + console.log(`connection ${index} has been closed by both sides,` + + ` ${clientReceivedFIN} clients have closed`); + })); + }); + } + + function serverOnClose() { + console.log(`Server has been closed: + FIN ${serverReceivedFIN} received by server + FIN ${clientReceivedFIN} received by client + FIN ${clientSentFIN} sent by client + FIN ${serverConnections} sent by server`.replace(/ {3,}/g, '')); + } + + function serverOnListen() { + const port = server.address().port; + console.log(`Server started listening at ${host}:${port}`); + const opts = { allowHalfOpen: true, host, port }; + // 6 variations === CLIENT_VARIANTS + net.connect(opts, clientOnConnect(1)); + net.connect(opts).on('connect', clientOnConnect(2)); + net.createConnection(opts, clientOnConnect(3)); + net.createConnection(opts).on('connect', clientOnConnect(4)); + new net.Socket(opts).connect(opts, clientOnConnect(5)); + new net.Socket(opts).connect(opts).on('connect', clientOnConnect(6)); + } + + const CLIENT_VARIANTS = 6; + + // The trigger + const server = net.createServer({ allowHalfOpen: true }) + .on('connection', common.mustCall(serverOnConnection, CLIENT_VARIANTS)) + .on('close', common.mustCall(serverOnClose)) + .listen(0, host, common.mustCall(serverOnListen)); +} diff --git a/test/js/node/test/parallel/test-net-connect-paused-connection.js b/test/js/node/test/parallel/test-net-connect-paused-connection.js new file mode 100644 index 000000000000..801bba1cf5b2 --- /dev/null +++ b/test/js/node/test/parallel/test-net-connect-paused-connection.js @@ -0,0 +1,33 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); + +const net = require('net'); + +net.createServer(function(conn) { + conn.unref(); +}).listen(0, common.mustCall(function() { + net.connect(this.address().port, 'localhost').pause(); + + setTimeout(common.mustNotCall('expected to exit'), 1000).unref(); +})).unref(); diff --git a/test/js/node/test/parallel/test-net-connect-reset-after-destroy.js b/test/js/node/test/parallel/test-net-connect-reset-after-destroy.js new file mode 100644 index 000000000000..89e459229ab1 --- /dev/null +++ b/test/js/node/test/parallel/test-net-connect-reset-after-destroy.js @@ -0,0 +1,29 @@ +'use strict'; +const common = require('../common'); +const net = require('net'); +const assert = require('assert'); + +const server = net.createServer(); +server.listen(0, common.mustCall(function() { + const port = server.address().port; + const conn = net.createConnection(port); + server.on('connection', (socket) => { + socket.on('error', common.expectsError({ + code: 'ECONNRESET', + message: 'read ECONNRESET', + name: 'Error' + })); + }); + + conn.on('connect', common.mustCall(function() { + assert.strictEqual(conn, conn.resetAndDestroy().destroy()); + conn.on('error', common.mustNotCall()); + + conn.write(Buffer.from('fzfzfzfzfz'), common.expectsError({ + code: 'ERR_STREAM_DESTROYED', + message: 'Cannot call write after a stream was destroyed', + name: 'Error' + })); + server.close(); + })); +})); diff --git a/test/js/node/test/parallel/test-net-connect-reset-until-connected.js b/test/js/node/test/parallel/test-net-connect-reset-until-connected.js new file mode 100644 index 000000000000..9c2493eaaf05 --- /dev/null +++ b/test/js/node/test/parallel/test-net-connect-reset-until-connected.js @@ -0,0 +1,29 @@ +'use strict'; + +const common = require('../common'); +const net = require('net'); + +function barrier(count, cb) { + return function() { + if (--count === 0) + cb(); + }; +} + +const server = net.createServer(); +server.listen(0, common.mustCall(function() { + const port = server.address().port; + const conn = net.createConnection(port); + const connok = barrier(2, () => conn.resetAndDestroy()); + conn.on('close', common.mustCall()); + server.on('connection', (socket) => { + connok(); + socket.on('error', common.expectsError({ + code: 'ECONNRESET', + message: 'read ECONNRESET', + name: 'Error' + })); + server.close(); + }); + conn.on('connect', connok); +})); diff --git a/test/js/node/test/parallel/test-net-end-destroyed.js b/test/js/node/test/parallel/test-net-end-destroyed.js new file mode 100644 index 000000000000..1670c1b92b21 --- /dev/null +++ b/test/js/node/test/parallel/test-net-end-destroyed.js @@ -0,0 +1,26 @@ +'use strict'; + +const common = require('../common'); +const net = require('net'); +const assert = require('assert'); + +const server = net.createServer(); + +server.on('connection', common.mustCall()); + +// Ensure that the socket is not destroyed when the 'end' event is emitted. + +server.listen(common.mustCall(function() { + const socket = net.createConnection({ + port: server.address().port + }); + + socket.on('connect', common.mustCall(function() { + socket.on('end', common.mustCall(function() { + assert.strictEqual(socket.destroyed, false); + server.close(); + })); + + socket.end(); + })); +})); diff --git a/test/js/node/test/parallel/test-net-error-twice.js b/test/js/node/test/parallel/test-net-error-twice.js new file mode 100644 index 000000000000..b26b825d16c1 --- /dev/null +++ b/test/js/node/test/parallel/test-net-error-twice.js @@ -0,0 +1,63 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +require('../common'); +const assert = require('assert'); +const net = require('net'); + +const buf = Buffer.alloc(10 * 1024 * 1024, 0x62); + +const errs = []; +let clientSocket; +let serverSocket; + +function ready() { + if (clientSocket && serverSocket) { + clientSocket.destroy(); + serverSocket.write(buf); + } +} + +const server = net.createServer(function onConnection(conn) { + conn.on('error', function(err) { + errs.push(err); + if (errs.length > 1 && errs[0] === errs[1]) + assert.fail('Should not emit the same error twice'); + }); + conn.on('close', function() { + server.unref(); + }); + serverSocket = conn; + ready(); +}).listen(0, function() { + const client = net.connect({ port: this.address().port }); + + client.on('connect', function() { + clientSocket = client; + ready(); + }); +}); + +process.on('exit', function() { + console.log(errs); + assert.strictEqual(errs.length, 1); +}); diff --git a/test/js/node/test/parallel/test-net-large-string.js b/test/js/node/test/parallel/test-net-large-string.js new file mode 100644 index 000000000000..93c0d41612a1 --- /dev/null +++ b/test/js/node/test/parallel/test-net-large-string.js @@ -0,0 +1,51 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); + +const kPoolSize = 40 * 1024; +const data = 'あ'.repeat(kPoolSize); +const encoding = 'UTF-8'; + +const server = net.createServer(common.mustCall(function(socket) { + let receivedSize = 0; + + socket.setEncoding(encoding); + socket.on('data', function(data) { + receivedSize += data.length; + }); + socket.on('end', common.mustCall(function() { + assert.strictEqual(receivedSize, kPoolSize); + socket.end(); + })); +})); + +server.listen(0, function() { + const client = net.createConnection(this.address().port); + client.on('end', function() { + server.close(); + }); + client.write(data, encoding); + client.end(); +}); diff --git a/test/js/node/test/parallel/test-net-pause-resume-connecting.js b/test/js/node/test/parallel/test-net-pause-resume-connecting.js new file mode 100644 index 000000000000..920522b76045 --- /dev/null +++ b/test/js/node/test/parallel/test-net-pause-resume-connecting.js @@ -0,0 +1,95 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); + +let connections = 0; +let dataEvents = 0; +let conn; + + +// Server +const server = net.createServer(function(conn) { + connections++; + conn.end('This was the year he fell to pieces.'); + + if (connections === 5) + server.close(); +}); + +server.listen(0, function() { + // Client 1 + conn = net.createConnection(this.address().port, 'localhost'); + conn.resume(); + conn.on('data', onDataOk); + + + // Client 2 + conn = net.createConnection(this.address().port, 'localhost'); + conn.pause(); + conn.resume(); + conn.on('data', onDataOk); + + + // Client 3 + conn = net.createConnection(this.address().port, 'localhost'); + conn.pause(); + conn.on('data', common.mustNotCall()); + scheduleTearDown(conn); + + + // Client 4 + conn = net.createConnection(this.address().port, 'localhost'); + conn.resume(); + conn.pause(); + conn.resume(); + conn.on('data', onDataOk); + + + // Client 5 + conn = net.createConnection(this.address().port, 'localhost'); + conn.resume(); + conn.resume(); + conn.pause(); + conn.on('data', common.mustNotCall()); + scheduleTearDown(conn); + + function onDataOk() { + dataEvents++; + } + + function scheduleTearDown(conn) { + setTimeout(function() { + conn.removeAllListeners('data'); + conn.resume(); + }, 100); + } +}); + + +// Exit sanity checks +process.on('exit', function() { + assert.strictEqual(connections, 5); + assert.strictEqual(dataEvents, 3); +}); diff --git a/test/js/node/test/parallel/test-net-perf_hooks.js b/test/js/node/test/parallel/test-net-perf_hooks.js new file mode 100644 index 000000000000..06b88ed7e7e7 --- /dev/null +++ b/test/js/node/test/parallel/test-net-perf_hooks.js @@ -0,0 +1,60 @@ +'use strict'; + +const common = require('../common'); +const tmpdir = require('../common/tmpdir'); +const assert = require('assert'); +const net = require('net'); + +tmpdir.refresh(); + +const { PerformanceObserver } = require('perf_hooks'); + +const entries = []; + +const obs = new PerformanceObserver(common.mustCallAtLeast((items) => { + entries.push(...items.getEntries()); +})); + +obs.observe({ type: 'net' }); + +{ + const server = net.createServer(common.mustCall((socket) => { + socket.destroy(); + })); + + server.listen(0, common.mustCall(async () => { + await new Promise((resolve, reject) => { + const socket = net.connect(server.address().port); + socket.on('end', resolve); + socket.on('error', reject); + }); + server.close(); + })); +} + +{ + const server = net.createServer(common.mustCall((socket) => { + socket.destroy(); + })); + + server.listen(common.PIPE, common.mustCall(async () => { + await new Promise((resolve, reject) => { + const socket = net.connect(common.PIPE); + socket.on('end', resolve); + socket.on('error', reject); + }); + server.close(); + })); +} + +process.on('exit', () => { + assert.strictEqual(entries.length, 1); + for (const entry of entries) { + assert.strictEqual(entry.name, 'connect'); + assert.strictEqual(entry.entryType, 'net'); + assert.strictEqual(typeof entry.startTime, 'number'); + assert.strictEqual(typeof entry.duration, 'number'); + assert.strictEqual(!!entry.detail.host, true); + assert.strictEqual(!!entry.detail.port, true); + } +}); diff --git a/test/js/node/test/parallel/test-net-pingpong.js b/test/js/node/test/parallel/test-net-pingpong.js new file mode 100644 index 000000000000..3bbe076b4b16 --- /dev/null +++ b/test/js/node/test/parallel/test-net-pingpong.js @@ -0,0 +1,133 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); + +function pingPongTest(port, host) { + const N = 1000; + let count = 0; + let sentPongs = 0; + let sent_final_ping = false; + + const server = net.createServer( + { allowHalfOpen: true }, + common.mustCall(onSocket) + ); + + function onSocket(socket) { + assert.strictEqual(socket.server, server); + assert.strictEqual( + server, + server.getConnections(common.mustSucceed((connections) => { + assert.strictEqual(connections, 1); + })) + ); + + socket.setNoDelay(); + socket.timeout = 0; + + socket.setEncoding('utf8'); + socket.on('data', common.mustCall(function(data) { + // Since we never queue data (we're always waiting for the PING + // before sending a pong) the writeQueueSize should always be less + // than one message. + assert.ok(socket.bufferSize >= 0 && socket.bufferSize <= 4); + + assert.strictEqual(socket.writable, true); + assert.strictEqual(socket.readable, true); + assert.ok(count <= N); + assert.strictEqual(data, 'PING'); + + socket.write('PONG', common.mustCall(function() { + sentPongs++; + })); + }, N + 1)); + + socket.on('end', common.mustCall(function() { + assert.strictEqual(socket.allowHalfOpen, true); + assert.strictEqual(socket.writable, true); // Because allowHalfOpen + assert.strictEqual(socket.readable, false); + socket.end(); + })); + + socket.on('error', common.mustNotCall()); + + socket.on('close', common.mustCall(function() { + assert.strictEqual(socket.writable, false); + assert.strictEqual(socket.readable, false); + socket.server.close(); + })); + } + + + server.listen(port, host, common.mustCall(function() { + if (this.address().port) + port = this.address().port; + + const client = net.createConnection(port, host); + + client.setEncoding('ascii'); + client.on('connect', common.mustCall(function() { + assert.strictEqual(client.readable, true); + assert.strictEqual(client.writable, true); + client.write('PING'); + })); + + client.on('data', common.mustCall(function(data) { + assert.strictEqual(data, 'PONG'); + count += 1; + + if (sent_final_ping) { + assert.strictEqual(client.writable, false); + assert.strictEqual(client.readable, true); + return; + } + assert.strictEqual(client.writable, true); + assert.strictEqual(client.readable, true); + + if (count < N) { + client.write('PING'); + } else { + sent_final_ping = true; + client.write('PING'); + client.end(); + } + }, N + 1)); + + client.on('close', common.mustCall(function() { + assert.strictEqual(count, N + 1); + assert.strictEqual(sentPongs, N + 1); + assert.strictEqual(sent_final_ping, true); + })); + + client.on('error', common.mustNotCall()); + })); +} + +/* All are run at once, so run on different ports */ +const tmpdir = require('../common/tmpdir'); +tmpdir.refresh(); +pingPongTest(common.PIPE); +pingPongTest(0); +if (common.hasIPv6) pingPongTest(0, '::1'); else pingPongTest(0, '127.0.0.1'); diff --git a/test/js/node/test/parallel/test-net-pipe-connect-errors.js b/test/js/node/test/parallel/test-net-pipe-connect-errors.js new file mode 100644 index 000000000000..fec4259b348d --- /dev/null +++ b/test/js/node/test/parallel/test-net-pipe-connect-errors.js @@ -0,0 +1,97 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); +const fixtures = require('../common/fixtures'); +const fs = require('fs'); +const net = require('net'); +const assert = require('assert'); + +// Test if ENOTSOCK is fired when trying to connect to a file which is not +// a socket. + +let emptyTxt; + +if (common.isWindows) { + // On Win, common.PIPE will be a named pipe, so we use an existing empty + // file instead + emptyTxt = fixtures.path('empty.txt'); +} else { + const tmpdir = require('../common/tmpdir'); + tmpdir.refresh(); + // Keep the file name very short so that we don't exceed the 108 char limit + // on CI for a POSIX socket. Even though this isn't actually a socket file, + // the error will be different from the one we are expecting if we exceed the + // limit. + emptyTxt = `${tmpdir.path}0.txt`; + + function cleanup() { + try { + fs.unlinkSync(emptyTxt); + } catch (e) { + assert.strictEqual(e.code, 'ENOENT'); + } + } + process.on('exit', cleanup); + cleanup(); + fs.writeFileSync(emptyTxt, ''); +} + +const notSocketClient = net.createConnection(emptyTxt, function() { + assert.fail('connection callback should not run'); +}); + +notSocketClient.on('error', common.mustCall(function(err) { + assert(err.code === 'ENOTSOCK' || err.code === 'ECONNREFUSED', + `received ${err.code} instead of ENOTSOCK or ECONNREFUSED`); +})); + + +// Trying to connect to not-existing socket should result in ENOENT error +const noEntSocketClient = net.createConnection('no-ent-file', function() { + assert.fail('connection to non-existent socket, callback should not run'); +}); + +noEntSocketClient.on('error', common.mustCall(function(err) { + assert.strictEqual(err.code, 'ENOENT'); +})); + + +// On Windows or IBMi or when running as root, +// a chmod has no effect on named pipes +if (!common.isWindows && !common.isIBMi && process.getuid() !== 0) { + // Trying to connect to a socket one has no access to should result in EACCES + const accessServer = net.createServer( + common.mustNotCall('server callback should not run')); + accessServer.listen(common.PIPE, common.mustCall(function() { + fs.chmodSync(common.PIPE, 0); + + const accessClient = net.createConnection(common.PIPE, function() { + assert.fail('connection should get EACCES, callback should not run'); + }); + + accessClient.on('error', common.mustCall(function(err) { + assert.strictEqual(err.code, 'EACCES'); + accessServer.close(); + })); + })); +} diff --git a/test/js/node/test/parallel/test-net-pipe-with-long-path.js b/test/js/node/test/parallel/test-net-pipe-with-long-path.js new file mode 100644 index 000000000000..e35fd4a1e0ca --- /dev/null +++ b/test/js/node/test/parallel/test-net-pipe-with-long-path.js @@ -0,0 +1,36 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); +const fs = require('fs'); +const tmpdir = require('../common/tmpdir'); +tmpdir.refresh(); + +// Test UV_PIPE_NO_TRUNCATE + +// See pipe_overlong_path in https://github.com/libuv/libuv/blob/master/test/test-pipe-bind-error.c +if (common.isWindows) { + common.skip('UV_PIPE_NO_TRUNCATE is not supported on window'); +} + +// See https://github.com/libuv/libuv/issues/4231 +const pipePath = `${tmpdir.path}/${'x'.repeat(10000)}.sock`; + +const server = net.createServer() + .listen(pipePath) + // It may work on some operating systems + .on('listening', common.mustCallAtLeast(() => { + // The socket file must exist + assert.ok(fs.existsSync(pipePath)); + const socket = net.connect(pipePath, common.mustCall(() => { + socket.destroy(); + server.close(); + })); + }, 0)) + .on('error', common.mustCall((error) => { + assert.ok(error.code === 'EINVAL', error.message); + net.connect(pipePath) + .on('error', common.mustCall((error) => { + assert.ok(error.code === 'EINVAL', error.message); + })); + })); diff --git a/test/js/node/test/parallel/test-net-server-keepalive.js b/test/js/node/test/parallel/test-net-server-keepalive.js new file mode 100644 index 000000000000..6f3db6468f66 --- /dev/null +++ b/test/js/node/test/parallel/test-net-server-keepalive.js @@ -0,0 +1,35 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); + +const server = net.createServer({ + keepAlive: true, + keepAliveInitialDelay: 1000 +}, common.mustCall((socket) => { + const setKeepAlive = socket._handle.setKeepAlive; + socket._handle.setKeepAlive = common.mustCall((enable, initialDelay) => { + assert.strictEqual(enable, true); + assert.match(String(initialDelay), /^2|3$/); + return setKeepAlive.call(socket._handle, enable, initialDelay); + }, 2); + socket.setKeepAlive(true, 1000); + socket.setKeepAlive(true, 2000); + socket.setKeepAlive(true, 3000); + socket.destroy(); + server.close(); +})).listen(0, common.mustCall(() => { + net.connect(server.address().port); +})); + +const onconnection = server._handle.onconnection; +server._handle.onconnection = common.mustCall((err, clientHandle) => { + const setKeepAlive = clientHandle.setKeepAlive; + clientHandle.setKeepAlive = common.mustCall((enable, initialDelayMsecs) => { + assert.strictEqual(enable, server.keepAlive); + assert.strictEqual(initialDelayMsecs, server.keepAliveInitialDelay); + setKeepAlive.call(clientHandle, enable, initialDelayMsecs); + clientHandle.setKeepAlive = setKeepAlive; + }); + onconnection.call(server._handle, err, clientHandle); +}); diff --git a/test/js/node/test/parallel/test-net-server-listen-options.js b/test/js/node/test/parallel/test-net-server-listen-options.js new file mode 100644 index 000000000000..7e306af8ab08 --- /dev/null +++ b/test/js/node/test/parallel/test-net-server-listen-options.js @@ -0,0 +1,94 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); + +function close() { this.close(); } + +{ + // Test listen() + net.createServer().listen().on('listening', common.mustCall(close)); + // Test listen(cb) + net.createServer().listen(common.mustCall(close)); + // Test listen(port) + net.createServer().listen(0).on('listening', common.mustCall(close)); + // Test listen({port}) + net.createServer().listen({ port: 0 }) + .on('listening', common.mustCall(close)); +} + +// Test listen(port, cb) and listen({ port }, cb) combinations +const listenOnPort = [ + (port, cb) => net.createServer().listen({ port }, cb), + (port, cb) => net.createServer().listen(port, cb), +]; + +{ + const assertPort = () => { + return common.expectsError({ + code: 'ERR_SOCKET_BAD_PORT', + name: 'RangeError' + }); + }; + + for (const listen of listenOnPort) { + // Arbitrary unused ports + listen('0', common.mustCall(close)); + listen(0, common.mustCall(close)); + listen(undefined, common.mustCall(close)); + listen(null, common.mustCall(close)); + // Test invalid ports + assert.throws(() => listen(-1, common.mustNotCall()), assertPort()); + assert.throws(() => listen(NaN, common.mustNotCall()), assertPort()); + assert.throws(() => listen(123.456, common.mustNotCall()), assertPort()); + assert.throws(() => listen(65536, common.mustNotCall()), assertPort()); + assert.throws(() => listen(1 / 0, common.mustNotCall()), assertPort()); + assert.throws(() => listen(-1 / 0, common.mustNotCall()), assertPort()); + } + // In listen(options, cb), port takes precedence over path + assert.throws(() => { + net.createServer().listen({ port: -1, path: common.PIPE }, + common.mustNotCall()); + }, assertPort()); +} + +{ + function shouldFailToListen(options) { + const fn = () => { + net.createServer().listen(options, common.mustNotCall()); + }; + + if (typeof options === 'object' && + !(('port' in options) || ('path' in options))) { + assert.throws(fn, + { + code: 'ERR_INVALID_ARG_VALUE', + name: 'TypeError', + message: /^The argument 'options' must have the property "port" or "path"\. Received .+$/, + }); + } else { + assert.throws(fn, + { + code: 'ERR_INVALID_ARG_VALUE', + name: 'TypeError', + message: /^The argument 'options' is invalid\. Received .+$/, + }); + } + } + + shouldFailToListen(false, { port: false }); + shouldFailToListen({ port: false }); + shouldFailToListen(true); + shouldFailToListen({ port: true }); + // Invalid fd as listen(handle) + shouldFailToListen({ fd: -1 }); + // Invalid path in listen(options) + shouldFailToListen({ path: -1 }); + + // Neither port or path are specified in options + shouldFailToListen({}); + shouldFailToListen({ host: 'localhost' }); + shouldFailToListen({ host: 'localhost:3000' }); + shouldFailToListen({ host: { port: 3000 } }); + shouldFailToListen({ exclusive: true }); +} diff --git a/test/js/node/test/parallel/test-net-server-listen-path.js b/test/js/node/test/parallel/test-net-server-listen-path.js new file mode 100644 index 000000000000..8c9d209b3d4a --- /dev/null +++ b/test/js/node/test/parallel/test-net-server-listen-path.js @@ -0,0 +1,91 @@ +'use strict'; + +const common = require('../common'); +const net = require('net'); +const assert = require('assert'); +const fs = require('fs'); + +const tmpdir = require('../common/tmpdir'); +tmpdir.refresh(); + +function closeServer() { + return common.mustCall(function() { + this.close(); + }); +} + +let counter = 0; + +// Avoid conflict with listen-handle +function randomPipePath() { + return `${common.PIPE}-listen-path-${counter++}`; +} + +// Test listen(path) +{ + const handlePath = randomPipePath(); + net.createServer() + .listen(handlePath) + .on('listening', closeServer()); +} + +// Test listen({path}) +{ + const handlePath = randomPipePath(); + net.createServer() + .listen({ path: handlePath }) + .on('listening', closeServer()); +} + +// Test listen(path, cb) +{ + const handlePath = randomPipePath(); + net.createServer() + .listen(handlePath, closeServer()); +} + +// Test listen(path, cb) +{ + const handlePath = randomPipePath(); + net.createServer() + .listen({ path: handlePath }, closeServer()); +} + +// Test pipe chmod +{ + const handlePath = randomPipePath(); + + const server = net.createServer() + .listen({ + path: handlePath, + readableAll: true, + writableAll: true + }, common.mustCall(() => { + if (process.platform !== 'win32') { + const mode = fs.statSync(handlePath).mode; + assert.notStrictEqual(mode & fs.constants.S_IROTH, 0); + assert.notStrictEqual(mode & fs.constants.S_IWOTH, 0); + } + server.close(); + })); +} + +// Test should emit "error" events when listening fails. +{ + const handlePath = randomPipePath(); + const server1 = net.createServer().listen({ path: handlePath }, common.mustCall(() => { + // As the handlePath is in use, binding to the same address again should + // make the server emit an 'EADDRINUSE' error. + const server2 = net.createServer() + .listen({ + path: handlePath, + writableAll: true, + }, common.mustNotCall()); + + server2.on('error', common.mustCall((err) => { + server1.close(); + assert.strictEqual(err.code, 'EADDRINUSE'); + assert.match(err.message, /^listen EADDRINUSE: address already in use/); + })); + })); +} diff --git a/test/js/node/test/parallel/test-net-server-nodelay.js b/test/js/node/test/parallel/test-net-server-nodelay.js new file mode 100644 index 000000000000..a7f11475abe2 --- /dev/null +++ b/test/js/node/test/parallel/test-net-server-nodelay.js @@ -0,0 +1,26 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); + +const server = net.createServer({ + noDelay: true +}, common.mustCall((socket) => { + socket._handle.setNoDelay = common.mustNotCall(); + socket.setNoDelay(true); + socket.destroy(); + server.close(); +})).listen(0, common.mustCall(() => { + net.connect(server.address().port); +})); + +const onconnection = server._handle.onconnection; +server._handle.onconnection = common.mustCall((err, clientHandle) => { + const setNoDelay = clientHandle.setNoDelay; + clientHandle.setNoDelay = common.mustCall((enable) => { + assert.strictEqual(enable, server.noDelay); + setNoDelay.call(clientHandle, enable); + clientHandle.setNoDelay = setNoDelay; + }); + onconnection.call(server._handle, err, clientHandle); +}); diff --git a/test/js/node/test/parallel/test-net-server-reset.js b/test/js/node/test/parallel/test-net-server-reset.js new file mode 100644 index 000000000000..9e6e9c45fd40 --- /dev/null +++ b/test/js/node/test/parallel/test-net-server-reset.js @@ -0,0 +1,30 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); + +const sockets = []; + +const server = net.createServer(common.mustCallAtLeast((c) => { + c.on('close', common.mustCall()); + + sockets.push(c); + + if (sockets.length === 2) { + assert.strictEqual(server.close(), server); + sockets.forEach((c) => c.resetAndDestroy()); + } +})); + +server.on('close', common.mustCall()); + +assert.strictEqual(server, server.listen(0, common.mustCall(() => { + net.createConnection(server.address().port) + .on('error', common.mustCall((error) => { + assert.strictEqual(error.code, 'ECONNRESET'); + })); + net.createConnection(server.address().port) + .on('error', common.mustCall((error) => { + assert.strictEqual(error.code, 'ECONNRESET'); + })); +}))); diff --git a/test/js/node/test/parallel/test-net-socket-reset-send.js b/test/js/node/test/parallel/test-net-socket-reset-send.js new file mode 100644 index 000000000000..b7b9f66cb93d --- /dev/null +++ b/test/js/node/test/parallel/test-net-socket-reset-send.js @@ -0,0 +1,30 @@ +'use strict'; + +const common = require('../common'); +const net = require('net'); +const assert = require('assert'); + +const server = net.createServer(); +server.listen(0, common.mustCall(() => { + const port = server.address().port; + const conn = net.createConnection(port); + server.on('connection', (socket) => { + socket.on('error', common.expectsError({ + code: 'ECONNRESET', + message: 'read ECONNRESET', + name: 'Error' + })); + }); + + conn.on('connect', common.mustCall(() => { + assert.strictEqual(conn, conn.resetAndDestroy().destroy()); + conn.on('error', common.mustNotCall()); + + conn.write(Buffer.from('fzfzfzfzfz'), common.expectsError({ + code: 'ERR_STREAM_DESTROYED', + message: 'Cannot call write after a stream was destroyed', + name: 'Error' + })); + server.close(); + })); +})); diff --git a/test/js/node/test/parallel/test-net-socket-setnodelay.js b/test/js/node/test/parallel/test-net-socket-setnodelay.js new file mode 100644 index 000000000000..97cf992b162e --- /dev/null +++ b/test/js/node/test/parallel/test-net-socket-setnodelay.js @@ -0,0 +1,56 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); + +const truthyValues = [true, 1, 'true', {}, []]; +const falseyValues = [false, 0, '']; +const genSetNoDelay = common.mustCall((desiredArg) => common.mustCall((enable) => { + assert.strictEqual(enable, desiredArg); +}), 2); + +// setNoDelay should default to true +let socket = new net.Socket({ + handle: { + setNoDelay: genSetNoDelay(true), + readStart() {} + } +}); +socket.setNoDelay(); + +socket = new net.Socket({ + handle: { + setNoDelay: genSetNoDelay(true), + readStart() {} + } +}); +truthyValues.forEach((testVal) => socket.setNoDelay(testVal)); + +socket = new net.Socket({ + handle: { + setNoDelay: common.mustNotCall(), + readStart() {} + } +}); +falseyValues.forEach((testVal) => socket.setNoDelay(testVal)); + +socket = new net.Socket({ + handle: { + setNoDelay: common.mustCall(3), + readStart() {} + } +}); +truthyValues.concat(falseyValues).concat(truthyValues) + .forEach((testVal) => socket.setNoDelay(testVal)); + +// If a handler doesn't have a setNoDelay function it shouldn't be called. +// In the case below, if it is called an exception will be thrown +socket = new net.Socket({ + handle: { + setNoDelay: null, + readStart() {} + } +}); +const returned = socket.setNoDelay(true); +assert.ok(returned instanceof net.Socket); diff --git a/test/js/node/test/parallel/test-net-socket-tos.js b/test/js/node/test/parallel/test-net-socket-tos.js new file mode 100644 index 000000000000..8510e840daa5 --- /dev/null +++ b/test/js/node/test/parallel/test-net-socket-tos.js @@ -0,0 +1,100 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); + +const server = net.createServer( + common.mustCall((socket) => { + socket.end(); + }), +); + +server.listen( + 0, + common.mustCall(() => { + const port = server.address().port; + const client = new net.Socket(); + + // Set TOS before connection to test caching behavior + client.setTypeOfService(0x10); + client.connect(port); + + client.on( + 'connect', + common.mustCall(() => { + // TEST 1: setTypeOfService validation + // Should throw if value is not a number, is NaN, or is out of range (0-255) + assert.throws(() => client.setTypeOfService('invalid'), { + code: 'ERR_INVALID_ARG_TYPE', + }); + assert.throws(() => client.setTypeOfService(NaN), { + code: 'ERR_INVALID_ARG_TYPE', + }); + assert.throws(() => client.setTypeOfService(256), { + code: 'ERR_OUT_OF_RANGE', + }); + assert.throws(() => client.setTypeOfService(-1), { + code: 'ERR_OUT_OF_RANGE', + }); + + // TEST 2a: Verify deferred application + // Check if the TOS value set before connect() was cached and applied. + // We mask with 0xFC to check only the high 6 bits (DSCP), + // ignoring the lowest 2 bits (ECN) which the OS may modify or zero out. + const mask = 0xFC; + const preConnectGot = client.getTypeOfService(); + + // Windows often resets TOS or ignores it without admin/registry tweaks. + // We only assert strict equality on non-Windows platforms. + if (!common.isWindows) { + assert.strictEqual( + preConnectGot & mask, + 0x10 & mask, + `Pre-connect TOS should be ${0x10 & mask}, got ${preConnectGot & mask}`, + ); + } + + // TEST 2b: Setting and getting TOS on an active connection + const tosValue = 0x10; // IPTOS_LOWDELAY (16) + + // On all platforms, this should succeed (tries both IPv4 and IPv6) + client.setTypeOfService(tosValue); + + // Verify values + const got = client.getTypeOfService(); + + if (!common.isWindows) { + assert.strictEqual( + got & mask, + tosValue & mask, + `Expected TOS ${tosValue & mask}, got ${got & mask}`, + ); + } + + // TEST 3: Boundary values + // Check min (0x00), max (0xFF), and arbitrary intermediate values + for (const boundaryValue of [0x00, 0xFF, 0x3F]) { + client.setTypeOfService(boundaryValue); + const gotBoundary = client.getTypeOfService(); + + if (!common.isWindows) { + assert.strictEqual( + gotBoundary & mask, + boundaryValue & mask, + `Expected TOS ${boundaryValue & mask}, got ${gotBoundary & mask}`, + ); + } + } + + client.end(); + }), + ); + + client.on( + 'end', + common.mustCall(() => { + server.close(); + }), + ); + }), +); diff --git a/test/js/node/test/parallel/test-net-socket-write-after-close.js b/test/js/node/test/parallel/test-net-socket-write-after-close.js new file mode 100644 index 000000000000..3c6537937d28 --- /dev/null +++ b/test/js/node/test/parallel/test-net-socket-write-after-close.js @@ -0,0 +1,42 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); + +{ + const server = net.createServer(); + + server.listen(common.mustCall(() => { + const port = server.address().port; + const client = net.connect({ port }, common.mustCall(() => { + client.on('error', common.mustCall((err) => { + server.close(); + assert.strictEqual(err.constructor, Error); + assert.strictEqual(err.message, `write ${common.isWindows ? 'EPIPE' : 'EBADF'}`); + })); + client._handle.close(); + client.write('foo'); + })); + })); +} + +{ + const server = net.createServer(); + + server.listen(common.mustCall(() => { + const port = server.address().port; + const client = net.connect({ port }, common.mustCall(() => { + client.on('error', common.expectsError({ + code: 'ERR_SOCKET_CLOSED', + message: 'Socket is closed', + name: 'Error' + })); + + server.close(); + + client._handle.close(); + client._handle = null; + client.write('foo'); + })); + })); +} diff --git a/test/js/node/test/parallel/test-net-stream.js b/test/js/node/test/parallel/test-net-stream.js new file mode 100644 index 000000000000..cf6d615591ea --- /dev/null +++ b/test/js/node/test/parallel/test-net-stream.js @@ -0,0 +1,51 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); + +const SIZE = 2E6; +const N = 10; +const buf = Buffer.alloc(SIZE, 'a'); + +const server = net.createServer(common.mustCall((socket) => { + socket.setNoDelay(); + + socket.on('error', common.mustCall(() => socket.destroy())) + .on('close', common.mustCall(() => server.close())); + + for (let i = 0; i < N; ++i) { + socket.write(buf, () => {}); + } + socket.end(); + +})).listen(0, common.mustCall(function() { + const conn = net.connect(this.address().port); + conn.on('data', common.mustCall((buf) => { + assert.strictEqual(conn, conn.pause()); + setTimeout(function() { + conn.destroy(); + }, 20); + })); +})); diff --git a/test/js/node/test/parallel/test-net-write-after-close.js b/test/js/node/test/parallel/test-net-write-after-close.js new file mode 100644 index 000000000000..9f259935f491 --- /dev/null +++ b/test/js/node/test/parallel/test-net-write-after-close.js @@ -0,0 +1,52 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); +const assert = require('assert'); + +const net = require('net'); + +let serverSocket; + +const server = net.createServer(common.mustCall(function(socket) { + serverSocket = socket; + + socket.resume(); + + socket.on('error', common.mustNotCall()); +})); + +server.listen(0, common.mustCall(function() { + const client = net.connect(this.address().port, common.mustCall(() => { + // client.end() will close both the readable and writable side + // of the duplex because allowHalfOpen defaults to false. + // Then 'end' will be emitted when it receives a FIN packet from + // the other side. + client.on('end', common.mustCall(() => { + serverSocket.write('test', common.mustCall((err) => { + assert(err); + server.close(); + })); + })); + client.end(); + })); +})); diff --git a/test/js/node/test/parallel/test-net-write-after-end-nt.js b/test/js/node/test/parallel/test-net-write-after-end-nt.js new file mode 100644 index 000000000000..bcb986c22496 --- /dev/null +++ b/test/js/node/test/parallel/test-net-write-after-end-nt.js @@ -0,0 +1,32 @@ +'use strict'; +const common = require('../common'); + +const assert = require('assert'); +const net = require('net'); + +const { expectsError, mustCall } = common; + +// This test ensures those errors caused by calling `net.Socket.write()` +// after sockets ending will be emitted in the next tick. +const server = net.createServer(mustCall((socket) => { + socket.end(); +})).listen(mustCall(() => { + const client = net.connect(server.address().port, mustCall(() => { + let hasError = false; + client.on('error', mustCall((err) => { + hasError = true; + server.close(); + })); + client.on('end', mustCall(() => { + const ret = client.write('hello', expectsError({ + code: 'EPIPE', + message: 'This socket has been ended by the other party', + name: 'Error' + })); + + assert.strictEqual(ret, false); + assert(!hasError, 'The error should be emitted in the next tick.'); + })); + client.end(); + })); +})); diff --git a/test/js/node/test/parallel/test-tls-basic-validations.js b/test/js/node/test/parallel/test-tls-basic-validations.js new file mode 100644 index 000000000000..0446b6aef219 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-basic-validations.js @@ -0,0 +1,137 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const tls = require('tls'); + +assert.throws( + () => tls.createSecureContext({ ciphers: 1 }), + { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + message: 'The "options.ciphers" property must be of type string.' + + ' Received type number (1)' + }); + +assert.throws( + () => tls.createServer({ ciphers: 1 }), + { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + message: 'The "options.ciphers" property must be of type string.' + + ' Received type number (1)' + }); + +assert.throws( + () => tls.createSecureContext({ key: 'dummykey', passphrase: 1 }), + { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + message: /The "options\.passphrase" property must be of type string/ + }); + +assert.throws( + () => tls.createServer({ key: 'dummykey', passphrase: 1 }), + { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + message: /The "options\.passphrase" property must be of type string/ + }); + +assert.throws( + () => tls.createServer({ ecdhCurve: 1 }), + { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + message: /The "options\.ecdhCurve" property must be of type string/ + }); + +assert.throws( + () => tls.createServer({ handshakeTimeout: 'abcd' }), + { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + message: 'The "options.handshakeTimeout" property must be of type number.' + + " Received type string ('abcd')" + } +); + +assert.throws( + () => tls.createServer({ sessionTimeout: 'abcd' }), + { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + message: /The "options\.sessionTimeout" property must be of type number/ + }); + +assert.throws( + () => tls.createServer({ ticketKeys: 'abcd' }), + { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + message: /The "options\.ticketKeys" property must be an instance of/ + }); + +assert.throws(() => tls.createServer({ ticketKeys: Buffer.alloc(0) }), { + code: 'ERR_INVALID_ARG_VALUE', + message: /The property 'options\.ticketKeys' must be exactly 48 bytes/ +}); + +{ + const buffer = Buffer.from('abcd'); + const out = {}; + tls.convertALPNProtocols(buffer, out); + out.ALPNProtocols.write('efgh'); + assert(buffer.equals(Buffer.from('abcd'))); + assert(out.ALPNProtocols.equals(Buffer.from('efgh'))); +} + +{ + const arrayBufferViewStr = 'abcd'; + const inputBuffer = Buffer.from(arrayBufferViewStr.repeat(8), 'utf8'); + for (const expectView of common.getArrayBufferViews(inputBuffer)) { + const out = {}; + const expected = Buffer.from(expectView.buffer.slice(), + expectView.byteOffset, + expectView.byteLength); + tls.convertALPNProtocols(expectView, out); + assert(out.ALPNProtocols.equals(expected)); + } +} + +{ + const protocols = [(new String('a')).repeat(500)]; + const out = {}; + assert.throws( + () => tls.convertALPNProtocols(protocols, out), + { + code: 'ERR_OUT_OF_RANGE', + message: 'The byte length of the protocol at index 0 exceeds the ' + + 'maximum length. It must be <= 255. Received 500' + } + ); +} + +assert.throws(() => { tls.createSecureContext({ minVersion: 'fhqwhgads' }); }, + { + code: 'ERR_TLS_INVALID_PROTOCOL_VERSION', + name: 'TypeError' + }); + +assert.throws(() => { tls.createSecureContext({ maxVersion: 'fhqwhgads' }); }, + { + code: 'ERR_TLS_INVALID_PROTOCOL_VERSION', + name: 'TypeError' + }); + +for (const checkServerIdentity of [undefined, null, 1, true]) { + assert.throws(() => { + tls.connect({ checkServerIdentity }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + }); +} diff --git a/test/js/node/test/parallel/test-tls-buffersize.js b/test/js/node/test/parallel/test-tls-buffersize.js new file mode 100644 index 000000000000..eadd4cb1e40c --- /dev/null +++ b/test/js/node/test/parallel/test-tls-buffersize.js @@ -0,0 +1,43 @@ +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); +const assert = require('assert'); +const fixtures = require('../common/fixtures'); +const tls = require('tls'); + +const iter = 10; + +const server = tls.createServer({ + key: fixtures.readKey('agent2-key.pem'), + cert: fixtures.readKey('agent2-cert.pem') +}, common.mustCall((socket) => { + let str = ''; + socket.setEncoding('utf-8'); + socket.on('data', (chunk) => { str += chunk; }); + + socket.on('end', common.mustCall(() => { + assert.strictEqual(str, 'a'.repeat(iter - 1)); + server.close(); + })); +})); + +server.listen(0, common.mustCall(() => { + const client = tls.connect({ + port: server.address().port, + rejectUnauthorized: false + }, common.mustCall(() => { + assert.strictEqual(client.bufferSize, 0); + + for (let i = 1; i < iter; i++) { + client.write('a'); + assert.strictEqual(client.bufferSize, i); + } + + client.on('finish', common.mustCall(() => { + assert.strictEqual(client.bufferSize, 0); + })); + + client.end(); + })); +})); diff --git a/test/js/node/test/parallel/test-tls-cert-chains-concat.js b/test/js/node/test/parallel/test-tls-cert-chains-concat.js new file mode 100644 index 000000000000..ffb29cf5aae2 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-cert-chains-concat.js @@ -0,0 +1,48 @@ +'use strict'; +const common = require('../common'); +const fixtures = require('../common/fixtures'); + +// Check cert chain is received by client, and is completed with the ca cert +// known to the client. + +const { + assert, connect, debug, keys +} = require(fixtures.path('tls-connect')); + +// agent6-cert.pem includes cert for agent6 and ca3 +connect({ + client: { + checkServerIdentity: (servername, cert) => { }, + ca: keys.agent6.ca, + }, + server: { + cert: keys.agent6.cert, + key: keys.agent6.key, + }, +}, common.mustSucceed((pair, cleanup) => { + const peer = pair.client.conn.getPeerCertificate(); + debug('peer:\n', peer); + assert.strictEqual(peer.subject.emailAddress, 'adam.lippai@tresorit.com'); + assert.strictEqual(peer.subject.CN, 'Ádám Lippai'); + assert.strictEqual(peer.issuer.CN, 'ca3'); + assert.match(peer.serialNumber, /5B75D77EDC7FB5B7FA9F1424DA4C64FB815DCBDE/i); + + const next = pair.client.conn.getPeerCertificate(true).issuerCertificate; + const root = next.issuerCertificate; + delete next.issuerCertificate; + debug('next:\n', next); + assert.strictEqual(next.subject.CN, 'ca3'); + assert.strictEqual(next.issuer.CN, 'ca1'); + assert.match(next.serialNumber, /147D36C1C2F74206DE9FAB5F2226D78ADB00A425/i); + + debug('root:\n', root); + assert.strictEqual(root.subject.CN, 'ca1'); + assert.strictEqual(root.issuer.CN, 'ca1'); + assert.match(root.serialNumber, /4AB16C8DFD6A7D0D2DFCABDF9C4B0E92C6AD0229/i); + + // No client cert, so empty object returned. + assert.deepStrictEqual(pair.server.conn.getPeerCertificate(), {}); + assert.deepStrictEqual(pair.server.conn.getPeerCertificate(true), {}); + + return cleanup(); +})); diff --git a/test/js/node/test/parallel/test-tls-cli-max-version-1.2.js b/test/js/node/test/parallel/test-tls-cli-max-version-1.2.js new file mode 100644 index 000000000000..9bbc9ff0ecad --- /dev/null +++ b/test/js/node/test/parallel/test-tls-cli-max-version-1.2.js @@ -0,0 +1,15 @@ +// Flags: --tls-max-v1.2 +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) common.skip('missing crypto'); + +// Check that node `--tls-max-v1.2` is supported. + +const assert = require('assert'); +const tls = require('tls'); + +assert.strictEqual(tls.DEFAULT_MAX_VERSION, 'TLSv1.2'); +assert.strictEqual(tls.DEFAULT_MIN_VERSION, 'TLSv1.2'); + +// Check the min-max version protocol versions against these CLI settings. +require('./test-tls-min-max-version.js'); diff --git a/test/js/node/test/parallel/test-tls-cli-max-version-1.3.js b/test/js/node/test/parallel/test-tls-cli-max-version-1.3.js new file mode 100644 index 000000000000..c04354fe4ac9 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-cli-max-version-1.3.js @@ -0,0 +1,15 @@ +// Flags: --tls-max-v1.3 +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) common.skip('missing crypto'); + +// Check that node `--tls-max-v1.3` is supported. + +const assert = require('assert'); +const tls = require('tls'); + +assert.strictEqual(tls.DEFAULT_MAX_VERSION, 'TLSv1.3'); +assert.strictEqual(tls.DEFAULT_MIN_VERSION, 'TLSv1.2'); + +// Check the min-max version protocol versions against these CLI settings. +require('./test-tls-min-max-version.js'); diff --git a/test/js/node/test/parallel/test-tls-cli-min-version-1.0.js b/test/js/node/test/parallel/test-tls-cli-min-version-1.0.js new file mode 100644 index 000000000000..577562782ece --- /dev/null +++ b/test/js/node/test/parallel/test-tls-cli-min-version-1.0.js @@ -0,0 +1,15 @@ +// Flags: --tls-min-v1.0 --tls-min-v1.1 +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) common.skip('missing crypto'); + +// Check that `node --tls-v1.0` is supported, and overrides --tls-v1.1. + +const assert = require('assert'); +const tls = require('tls'); + +assert.strictEqual(tls.DEFAULT_MAX_VERSION, 'TLSv1.3'); +assert.strictEqual(tls.DEFAULT_MIN_VERSION, 'TLSv1'); + +// Check the min-max version protocol versions against these CLI settings. +require('./test-tls-min-max-version.js'); diff --git a/test/js/node/test/parallel/test-tls-cli-min-version-1.1.js b/test/js/node/test/parallel/test-tls-cli-min-version-1.1.js new file mode 100644 index 000000000000..3af2b39546c4 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-cli-min-version-1.1.js @@ -0,0 +1,15 @@ +// Flags: --tls-min-v1.1 +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) common.skip('missing crypto'); + +// Check that node `--tls-v1.1` is supported. + +const assert = require('assert'); +const tls = require('tls'); + +assert.strictEqual(tls.DEFAULT_MAX_VERSION, 'TLSv1.3'); +assert.strictEqual(tls.DEFAULT_MIN_VERSION, 'TLSv1.1'); + +// Check the min-max version protocol versions against these CLI settings. +require('./test-tls-min-max-version.js'); diff --git a/test/js/node/test/parallel/test-tls-cli-min-version-1.2.js b/test/js/node/test/parallel/test-tls-cli-min-version-1.2.js new file mode 100644 index 000000000000..8385eabd0bab --- /dev/null +++ b/test/js/node/test/parallel/test-tls-cli-min-version-1.2.js @@ -0,0 +1,15 @@ +// Flags: --tls-min-v1.2 +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) common.skip('missing crypto'); + +// Check that node `--tls-min-v1.2` is supported. + +const assert = require('assert'); +const tls = require('tls'); + +assert.strictEqual(tls.DEFAULT_MAX_VERSION, 'TLSv1.3'); +assert.strictEqual(tls.DEFAULT_MIN_VERSION, 'TLSv1.2'); + +// Check the min-max version protocol versions against these CLI settings. +require('./test-tls-min-max-version.js'); diff --git a/test/js/node/test/parallel/test-tls-cli-min-version-1.3.js b/test/js/node/test/parallel/test-tls-cli-min-version-1.3.js new file mode 100644 index 000000000000..1bccc2f6cd33 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-cli-min-version-1.3.js @@ -0,0 +1,15 @@ +// Flags: --tls-min-v1.3 +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) common.skip('missing crypto'); + +// Check that node `--tls-min-v1.3` is supported. + +const assert = require('assert'); +const tls = require('tls'); + +assert.strictEqual(tls.DEFAULT_MAX_VERSION, 'TLSv1.3'); +assert.strictEqual(tls.DEFAULT_MIN_VERSION, 'TLSv1.3'); + +// Check the min-max version protocol versions against these CLI settings. +require('./test-tls-min-max-version.js'); diff --git a/test/js/node/test/parallel/test-tls-client-getephemeralkeyinfo.js b/test/js/node/test/parallel/test-tls-client-getephemeralkeyinfo.js new file mode 100644 index 000000000000..0584e4d11e40 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-client-getephemeralkeyinfo.js @@ -0,0 +1,88 @@ +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +if (process.features.openssl_is_boringssl) { + require('../common/boringssl').testEphemeralKeyInfoUnsupported(); + return; +} + +const fixtures = require('../common/fixtures'); +const { hasOpenSSL } = require('../common/crypto'); + +const assert = require('assert'); +const { X509Certificate } = require('crypto'); +const tls = require('tls'); + +const key = fixtures.readKey('agent2-key.pem'); +const cert = fixtures.readKey('agent2-cert.pem'); + +function loadDHParam(n) { + return fixtures.readKey(`dh${n}.pem`); +} + +function test(size, type, name, cipher) { + assert(cipher); + + const options = { + key: key, + cert: cert, + ciphers: cipher, + maxVersion: 'TLSv1.2', + }; + + if (name) options.ecdhCurve = name; + + if (type === 'DH') { + if (size === 'auto') { + options.dhparam = 'auto'; + // The DHE parameters selected by OpenSSL depend on the strength of the + // certificate's key. For this test, we can assume that the modulus length + // of the certificate's key is equal to the size of the DHE parameter, but + // that is really only true for a few modulus lengths. + ({ + publicKey: { asymmetricKeyDetails: { modulusLength: size } } + } = new X509Certificate(cert)); + } else { + options.dhparam = loadDHParam(size); + } + } + + const server = tls.createServer(options, common.mustCall((conn) => { + assert.strictEqual(conn.getEphemeralKeyInfo(), null); + conn.end(); + })); + + server.on('close', common.mustSucceed()); + + server.listen(0, common.mustCall(() => { + const client = tls.connect({ + port: server.address().port, + rejectUnauthorized: false + }, common.mustCall(function() { + const ekeyinfo = client.getEphemeralKeyInfo(); + assert.strictEqual(ekeyinfo.type, type); + assert.strictEqual(ekeyinfo.size, size); + assert.strictEqual(ekeyinfo.name, name); + server.close(); + })); + client.on('secureConnect', common.mustCall()); + })); +} + +test(undefined, undefined, undefined, 'AES256-SHA256'); +test('auto', 'DH', undefined, 'DHE-RSA-AES256-GCM-SHA384'); +if (hasOpenSSL(4, 0)) { + // OpenSSL 4.0 implements RFC 7919 FFDHE negotiation for TLS 1.2 and + // always selects FFDHE-2048 regardless of the server-supplied dhparam. +} else if (!hasOpenSSL(3, 2)) { + test(1024, 'DH', undefined, 'DHE-RSA-AES256-GCM-SHA384'); +} else { + test(3072, 'DH', undefined, 'DHE-RSA-AES256-GCM-SHA384'); +} +test(2048, 'DH', undefined, 'DHE-RSA-AES256-GCM-SHA384'); +test(256, 'ECDH', 'prime256v1', 'ECDHE-RSA-AES256-GCM-SHA384'); +test(521, 'ECDH', 'secp521r1', 'ECDHE-RSA-AES256-GCM-SHA384'); +test(253, 'ECDH', 'X25519', 'ECDHE-RSA-AES256-GCM-SHA384'); +test(448, 'ECDH', 'X448', 'ECDHE-RSA-AES256-GCM-SHA384'); diff --git a/test/js/node/test/parallel/test-tls-client-reject-12.js b/test/js/node/test/parallel/test-tls-client-reject-12.js new file mode 100644 index 000000000000..f77d463f44dc --- /dev/null +++ b/test/js/node/test/parallel/test-tls-client-reject-12.js @@ -0,0 +1,13 @@ +'use strict'; + +// test-tls-client-reject specifically for TLS1.2. + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const tls = require('tls'); + +tls.DEFAULT_MAX_VERSION = 'TLSv1.2'; + +require('./test-tls-client-reject.js'); diff --git a/test/js/node/test/parallel/test-tls-client-reject.js b/test/js/node/test/parallel/test-tls-client-reject.js new file mode 100644 index 000000000000..cff0aabc89a7 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-client-reject.js @@ -0,0 +1,112 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); + +const options = { + key: fixtures.readKey('rsa_private.pem'), + cert: fixtures.readKey('rsa_cert.crt'), + ...(process.features.openssl_is_boringssl ? { maxVersion: 'TLSv1.2' } : {}), +}; + +const server = tls.createServer(options, function(socket) { + socket.pipe(socket); + // Pipe already ends... but leaving this here tests .end() after .end(). + socket.on('end', () => socket.end()); +}).listen(0, common.mustCall(function() { + unauthorized(); +})); + +function unauthorized() { + console.log('connect unauthorized'); + const socket = tls.connect({ + port: server.address().port, + servername: 'localhost', + rejectUnauthorized: false, + ...(process.features.openssl_is_boringssl ? { maxVersion: 'TLSv1.2' } : {}), + }, common.mustCall(function() { + let _data; + assert(!socket.authorized); + socket.on('data', common.mustCall((data) => { + assert.strictEqual(data.toString(), 'ok'); + _data = data; + })); + socket.on('end', common.mustCall(() => { + assert(_data, 'data failed to echo!'); + })); + socket.on('end', () => rejectUnauthorized()); + })); + socket.once('session', common.mustCall()); + socket.on('error', common.mustNotCall()); + socket.end('ok'); +} + +function rejectUnauthorized() { + console.log('reject unauthorized'); + const socket = tls.connect(server.address().port, { + servername: 'localhost', + ...(process.features.openssl_is_boringssl ? { maxVersion: 'TLSv1.2' } : {}), + }, common.mustNotCall()); + socket.on('data', common.mustNotCall()); + socket.on('error', common.mustCall(function(err) { + rejectUnauthorizedUndefined(); + })); + socket.end('ng'); +} + +function rejectUnauthorizedUndefined() { + console.log('reject unauthorized undefined'); + const socket = tls.connect(server.address().port, { + servername: 'localhost', + rejectUnauthorized: undefined, + ...(process.features.openssl_is_boringssl ? { maxVersion: 'TLSv1.2' } : {}), + }, common.mustNotCall()); + socket.on('data', common.mustNotCall()); + socket.on('error', common.mustCall(function(err) { + authorized(); + })); + socket.end('ng'); +} + +function authorized() { + console.log('connect authorized'); + const socket = tls.connect(server.address().port, { + ca: [fixtures.readKey('rsa_cert.crt')], + servername: 'localhost', + ...(process.features.openssl_is_boringssl ? { maxVersion: 'TLSv1.2' } : {}), + }, common.mustCall(function() { + console.log('... authorized'); + assert(socket.authorized); + socket.on('data', common.mustCall((data) => { + assert.strictEqual(data.toString(), 'ok'); + })); + socket.on('end', () => server.close()); + })); + socket.on('error', common.mustNotCall()); + socket.end('ok'); +} diff --git a/test/js/node/test/parallel/test-tls-client-renegotiation-13.js b/test/js/node/test/parallel/test-tls-client-renegotiation-13.js new file mode 100644 index 000000000000..80c4753d065e --- /dev/null +++ b/test/js/node/test/parallel/test-tls-client-renegotiation-13.js @@ -0,0 +1,55 @@ +'use strict'; + +const common = require('../common'); + +if (!common.hasCrypto) { + common.skip('missing crypto'); +} +const { hasOpenSSL3 } = require('../common/crypto'); + +const fixtures = require('../common/fixtures'); + +// Confirm that for TLSv1.3, renegotiate() is disallowed. + +const { + assert, connect, keys +} = require(fixtures.path('tls-connect')); + +const server = keys.agent10; + +connect({ + client: { + ca: server.ca, + checkServerIdentity: common.mustCall(), + }, + server: { + key: server.key, + cert: server.cert, + }, +}, common.mustSucceed((pair, cleanup) => { + const client = pair.client.conn; + + assert.strictEqual(client.getProtocol(), 'TLSv1.3'); + + const ok = client.renegotiate({}, common.mustCall((err) => { + if (process.features.openssl_is_boringssl) { + assert.throws(() => { throw err; }, { + message: 'TLS session renegotiation is unsupported by this TLS ' + + 'implementation', + code: 'ERR_TLS_RENEGOTIATION_UNSUPPORTED', + }); + } else { + assert.throws(() => { throw err; }, { + message: hasOpenSSL3 ? + 'error:0A00010A:SSL routines::wrong ssl version' : + 'error:1420410A:SSL routines:SSL_renegotiate:wrong ssl version', + code: 'ERR_SSL_WRONG_SSL_VERSION', + library: 'SSL routines', + reason: 'wrong ssl version', + }); + } + cleanup(); + })); + + assert.strictEqual(ok, false); +})); diff --git a/test/js/node/test/parallel/test-tls-client-resume-12.js b/test/js/node/test/parallel/test-tls-client-resume-12.js new file mode 100644 index 000000000000..7767d3dd2a5c --- /dev/null +++ b/test/js/node/test/parallel/test-tls-client-resume-12.js @@ -0,0 +1,13 @@ +'use strict'; + +// test-tls-client-resume specifically for TLS1.2. + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const tls = require('tls'); + +tls.DEFAULT_MAX_VERSION = 'TLSv1.2'; + +require('./test-tls-client-resume.js'); diff --git a/test/js/node/test/parallel/test-tls-client-resume.js b/test/js/node/test/parallel/test-tls-client-resume.js new file mode 100644 index 000000000000..7d1e964d8ec2 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-client-resume.js @@ -0,0 +1,115 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; + +// Check that the ticket from the first connection causes session resumption +// when used to make a second connection. + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); + +const options = { + key: fixtures.readKey('agent2-key.pem'), + cert: fixtures.readKey('agent2-cert.pem') +}; + +// create server +const server = tls.Server(options, common.mustCall((socket) => { + socket.end('Goodbye'); +}, 2)); + +// start listening +server.listen(0, common.mustCall(function() { + let sessionx = null; // From right after connect, invalid for TLS1.3 + let session1 = null; // Delivered by the session event, always valid. + let sessions = 0; + let tls13; + const client1 = tls.connect({ + port: this.address().port, + rejectUnauthorized: false + }, common.mustCall(() => { + tls13 = client1.getProtocol() === 'TLSv1.3'; + assert.strictEqual(client1.isSessionReused(), false); + sessionx = client1.getSession(); + assert(sessionx); + + if (session1) + reconnect(); + })); + + client1.on('data', common.mustCall()); + + client1.once('session', common.mustCall((session) => { + console.log('session1'); + session1 = session; + assert(session1); + if (sessionx) + reconnect(); + })); + + client1.on('session', () => { + console.log('client1 session#', ++sessions); + }); + + client1.on('close', common.mustCall(() => { + console.log('client1 close'); + assert.strictEqual(sessions, tls13 ? 2 : 1); + })); + + function reconnect() { + assert(sessionx); + assert(session1); + if (tls13) + // For TLS1.3, the session immediately after handshake is a dummy, + // unresumable session. The one delivered later in session event is + // resumable. + assert.notStrictEqual(sessionx.compare(session1), 0); + else + // For TLS1.2, they are identical. + assert.strictEqual(sessionx.compare(session1), 0); + + const opts = { + port: server.address().port, + rejectUnauthorized: false, + session: session1, + }; + + const client2 = tls.connect(opts, common.mustCall(() => { + console.log('connect2'); + assert.strictEqual(client2.isSessionReused(), true); + })); + + client2.on('close', common.mustCall(() => { + console.log('close2'); + server.close(); + })); + + client2.resume(); + } + + client1.resume(); +})); diff --git a/test/js/node/test/parallel/test-tls-clientcertengine-invalid-arg-type.js b/test/js/node/test/parallel/test-tls-clientcertengine-invalid-arg-type.js new file mode 100644 index 000000000000..811e320b0788 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-clientcertengine-invalid-arg-type.js @@ -0,0 +1,15 @@ +'use strict'; +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const tls = require('tls'); + +{ + assert.throws( + () => { tls.createSecureContext({ clientCertEngine: 0 }); }, + { code: 'ERR_INVALID_ARG_TYPE', + message: / Received type number \(0\)/ }); +} diff --git a/test/js/node/test/parallel/test-tls-cnnic-whitelist.js b/test/js/node/test/parallel/test-tls-cnnic-whitelist.js new file mode 100644 index 000000000000..99ad02ee1c66 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-cnnic-whitelist.js @@ -0,0 +1,56 @@ +// Flags: --use-bundled-ca +'use strict'; +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); + +function loadPEM(n) { + return fixtures.readKey(`${n}.pem`); +} + +const testCases = [ + // Test 1: for the fix of node#2061 + // agent6-cert.pem is signed by intermediate cert of ca3. + // The server has a cert chain of agent6->ca3->ca1(root) but + // tls.connect should be failed with an error of + // UNABLE_TO_GET_ISSUER_CERT_LOCALLY since the root CA of ca1 is not + // installed locally. + { + serverOpts: { + ca: loadPEM('ca3-key'), + key: loadPEM('agent6-key'), + cert: loadPEM('agent6-cert') + }, + clientOpts: { + port: undefined, + rejectUnauthorized: true + }, + errorCode: 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY' + }, +]; + +function runTest(tindex) { + const tcase = testCases[tindex]; + + if (!tcase) return; + + const server = tls.createServer(tcase.serverOpts, (s) => { + s.resume(); + }).listen(0, common.mustCall(function() { + tcase.clientOpts.port = this.address().port; + const client = tls.connect(tcase.clientOpts); + client.on('error', common.mustCall((e) => { + assert.strictEqual(e.code, tcase.errorCode); + server.close(common.mustCall(() => { + runTest(tindex + 1); + })); + })); + })); +} + +runTest(0); diff --git a/test/js/node/test/parallel/test-tls-connect-given-socket.js b/test/js/node/test/parallel/test-tls-connect-given-socket.js new file mode 100644 index 000000000000..f25cf4582d59 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-connect-given-socket.js @@ -0,0 +1,85 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); +const fixtures = require('../common/fixtures'); + +const assert = require('assert'); +const tls = require('tls'); +const net = require('net'); + +const options = { + key: fixtures.readKey('rsa_private.pem'), + cert: fixtures.readKey('rsa_cert.crt') +}; + +const server = tls.createServer(options, common.mustCall((socket) => { + socket.end('Hello'); +}, 2)).listen(0, common.mustCall(() => { + let waiting = 2; + function establish(socket, calls) { + const client = tls.connect({ + rejectUnauthorized: false, + socket: socket + }, common.mustCall(() => { + let data = ''; + client.on('data', common.mustCall((chunk) => { + data += chunk.toString(); + })); + client.on('end', common.mustCall(() => { + assert.strictEqual(data, 'Hello'); + if (--waiting === 0) + server.close(); + })); + }, calls)); + assert(client.readable); + assert(client.writable); + + return client; + } + + const { port } = server.address(); + + // Immediate death socket + const immediateDeath = net.connect(port); + establish(immediateDeath, 0).destroy(); + + // Outliving + const outlivingTCP = net.connect(port, common.mustCall(() => { + outlivingTLS.destroy(); + next(); + })); + const outlivingTLS = establish(outlivingTCP, 0); + + function next() { + // Already connected socket + const connected = net.connect(port, common.mustCall(() => { + establish(connected); + })); + + // Connecting socket + const connecting = net.connect(port); + establish(connecting); + } +})); diff --git a/test/js/node/test/parallel/test-tls-connect-memleak.js b/test/js/node/test/parallel/test-tls-connect-memleak.js new file mode 100644 index 000000000000..220ea4a9248e --- /dev/null +++ b/test/js/node/test/parallel/test-tls-connect-memleak.js @@ -0,0 +1,66 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +// Flags: --expose-gc + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const { onGC } = require('../common/gc'); +const assert = require('assert'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); + +// Test that the implicit listener for an 'connect' event on tls.Sockets is +// added using `once()`, i.e. can be gc'ed once that event has occurred. + +const server = tls.createServer({ + cert: fixtures.readKey('rsa_cert.crt'), + key: fixtures.readKey('rsa_private.pem') +}).listen(0); + +let collected = false; +const gcListener = { ongc() { collected = true; } }; + +{ + const gcObject = {}; + onGC(gcObject, gcListener); + + const sock = tls.connect( + server.address().port, + { rejectUnauthorized: false }, + common.mustCall(() => { + assert.strictEqual(gcObject, gcObject); // Keep reference alive + assert.strictEqual(collected, false); + setImmediate(done, sock); + })); +} + +function done(sock) { + globalThis.gc(); + setImmediate(common.mustCall(() => { + assert.strictEqual(collected, true); + sock.end(); + server.close(); + })); +} diff --git a/test/js/node/test/parallel/test-tls-connect-timeout-option.js b/test/js/node/test/parallel/test-tls-connect-timeout-option.js new file mode 100644 index 000000000000..3c4328d94d91 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-connect-timeout-option.js @@ -0,0 +1,20 @@ +'use strict'; + +const common = require('../common'); + +// This test verifies that `tls.connect()` honors the `timeout` option when the +// socket is internally created. + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const tls = require('tls'); + +const socket = tls.connect({ + port: 42, + lookup: () => {}, + timeout: 1000 +}); + +assert.strictEqual(socket.timeout, 1000); diff --git a/test/js/node/test/parallel/test-tls-dhparam-auto-boringssl.js b/test/js/node/test/parallel/test-tls-dhparam-auto-boringssl.js new file mode 100644 index 000000000000..54f2190d1a94 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-dhparam-auto-boringssl.js @@ -0,0 +1,19 @@ +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +if (!process.features.openssl_is_boringssl) + common.skip('only applies to BoringSSL builds'); + +const assert = require('assert'); +const tls = require('tls'); + +// BoringSSL does not provide SSL_CTX_set_dh_auto, so requesting automatic +// DH parameter selection via `dhparam: 'auto'` must throw. +assert.throws(() => { + tls.createSecureContext({ dhparam: 'auto' }); +}, { + code: 'ERR_CRYPTO_UNSUPPORTED_OPERATION', + message: 'Automatic DH parameter selection is not supported', +}); diff --git a/test/js/node/test/parallel/test-tls-disable-renegotiation.js b/test/js/node/test/parallel/test-tls-disable-renegotiation.js new file mode 100644 index 000000000000..84a6ead4a544 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-disable-renegotiation.js @@ -0,0 +1,99 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const fixtures = require('../common/fixtures'); + +// Tests that calling disableRenegotiation on a TLSSocket stops renegotiation. + +if (!common.hasCrypto) + common.skip('missing crypto'); + +if (process.features.openssl_is_boringssl) { + require('../common/boringssl').testRenegotiationUnsupported(); + return; +} + +const tls = require('tls'); + +// Renegotiation as a protocol feature was dropped after TLS1.2. +tls.DEFAULT_MAX_VERSION = 'TLSv1.2'; + +const options = { + key: fixtures.readKey('agent1-key.pem'), + cert: fixtures.readKey('agent1-cert.pem'), +}; + +const server = tls.Server(options, common.mustCall((socket) => { + socket.on('error', common.mustCall((err) => { + common.expectsError({ + name: 'Error', + code: 'ERR_TLS_RENEGOTIATION_DISABLED', + message: 'TLS session renegotiation disabled for this socket' + })(err); + socket.destroy(); + server.close(); + })); + // Disable renegotiation after the first chunk of data received. + // Demonstrates that renegotiation works successfully up until + // disableRenegotiation is called. + socket.on('data', common.mustCall((chunk) => { + socket.write(chunk); + socket.disableRenegotiation(); + })); + socket.on('secure', common.mustCall(() => { + assert(socket._handle.handshakes < 2, + `Too many handshakes [${socket._handle.handshakes}]`); + })); +})); + + +server.listen(0, common.mustCall(() => { + const port = server.address().port; + const options = { + rejectUnauthorized: false, + port + }; + const client = tls.connect(options, common.mustCall(() => { + + assert.throws(() => client.renegotiate(), { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + }); + + assert.throws(() => client.renegotiate(common.mustNotCall()), { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + }); + + assert.throws(() => client.renegotiate({}, false), { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + }); + + assert.throws(() => client.renegotiate({}, null), { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + }); + + + // Negotiation is still permitted for this first + // attempt. This should succeed. + let ok = client.renegotiate(options, common.mustSucceed(() => { + // Once renegotiation completes, we write some + // data to the socket, which triggers the on + // data event on the server. After that data + // is received, disableRenegotiation is called. + client.write('data', common.mustCall(() => { + // This second renegotiation attempt should fail + // and the callback should never be invoked. The + // server will simply drop the connection after + // emitting the error. + ok = client.renegotiate(options, common.mustNotCall()); + assert.strictEqual(ok, true); + })); + })); + assert.strictEqual(ok, true); + client.on('secureConnect', common.mustCall()); + client.on('secure', common.mustCall()); + })); +})); diff --git a/test/js/node/test/parallel/test-tls-econnreset.js b/test/js/node/test/parallel/test-tls-econnreset.js index a056f908190f..8308c8904d99 100644 --- a/test/js/node/test/parallel/test-tls-econnreset.js +++ b/test/js/node/test/parallel/test-tls-econnreset.js @@ -34,11 +34,11 @@ let clientError = null; const server = tls.createServer({ cert: fixtures.readKey('agent1-cert.pem'), key: fixtures.readKey('agent1-key.pem'), -}, common.mustNotCall()).on('tlsClientError', function(err, conn) { +}, common.mustNotCall()).on('tlsClientError', common.mustCall(function(err, conn) { assert(!clientError && conn); clientError = err; server.close(); -}).listen(0, function() { +})).listen(0, function() { net.connect(this.address().port, function() { // Destroy the socket once it is connected, so the server sees ECONNRESET. this.destroy(); diff --git a/test/js/node/test/parallel/test-tls-empty-sni-context.js b/test/js/node/test/parallel/test-tls-empty-sni-context.js new file mode 100644 index 000000000000..6ecdfbeecbe3 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-empty-sni-context.js @@ -0,0 +1,35 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); +const { hasOpenSSL } = require('../common/crypto'); +const assert = require('assert'); +const tls = require('tls'); + +const options = { + SNICallback: (name, callback) => { + callback(null, tls.createSecureContext()); + } +}; + +const server = tls.createServer(options, (c) => { + assert.fail('Should not be called'); +}).on('tlsClientError', common.mustCall((err, c) => { + assert.match(err.message, /no suitable signature algorithm|NO_CERTIFICATE_SET/i); + server.close(); +})).listen(0, common.mustCall(() => { + const c = tls.connect({ + port: server.address().port, + rejectUnauthorized: false, + servername: 'any.name' + }, common.mustNotCall()); + + c.on('error', common.mustCall((err) => { + const expectedErr = process.features.openssl_is_boringssl ? + 'ERR_SSL_TLSV1_ALERT_INTERNAL_ERROR' : hasOpenSSL(4, 0) ? + 'ERR_SSL_TLS_ALERT_HANDSHAKE_FAILURE' : hasOpenSSL(3, 2) ? + 'ERR_SSL_SSL/TLS_ALERT_HANDSHAKE_FAILURE' : 'ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE'; + assert.strictEqual(err.code, expectedErr); + })); +})); diff --git a/test/js/node/test/parallel/test-tls-enable-keylog-cli.js b/test/js/node/test/parallel/test-tls-enable-keylog-cli.js new file mode 100644 index 000000000000..68378d0c6d0d --- /dev/null +++ b/test/js/node/test/parallel/test-tls-enable-keylog-cli.js @@ -0,0 +1,61 @@ +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) common.skip('missing crypto'); +const fixtures = require('../common/fixtures'); + +// Test --tls-keylog CLI flag. + +const assert = require('assert'); +const fs = require('fs'); +const { fork } = require('child_process'); + +if (process.argv[2] === 'test') + return test(); + +const tmpdir = require('../common/tmpdir'); +tmpdir.refresh(); +const file = tmpdir.resolve('keylog.log'); + +const child = fork(__filename, ['test'], { + execArgv: ['--tls-keylog=' + file] +}); + +child.on('close', common.mustCall((code, signal) => { + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + const log = fs.readFileSync(file, 'utf8').trim().split('\n'); + // Both client and server should log their secrets, + // so we should have two identical lines in the log + assert.strictEqual(log.length, 2); + assert.strictEqual(log[0], log[1]); +})); + +function test() { + const { + connect, keys + } = require(fixtures.path('tls-connect')); + + connect({ + client: { + checkServerIdentity: (servername, cert) => { }, + ca: `${keys.agent1.cert}\n${keys.agent6.ca}`, + }, + server: { + cert: keys.agent6.cert, + key: keys.agent6.key, + // Number of keylog events is dependent on protocol version + maxVersion: 'TLSv1.2', + }, + }, common.mustCall((err, pair, cleanup) => { + if (pair.server.err) { + console.trace('server', pair.server.err); + } + if (pair.client.err) { + console.trace('client', pair.client.err); + } + assert.ifError(pair.server.err); + assert.ifError(pair.client.err); + + return cleanup(); + })); +} diff --git a/test/js/node/test/parallel/test-tls-env-bad-extra-ca.js b/test/js/node/test/parallel/test-tls-env-bad-extra-ca.js new file mode 100644 index 000000000000..c9db7e4d0312 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-env-bad-extra-ca.js @@ -0,0 +1,44 @@ +// Setting NODE_EXTRA_CA_CERTS to non-existent file emits a warning + +'use strict'; +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const fixtures = require('../common/fixtures'); +const fork = require('child_process').fork; +const tls = require('tls'); + +if (process.env.CHILD) { + // This will try to load the extra CA certs, and emit a warning when it fails. + return tls.createServer({}); +} + +const env = { + ...process.env, + CHILD: 'yes', + NODE_EXTRA_CA_CERTS: `${fixtures.fixturesDir}/no-such-file-exists-🐢`, +}; + +const opts = { + env: env, + silent: true, +}; +let stderr = ''; + +fork(__filename, opts) + .on('exit', common.mustCall(function(status) { + // Check that client succeeded in connecting. + assert.strictEqual(status, 0); + })) + .on('close', common.mustCall(function() { + if (!common.isWindows) { + const re = /Warning: Ignoring extra certs from.*no-such-file-exists-🐢.* load failed:.*No such file or directory/; + assert.match(stderr, re); + } + })) + .stderr.setEncoding('utf8').on('data', function(str) { + stderr += str; + }); diff --git a/test/js/node/test/parallel/test-tls-env-extra-ca-with-options.js b/test/js/node/test/parallel/test-tls-env-extra-ca-with-options.js new file mode 100644 index 000000000000..8f04decf670c --- /dev/null +++ b/test/js/node/test/parallel/test-tls-env-extra-ca-with-options.js @@ -0,0 +1,82 @@ +'use strict'; + +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('node:assert'); +const tls = require('node:tls'); +const { fork } = require('node:child_process'); +const fixtures = require('../common/fixtures'); + +const tests = [ + { + get clientOptions() { + const secureContext = tls.createSecureContext(); + secureContext.context.addCACert( + fixtures.readKey('ca1-cert.pem') + ); + + return { + secureContext + }; + } + }, + { + clientOptions: { + crl: fixtures.readKey('ca2-crl.pem') + } + }, + { + clientOptions: { + pfx: fixtures.readKey('agent1.pfx'), + passphrase: 'sample' + } + }, +]; + +if (process.argv[2]) { + const testNumber = parseInt(process.argv[2], 10); + assert(testNumber >= 0 && testNumber < tests.length); + + const test = tests[testNumber]; + + const clientOptions = { + ...test.clientOptions, + port: process.argv[3], + checkServerIdentity: common.mustCall() + }; + + const client = tls.connect(clientOptions, common.mustCall(() => { + client.end('hi'); + })); +} else { + const serverOptions = { + key: fixtures.readKey('agent3-key.pem'), + cert: fixtures.readKey('agent3-cert.pem') + }; + + for (const testNumber in tests) { + const server = tls.createServer(serverOptions, common.mustCall((socket) => { + socket.end('bye'); + server.close(); + })); + + server.listen(0, common.mustCall(() => { + const env = { + ...process.env, + NODE_EXTRA_CA_CERTS: fixtures.path('keys', 'ca2-cert.pem') + }; + + const args = [ + testNumber, + server.address().port, + ]; + + fork(__filename, args, { env }).on('exit', common.mustCall((status) => { + assert.strictEqual(status, 0); + })); + })); + } +} diff --git a/test/js/node/test/parallel/test-tls-env-extra-ca.js b/test/js/node/test/parallel/test-tls-env-extra-ca.js new file mode 100644 index 000000000000..7ac5ca3c86e5 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-env-extra-ca.js @@ -0,0 +1,46 @@ +// Certs in NODE_EXTRA_CA_CERTS are used for TLS peer validation + +'use strict'; +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); + +const { fork } = require('child_process'); + +if (process.env.CHILD) { + const copts = { + port: process.env.PORT, + checkServerIdentity: common.mustCall(), + }; + const client = tls.connect(copts, common.mustCall(function() { + client.end('hi'); + })); + return; +} + +const options = { + key: fixtures.readKey('agent1-key.pem'), + cert: fixtures.readKey('agent1-cert.pem'), +}; + +const server = tls.createServer(options, common.mustCall(function(s) { + s.end('bye'); + server.close(); +})).listen(0, common.mustCall(function() { + const env = { + ...process.env, + CHILD: 'yes', + PORT: this.address().port, + NODE_EXTRA_CA_CERTS: fixtures.path('keys', 'ca1-cert.pem') + }; + + fork(__filename, { env }).on('exit', common.mustCall(function(status) { + // Client did not succeed in connecting + assert.strictEqual(status, 0); + })); +})); diff --git a/test/js/node/test/parallel/test-tls-error-servername.js b/test/js/node/test/parallel/test-tls-error-servername.js new file mode 100644 index 000000000000..597b7f29a406 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-error-servername.js @@ -0,0 +1,48 @@ +'use strict'; + +// This tests the errors thrown from TLSSocket.prototype.setServername + +const common = require('../common'); +const fixtures = require('../common/fixtures'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const { connect, TLSSocket } = require('tls'); +const { duplexPair } = require('stream'); +const [ clientSide, serverSide ] = duplexPair(); + +const key = fixtures.readKey('agent1-key.pem'); +const cert = fixtures.readKey('agent1-cert.pem'); +const ca = fixtures.readKey('ca1-cert.pem'); + +const client = connect({ + socket: clientSide, + ca, + host: 'agent1' // Hostname from certificate +}); + +[undefined, null, 1, true, {}].forEach((value) => { + assert.throws(() => { + client.setServername(value); + }, { + code: 'ERR_INVALID_ARG_TYPE', + message: 'The "name" argument must be of type string.' + + common.invalidArgTypeHelper(value) + }); +}); + +const server = new TLSSocket(serverSide, { + isServer: true, + key, + cert, + ca +}); + +assert.throws(() => { + server.setServername('localhost'); +}, { + code: 'ERR_TLS_SNI_FROM_SERVER', + message: 'Cannot issue SNI from a TLS server-side socket' +}); diff --git a/test/js/node/test/parallel/test-tls-error-stack.js b/test/js/node/test/parallel/test-tls-error-stack.js new file mode 100644 index 000000000000..02021b060ecb --- /dev/null +++ b/test/js/node/test/parallel/test-tls-error-stack.js @@ -0,0 +1,21 @@ +'use strict'; + +// This tests that the crypto error stack can be correctly converted. +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const tls = require('tls'); + +assert.throws(() => { + tls.createSecureContext({ clientCertEngine: 'x' }); +}, (err) => { + if (err.code === 'ERR_CRYPTO_CUSTOM_ENGINE_NOT_SUPPORTED') + common.skip('OpenSSL dropped engine support'); + + return err.name === 'Error' && + /could not load the shared library/.test(err.message) && + Array.isArray(err.opensslErrorStack) && + err.opensslErrorStack.length > 0; +}); diff --git a/test/js/node/test/parallel/test-tls-exportkeyingmaterial.js b/test/js/node/test/parallel/test-tls-exportkeyingmaterial.js new file mode 100644 index 000000000000..5f3281ffc4f8 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-exportkeyingmaterial.js @@ -0,0 +1,102 @@ +'use strict'; + +// Test return value of tlsSocket.exportKeyingMaterial + +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const net = require('net'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); + +const key = fixtures.readKey('agent1-key.pem'); +const cert = fixtures.readKey('agent1-cert.pem'); + +const server = net.createServer(common.mustCall((s) => { + const tlsSocket = new tls.TLSSocket(s, { + isServer: true, + server: server, + secureContext: tls.createSecureContext({ key, cert }) + }); + + assert.throws(() => { + tlsSocket.exportKeyingMaterial(128, 'label'); + }, { + name: 'Error', + message: 'TLS socket connection must be securely established', + code: 'ERR_TLS_INVALID_STATE' + }); + + tlsSocket.on('secure', common.mustCall(() => { + const label = 'client finished'; + + const validKeyingMaterial = tlsSocket.exportKeyingMaterial(128, label); + assert.strictEqual(validKeyingMaterial.length, 128); + + const validKeyingMaterialWithContext = tlsSocket + .exportKeyingMaterial(128, label, Buffer.from([0, 1, 2, 3])); + assert.strictEqual(validKeyingMaterialWithContext.length, 128); + + // Ensure providing a context results in a different key than without + assert.notStrictEqual(validKeyingMaterial, validKeyingMaterialWithContext); + + const validKeyingMaterialWithEmptyContext = tlsSocket + .exportKeyingMaterial(128, label, Buffer.from([])); + assert.strictEqual(validKeyingMaterialWithEmptyContext.length, 128); + + assert.throws(() => { + tlsSocket.exportKeyingMaterial(128, label, 'stringAsContextNotSupported'); + }, { + name: 'TypeError', + code: 'ERR_INVALID_ARG_TYPE' + }); + + assert.throws(() => { + tlsSocket.exportKeyingMaterial(128, label, 1234); + }, { + name: 'TypeError', + code: 'ERR_INVALID_ARG_TYPE' + }); + + assert.throws(() => { + tlsSocket.exportKeyingMaterial(10, null); + }, { + name: 'TypeError', + code: 'ERR_INVALID_ARG_TYPE' + }); + + assert.throws(() => { + tlsSocket.exportKeyingMaterial('length', 1234); + }, { + name: 'TypeError', + code: 'ERR_INVALID_ARG_TYPE' + }); + + assert.throws(() => { + tlsSocket.exportKeyingMaterial(-3, 'a'); + }, { + name: 'RangeError', + code: 'ERR_OUT_OF_RANGE' + }); + + assert.throws(() => { + tlsSocket.exportKeyingMaterial(0, 'a'); + }, { + name: 'RangeError', + code: 'ERR_OUT_OF_RANGE' + }); + + tlsSocket.end(); + server.close(); + })); +})).listen(0, common.mustCall(() => { + const opts = { + port: server.address().port, + rejectUnauthorized: false + }; + + tls.connect(opts, common.mustCall(function() { this.end(); })); +})); diff --git a/test/js/node/test/parallel/test-tls-fast-writing.js b/test/js/node/test/parallel/test-tls-fast-writing.js index 4718acf28584..e59a1c27ccd1 100644 --- a/test/js/node/test/parallel/test-tls-fast-writing.js +++ b/test/js/node/test/parallel/test-tls-fast-writing.js @@ -37,7 +37,7 @@ let gotChunk = false; let gotDrain = false; function onconnection(conn) { - conn.on('data', function(c) { + conn.on('data', common.mustCall(function(c) { if (!gotChunk) { gotChunk = true; console.log('ok - got chunk'); @@ -49,7 +49,7 @@ function onconnection(conn) { if (gotDrain) process.exit(0); - }); + })); } server.listen(0, function() { diff --git a/test/js/node/test/parallel/test-tls-finished.js b/test/js/node/test/parallel/test-tls-finished.js new file mode 100644 index 000000000000..b23b4567d27e --- /dev/null +++ b/test/js/node/test/parallel/test-tls-finished.js @@ -0,0 +1,68 @@ +'use strict'; + +const common = require('../common'); +const fixtures = require('../common/fixtures'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +// This test ensures that tlsSocket.getFinished() and +// tlsSocket.getPeerFinished() return undefined before +// secure connection is established, and return non-empty +// Buffer objects with Finished messages afterwards, also +// verifying alice.getFinished() == bob.getPeerFinished() +// and alice.getPeerFinished() == bob.getFinished(). + +const assert = require('assert'); +const tls = require('tls'); + +const msg = {}; +const pem = (n) => fixtures.readKey(`${n}.pem`); +const server = tls.createServer({ + key: pem('agent1-key'), + cert: pem('agent1-cert'), + ...(process.features.openssl_is_boringssl ? { maxVersion: 'TLSv1.2' } : {}), +}, common.mustCall((alice) => { + msg.server = { + alice: alice.getFinished(), + bob: alice.getPeerFinished() + }; + server.close(); +})); + +server.listen(0, common.mustCall(() => { + const bob = tls.connect({ + port: server.address().port, + rejectUnauthorized: false, + ...(process.features.openssl_is_boringssl ? { maxVersion: 'TLSv1.2' } : {}), + }, common.mustCall(() => { + msg.client = { + alice: bob.getPeerFinished(), + bob: bob.getFinished() + }; + bob.end(); + })); + + msg.before = { + alice: bob.getPeerFinished(), + bob: bob.getFinished() + }; +})); + +process.on('exit', () => { + assert.strictEqual(undefined, msg.before.alice); + assert.strictEqual(undefined, msg.before.bob); + + assert(Buffer.isBuffer(msg.server.alice)); + assert(Buffer.isBuffer(msg.server.bob)); + assert(Buffer.isBuffer(msg.client.alice)); + assert(Buffer.isBuffer(msg.client.bob)); + + assert(msg.server.alice.length > 0); + assert(msg.server.bob.length > 0); + assert(msg.client.alice.length > 0); + assert(msg.client.bob.length > 0); + + assert(msg.server.alice.equals(msg.client.alice)); + assert(msg.server.bob.equals(msg.client.bob)); +}); diff --git a/test/js/node/test/parallel/test-tls-get-ca-certificates-system-without-flag.js b/test/js/node/test/parallel/test-tls-get-ca-certificates-system-without-flag.js new file mode 100644 index 000000000000..026e44fcaeda --- /dev/null +++ b/test/js/node/test/parallel/test-tls-get-ca-certificates-system-without-flag.js @@ -0,0 +1,36 @@ +'use strict'; + +// This tests that tls.getCACertificates() returns the system +// certificates correctly when --use-system-ca is disabled. + +const common = require('../common'); +if (!common.hasCrypto) common.skip('missing crypto'); + +const tmpdir = require('../common/tmpdir'); +const fs = require('fs'); + +const assert = require('assert'); +const { spawnSyncAndExitWithoutError } = require('../common/child_process'); +const fixtures = require('../common/fixtures'); +const tls = require('tls'); + +const certs = tls.getCACertificates('system'); +if (certs.length === 0) { + common.skip('No trusted system certificates installed. Skip.'); +} + +tmpdir.refresh(); +const certsJSON = tmpdir.resolve('certs.json'); +spawnSyncAndExitWithoutError(process.execPath, [ + '--no-use-system-ca', + fixtures.path('tls-get-ca-certificates.js'), +], { + env: { + ...process.env, + CA_TYPE: 'system', + CA_OUT: certsJSON, + } +}); + +const parsed = JSON.parse(fs.readFileSync(certsJSON, 'utf-8')); +assert.deepStrictEqual(parsed, certs); diff --git a/test/js/node/test/parallel/test-tls-getcertificate-x509.js b/test/js/node/test/parallel/test-tls-getcertificate-x509.js new file mode 100644 index 000000000000..704aa33e6edf --- /dev/null +++ b/test/js/node/test/parallel/test-tls-getcertificate-x509.js @@ -0,0 +1,38 @@ +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); +const { X509Certificate } = require('crypto'); + +const options = { + key: fixtures.readKey('agent6-key.pem'), + cert: fixtures.readKey('agent6-cert.pem') +}; + +const server = tls.createServer(options, function(cleartext) { + cleartext.end('World'); +}); + +server.once('secureConnection', common.mustCall(function(socket) { + const cert = socket.getX509Certificate(); + assert(cert instanceof X509Certificate); + assert.match(cert.serialNumber, /5B75D77EDC7FB5B7FA9F1424DA4C64FB815DCBDE/i); +})); + +server.listen(0, common.mustCall(function() { + const socket = tls.connect({ + port: this.address().port, + rejectUnauthorized: false + }, common.mustCall(function() { + const peerCert = socket.getPeerX509Certificate(); + assert(peerCert.issuerCertificate instanceof X509Certificate); + assert.strictEqual(peerCert.issuerCertificate.issuerCertificate, undefined); + assert.match(peerCert.issuerCertificate.serialNumber, /147D36C1C2F74206DE9FAB5F2226D78ADB00A425/i); + server.close(); + })); + socket.end('Hello'); +})); diff --git a/test/js/node/test/parallel/test-tls-getprotocol.js b/test/js/node/test/parallel/test-tls-getprotocol.js new file mode 100644 index 000000000000..2945ff99b5a2 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-getprotocol.js @@ -0,0 +1,68 @@ +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const { hasOpenSSL } = require('../common/crypto'); + +// This test ensures that `getProtocol` returns the right protocol +// from a TLS connection + +const assert = require('assert'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); + +let clientConfigs = [ + { + secureProtocol: 'TLSv1_method', + version: 'TLSv1', + ciphers: (hasOpenSSL(3, 1) ? 'DEFAULT:@SECLEVEL=0' : 'DEFAULT') + }, { + secureProtocol: 'TLSv1_1_method', + version: 'TLSv1.1', + ciphers: (hasOpenSSL(3, 1) ? 'DEFAULT:@SECLEVEL=0' : 'DEFAULT') + }, { + secureProtocol: 'TLSv1_2_method', + version: 'TLSv1.2' + }, +]; + +if (process.features.openssl_is_boringssl) { + // Remove the TLSv1 and TLSv1.1 cases. BoringSSL does not negotiate those + // legacy protocols in this configuration; keep TLSv1.2 to cover getProtocol() + // on a successful BoringSSL TLS handshake. + common.printSkipMessage('BoringSSL: skipping TLSv1/TLSv1.1 getProtocol cases'); + clientConfigs = clientConfigs.filter(({ version }) => version === 'TLSv1.2'); +} + +const serverConfig = { + secureProtocol: 'TLS_method', + key: fixtures.readKey('agent2-key.pem'), + cert: fixtures.readKey('agent2-cert.pem') +}; + +if (!process.features.openssl_is_boringssl) { + serverConfig.ciphers = 'RSA@SECLEVEL=0'; +} + +const server = tls.createServer(serverConfig, common.mustCall(clientConfigs.length)) +.listen(0, common.localhostIPv4, common.mustCall(function() { + let connected = 0; + for (const v of clientConfigs) { + tls.connect({ + host: common.localhostIPv4, + port: server.address().port, + ciphers: v.ciphers, + rejectUnauthorized: false, + secureProtocol: v.secureProtocol + }, common.mustCall(function() { + assert.strictEqual(this.getProtocol(), v.version); + this.on('end', common.mustCall()); + this.on('close', common.mustCall(function() { + assert.strictEqual(this.getProtocol(), null); + })).end(); + if (++connected === clientConfigs.length) + server.close(); + })); + } +})); diff --git a/test/js/node/test/parallel/test-tls-invalid-pfx.js b/test/js/node/test/parallel/test-tls-invalid-pfx.js new file mode 100644 index 000000000000..c16858f0f788 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-invalid-pfx.js @@ -0,0 +1,23 @@ +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); +const fixtures = require('../common/fixtures'); + +const { + assert, connect, keys +} = require(fixtures.path('tls-connect')); + +const invalidPfx = fixtures.readKey('cert-without-key.pfx'); + +connect({ + client: { + pfx: invalidPfx, + passphrase: 'test', + rejectUnauthorized: false + }, + server: keys.agent1 +}, common.mustCall((e, pair, cleanup) => { + assert.strictEqual(e.message, 'Unable to load private key from PFX data'); + cleanup(); +})); diff --git a/test/js/node/test/parallel/test-tls-ip-servername-forbidden.js b/test/js/node/test/parallel/test-tls-ip-servername-forbidden.js new file mode 100644 index 000000000000..646029501411 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-ip-servername-forbidden.js @@ -0,0 +1,18 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const tls = require('tls'); + +// Verify that passing an IP address the the servername option +// throws an error. +assert.throws(() => tls.connect({ + port: 1234, + servername: '127.0.0.1', +}, common.mustNotCall()), { + code: 'ERR_INVALID_ARG_VALUE', +}); diff --git a/test/js/node/test/parallel/test-tls-js-stream.js b/test/js/node/test/parallel/test-tls-js-stream.js new file mode 100644 index 000000000000..298252962ba9 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-js-stream.js @@ -0,0 +1,66 @@ +'use strict'; +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const fixtures = require('../common/fixtures'); + +const net = require('net'); +const stream = require('stream'); +const tls = require('tls'); + +const server = tls.createServer({ + key: fixtures.readKey('agent1-key.pem'), + cert: fixtures.readKey('agent1-cert.pem') +}, common.mustCall(function(c) { + console.log('new client'); + + c.resume(); + c.end('ohai'); +})).listen(0, common.mustCall(function() { + const raw = net.connect(this.address().port); + + let pending = false; + raw.on('readable', function() { + if (pending) + p._read(); + }); + + raw.on('end', function() { + p.push(null); + }); + + const p = new stream.Duplex({ + read: function read() { + pending = false; + + const chunk = raw.read(); + if (chunk) { + console.log('read', chunk); + this.push(chunk); + } else { + pending = true; + } + }, + write: function write(data, enc, cb) { + console.log('write', data, enc); + raw.write(data, enc, cb); + } + }); + + const socket = tls.connect({ + socket: p, + rejectUnauthorized: false + }, common.mustCall(function() { + console.log('client secure'); + + socket.resume(); + socket.end('hello'); + })); + + socket.once('close', function() { + console.log('client close'); + server.close(); + }); +})); diff --git a/test/js/node/test/parallel/test-tls-key-mismatch.js b/test/js/node/test/parallel/test-tls-key-mismatch.js new file mode 100644 index 000000000000..797c7c171dc5 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-key-mismatch.js @@ -0,0 +1,47 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); + +if (!common.hasCrypto) { + common.skip('missing crypto'); +} + +const fixtures = require('../common/fixtures'); +const { hasOpenSSL3 } = require('../common/crypto'); + +const assert = require('assert'); +const tls = require('tls'); +const errorMessageRegex = process.features.openssl_is_boringssl ? + /^Error: error:0b000074:X\.509 certificate routines:OPENSSL_internal:KEY_VALUES_MISMATCH$/ : + hasOpenSSL3 ? + /^Error: error:05800074:x509 certificate routines::key values mismatch$/ : + /^Error: error:0B080074:x509 certificate routines:X509_check_private_key:key values mismatch$/; + +const options = { + key: fixtures.readKey('agent1-key.pem'), + cert: fixtures.readKey('agent2-cert.pem') +}; + +assert.throws(function() { + tls.createSecureContext(options); +}, errorMessageRegex); diff --git a/test/js/node/test/parallel/test-tls-keylog-tlsv13.js b/test/js/node/test/parallel/test-tls-keylog-tlsv13.js new file mode 100644 index 000000000000..0ee20496c964 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-keylog-tlsv13.js @@ -0,0 +1,36 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); + +const server = tls.createServer({ + key: fixtures.readKey('agent2-key.pem'), + cert: fixtures.readKey('agent2-cert.pem'), + // Amount of keylog events depends on negotiated protocol + // version, so force a specific one: + minVersion: 'TLSv1.3', + maxVersion: 'TLSv1.3', +}).listen(common.mustCall(() => { + const client = tls.connect({ + port: server.address().port, + rejectUnauthorized: false, + }); + + server.on('keylog', common.mustCall((line, tlsSocket) => { + assert(Buffer.isBuffer(line)); + assert.strictEqual(tlsSocket.encrypted, true); + }, 5)); + client.on('keylog', common.mustCall((line) => { + assert(Buffer.isBuffer(line)); + }, 5)); + + client.once('secureConnect', () => { + server.close(); + client.end(); + }); +})); diff --git a/test/js/node/test/parallel/test-tls-min-max-version.js b/test/js/node/test/parallel/test-tls-min-max-version.js new file mode 100644 index 000000000000..abddbbeb0eba --- /dev/null +++ b/test/js/node/test/parallel/test-tls-min-max-version.js @@ -0,0 +1,287 @@ +'use strict'; +const common = require('../common'); + +if (!common.hasCrypto) { + common.skip('missing crypto'); +} + +if (process.features.openssl_is_boringssl) { + require('../common/boringssl').testLegacyProtocolUnsupported(); + return; +} + +const { + hasOpenSSL, + hasOpenSSL3, +} = require('../common/crypto'); +const fixtures = require('../common/fixtures'); +const { inspect } = require('util'); + +// Check min/max protocol versions. + +const { + assert, connect, keys, tls +} = require(fixtures.path('tls-connect')); +const DEFAULT_MIN_VERSION = tls.DEFAULT_MIN_VERSION; +const DEFAULT_MAX_VERSION = tls.DEFAULT_MAX_VERSION; + + +function test(cmin, cmax, cprot, smin, smax, sprot, proto, cerr, serr) { + assert(proto || cerr || serr, 'test missing any expectations'); + + let ciphers; + if (hasOpenSSL3 && (proto === 'TLSv1' || proto === 'TLSv1.1' || + proto === 'TLSv1_1_method' || proto === 'TLSv1_method' || + sprot === 'TLSv1_1_method' || sprot === 'TLSv1_method')) { + if (serr !== 'ERR_SSL_UNSUPPORTED_PROTOCOL') + ciphers = 'ALL@SECLEVEL=0'; + } + if (hasOpenSSL(3, 1) && cerr === 'ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION') { + ciphers = 'DEFAULT@SECLEVEL=0'; + } + // Report where test was called from. Strip leading garbage from + // at Object. (file:line) + // from the stack location, we only want the file:line part. + const where = inspect(new Error()).split('\n')[2].replace(/[^(]*/, ''); + connect({ + client: { + checkServerIdentity: (servername, cert) => { }, + ca: `${keys.agent1.cert}\n${keys.agent6.ca}`, + minVersion: cmin, + maxVersion: cmax, + secureProtocol: cprot, + ciphers: ciphers + }, + server: { + cert: keys.agent6.cert, + key: keys.agent6.key, + minVersion: smin, + maxVersion: smax, + secureProtocol: sprot, + ciphers: ciphers + }, + }, common.mustCall((err, pair, cleanup) => { + function u(_) { return _ === undefined ? 'U' : _; } + console.log('test:', u(cmin), u(cmax), u(cprot), u(smin), u(smax), u(sprot), + u(ciphers), 'expect', u(proto), u(cerr), u(serr)); + console.log(' ', where); + if (!proto) { + console.log('client', pair.client.err ? pair.client.err.code : undefined); + console.log('server', pair.server.err ? pair.server.err.code : undefined); + if (cerr) { + assert(pair.client.err); + // Accept these codes as aliases, the one reported depends on the + // OpenSSL version. + if (cerr === 'ERR_SSL_UNSUPPORTED_PROTOCOL' && + pair.client.err.code === 'ERR_SSL_VERSION_TOO_LOW') + cerr = 'ERR_SSL_VERSION_TOO_LOW'; + assert.strictEqual(pair.client.err.code, cerr); + } + if (serr) { + assert(pair.server.err); + assert.strictEqual(pair.server.err.code, serr); + } + return cleanup(); + } + + assert.ifError(err); + assert.ifError(pair.server.err); + assert.ifError(pair.client.err); + assert(pair.server.conn); + assert(pair.client.conn); + assert.strictEqual(pair.client.conn.getProtocol(), proto); + assert.strictEqual(pair.server.conn.getProtocol(), proto); + return cleanup(); + })); +} + +const U = undefined; + +// Default protocol is the max version. +test(U, U, U, U, U, U, DEFAULT_MAX_VERSION); + +// Insecure or invalid protocols cannot be enabled. +test(U, U, U, U, U, 'SSLv2_method', + U, U, 'ERR_TLS_INVALID_PROTOCOL_METHOD'); +test(U, U, U, U, U, 'SSLv3_method', + U, U, 'ERR_TLS_INVALID_PROTOCOL_METHOD'); +test(U, U, 'SSLv2_method', U, U, U, + U, 'ERR_TLS_INVALID_PROTOCOL_METHOD'); +test(U, U, 'SSLv3_method', U, U, U, + U, 'ERR_TLS_INVALID_PROTOCOL_METHOD'); +test(U, U, 'hokey-pokey', U, U, U, + U, 'ERR_TLS_INVALID_PROTOCOL_METHOD'); +test(U, U, U, U, U, 'hokey-pokey', + U, U, 'ERR_TLS_INVALID_PROTOCOL_METHOD'); + +// Regression test: this should not crash because node should not pass the error +// message (including unsanitized user input) to a printf-like function. +test(U, U, U, U, U, '%s_method', + U, U, 'ERR_TLS_INVALID_PROTOCOL_METHOD'); + +// Cannot use secureProtocol and min/max versions simultaneously. +test(U, U, U, U, 'TLSv1.2', 'TLS1_2_method', + U, U, 'ERR_TLS_PROTOCOL_VERSION_CONFLICT'); +test(U, U, U, 'TLSv1.2', U, 'TLS1_2_method', + U, U, 'ERR_TLS_PROTOCOL_VERSION_CONFLICT'); +test(U, 'TLSv1.2', 'TLS1_2_method', U, U, U, + U, 'ERR_TLS_PROTOCOL_VERSION_CONFLICT'); +test('TLSv1.2', U, 'TLS1_2_method', U, U, U, + U, 'ERR_TLS_PROTOCOL_VERSION_CONFLICT'); + +// TLS_method means "any supported protocol". +test(U, U, 'TLSv1_2_method', U, U, 'TLS_method', 'TLSv1.2'); +test(U, U, 'TLSv1_1_method', U, U, 'TLS_method', 'TLSv1.1'); +test(U, U, 'TLSv1_method', U, U, 'TLS_method', 'TLSv1'); +test(U, U, 'TLS_method', U, U, 'TLSv1_2_method', 'TLSv1.2'); +test(U, U, 'TLS_method', U, U, 'TLSv1_1_method', 'TLSv1.1'); +test(U, U, 'TLS_method', U, U, 'TLSv1_method', 'TLSv1'); + +// OpenSSL 1.1.1 and 3.0 use a different error code and alert (sent to the +// client) when no protocols are enabled on the server. +const NO_PROTOCOLS_AVAILABLE_SERVER = hasOpenSSL3 ? + 'ERR_SSL_NO_PROTOCOLS_AVAILABLE' : 'ERR_SSL_INTERNAL_ERROR'; +const NO_PROTOCOLS_AVAILABLE_SERVER_ALERT = hasOpenSSL3 ? + 'ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION' : 'ERR_SSL_TLSV1_ALERT_INTERNAL_ERROR'; + +// SSLv23 also means "any supported protocol" greater than the default +// minimum (which is configurable via command line). +if (DEFAULT_MIN_VERSION === 'TLSv1.3') { + test(U, U, 'TLSv1_2_method', U, U, 'SSLv23_method', + U, NO_PROTOCOLS_AVAILABLE_SERVER_ALERT, NO_PROTOCOLS_AVAILABLE_SERVER); +} else { + test(U, U, 'TLSv1_2_method', U, U, 'SSLv23_method', 'TLSv1.2'); +} + +if (DEFAULT_MIN_VERSION === 'TLSv1.3') { + test(U, U, 'TLSv1_1_method', U, U, 'SSLv23_method', + U, NO_PROTOCOLS_AVAILABLE_SERVER_ALERT, NO_PROTOCOLS_AVAILABLE_SERVER); + test(U, U, 'TLSv1_method', U, U, 'SSLv23_method', + U, NO_PROTOCOLS_AVAILABLE_SERVER_ALERT, NO_PROTOCOLS_AVAILABLE_SERVER); + test(U, U, 'SSLv23_method', U, U, 'TLSv1_1_method', + U, 'ERR_SSL_NO_PROTOCOLS_AVAILABLE', 'ERR_SSL_UNEXPECTED_MESSAGE'); + test(U, U, 'SSLv23_method', U, U, 'TLSv1_method', + U, 'ERR_SSL_NO_PROTOCOLS_AVAILABLE', 'ERR_SSL_UNEXPECTED_MESSAGE'); +} + +if (DEFAULT_MIN_VERSION === 'TLSv1.2') { + test(U, U, 'TLSv1_1_method', U, U, 'SSLv23_method', + U, 'ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION', + 'ERR_SSL_UNSUPPORTED_PROTOCOL'); + test(U, U, 'TLSv1_method', U, U, 'SSLv23_method', + U, 'ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION', + 'ERR_SSL_UNSUPPORTED_PROTOCOL'); + test(U, U, 'SSLv23_method', U, U, 'TLSv1_1_method', + U, 'ERR_SSL_UNSUPPORTED_PROTOCOL', 'ERR_SSL_WRONG_VERSION_NUMBER'); + test(U, U, 'SSLv23_method', U, U, 'TLSv1_method', + U, 'ERR_SSL_UNSUPPORTED_PROTOCOL', 'ERR_SSL_WRONG_VERSION_NUMBER'); +} + +if (DEFAULT_MIN_VERSION === 'TLSv1.1') { + test(U, U, 'TLSv1_1_method', U, U, 'SSLv23_method', 'TLSv1.1'); + test(U, U, 'TLSv1_method', U, U, 'SSLv23_method', + U, 'ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION', + 'ERR_SSL_UNSUPPORTED_PROTOCOL'); + test(U, U, 'SSLv23_method', U, U, 'TLSv1_1_method', 'TLSv1.1'); + test(U, U, 'SSLv23_method', U, U, 'TLSv1_method', + U, 'ERR_SSL_UNSUPPORTED_PROTOCOL', 'ERR_SSL_WRONG_VERSION_NUMBER'); +} + +if (DEFAULT_MIN_VERSION === 'TLSv1') { + test(U, U, 'TLSv1_1_method', U, U, 'SSLv23_method', 'TLSv1.1'); + test(U, U, 'TLSv1_method', U, U, 'SSLv23_method', 'TLSv1'); + test(U, U, 'SSLv23_method', U, U, 'TLSv1_1_method', 'TLSv1.1'); + test(U, U, 'SSLv23_method', U, U, 'TLSv1_method', 'TLSv1'); +} + +// TLSv1 thru TLSv1.2 are only supported with explicit configuration with API or +// CLI (--tls-v1.0 and --tls-v1.1). +test(U, U, 'TLSv1_2_method', U, U, 'TLSv1_2_method', 'TLSv1.2'); +test(U, U, 'TLSv1_1_method', U, U, 'TLSv1_1_method', 'TLSv1.1'); +test(U, U, 'TLSv1_method', U, U, 'TLSv1_method', 'TLSv1'); + +// The default default. +if (DEFAULT_MIN_VERSION === 'TLSv1.2') { + test(U, U, 'TLSv1_1_method', U, U, U, + U, 'ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION', + 'ERR_SSL_UNSUPPORTED_PROTOCOL'); + test(U, U, 'TLSv1_method', U, U, U, + U, 'ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION', + 'ERR_SSL_UNSUPPORTED_PROTOCOL'); + + if (DEFAULT_MAX_VERSION === 'TLSv1.2') { + test(U, U, U, U, U, 'TLSv1_1_method', + U, 'ERR_SSL_UNSUPPORTED_PROTOCOL', 'ERR_SSL_WRONG_VERSION_NUMBER'); + test(U, U, U, U, U, 'TLSv1_method', + U, 'ERR_SSL_UNSUPPORTED_PROTOCOL', 'ERR_SSL_WRONG_VERSION_NUMBER'); + } else { + // TLS1.3 client hellos are are not understood by TLS1.1 or below. + test(U, U, U, U, U, 'TLSv1_1_method', + U, 'ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION', + 'ERR_SSL_UNSUPPORTED_PROTOCOL'); + test(U, U, U, U, U, 'TLSv1_method', + U, 'ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION', + 'ERR_SSL_UNSUPPORTED_PROTOCOL'); + } +} + +// The default with --tls-v1.1. +if (DEFAULT_MIN_VERSION === 'TLSv1.1') { + test(U, U, 'TLSv1_1_method', U, U, U, 'TLSv1.1'); + test(U, U, 'TLSv1_method', U, U, U, + U, 'ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION', + 'ERR_SSL_UNSUPPORTED_PROTOCOL'); + test(U, U, U, U, U, 'TLSv1_1_method', 'TLSv1.1'); + + if (DEFAULT_MAX_VERSION === 'TLSv1.2') { + test(U, U, U, U, U, 'TLSv1_method', + U, 'ERR_SSL_UNSUPPORTED_PROTOCOL', 'ERR_SSL_WRONG_VERSION_NUMBER'); + } else { + // TLS1.3 client hellos are are not understood by TLS1.1 or below. + test(U, U, U, U, U, 'TLSv1_method', + U, 'ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION', + 'ERR_SSL_UNSUPPORTED_PROTOCOL'); + } +} + +// The default with --tls-v1.0. +if (DEFAULT_MIN_VERSION === 'TLSv1') { + test(U, U, 'TLSv1_1_method', U, U, U, 'TLSv1.1'); + test(U, U, 'TLSv1_method', U, U, U, 'TLSv1'); + test(U, U, U, U, U, 'TLSv1_1_method', 'TLSv1.1'); + test(U, U, U, U, U, 'TLSv1_method', 'TLSv1'); +} + +// TLS min/max are respected when set with no secureProtocol. +test('TLSv1', 'TLSv1.2', U, U, U, 'TLSv1_method', 'TLSv1'); +test('TLSv1', 'TLSv1.2', U, U, U, 'TLSv1_1_method', 'TLSv1.1'); +test('TLSv1', 'TLSv1.2', U, U, U, 'TLSv1_2_method', 'TLSv1.2'); +test('TLSv1', 'TLSv1.2', U, U, U, 'TLS_method', 'TLSv1.2'); + +test(U, U, 'TLSv1_method', 'TLSv1', 'TLSv1.2', U, 'TLSv1'); +test(U, U, 'TLSv1_1_method', 'TLSv1', 'TLSv1.2', U, 'TLSv1.1'); +test(U, U, 'TLSv1_2_method', 'TLSv1', 'TLSv1.2', U, 'TLSv1.2'); + +test('TLSv1', 'TLSv1.1', U, 'TLSv1', 'TLSv1.3', U, 'TLSv1.1'); +test('TLSv1', 'TLSv1.1', U, 'TLSv1', 'TLSv1.2', U, 'TLSv1.1'); +test('TLSv1', 'TLSv1.2', U, 'TLSv1', 'TLSv1.1', U, 'TLSv1.1'); +test('TLSv1', 'TLSv1.3', U, 'TLSv1', 'TLSv1.1', U, 'TLSv1.1'); +test('TLSv1', 'TLSv1', U, 'TLSv1', 'TLSv1.1', U, 'TLSv1'); +test('TLSv1', 'TLSv1.2', U, 'TLSv1', 'TLSv1', U, 'TLSv1'); +test('TLSv1', 'TLSv1.3', U, 'TLSv1', 'TLSv1', U, 'TLSv1'); +test('TLSv1.1', 'TLSv1.1', U, 'TLSv1', 'TLSv1.2', U, 'TLSv1.1'); +test('TLSv1', 'TLSv1.2', U, 'TLSv1.1', 'TLSv1.1', U, 'TLSv1.1'); +test('TLSv1', 'TLSv1.2', U, 'TLSv1', 'TLSv1.3', U, 'TLSv1.2'); + +// v-any client can connect to v-specific server +test('TLSv1', 'TLSv1.3', U, 'TLSv1.3', 'TLSv1.3', U, 'TLSv1.3'); +test('TLSv1', 'TLSv1.3', U, 'TLSv1.2', 'TLSv1.3', U, 'TLSv1.3'); +test('TLSv1', 'TLSv1.3', U, 'TLSv1.2', 'TLSv1.2', U, 'TLSv1.2'); +test('TLSv1', 'TLSv1.3', U, 'TLSv1.1', 'TLSv1.1', U, 'TLSv1.1'); +test('TLSv1', 'TLSv1.3', U, 'TLSv1', 'TLSv1', U, 'TLSv1'); + +// v-specific client can connect to v-any server +test('TLSv1.3', 'TLSv1.3', U, 'TLSv1', 'TLSv1.3', U, 'TLSv1.3'); +test('TLSv1.2', 'TLSv1.2', U, 'TLSv1', 'TLSv1.3', U, 'TLSv1.2'); +test('TLSv1.1', 'TLSv1.1', U, 'TLSv1', 'TLSv1.3', U, 'TLSv1.1'); +test('TLSv1', 'TLSv1', U, 'TLSv1', 'TLSv1.3', U, 'TLSv1'); diff --git a/test/js/node/test/parallel/test-tls-multi-key.js b/test/js/node/test/parallel/test-tls-multi-key.js new file mode 100644 index 000000000000..0a9c6f108bf6 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-multi-key.js @@ -0,0 +1,196 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); + +// Test multi-identity ('key')/multi-algorithm scenarios. + +if (!common.hasCrypto) + common.skip('missing crypto'); + +if (process.features.openssl_is_boringssl) { + require('../common/boringssl').assertMultiKeyUnsupported(); + return; +} + +const fixtures = require('../common/fixtures'); +const assert = require('assert'); +const tls = require('tls'); + +// Key is ordered as ec, rsa, cert is ordered as rsa, ec. +test({ + key: [ + fixtures.readKey('ec10-key.pem'), + fixtures.readKey('agent1-key.pem'), + ], + cert: [ + fixtures.readKey('agent1-cert.pem'), + fixtures.readKey('ec10-cert.pem'), + ], + eccCN: 'agent10.example.com', + client: { ca: [ + fixtures.readKey('ca5-cert.pem'), + fixtures.readKey('ca1-cert.pem'), + ] }, +}); + +// Key and cert are ordered as ec, rsa. +test({ + key: [ + fixtures.readKey('ec10-key.pem'), + fixtures.readKey('agent1-key.pem'), + ], + cert: [ + fixtures.readKey('agent1-cert.pem'), + fixtures.readKey('ec10-cert.pem'), + ], + eccCN: 'agent10.example.com', + client: { ca: [ + fixtures.readKey('ca5-cert.pem'), + fixtures.readKey('ca1-cert.pem'), + ] }, +}); + +// Key, cert, and pfx options can be used simultaneously. +test({ + key: [ + fixtures.readKey('ec-key.pem'), + ], + cert: [ + fixtures.readKey('ec-cert.pem'), + ], + pfx: fixtures.readKey('agent1.pfx'), + passphrase: 'sample', + client: { ca: [ + fixtures.readKey('ec-cert.pem'), + fixtures.readKey('ca1-cert.pem'), + ] }, +}); + +// Key and cert with mixed algorithms, and cert chains with intermediate CAs +test({ + key: [ + fixtures.readKey('ec10-key.pem'), + fixtures.readKey('agent10-key.pem'), + ], + cert: [ + fixtures.readKey('agent10-cert.pem'), + fixtures.readKey('ec10-cert.pem'), + ], + rsaCN: 'agent10.example.com', + eccCN: 'agent10.example.com', + client: { ca: [ + fixtures.readKey('ca2-cert.pem'), + fixtures.readKey('ca5-cert.pem'), + ] }, +}); + +// Key and cert with mixed algorithms, and cert chains with intermediate CAs, +// using PFX for EC. +test({ + key: [ + fixtures.readKey('agent10-key.pem'), + ], + cert: [ + fixtures.readKey('agent10-cert.pem'), + ], + pfx: fixtures.readKey('ec10.pfx'), + passphrase: 'sample', + rsaCN: 'agent10.example.com', + eccCN: 'agent10.example.com', + client: { ca: [ + fixtures.readKey('ca2-cert.pem'), + fixtures.readKey('ca5-cert.pem'), + ] }, +}); + +// Key and cert with mixed algorithms, and cert chains with intermediate CAs, +// using PFX for RSA. +test({ + key: [ + fixtures.readKey('ec10-key.pem'), + ], + cert: [ + fixtures.readKey('ec10-cert.pem'), + ], + pfx: fixtures.readKey('agent10.pfx'), + passphrase: 'sample', + rsaCN: 'agent10.example.com', + eccCN: 'agent10.example.com', + client: { ca: [ + fixtures.readKey('ca2-cert.pem'), + fixtures.readKey('ca5-cert.pem'), + ] }, +}); + +function test(options) { + const rsaCN = options.rsaCN || 'agent1'; + const eccCN = options.eccCN || 'agent2'; + const clientTrustRoots = options.client.ca; + delete options.rsaCN; + delete options.eccCN; + delete options.client; + const server = tls.createServer(options, function(conn) { + conn.end('ok'); + }).listen(0, common.mustCall(connectWithEcdsa)); + + function connectWithEcdsa() { + const ecdsa = tls.connect(this.address().port, { + ciphers: 'ECDHE-ECDSA-AES256-GCM-SHA384', + rejectUnauthorized: true, + ca: clientTrustRoots, + checkServerIdentity: common.mustCall((_, c) => assert.strictEqual(c.subject.CN, eccCN)), + maxVersion: 'TLSv1.2', + }, common.mustCall(function() { + assert.deepStrictEqual(ecdsa.getCipher(), { + name: 'ECDHE-ECDSA-AES256-GCM-SHA384', + standardName: 'TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384', + version: 'TLSv1.2', + }); + + assert.strictEqual(ecdsa.getPeerCertificate().subject.CN, eccCN); + assert.strictEqual(ecdsa.getPeerCertificate().asn1Curve, 'prime256v1'); + ecdsa.end(); + connectWithRsa(); + })); + } + + function connectWithRsa() { + const rsa = tls.connect(server.address().port, { + ciphers: 'ECDHE-RSA-AES256-GCM-SHA384', + rejectUnauthorized: true, + ca: clientTrustRoots, + checkServerIdentity: common.mustCallAtLeast((_, c) => assert.strictEqual(c.subject.CN, rsaCN)), + maxVersion: 'TLSv1.2', + }, common.mustCall(function() { + assert.deepStrictEqual(rsa.getCipher(), { + name: 'ECDHE-RSA-AES256-GCM-SHA384', + standardName: 'TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384', + version: 'TLSv1.2', + }); + assert.strictEqual(rsa.getPeerCertificate().subject.CN, rsaCN); + assert(rsa.getPeerCertificate().exponent, 'cert for an RSA key'); + rsa.end(); + server.close(); + })); + } +} diff --git a/test/js/node/test/parallel/test-tls-net-socket-keepalive-12.js b/test/js/node/test/parallel/test-tls-net-socket-keepalive-12.js new file mode 100644 index 000000000000..d2fb230796e5 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-net-socket-keepalive-12.js @@ -0,0 +1,13 @@ +'use strict'; + +// test-tls-net-socket-keepalive specifically for TLS1.2. + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const tls = require('tls'); + +tls.DEFAULT_MAX_VERSION = 'TLSv1.2'; + +require('./test-tls-net-socket-keepalive.js'); diff --git a/test/js/node/test/parallel/test-tls-net-socket-keepalive.js b/test/js/node/test/parallel/test-tls-net-socket-keepalive.js new file mode 100644 index 000000000000..4acb4e80224e --- /dev/null +++ b/test/js/node/test/parallel/test-tls-net-socket-keepalive.js @@ -0,0 +1,57 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const fixtures = require('../common/fixtures'); +const tls = require('tls'); +const net = require('net'); + +// This test ensures that when tls sockets are created with `allowHalfOpen`, +// they won't hang. +const key = fixtures.readKey('agent1-key.pem'); +const cert = fixtures.readKey('agent1-cert.pem'); +const ca = fixtures.readKey('ca1-cert.pem'); +const options = { + key, + cert, + ca: [ca], +}; + +const server = tls.createServer(options, common.mustCall((conn) => { + conn.write('hello', common.mustCall()); + conn.on('data', common.mustCall()); + conn.on('end', common.mustCall()); + conn.on('data', common.mustCall()); + conn.on('close', common.mustCall()); + conn.end(); +})).listen(0, common.mustCall(() => { + const netSocket = new net.Socket({ + allowHalfOpen: true, + }); + + const socket = tls.connect({ + socket: netSocket, + rejectUnauthorized: false, + }); + + const { port, address } = server.address(); + + // Doing `net.Socket.connect()` after `tls.connect()` will make tls module + // wrap the socket in StreamWrap. + netSocket.connect({ + port, + address, + }); + + socket.on('secureConnect', common.mustCall()); + socket.on('end', common.mustCall()); + socket.on('data', common.mustCall()); + socket.on('close', common.mustCall(() => { + server.close(); + })); + + socket.write('hello'); + socket.end(); +})); diff --git a/test/js/node/test/parallel/test-tls-no-cert-required.js b/test/js/node/test/parallel/test-tls-no-cert-required.js new file mode 100644 index 000000000000..499ab2dfd14e --- /dev/null +++ b/test/js/node/test/parallel/test-tls-no-cert-required.js @@ -0,0 +1,62 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const tls = require('tls'); + +// Omitting the cert or pfx option to tls.createServer() should not throw. +if (process.features.openssl_is_boringssl) { + // AECDH-NULL-SHA is a no-authentication/no-encryption cipher and hence + // does not need a certificate. BoringSSL does not provide that anonymous + // cipher suite, so only this cipher-specific no-cert case is skipped. + common.printSkipMessage('BoringSSL: skipping anonymous AECDH-NULL-SHA case'); +} else { + tls.createServer({ ciphers: 'AECDH-NULL-SHA' }) + .listen(0, common.mustCall(close)); +} + +tls.createServer(assert.fail) + .listen(0, common.mustCall(close)); + +tls.createServer({}) + .listen(0, common.mustCall(close)); + +assert.throws( + () => tls.createServer('this is not valid'), + { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + message: 'The "options" argument must be of type object. ' + + "Received type string ('this is not valid')" + } +); + +tls.createServer() + .listen(0, common.mustCall(close)); + +function close() { + this.close(); +} diff --git a/test/js/node/test/parallel/test-tls-no-sslv23.js b/test/js/node/test/parallel/test-tls-no-sslv23.js new file mode 100644 index 000000000000..f1ba670ff076 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-no-sslv23.js @@ -0,0 +1,58 @@ +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const tls = require('tls'); + +assert.throws(function() { + tls.createSecureContext({ secureProtocol: 'blargh' }); +}, { + code: 'ERR_TLS_INVALID_PROTOCOL_METHOD', + message: 'Unknown method: blargh', +}); + +const errMessageSSLv2 = /SSLv2 methods disabled/; + +assert.throws(function() { + tls.createSecureContext({ secureProtocol: 'SSLv2_method' }); +}, errMessageSSLv2); + +assert.throws(function() { + tls.createSecureContext({ secureProtocol: 'SSLv2_client_method' }); +}, errMessageSSLv2); + +assert.throws(function() { + tls.createSecureContext({ secureProtocol: 'SSLv2_server_method' }); +}, errMessageSSLv2); + +const errMessageSSLv3 = /SSLv3 methods disabled/; + +assert.throws(function() { + tls.createSecureContext({ secureProtocol: 'SSLv3_method' }); +}, errMessageSSLv3); + +assert.throws(function() { + tls.createSecureContext({ secureProtocol: 'SSLv3_client_method' }); +}, errMessageSSLv3); + +assert.throws(function() { + tls.createSecureContext({ secureProtocol: 'SSLv3_server_method' }); +}, errMessageSSLv3); + +// Note that SSLv2 and SSLv3 are disallowed but SSLv2_method and friends are +// still accepted. They are OpenSSL's way of saying that all known protocols +// are supported unless explicitly disabled (which we do for SSLv2 and SSLv3.) +tls.createSecureContext({ secureProtocol: 'SSLv23_method' }); +tls.createSecureContext({ secureProtocol: 'SSLv23_client_method' }); +tls.createSecureContext({ secureProtocol: 'SSLv23_server_method' }); +tls.createSecureContext({ secureProtocol: 'TLSv1_method' }); +tls.createSecureContext({ secureProtocol: 'TLSv1_client_method' }); +tls.createSecureContext({ secureProtocol: 'TLSv1_server_method' }); +tls.createSecureContext({ secureProtocol: 'TLSv1_1_method' }); +tls.createSecureContext({ secureProtocol: 'TLSv1_1_client_method' }); +tls.createSecureContext({ secureProtocol: 'TLSv1_1_server_method' }); +tls.createSecureContext({ secureProtocol: 'TLSv1_2_method' }); +tls.createSecureContext({ secureProtocol: 'TLSv1_2_client_method' }); +tls.createSecureContext({ secureProtocol: 'TLSv1_2_server_method' }); diff --git a/test/js/node/test/parallel/test-tls-off-thread-cert-loading-disabled.js b/test/js/node/test/parallel/test-tls-off-thread-cert-loading-disabled.js new file mode 100644 index 000000000000..c2e466fcae1d --- /dev/null +++ b/test/js/node/test/parallel/test-tls-off-thread-cert-loading-disabled.js @@ -0,0 +1,40 @@ +'use strict'; +// This tests that when --use-openssl-ca is specified, no off-thread cert loading happens. + +const common = require('../common'); +if (!common.hasCrypto) { + common.skip('missing crypto'); +} +const { spawnSyncAndAssert } = require('../common/child_process'); +const fixtures = require('../common/fixtures'); +const assert = require('assert'); + +spawnSyncAndAssert( + process.execPath, + [ '--use-openssl-ca', fixtures.path('list-certs.js') ], + { + env: { + ...process.env, + NODE_DEBUG_NATIVE: 'crypto', + NODE_EXTRA_CA_CERTS: fixtures.path('keys', 'fake-startcom-root-cert.pem'), + CERTS_TYPE: 'default', + } + }, + { + stderr(output) { + assert.doesNotMatch( + output, + /Started loading bundled root certificates off-thread/ + ); + assert.doesNotMatch( + output, + /Started loading extra root certificates off-thread/ + ); + assert.doesNotMatch( + output, + /Started loading system root certificates off-thread/ + ); + return true; + } + } +); diff --git a/test/js/node/test/parallel/test-tls-psk-alpn-callback-exception-handling.js b/test/js/node/test/parallel/test-tls-psk-alpn-callback-exception-handling.js new file mode 100644 index 000000000000..cdeb9f3b31f8 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-psk-alpn-callback-exception-handling.js @@ -0,0 +1,430 @@ +'use strict'; + +// This test verifies that exceptions in pskCallback and ALPNCallback are +// properly routed through tlsClientError instead of becoming uncaught +// exceptions. This is a regression test for a vulnerability where callback +// validation errors would bypass all standard TLS error handlers. +// +// The vulnerability allows remote attackers to crash TLS servers or cause +// resource exhaustion (file descriptor leaks) when pskCallback or ALPNCallback +// throw exceptions during validation. + +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +if (process.features.openssl_is_boringssl) { + require('../common/boringssl').testPskTls13Unsupported(); + return; +} + +const assert = require('assert'); +const { describe, it } = require('node:test'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); + +const CIPHERS = 'PSK+HIGH'; +const TEST_TIMEOUT = 5000; + +// Helper to create a promise that rejects on uncaughtException or timeout +function createTestPromise() { + const { promise, resolve, reject } = Promise.withResolvers(); + let settled = false; + + const cleanup = () => { + if (!settled) { + settled = true; + process.removeListener('uncaughtException', onUncaught); + clearTimeout(timeout); + } + }; + + const onUncaught = (err) => { + cleanup(); + reject(new Error( + `Uncaught exception instead of tlsClientError: ${err.code || err.message}` + )); + }; + + const timeout = setTimeout(() => { + cleanup(); + reject(new Error('Test timed out - tlsClientError was not emitted')); + }, TEST_TIMEOUT); + + process.on('uncaughtException', onUncaught); + + return { + resolve: (value) => { + cleanup(); + resolve(value); + }, + reject: (err) => { + cleanup(); + reject(err); + }, + promise, + }; +} + +describe('TLS callback exception handling', () => { + + // Test 1: PSK server callback returning invalid type should emit tlsClientError + it('pskCallback returning invalid type emits tlsClientError', async (t) => { + const server = tls.createServer({ + ciphers: CIPHERS, + pskCallback: () => { + // Return invalid type (string instead of object/Buffer) + return 'invalid-should-be-object-or-buffer'; + }, + pskIdentityHint: 'test-hint', + }); + + t.after(() => server.close()); + + const { promise, resolve, reject } = createTestPromise(); + + server.on('tlsClientError', common.mustCall((err, socket) => { + try { + assert.ok(err instanceof Error); + assert.strictEqual(err.code, 'ERR_INVALID_ARG_TYPE'); + socket.destroy(); + resolve(); + } catch (e) { + reject(e); + } + })); + + server.on('secureConnection', () => { + reject(new Error('secureConnection should not fire')); + }); + + await new Promise((res) => server.listen(0, res)); + + const client = tls.connect({ + port: server.address().port, + host: '127.0.0.1', + ciphers: CIPHERS, + checkServerIdentity: () => {}, + pskCallback: () => ({ + psk: Buffer.alloc(32), + identity: 'test-identity', + }), + }); + + client.on('error', () => {}); + + await promise; + }); + + // Test 2: PSK server callback throwing should emit tlsClientError + it('pskCallback throwing emits tlsClientError', async (t) => { + const server = tls.createServer({ + ciphers: CIPHERS, + pskCallback: () => { + throw new Error('Intentional callback error'); + }, + pskIdentityHint: 'test-hint', + }); + + t.after(() => server.close()); + + const { promise, resolve, reject } = createTestPromise(); + + server.on('tlsClientError', common.mustCall((err, socket) => { + try { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Intentional callback error'); + socket.destroy(); + resolve(); + } catch (e) { + reject(e); + } + })); + + server.on('secureConnection', () => { + reject(new Error('secureConnection should not fire')); + }); + + await new Promise((res) => server.listen(0, res)); + + const client = tls.connect({ + port: server.address().port, + host: '127.0.0.1', + ciphers: CIPHERS, + checkServerIdentity: () => {}, + pskCallback: () => ({ + psk: Buffer.alloc(32), + identity: 'test-identity', + }), + }); + + client.on('error', () => {}); + + await promise; + }); + + // Test 3: ALPN callback returning non-matching protocol should emit tlsClientError + it('ALPNCallback returning invalid result emits tlsClientError', async (t) => { + const server = tls.createServer({ + key: fixtures.readKey('agent2-key.pem'), + cert: fixtures.readKey('agent2-cert.pem'), + ALPNCallback: () => { + // Return a protocol not in the client's list + return 'invalid-protocol-not-in-list'; + }, + }); + + t.after(() => server.close()); + + const { promise, resolve, reject } = createTestPromise(); + + server.on('tlsClientError', common.mustCall((err, socket) => { + try { + assert.ok(err instanceof Error); + assert.strictEqual(err.code, 'ERR_TLS_ALPN_CALLBACK_INVALID_RESULT'); + socket.destroy(); + resolve(); + } catch (e) { + reject(e); + } + })); + + server.on('secureConnection', () => { + reject(new Error('secureConnection should not fire')); + }); + + await new Promise((res) => server.listen(0, res)); + + const client = tls.connect({ + port: server.address().port, + host: '127.0.0.1', + rejectUnauthorized: false, + ALPNProtocols: ['http/1.1', 'h2'], + }); + + client.on('error', () => {}); + + await promise; + }); + + // Test 4: ALPN callback throwing should emit tlsClientError + it('ALPNCallback throwing emits tlsClientError', async (t) => { + const server = tls.createServer({ + key: fixtures.readKey('agent2-key.pem'), + cert: fixtures.readKey('agent2-cert.pem'), + ALPNCallback: () => { + throw new Error('Intentional ALPN callback error'); + }, + }); + + t.after(() => server.close()); + + const { promise, resolve, reject } = createTestPromise(); + + server.on('tlsClientError', common.mustCall((err, socket) => { + try { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Intentional ALPN callback error'); + socket.destroy(); + resolve(); + } catch (e) { + reject(e); + } + })); + + server.on('secureConnection', () => { + reject(new Error('secureConnection should not fire')); + }); + + await new Promise((res) => server.listen(0, res)); + + const client = tls.connect({ + port: server.address().port, + host: '127.0.0.1', + rejectUnauthorized: false, + ALPNProtocols: ['http/1.1'], + }); + + client.on('error', () => {}); + + await promise; + }); + + // Test 5: PSK client callback returning invalid type should emit error event + it('client pskCallback returning invalid type emits error', async (t) => { + const PSK = Buffer.alloc(32); + + const server = tls.createServer({ + ciphers: CIPHERS, + pskCallback: () => PSK, + pskIdentityHint: 'test-hint', + }); + + t.after(() => server.close()); + + const { promise, resolve, reject } = createTestPromise(); + + server.on('secureConnection', () => { + reject(new Error('secureConnection should not fire')); + }); + + await new Promise((res) => server.listen(0, res)); + + const client = tls.connect({ + port: server.address().port, + host: '127.0.0.1', + ciphers: CIPHERS, + checkServerIdentity: () => {}, + pskCallback: () => { + // Return invalid type - should cause validation error + return 'invalid-should-be-object'; + }, + }); + + client.on('error', common.mustCall((err) => { + try { + assert.ok(err instanceof Error); + assert.strictEqual(err.code, 'ERR_INVALID_ARG_TYPE'); + resolve(); + } catch (e) { + reject(e); + } + })); + + await promise; + }); + + // Test 6: PSK client callback throwing should emit error event + it('client pskCallback throwing emits error', async (t) => { + const PSK = Buffer.alloc(32); + + const server = tls.createServer({ + ciphers: CIPHERS, + pskCallback: () => PSK, + pskIdentityHint: 'test-hint', + }); + + t.after(() => server.close()); + + const { promise, resolve, reject } = createTestPromise(); + + server.on('secureConnection', () => { + reject(new Error('secureConnection should not fire')); + }); + + await new Promise((res) => server.listen(0, res)); + + const client = tls.connect({ + port: server.address().port, + host: '127.0.0.1', + ciphers: CIPHERS, + checkServerIdentity: () => {}, + pskCallback: () => { + throw new Error('Intentional client PSK callback error'); + }, + }); + + client.on('error', common.mustCall((err) => { + try { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Intentional client PSK callback error'); + resolve(); + } catch (e) { + reject(e); + } + })); + + await promise; + }); + + // Test 7: SNI callback throwing should emit tlsClientError + it('SNICallback throwing emits tlsClientError', async (t) => { + const server = tls.createServer({ + key: fixtures.readKey('agent2-key.pem'), + cert: fixtures.readKey('agent2-cert.pem'), + SNICallback: (servername, cb) => { + throw new Error('Intentional SNI callback error'); + }, + }); + + t.after(() => server.close()); + + const { promise, resolve, reject } = createTestPromise(); + + server.on('tlsClientError', common.mustCall((err, socket) => { + try { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Intentional SNI callback error'); + socket.destroy(); + resolve(); + } catch (e) { + reject(e); + } + })); + + server.on('secureConnection', () => { + reject(new Error('secureConnection should not fire')); + }); + + await new Promise((res) => server.listen(0, res)); + + const client = tls.connect({ + port: server.address().port, + host: '127.0.0.1', + servername: 'evil.attacker.com', + rejectUnauthorized: false, + }); + + client.on('error', () => {}); + + await promise; + }); + + // Test 8: SNI callback with validation error should emit tlsClientError + it('SNICallback validation error emits tlsClientError', async (t) => { + const server = tls.createServer({ + key: fixtures.readKey('agent2-key.pem'), + cert: fixtures.readKey('agent2-cert.pem'), + SNICallback: (servername, cb) => { + // Simulate common developer pattern: throw on unknown servername + if (servername !== 'expected.example.com') { + throw new Error(`Unknown servername: ${servername}`); + } + cb(null, null); + }, + }); + + t.after(() => server.close()); + + const { promise, resolve, reject } = createTestPromise(); + + server.on('tlsClientError', common.mustCall((err, socket) => { + try { + assert.ok(err instanceof Error); + assert.ok(err.message.includes('Unknown servername')); + socket.destroy(); + resolve(); + } catch (e) { + reject(e); + } + })); + + server.on('secureConnection', () => { + reject(new Error('secureConnection should not fire')); + }); + + await new Promise((res) => server.listen(0, res)); + + const client = tls.connect({ + port: server.address().port, + host: '127.0.0.1', + servername: 'unexpected.domain.com', + rejectUnauthorized: false, + }); + + client.on('error', () => {}); + + await promise; + }); +}); diff --git a/test/js/node/test/parallel/test-tls-psk-circuit.js b/test/js/node/test/parallel/test-tls-psk-circuit.js new file mode 100644 index 000000000000..c9c93d533501 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-psk-circuit.js @@ -0,0 +1,81 @@ +'use strict'; +const common = require('../common'); + +if (!common.hasCrypto) { + common.skip('missing crypto'); +} + +if (process.features.openssl_is_boringssl) { + require('../common/boringssl').testPskTls13Unsupported(); + return; +} + +const { hasOpenSSL } = require('../common/crypto'); +const assert = require('assert'); +const tls = require('tls'); + +const CIPHERS = 'PSK+HIGH:TLS_AES_128_GCM_SHA256'; +const USERS = { + UserA: Buffer.allocUnsafe(128), + UserB: Buffer.from('82072606b502b0f4025e90eb75fe137d', 'hex'), +}; +const TEST_DATA = 'x'; + +const serverOptions = { + ciphers: CIPHERS, + pskCallback: common.mustCallAtLeast((socket, id) => { + assert.ok(socket instanceof tls.TLSSocket); + assert.ok(typeof id === 'string'); + return USERS[id]; + }), +}; + +function test(secret, opts, error) { + const cb = !error ? + common.mustCall((c) => { c.pipe(c); }) : + common.mustNotCall(); + const server = tls.createServer(serverOptions, cb); + server.listen(0, common.mustCall(() => { + const options = { + port: server.address().port, + ciphers: CIPHERS, + checkServerIdentity: () => {}, + pskCallback: common.mustCall(() => secret), + ...opts, + }; + + if (!error) { + const client = tls.connect(options, common.mustCall(() => { + client.end(TEST_DATA); + + client.on('data', common.mustCall((data) => { + assert.strictEqual(data.toString(), TEST_DATA); + })); + client.on('close', common.mustCall(() => server.close())); + })); + } else { + const client = tls.connect(options, common.mustNotCall()); + client.on('error', common.mustCall((err) => { + assert.strictEqual(err.code, error); + server.close(); + })); + } + })); +} + +test({ psk: USERS.UserA, identity: 'UserA' }); +test({ psk: USERS.UserA, identity: 'UserA' }, { maxVersion: 'TLSv1.2' }); +test({ psk: USERS.UserA, identity: 'UserA' }, { minVersion: 'TLSv1.3' }); +test({ psk: USERS.UserB, identity: 'UserB' }); +test({ psk: USERS.UserB, identity: 'UserB' }, { minVersion: 'TLSv1.3' }); +// Unrecognized user should fail handshake +const expectedHandshakeErr = hasOpenSSL(4, 0) ? + 'ERR_SSL_TLS_ALERT_HANDSHAKE_FAILURE' : hasOpenSSL(3, 2) ? + 'ERR_SSL_SSL/TLS_ALERT_HANDSHAKE_FAILURE' : 'ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE'; +test({ psk: USERS.UserB, identity: 'UserC' }, {}, expectedHandshakeErr); +// Recognized user but incorrect secret should fail handshake +const expectedIllegalParameterErr = hasOpenSSL(3, 4) ? 'ERR_SSL_TLSV1_ALERT_DECRYPT_ERROR' : + hasOpenSSL(3, 2) ? + 'ERR_SSL_SSL/TLS_ALERT_ILLEGAL_PARAMETER' : 'ERR_SSL_SSLV3_ALERT_ILLEGAL_PARAMETER'; +test({ psk: USERS.UserA, identity: 'UserB' }, {}, expectedIllegalParameterErr); +test({ psk: USERS.UserB, identity: 'UserB' }); diff --git a/test/js/node/test/parallel/test-tls-psk-server.js b/test/js/node/test/parallel/test-tls-psk-server.js index b92609584015..692550fc1c19 100644 --- a/test/js/node/test/parallel/test-tls-psk-server.js +++ b/test/js/node/test/parallel/test-tls-psk-server.js @@ -1,10 +1,20 @@ 'use strict'; const common = require('../common'); -if (!common.hasCrypto) +if (!common.hasCrypto) { common.skip('missing crypto'); -if (!common.opensslCli) +} + +if (process.features.openssl_is_boringssl) { + require('../common/boringssl').testPskTls13Unsupported(); + return; +} + +const { opensslCli } = require('../common/crypto'); + +if (!opensslCli) { common.skip('missing openssl cli'); +} const assert = require('assert'); @@ -18,12 +28,12 @@ const IDENTITY = 'TestUser'; const server = tls.createServer({ ciphers: CIPHERS, pskIdentityHint: IDENTITY, - pskCallback(socket, identity) { + pskCallback: common.mustCall((socket, identity) => { assert.ok(socket instanceof tls.TLSSocket); assert.ok(typeof identity === 'string'); if (identity === IDENTITY) return Buffer.from(KEY, 'hex'); - } + }), }); server.on('connection', common.mustCall()); @@ -40,8 +50,8 @@ let gotHello = false; let sentWorld = false; let gotWorld = false; -server.listen(0, () => { - const client = spawn(common.opensslCli, [ +server.listen(0, common.mustCall(() => { + const client = spawn(opensslCli, [ 's_client', '-connect', `127.0.0.1:${server.address().port}`, '-cipher', CIPHERS, @@ -74,4 +84,4 @@ server.listen(0, () => { assert.strictEqual(code, 0); server.close(); })); -}); +})); diff --git a/test/js/node/test/parallel/test-tls-reduced-SECLEVEL-in-cipher.js b/test/js/node/test/parallel/test-tls-reduced-SECLEVEL-in-cipher.js new file mode 100644 index 000000000000..cca22067a0fe --- /dev/null +++ b/test/js/node/test/parallel/test-tls-reduced-SECLEVEL-in-cipher.js @@ -0,0 +1,31 @@ +'use strict'; +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +if (process.features.openssl_is_boringssl) { + require('../common/boringssl').assertOpenSSLSecurityLevelsUnsupported(); + return; +} + +const assert = require('assert'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); + +{ + const options = { + key: fixtures.readKey('agent11-key.pem'), + cert: fixtures.readKey('agent11-cert.pem'), + ciphers: 'DEFAULT' + }; + + // Should throw error as key is too small because openssl v3 doesn't allow it + assert.throws(() => tls.createServer(options, common.mustNotCall()), + /key too small/i); + + // Reducing SECLEVEL to 0 in ciphers retains compatibility with previous versions of OpenSSL like using a small key. + // As ciphers are getting set before the cert and key get loaded. + options.ciphers = 'DEFAULT:@SECLEVEL=0'; + assert.ok(tls.createServer(options, common.mustNotCall())); +} diff --git a/test/js/node/test/parallel/test-tls-secure-session.js b/test/js/node/test/parallel/test-tls-secure-session.js new file mode 100644 index 000000000000..b4b9638a2ccc --- /dev/null +++ b/test/js/node/test/parallel/test-tls-secure-session.js @@ -0,0 +1,46 @@ +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); +const fixtures = require('../common/fixtures'); +const assert = require('assert'); +const tls = require('tls'); + +const options = { + key: fixtures.readKey('agent1-key.pem'), + + // NOTE: Certificate Common Name is 'agent1' + cert: fixtures.readKey('agent1-cert.pem'), + + // NOTE: TLS 1.3 creates new session ticket **after** handshake so + // `getSession()` output will be different even if the session was reused + // during the handshake. + secureProtocol: 'TLSv1_2_method' +}; + +const server = tls.createServer(options, common.mustCall((socket) => { + socket.end(); +})).listen(0, common.mustCall(() => { + let connected = false; + let session = null; + + const client = tls.connect({ + rejectUnauthorized: false, + port: server.address().port, + }, common.mustCall(() => { + assert(!connected); + assert(!session); + + connected = true; + })); + + client.on('session', common.mustCall((newSession) => { + assert(connected); + assert(!session); + + session = newSession; + + client.end(); + server.close(); + })); +})); diff --git a/test/js/node/test/parallel/test-tls-server-capture-rejection.js b/test/js/node/test/parallel/test-tls-server-capture-rejection.js new file mode 100644 index 000000000000..f9bd3320e101 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-server-capture-rejection.js @@ -0,0 +1,34 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const events = require('events'); +const fixtures = require('../common/fixtures'); +const { createServer, connect } = require('tls'); +const cert = fixtures.readKey('rsa_cert.crt'); +const key = fixtures.readKey('rsa_private.pem'); + +events.captureRejections = true; + +const server = createServer({ cert, key }, common.mustCall(async (sock) => { + server.close(); + + const _err = new Error('kaboom'); + sock.on('error', common.mustCall((err) => { + assert.strictEqual(err, _err); + })); + throw _err; +})); + +server.listen(0, common.mustCall(() => { + const sock = connect({ + port: server.address().port, + host: server.address().host, + rejectUnauthorized: false + }); + + sock.on('close', common.mustCall()); +})); diff --git a/test/js/node/test/parallel/test-tls-server-failed-handshake-emits-clienterror.js b/test/js/node/test/parallel/test-tls-server-failed-handshake-emits-clienterror.js new file mode 100644 index 000000000000..9c30989af0af --- /dev/null +++ b/test/js/node/test/parallel/test-tls-server-failed-handshake-emits-clienterror.js @@ -0,0 +1,29 @@ +'use strict'; +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const tls = require('tls'); +const net = require('net'); +const assert = require('assert'); + +const bonkers = Buffer.alloc(1024, 42); + + +const server = tls.createServer({}) + .listen(0, function() { + const c = net.connect({ port: this.address().port }, function() { + c.write(bonkers); + }); + + }).on('tlsClientError', common.mustCall(function(e) { + assert.ok(e instanceof Error, + 'Instance of Error should be passed to error handler'); + assert.match( + e.message, + /SSL routines:[^:]*:wrong[ _]version[ _]number/i, + ); + + server.close(); + })); diff --git a/test/js/node/test/parallel/test-tls-session-timeout-errors.js b/test/js/node/test/parallel/test-tls-session-timeout-errors.js new file mode 100644 index 000000000000..6e5646127c80 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-session-timeout-errors.js @@ -0,0 +1,36 @@ +'use strict'; +// This tests validation of sessionTimeout option in TLS server. +const common = require('../common'); + +if (!common.hasCrypto) { + common.skip('missing crypto'); +} + +const tmpdir = require('../common/tmpdir'); +tmpdir.refresh(); + +const assert = require('assert'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); + +const key = fixtures.readKey('rsa_private.pem'); +const cert = fixtures.readKey('rsa_cert.crt'); + +// Node.js should not allow setting negative timeouts since new versions of +// OpenSSL do not handle those as users might expect + +for (const sessionTimeout of [-1, -100, -(2 ** 31)]) { + assert.throws(() => { + tls.createServer({ + key: key, + cert: cert, + ca: [cert], + sessionTimeout, + maxVersion: 'TLSv1.2', + }); + }, { + code: 'ERR_OUT_OF_RANGE', + message: 'The value of "options.sessionTimeout" is out of range. It ' + + `must be >= 0 && <= ${2 ** 31 - 1}. Received ${sessionTimeout}`, + }); +} diff --git a/test/js/node/test/parallel/test-tls-set-ciphers.js b/test/js/node/test/parallel/test-tls-set-ciphers.js index 1e63e9376e13..82a19bb9e90f 100644 --- a/test/js/node/test/parallel/test-tls-set-ciphers.js +++ b/test/js/node/test/parallel/test-tls-set-ciphers.js @@ -90,7 +90,9 @@ function test(cciphers, sciphers, cipher, cerr, serr, options) { const U = undefined; let expectedTLSAlertError = 'ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE'; -if (hasOpenSSL(3, 2)) { +if (hasOpenSSL(4, 0)) { + expectedTLSAlertError = 'ERR_SSL_TLS_ALERT_HANDSHAKE_FAILURE'; +} else if (hasOpenSSL(3, 2)) { expectedTLSAlertError = 'ERR_SSL_SSL/TLS_ALERT_HANDSHAKE_FAILURE'; } diff --git a/test/js/node/test/parallel/test-tls-set-default-ca-certificates-array-buffer.js b/test/js/node/test/parallel/test-tls-set-default-ca-certificates-array-buffer.js new file mode 100644 index 000000000000..0ea30721e57c --- /dev/null +++ b/test/js/node/test/parallel/test-tls-set-default-ca-certificates-array-buffer.js @@ -0,0 +1,39 @@ +// Flags: --no-use-system-ca +'use strict'; + +// This tests tls.setDefaultCACertificates() support ArrayBufferView. + +const common = require('../common'); +if (!common.hasCrypto) common.skip('missing crypto'); + +const tls = require('tls'); +const fixtures = require('../common/fixtures'); +const { assertEqualCerts } = require('../common/tls'); + +const fixtureCert = fixtures.readKey('fake-startcom-root-cert.pem'); + +// Should accept Buffer. +tls.setDefaultCACertificates([Buffer.from(fixtureCert)]); +const result = tls.getCACertificates('default'); +assertEqualCerts(result, [fixtureCert]); + +// Reset it to empty. +tls.setDefaultCACertificates([]); +assertEqualCerts(tls.getCACertificates('default'), []); + +// Should accept Uint8Array. +const encoder = new TextEncoder(); +const uint8Cert = encoder.encode(fixtureCert); +tls.setDefaultCACertificates([uint8Cert]); +const uint8Result = tls.getCACertificates('default'); +assertEqualCerts(uint8Result, [fixtureCert]); + +// Reset it to empty. +tls.setDefaultCACertificates([]); +assertEqualCerts(tls.getCACertificates('default'), []); + +// Should accept DataView. +const dataViewCert = new DataView(uint8Cert.buffer, uint8Cert.byteOffset, uint8Cert.byteLength); +tls.setDefaultCACertificates([dataViewCert]); +const dataViewResult = tls.getCACertificates('default'); +assertEqualCerts(dataViewResult, [fixtureCert]); diff --git a/test/js/node/test/parallel/test-tls-set-default-ca-certificates-basic.js b/test/js/node/test/parallel/test-tls-set-default-ca-certificates-basic.js new file mode 100644 index 000000000000..f6772110e54e --- /dev/null +++ b/test/js/node/test/parallel/test-tls-set-default-ca-certificates-basic.js @@ -0,0 +1,58 @@ +'use strict'; + +// This tests the basic functionality of tls.setDefaultCACertificates(). + +const common = require('../common'); +if (!common.hasCrypto) common.skip('missing crypto'); + +const tls = require('tls'); +const fixtures = require('../common/fixtures'); +const { assertEqualCerts } = require('../common/tls'); + +const originalBundled = tls.getCACertificates('bundled'); +const originalSystem = tls.getCACertificates('system'); +const fixtureCert = fixtures.readKey('fake-startcom-root-cert.pem'); + +function testSetCertificates(certs) { + // Test setting it can be verified with tls.getCACertificates(). + tls.setDefaultCACertificates(certs); + const result = tls.getCACertificates('default'); + assertEqualCerts(result, certs); + + // Verify that other certificate types are unchanged + const newBundled = tls.getCACertificates('bundled'); + const newSystem = tls.getCACertificates('system'); + assertEqualCerts(newBundled, originalBundled); + assertEqualCerts(newSystem, originalSystem); + + // Test implicit defaults. + const implicitDefaults = tls.getCACertificates(); + assertEqualCerts(implicitDefaults, certs); + + // Test cached results. + const cachedResult = tls.getCACertificates('default'); + assertEqualCerts(cachedResult, certs); + const cachedImplicitDefaults = tls.getCACertificates(); + assertEqualCerts(cachedImplicitDefaults, certs); +} + +// Test setting with fixture certificate. +testSetCertificates([fixtureCert]); + +// Test setting with empty array. +testSetCertificates([]); + +// Test setting with bundled certificates +testSetCertificates(originalBundled); + +// Test combining bundled and extra certificates. +testSetCertificates([...originalBundled, fixtureCert]); + +// Test setting with a subset of bundled certificates +if (originalBundled.length >= 3) { + testSetCertificates(originalBundled.slice(0, 3)); +} + +// Test duplicate certificates +tls.setDefaultCACertificates([fixtureCert, fixtureCert, fixtureCert]); +assertEqualCerts(tls.getCACertificates('default'), [fixtureCert]); diff --git a/test/js/node/test/parallel/test-tls-set-default-ca-certificates-error.js b/test/js/node/test/parallel/test-tls-set-default-ca-certificates-error.js new file mode 100644 index 000000000000..1d529a97265a --- /dev/null +++ b/test/js/node/test/parallel/test-tls-set-default-ca-certificates-error.js @@ -0,0 +1,41 @@ +'use strict'; + +// This tests input validation of tls.setDefaultCACertificates(). + +const common = require('../common'); +if (!common.hasCrypto) common.skip('missing crypto'); + +const fixtures = require('../common/fixtures'); +const assert = require('assert'); +const tls = require('tls'); +const { assertEqualCerts } = require('../common/tls'); + +const defaultCerts = tls.getCACertificates('default'); +const fixtureCert = fixtures.readKey('fake-startcom-root-cert.pem'); + +for (const invalid of [null, undefined, 'string', 42, {}, true]) { + // Test input validation - should throw when not passed an array + assert.throws(() => tls.setDefaultCACertificates(invalid), { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "certs" argument must be an instance of Array/ + }); + // Verify that default certificates remain unchanged after error. + assertEqualCerts(tls.getCACertificates('default'), defaultCerts); +} + +for (const invalid of [null, undefined, 42, {}, true]) { + // Test input validation - should throw when passed an array with invalid elements + assert.throws(() => tls.setDefaultCACertificates([invalid]), { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "certs\[0\]" argument must be of type string or an instance of ArrayBufferView/ + }); + // Verify that default certificates remain unchanged after error. + assertEqualCerts(tls.getCACertificates('default'), defaultCerts); + + assert.throws(() => tls.setDefaultCACertificates([fixtureCert, invalid]), { + code: 'ERR_INVALID_ARG_TYPE', + message: /The "certs\[1\]" argument must be of type string or an instance of ArrayBufferView/ + }); + // Verify that default certificates remain unchanged after error. + assertEqualCerts(tls.getCACertificates('default'), defaultCerts); +} diff --git a/test/js/node/test/parallel/test-tls-set-default-ca-certificates-extra-override.js b/test/js/node/test/parallel/test-tls-set-default-ca-certificates-extra-override.js new file mode 100644 index 000000000000..cf7790e5d083 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-set-default-ca-certificates-extra-override.js @@ -0,0 +1,19 @@ +'use strict'; + +// This tests that tls.setDefaultCACertificates() properly overrides certificates +// added through NODE_EXTRA_CA_CERTS environment variable. + +const common = require('../common'); +if (!common.hasCrypto) common.skip('missing crypto'); + +const fixtures = require('../common/fixtures'); +const { spawnSyncAndExitWithoutError } = require('../common/child_process'); + +spawnSyncAndExitWithoutError(process.execPath, [ + fixtures.path('tls-extra-ca-override.js'), +], { + env: { + ...process.env, + NODE_EXTRA_CA_CERTS: fixtures.path('keys', 'fake-startcom-root-cert.pem') + } +}); diff --git a/test/js/node/test/parallel/test-tls-set-default-ca-certificates-mixed-types.js b/test/js/node/test/parallel/test-tls-set-default-ca-certificates-mixed-types.js new file mode 100644 index 000000000000..2f22ed8ec343 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-set-default-ca-certificates-mixed-types.js @@ -0,0 +1,46 @@ +'use strict'; + +// This tests mixed input types for tls.setDefaultCACertificates(). + +const common = require('../common'); +if (!common.hasCrypto) common.skip('missing crypto'); + +const tls = require('tls'); +const { assertEqualCerts } = require('../common/tls'); + +const bundledCerts = tls.getCACertificates('bundled'); +if (bundledCerts.length < 4) { + common.skip('Not enough bundled CA certificates available'); +} + +const encoder = new TextEncoder(); + +// Test mixed array with string and Buffer. +{ + tls.setDefaultCACertificates([bundledCerts[0], Buffer.from(bundledCerts[1], 'utf8')]); + const result = tls.getCACertificates('default'); + assertEqualCerts(result, [bundledCerts[0], bundledCerts[1]]); +} + +// Test mixed array with string and Uint8Array. +{ + tls.setDefaultCACertificates([bundledCerts[1], encoder.encode(bundledCerts[2])]); + const result = tls.getCACertificates('default'); + assertEqualCerts(result, [bundledCerts[1], bundledCerts[2]]); +} + +// Test mixed array with string and DataView. +{ + const uint8Cert = encoder.encode(bundledCerts[3]); + const dataViewCert = new DataView(uint8Cert.buffer, uint8Cert.byteOffset, uint8Cert.byteLength); + tls.setDefaultCACertificates([bundledCerts[1], dataViewCert]); + const result = tls.getCACertificates('default'); + assertEqualCerts(result, [bundledCerts[1], bundledCerts[3]]); +} + +// Test mixed array with Buffer and Uint8Array. +{ + tls.setDefaultCACertificates([Buffer.from(bundledCerts[0], 'utf8'), encoder.encode(bundledCerts[2])]); + const result = tls.getCACertificates('default'); + assertEqualCerts(result, [bundledCerts[0], bundledCerts[2]]); +} diff --git a/test/js/node/test/parallel/test-tls-set-default-ca-certificates-precedence-bundled.js b/test/js/node/test/parallel/test-tls-set-default-ca-certificates-precedence-bundled.js new file mode 100644 index 000000000000..a9658adbb01e --- /dev/null +++ b/test/js/node/test/parallel/test-tls-set-default-ca-certificates-precedence-bundled.js @@ -0,0 +1,53 @@ +'use strict'; + +// This tests that per-connection ca option overrides bundled default CA certificates. + +const common = require('../common'); +if (!common.hasCrypto) common.skip('missing crypto'); + +const assert = require('assert'); +const https = require('https'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); +const { includesCert } = require('../common/tls'); + +const server = https.createServer({ + cert: fixtures.readKey('agent8-cert.pem'), + key: fixtures.readKey('agent8-key.pem'), +}, common.mustCall((req, res) => { + res.writeHead(200); + res.end('override works'); +}, 1)); + +server.listen(0, common.mustCall(() => { + const port = server.address().port; + const bundledCerts = tls.getCACertificates('bundled'); + const fakeStartcomCert = fixtures.readKey('fake-startcom-root-cert.pem'); + + // Set default CA to bundled certs (which don't include fake-startcom-root-cert) + tls.setDefaultCACertificates(bundledCerts); + + // Verify that fake-startcom-root-cert is not in default + const defaultCerts = tls.getCACertificates('default'); + assert(!includesCert(defaultCerts, fakeStartcomCert)); + + // Connection with per-connection ca should succeed despite wrong default + const req = https.request({ + hostname: 'localhost', + port: port, + path: '/', + method: 'GET', + ca: [fakeStartcomCert] // This should override the bundled defaults + }, common.mustCall((res) => { + assert.strictEqual(res.statusCode, 200); + let data = ''; + res.on('data', (chunk) => data += chunk); + res.on('end', common.mustCall(() => { + assert.strictEqual(data, 'override works'); + server.close(); + })); + })); + + req.on('error', common.mustNotCall('Should not error with per-connection ca option')); + req.end(); +})); diff --git a/test/js/node/test/parallel/test-tls-set-default-ca-certificates-precedence-empty.js b/test/js/node/test/parallel/test-tls-set-default-ca-certificates-precedence-empty.js new file mode 100644 index 000000000000..1eacbc3109d2 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-set-default-ca-certificates-precedence-empty.js @@ -0,0 +1,51 @@ +'use strict'; + +// This tests that per-connection ca option overrides empty default CA certificates + +const common = require('../common'); +if (!common.hasCrypto) common.skip('missing crypto'); + +const assert = require('assert'); +const https = require('https'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); + +const server = https.createServer({ + cert: fixtures.readKey('agent8-cert.pem'), + key: fixtures.readKey('agent8-key.pem'), +}, common.mustCall((req, res) => { + res.writeHead(200); + res.end('per-connection ca works'); +}, 1)); + +server.listen(0, common.mustCall(() => { + const port = server.address().port; + const fakeStartcomCert = fixtures.readKey('fake-startcom-root-cert.pem'); + + // Set default CA to empty array - connections should normally fail + tls.setDefaultCACertificates([]); + + // Verify that default CA is empty + const defaultCerts = tls.getCACertificates('default'); + assert.deepStrictEqual(defaultCerts, []); + + // Connection with per-connection ca option should succeed despite empty default + const req = https.request({ + hostname: 'localhost', + port: port, + path: '/', + method: 'GET', + ca: [fakeStartcomCert] // This should override the empty default + }, common.mustCall((res) => { + assert.strictEqual(res.statusCode, 200); + let data = ''; + res.on('data', (chunk) => data += chunk); + res.on('end', common.mustCall(() => { + assert.strictEqual(data, 'per-connection ca works'); + server.close(); + })); + })); + + req.on('error', common.mustNotCall('Should not error with per-connection ca option')); + req.end(); +})); diff --git a/test/js/node/test/parallel/test-tls-snicallback-error.js b/test/js/node/test/parallel/test-tls-snicallback-error.js new file mode 100644 index 000000000000..aac7cb9f9670 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-snicallback-error.js @@ -0,0 +1,24 @@ +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const net = require('net'); +const tls = require('tls'); + +for (const SNICallback of ['fhqwhgads', 42, {}, []]) { + assert.throws(() => { + tls.createServer({ SNICallback }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + }); + + assert.throws(() => { + new tls.TLSSocket(new net.Socket(), { isServer: true, SNICallback }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + name: 'TypeError', + }); +} diff --git a/test/js/node/test/parallel/test-tls-ticket-cluster.js b/test/js/node/test/parallel/test-tls-ticket-cluster.js new file mode 100644 index 000000000000..f183b53f24c0 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-ticket-cluster.js @@ -0,0 +1,140 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +if (process.features.openssl_is_boringssl) { + require('../common/boringssl').testTls13SessionTicketSemanticsDiffer(); + return; +} + +const assert = require('assert'); +const tls = require('tls'); +const cluster = require('cluster'); +const fixtures = require('../common/fixtures'); + +const workerCount = 4; +const expectedReqCount = 16; + +if (cluster.isPrimary) { + let listeningCount = 0; + let reusedCount = 0; + let reqCount = 0; + let lastSession = null; + let workerPort = null; + + function shoot() { + console.error('[primary] connecting', + workerPort, 'session?', !!lastSession); + const c = tls.connect(workerPort, { + session: lastSession, + rejectUnauthorized: false + }, () => { + c.on('end', c.end); + }).on('close', () => { + // Wait for close to shoot off another connection. We don't want to shoot + // until a new session is allocated, if one will be. The new session is + // not guaranteed on secureConnect (it depends on TLS1.2 vs TLS1.3), but + // it is guaranteed to happen before the connection is closed. + if (++reqCount === expectedReqCount) { + Object.keys(cluster.workers).forEach(function(id) { + cluster.workers[id].send('die'); + }); + } else { + shoot(); + } + }).once('session', common.mustCallAtLeast((session) => { + assert(!lastSession); + lastSession = session; + }, 0)); + + c.resume(); // See close_notify comment in server + } + + function fork() { + const worker = cluster.fork(); + worker.on('message', ({ msg, port }) => { + console.error('[primary] got %j', msg); + if (msg === 'reused') { + ++reusedCount; + } else if (msg === 'listening' && ++listeningCount === workerCount) { + workerPort = port; + shoot(); + } + }); + + worker.on('exit', () => { + console.error('[primary] worker died'); + }); + } + for (let i = 0; i < workerCount; i++) { + fork(); + } + + process.on('exit', () => { + assert.strictEqual(reqCount, expectedReqCount); + assert.strictEqual(reusedCount + 1, reqCount); + }); + return; +} + +const key = fixtures.readKey('rsa_private.pem'); +const cert = fixtures.readKey('rsa_cert.crt'); + +const options = { key, cert }; + +const server = tls.createServer(options, (c) => { + console.error('[worker] connection reused?', c.isSessionReused()); + if (c.isSessionReused()) { + process.send({ msg: 'reused' }); + } else { + process.send({ msg: 'not-reused' }); + } + // Used to just .end(), but that means client gets close_notify before + // NewSessionTicket. Send data until that problem is solved. + c.end('x'); +}); + +server.listen(0, () => { + const { port } = server.address(); + process.send({ + msg: 'listening', + port, + }); +}); + +process.on('message', function listener(msg) { + console.error('[worker] got %j', msg); + if (msg === 'die') { + server.close(() => { + console.error('[worker] server close'); + + process.exit(); + }); + } +}); + +process.on('exit', () => { + console.error('[worker] exit'); +}); diff --git a/test/js/node/test/parallel/test-tls-ticket-invalid-arg.js b/test/js/node/test/parallel/test-tls-ticket-invalid-arg.js new file mode 100644 index 000000000000..55143cdca31e --- /dev/null +++ b/test/js/node/test/parallel/test-tls-ticket-invalid-arg.js @@ -0,0 +1,24 @@ +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) { + common.skip('missing crypto'); +} + +const assert = require('assert'); +const tls = require('tls'); + +const server = new tls.Server(); + +[null, undefined, 0, 1, 1n, Symbol(), {}, [], true, false, '', () => {}] + .forEach((arg) => + assert.throws( + () => server.setTicketKeys(arg), + { code: 'ERR_INVALID_ARG_TYPE' } + )); + +[new Uint8Array(1), Buffer.from([1]), new DataView(new ArrayBuffer(2))].forEach( + (arg) => + assert.throws(() => { + server.setTicketKeys(arg); + }, /Session ticket keys must be a 48-byte buffer/) +); diff --git a/test/js/node/test/parallel/test-tls-ticket.js b/test/js/node/test/parallel/test-tls-ticket.js new file mode 100644 index 000000000000..08ff5853deb2 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-ticket.js @@ -0,0 +1,163 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const tls = require('tls'); +const net = require('net'); +const crypto = require('crypto'); +const fixtures = require('../common/fixtures'); + +if (process.features.openssl_is_boringssl && + tls.DEFAULT_MAX_VERSION !== 'TLSv1.2') { + require('../common/boringssl').testTls13SessionTicketSemanticsDiffer(); + return; +} + +const keys = crypto.randomBytes(48); +const serverLog = []; +const ticketLog = []; + +let s; + +let serverCount = 0; +function createServer() { + const id = serverCount++; + + let counter = 0; + let previousKey = null; + + const server = tls.createServer({ + key: fixtures.readKey('agent1-key.pem'), + cert: fixtures.readKey('agent1-cert.pem'), + ticketKeys: keys + }, common.mustCallAtLeast(function(c) { + serverLog.push(id); + c.end('x'); + + counter++; + + // Rotate ticket keys + // + // Take especial care to account for TLS1.2 and TLS1.3 differences around + // when ticket keys are encrypted. In TLS1.2, they are encrypted before the + // handshake complete callback, but in TLS1.3, they are encrypted after. + // There is no callback or way for us to know when they were sent, so hook + // the client's reception of the keys, and use it as proof that the current + // keys were used, and its safe to rotate them. + // + // Rotation can occur right away if the session was reused, the keys were + // already decrypted or we wouldn't have a reused session. + function setTicketKeys(keys) { + if (c.isSessionReused()) + server.setTicketKeys(keys); + else + s.once('session', () => { + server.setTicketKeys(keys); + }); + } + if (counter === 1) { + previousKey = server.getTicketKeys(); + assert.strictEqual(previousKey.compare(keys), 0); + setTicketKeys(crypto.randomBytes(48)); + } else if (counter === 2) { + setTicketKeys(previousKey); + } else if (counter === 3) { + // Use keys from counter=2 + } else { + throw new Error('UNREACHABLE'); + } + })); + + return server; +} + +const naturalServers = [ createServer(), createServer(), createServer() ]; + +// 3x servers +const servers = naturalServers.concat(naturalServers).concat(naturalServers); + +// Create one TCP server and balance sockets to multiple TLS server instances +const shared = net.createServer(function(c) { + servers.shift().emit('connection', c); +}).listen(0, function() { + start(function() { + shared.close(); + }); +}); + +// 'session' events only occur for new sessions. The first connection is new. +// After, for each set of 3 connections, the middle connection is made when the +// server has random keys set, so the client's ticket is silently ignored, and a +// new ticket is sent. +const onNewSession = common.mustCall((s, session) => { + assert(session); + assert.strictEqual(session.compare(s.getSession()), 0); +}, 4); + +function start(callback) { + let sess = null; + let left = servers.length; + + function connect() { + s = tls.connect(shared.address().port, { + session: sess, + rejectUnauthorized: false + }, function() { + if (s.isSessionReused()) + ticketLog.push(s.getTLSTicket().toString('hex')); + }); + s.on('data', () => { + s.end(); + }); + s.on('close', function() { + if (--left === 0) + callback(); + else + connect(); + }); + s.on('session', (session) => { + sess ||= session; + }); + s.once('session', (session) => onNewSession(s, session)); + s.once('session', () => ticketLog.push(s.getTLSTicket().toString('hex'))); + } + + connect(); +} + +process.on('exit', function() { + assert.strictEqual(ticketLog.length, serverLog.length); + for (let i = 0; i < naturalServers.length - 1; i++) { + assert.notStrictEqual(serverLog[i], serverLog[i + 1]); + assert.strictEqual(ticketLog[i], ticketLog[i + 1]); + + // 2nd connection should have different ticket + assert.notStrictEqual(ticketLog[i], ticketLog[i + naturalServers.length]); + + // 3rd connection should have the same ticket + assert.strictEqual(ticketLog[i], ticketLog[i + naturalServers.length * 2]); + } +}); diff --git a/test/js/node/test/parallel/test-tls-timeout-server.js b/test/js/node/test/parallel/test-tls-timeout-server.js new file mode 100644 index 000000000000..7ca85f14d7ae --- /dev/null +++ b/test/js/node/test/parallel/test-tls-timeout-server.js @@ -0,0 +1,47 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const tls = require('tls'); +const net = require('net'); +const fixtures = require('../common/fixtures'); + +const options = { + key: fixtures.readKey('agent1-key.pem'), + cert: fixtures.readKey('agent1-cert.pem'), + handshakeTimeout: 50 +}; + +const server = tls.createServer(options, common.mustNotCall()); + +server.on('tlsClientError', common.mustCall(function(err, conn) { + conn.destroy(); + server.close(); +})); + +server.listen(0, common.mustCall(function() { + net.connect({ host: '127.0.0.1', port: this.address().port }); +})); diff --git a/test/js/node/test/parallel/test-tls-wrap-econnreset-pipe.js b/test/js/node/test/parallel/test-tls-wrap-econnreset-pipe.js new file mode 100644 index 000000000000..f294f23f1d00 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-wrap-econnreset-pipe.js @@ -0,0 +1,48 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const tls = require('tls'); +const net = require('net'); +const { fork } = require('child_process'); + +const tmpdir = require('../common/tmpdir'); + +// Run in a child process because the PIPE file descriptor stays open until +// Node.js completes, blocking the tmpdir and preventing cleanup. + +if (process.argv[2] !== 'child') { + // Parent + tmpdir.refresh(); + + // Run test + const child = fork(__filename, ['child'], { stdio: 'inherit' }); + child.on('exit', common.mustCall(function(code) { + assert.strictEqual(code, 0); + })); + + return; +} + +// Child +const server = net.createServer((c) => { + c.end(); +}).listen(common.PIPE, common.mustCall(() => { + let errored = false; + tls.connect({ path: common.PIPE }) + .once('error', common.mustCall((e) => { + assert.strictEqual(e.code, 'ECONNRESET'); + assert.strictEqual(e.path, common.PIPE); + assert.strictEqual(e.port, undefined); + assert.strictEqual(e.host, undefined); + assert.strictEqual(e.localAddress, undefined); + server.close(); + errored = true; + })) + .on('close', common.mustCall(() => { + assert.strictEqual(errored, true); + })); +})); diff --git a/test/js/node/test/parallel/test-tls-wrap-event-emmiter.js b/test/js/node/test/parallel/test-tls-wrap-event-emmiter.js new file mode 100644 index 000000000000..47933da674bc --- /dev/null +++ b/test/js/node/test/parallel/test-tls-wrap-event-emmiter.js @@ -0,0 +1,17 @@ +'use strict'; + +// Issue: https://github.com/nodejs/node/issues/3655 +// Test checks if we get exception instead of runtime error + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); + +const TlsSocket = require('tls').TLSSocket; +const EventEmitter = require('events').EventEmitter; +assert.throws( + () => { new TlsSocket(new EventEmitter()); }, + TypeError +); diff --git a/test/js/node/test/parallel/test-vm-module-errors.js b/test/js/node/test/parallel/test-vm-module-errors.js index 61f9c68fa39b..ad247f83edcb 100644 --- a/test/js/node/test/parallel/test-vm-module-errors.js +++ b/test/js/node/test/parallel/test-vm-module-errors.js @@ -225,8 +225,8 @@ async function checkInvalidOptionForEvaluate() { function checkInvalidCachedData() { [true, false, 'foo', {}, Array, function() {}].forEach((invalidArg) => { - const message = 'The "options.cachedData" property must be of ' + - 'type Buffer, TypedArray, or DataView.' + + const message = 'The "options.cachedData" property must be an ' + + 'instance of Buffer, TypedArray, or DataView.' + common.invalidArgTypeHelper(invalidArg); assert.throws( () => new SourceTextModule('import "foo";', { cachedData: invalidArg }), diff --git a/test/js/node/test/sequential/test-net-listen-shared-ports.js b/test/js/node/test/sequential/test-net-listen-shared-ports.js new file mode 100644 index 000000000000..34091cba15ca --- /dev/null +++ b/test/js/node/test/sequential/test-net-listen-shared-ports.js @@ -0,0 +1,67 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const cluster = require('cluster'); +const net = require('net'); + +if (cluster.isPrimary) { + const worker1 = cluster.fork(); + + worker1.on('message', common.mustCall(function(msg) { + assert.strictEqual(msg, 'success'); + const worker2 = cluster.fork(); + + worker2.on('message', common.mustCall(function(msg) { + assert.strictEqual(msg, 'server2:EADDRINUSE'); + worker1.kill(); + worker2.kill(); + })); + })); +} else { + const server1 = net.createServer(common.mustNotCall()); + const server2 = net.createServer(common.mustNotCall()); + + server1.on('error', function(err) { + // no errors expected + process.send(`server1:${err.code}`); + }); + + server2.on('error', function(err) { + // An error is expected on the second worker + process.send(`server2:${err.code}`); + }); + + server1.listen({ + host: 'localhost', + port: common.PORT, + exclusive: false, + }, common.mustCall(function() { + server2.listen({ port: common.PORT + 1, exclusive: true }, + common.mustCall(function() { + // The first worker should succeed + process.send('success'); + }) + ); + })); +} diff --git a/test/js/node/test/sequential/test-net-localport.js b/test/js/node/test/sequential/test-net-localport.js new file mode 100644 index 000000000000..4539acaaee4c --- /dev/null +++ b/test/js/node/test/sequential/test-net-localport.js @@ -0,0 +1,20 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); + +const server = net.createServer(common.mustCall((socket) => { + assert.strictEqual(socket.remotePort, common.PORT); + socket.end(); + socket.on('close', function() { + server.close(); + }); +})).listen(0).on('listening', common.mustCall(function() { + const client = net.connect({ + host: '127.0.0.1', + port: this.address().port, + localPort: common.PORT, + }).on('connect', common.mustCall(() => { + assert.strictEqual(client.localPort, common.PORT); + })); +})); diff --git a/test/js/node/tls/node-tls-cert.test.ts b/test/js/node/tls/node-tls-cert.test.ts index dc1e426dfa71..beb012bc8049 100644 --- a/test/js/node/tls/node-tls-cert.test.ts +++ b/test/js/node/tls/node-tls-cert.test.ts @@ -610,69 +610,81 @@ describe("tls ciphers should work", () => { }); }); -it("server-side getPeerCertificate() should not leak", async () => { - // Guards against the SSL_get_peer_certificate X509 ref leak and the - // computeRaw BIO leak on the server getPeerCertificate() path. - const { promise: serverSocketPromise, resolve: onServerSocket } = Promise.withResolvers(); - const server = tls.createServer( - { - key: serverTls.key, - cert: serverTls.cert, - ca: [clientTls.ca], - requestCert: true, - rejectUnauthorized: false, - }, - socket => onServerSocket(socket), - ); - await once(server.listen(0, "127.0.0.1"), "listening"); - - const client = tls.connect({ - host: "127.0.0.1", - port: (server.address() as AddressInfo).port, - key: clientTls.key, - cert: clientTls.cert, - ca: [serverTls.ca], - checkServerIdentity, - }); - await once(client, "secureConnect"); +// A local `bun bd` debug build is ASAN-instrumented but not named `bun-asan`; +// ASAN's default 256MB quarantine retains every freed allocation, so RSS grows +// by the total allocation churn regardless of leaks and the threshold below +// cannot distinguish a leak from the quarantine. Skip there instead of +// converting an over-threshold measurement into a pass. +it.skipIf(isDebug && !isASAN)( + "server-side getPeerCertificate() should not leak", + async () => { + // Guards against the SSL_get_peer_certificate X509 ref leak and the + // computeRaw BIO leak on the server getPeerCertificate() path. + const { promise: serverSocketPromise, resolve: onServerSocket } = Promise.withResolvers(); + const server = tls.createServer( + { + key: serverTls.key, + cert: serverTls.cert, + ca: [clientTls.ca], + requestCert: true, + rejectUnauthorized: false, + }, + socket => onServerSocket(socket), + ); + await once(server.listen(0, "127.0.0.1"), "listening"); - const serverSocket = await serverSocketPromise; - try { - // Make sure the client actually sent a cert so we exercise the - // SSL_get_peer_certificate path rather than falling through to the - // cert-chain branch. - const first = serverSocket.getPeerCertificate(); - expect(first).toBeDefined(); - expect(first?.subject).toBeDefined(); - - function spin(n: number) { - for (let i = 0; i < n; i++) { - serverSocket.getPeerCertificate(); - serverSocket.getPeerCertificate(false); + const client = tls.connect({ + host: "127.0.0.1", + port: (server.address() as AddressInfo).port, + key: clientTls.key, + cert: clientTls.cert, + ca: [serverTls.ca], + checkServerIdentity, + }); + await once(client, "secureConnect"); + + const serverSocket = await serverSocketPromise; + try { + // Make sure the client actually sent a cert so we exercise the + // SSL_get_peer_certificate path rather than falling through to the + // cert-chain branch. + const first = serverSocket.getPeerCertificate(); + expect(first).toBeDefined(); + expect(first?.subject).toBeDefined(); + + function spin(n: number) { + for (let i = 0; i < n; i++) { + serverSocket.getPeerCertificate(); + serverSocket.getPeerCertificate(false); + } + Bun.gc(true); + Bun.gc(true); } - Bun.gc(true); - Bun.gc(true); - } - // Run in fixed-size rounds with a GC after each so the steady-state - // heap footprint stays bounded. The first few rounds grow the heap - // regardless of leaks, so take the baseline after warmup. - const perRound = isDebug ? 2_500 : 5_000; - for (let round = 0; round < 4; round++) spin(perRound); - const baseline = process.memoryUsage.rss(); - - for (let round = 0; round < 10; round++) spin(perRound); - const after = process.memoryUsage.rss(); - const growth = after - baseline; - - // Unpatched, the BIO leak alone is ~800 bytes/call → ~40MB over the - // 50k abbreviated calls here (~20MB for 25k in debug). Leave slack for - // allocator/ASAN noise but stay well below that. - const threshold = 1024 * 1024 * (isDebug ? 10 : isASAN ? 16 : 12); - expect(growth).toBeLessThan(threshold); - } finally { - client.end(); - serverSocket.end(); - server.close(); - } -}, 180_000); + // Run in fixed-size rounds with a GC after each so the steady-state + // heap footprint stays bounded. The first few rounds grow the heap + // regardless of leaks, so take the baseline after warmup. + const perRound = isDebug ? 2_500 : 5_000; + for (let round = 0; round < 4; round++) spin(perRound); + const baseline = process.memoryUsage.rss(); + + for (let round = 0; round < 10; round++) spin(perRound); + const after = process.memoryUsage.rss(); + const growth = after - baseline; + + // Unpatched, the BIO leak alone is ~800 bytes/call → ~40MB over the + // 50k abbreviated calls here (~20MB for 25k in debug). Leave slack for + // allocator/ASAN noise but stay well below that. Both calls in the loop + // build the full leaf-certificate object (getPeerCertificate(false) used + // to return {}), so the debug budget covers 2x the constructions. Local + // debug (non-`bun-asan`) builds skip this test entirely - see skipIf above. + const threshold = 1024 * 1024 * (isDebug ? 20 : isASAN ? 16 : 12); + expect(growth).toBeLessThan(threshold); + } finally { + client.end(); + serverSocket.end(); + server.close(); + } + }, + 180_000, +); diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index 62344267b18e..fc54c91f7e81 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -275,7 +275,10 @@ for (const { name, connect } of tests) { expect(cert.serialNumber).toBe("71A46AE89FD817EF81A34D5973E1DE42F09B9D63"); expect(cert.raw).toBeInstanceOf(Buffer); } finally { - socket.end(); + // Tear the socket down immediately: the local server is disposed right + // after this test, and a lingering half-closed connection would observe + // its hard close as ECONNRESET (Node surfaces the same error). + socket.destroy(); } }); @@ -544,7 +547,17 @@ it("setSession() should not leak the SSL_SESSION returned by d2i_SSL_SESSION", a // With it: ~5–10 MB (allocator noise, no per-call growth). await using proc = Bun.spawn({ cmd: [bunExe(), join(import.meta.dirname, "node-tls-set-session-leak.fixture.ts"), "20000"], - env: bunEnv, + env: { + ...bunEnv, + // ASAN's default 256MB quarantine retains every freed allocation, so + // RSS growth would measure the total allocation churn instead of leaks + // on any ASAN-instrumented build (including a local `bun bd` debug + // build, which is ASAN but not named `bun-asan`). Cap the quarantine + // so the measurement reflects live memory. + // Preserve the harness ASAN options (bunEnv sets allow_user_segv_handler / + // disable_coredump) instead of rebuilding from process.env only. + ASAN_OPTIONS: ["quarantine_size_mb=8", bunEnv.ASAN_OPTIONS ?? process.env.ASAN_OPTIONS].filter(Boolean).join(":"), + }, stdout: "pipe", stderr: "pipe", }); @@ -554,8 +567,7 @@ it("setSession() should not leak the SSL_SESSION returned by d2i_SSL_SESSION", a expect(calls).toBe(20000); // Leave generous headroom above the fixed-build measurement so unrelated // allocator changes don't turn this into a flaky test, while still being - // far below the ~125 MB leak signature. ASAN's quarantine retains freed - // allocations so widen the threshold there. - expect(growthBytes).toBeLessThan((isASAN ? 200 : 40) * 1024 * 1024); + // far below the ~125 MB leak signature. + expect(growthBytes).toBeLessThan((isASAN ? 60 : 40) * 1024 * 1024); expect(exitCode).toBe(0); }, 60_000); diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 14623946082c..963515436f59 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -700,6 +700,56 @@ it("connectionListener should emit the right amount of times, and with alpnProto expect(count).toBe(50); }); +it("destroying the socket from inside SNICallback or ALPNCallback does not crash the process", async () => { + // Both callbacks run synchronously from inside the native handshake; a + // destroy() there must defer the SSL teardown until the handshake call + // unwinds instead of freeing it out from under BoringSSL. + const connections: Array<{ destroy(): void }> = []; + for (const extra of [ + { + ALPNCallback(this: unknown, { protocols }: { protocols: string[] }) { + (this as { destroy(): void }).destroy(); + return protocols[0]; + }, + }, + { + SNICallback(_name: string, cb: (err: Error | null, ctx?: unknown) => void) { + connections.at(-1)?.destroy(); + cb(null, undefined); + }, + }, + ]) { + // Declared above the for-of's iterable so the SNICallback closure (built + // once when the array literal is evaluated) captures it; reset per case. + connections.length = 0; + const server = tls.createServer({ key: cert1.key, cert: cert1.cert, ...extra }, socket => socket.end()); + server.on("connection", socket => connections.push(socket)); + server.on("tlsClientError", () => {}); + await new Promise(resolve => server.listen(0, resolve)); + const { port } = server.address() as AddressInfo; + await new Promise(resolve => { + const client = tls.connect( + { + port, + rejectUnauthorized: false, + ALPNProtocols: ["x/1"], + servername: "x.test", + checkServerIdentity: () => undefined, + }, + () => { + client.end(); + resolve(); + }, + ); + client.on("error", () => resolve()); + client.on("close", () => resolve()); + }); + server.close(); + } + // Reaching here without an abort/ASAN report is the assertion. + expect(true).toBe(true); +}); + it("leaves socket.authorized false unless a client certificate was requested and verified", async () => { // A server that never requested a client certificate must not report the // connection as authorized (matches Node.js fail-closed semantics). @@ -771,3 +821,40 @@ it("leaves socket.authorized false unless a client certificate was requested and } } }); + +it("createServer({pfx, requestCert}) verifies client certificates against the pfx-embedded CA", async () => { + // agent1.pfx bundles agent1's key/cert plus ca1; a server built from it must + // be able to verify a client certificate signed by that embedded CA. + const fixtures = join(import.meta.dir, "../test/fixtures/keys"); + const { promise, resolve, reject } = Promise.withResolvers(); + const server: Server = createServer( + { + pfx: readFileSync(join(fixtures, "agent1.pfx")), + passphrase: "sample", + requestCert: true, + rejectUnauthorized: false, + }, + socket => { + resolve(socket.authorized); + socket.end(); + }, + ); + server.on("error", reject); + server.listen(0); + await once(server, "listening"); + const address = server.address() as AddressInfo; + const client = connect({ + port: address.port, + host: "127.0.0.1", + key: readFileSync(join(fixtures, "agent1-key.pem"), "utf8"), + cert: readFileSync(join(fixtures, "agent1-cert.pem"), "utf8"), + rejectUnauthorized: false, + }); + client.on("error", reject); + try { + expect(await promise).toBe(true); + } finally { + client.end(); + server.close(); + } +}); diff --git a/test/js/node/tls/ssl-ctx-cache.test.ts b/test/js/node/tls/ssl-ctx-cache.test.ts index 189b349ceb8b..a0eafca213fb 100644 --- a/test/js/node/tls/ssl-ctx-cache.test.ts +++ b/test/js/node/tls/ssl-ctx-cache.test.ts @@ -9,7 +9,7 @@ import tls from "node:tls"; // @ts-expect-error - debug-only export import { sslCtxLiveCount } from "bun:internal-for-testing"; import { tempDir, tls as tlsCerts } from "harness"; -import { writeFileSync } from "node:fs"; +import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; async function withServer(fn: (port: number) => Promise) { @@ -75,16 +75,19 @@ test("Bun.connect with servername-only tls reuses one SSL_CTX", async () => { } }); -// `tls.createSecureContext()` is now WeakGCMap-memoised by digest in native -// code (replacing the SHA-256/WeakRef Map that lived in tls.ts), so the same -// options return the same native handle. -test("createSecureContext returns the same native handle for identical configs", () => { +// The user-facing `tls.createSecureContext()` is uncached: every call owns its +// SSL_CTX exclusively (so addCACert on one context can never leak into +// another); only internal consumers (tls.connect / Bun.connect / fetch) share +// contexts through the per-digest native cache. +test("createSecureContext owns its native handle exclusively (identical configs get distinct SSL_CTXs)", () => { const opts = { ca: tlsCerts.cert, rejectUnauthorized: false }; const a = tls.createSecureContext(opts); const b = tls.createSecureContext({ ...opts }); // The JS wrapper carries per-call `servername`, so wrappers differ; the // SSL_CTX-owning `.context` is the deduped native cell. - expect(a.context).toBe(b.context); + // The user-facing createSecureContext() owns its SSL_CTX exclusively so + // addCACert on one context can never affect another. + expect(a.context).not.toBe(b.context); // Different config → different handle. const c = tls.createSecureContext({ rejectUnauthorized: false }); expect(c.context).not.toBe(a.context); @@ -177,14 +180,14 @@ test("file-backed config: in-place rotation invalidates cache (mtime+size in dig const caFile = join(String(dir), "ca.pem"); await withServer(async port => { - // Pin the wrapped SecureContext so GC between connects can't drop the - // count and turn the strict equalities below into flakes — `.context` is - // populated from the Symbol-keyed slot via `createSecureContext`. + // Exercise the cached connect path (which memoises by config digest); + // the user-facing createSecureContext() now owns its SSL_CTX exclusively, + // so it would create a fresh CTX per call and defeat the cache this test + // is about. Pin each socket so GC between connects can't drop the count. const pin: unknown[] = []; const connectOnce = async () => { - const sc = tls.createSecureContext({ caFile, rejectUnauthorized: false } as any); - pin.push(sc); - const s = tls.connect({ port, secureContext: sc }); + const s = tls.connect({ port, caFile, rejectUnauthorized: false } as any); + pin.push(s); await once(s, "secureConnect"); s.destroy(); await once(s, "close"); @@ -207,3 +210,30 @@ test("file-backed config: in-place rotation invalidates cache (mtime+size in dig pin.length = 0; }); }); + +test("addCACert on one user-facing context does not affect another with identical options", () => { + const a = tls.createSecureContext({}); + const b = tls.createSecureContext({}); + expect(a.context).not.toBe(b.context); + a.context.addCACert(tlsCerts.cert); + // b's native context is a different object and stays untouched. + expect(a.context).not.toBe(b.context); +}); + +test("setDefaultCACertificates() override applies to plain tls.connect (no explicit ca)", async () => { + const keys = (f: string) => readFileSync(join(import.meta.dir, "../test/fixtures/keys", f)); + const prev = tls.getCACertificates("default"); + try { + tls.setDefaultCACertificates([keys("ca1-cert.pem").toString()]); + const server = tls.createServer({ key: keys("agent1-key.pem"), cert: keys("agent1-cert.pem") }, s => s.end("ok")); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const port = (server.address() as any).port; + const socket = tls.connect({ port, host: "127.0.0.1", rejectUnauthorized: true, servername: "agent1" }); + await once(socket, "secureConnect"); + expect(socket.authorized).toBe(true); + socket.destroy(); + server.close(); + } finally { + tls.setDefaultCACertificates(prev); + } +}); diff --git a/test/js/node/tls/tls-connect-socket-churn.test.ts b/test/js/node/tls/tls-connect-socket-churn.test.ts index fd08c38a167c..306ffc164b57 100644 --- a/test/js/node/tls/tls-connect-socket-churn.test.ts +++ b/test/js/node/tls/tls-connect-socket-churn.test.ts @@ -59,12 +59,14 @@ test("tls.connect churn does not leak SSL_CTX or us_socket_context_t", async () // crypto, not a wait-for-condition. }, 30_000); -test("createSecureContext memoises the native SSL_CTX (not the wrapper) by config", () => { +test("createSecureContext owns its native SSL_CTX exclusively (fresh wrapper too)", () => { const a = tls.createSecureContext({ cert: tlsCerts.cert }); const b = tls.createSecureContext({ cert: tlsCerts.cert, servername: "other.example" }); - // Same SSL_CTX-relevant fields → same native handle… - expect(a.context).toBe(b.context); - // …but the wrapper is fresh so per-call fields don't leak across callers. + // The user-facing constructor owns its SSL_CTX exclusively so addCACert on + // one context can never affect another; only the internal connect/listen + // paths memoise by config digest. + expect(a.context).not.toBe(b.context); + // The wrapper is fresh too, so per-call fields don't leak across callers. expect(a).not.toBe(b); expect(b.servername).toBe("other.example"); expect(a.servername).toBeUndefined(); diff --git a/test/js/web/fetch/chunked-trailing.test.js b/test/js/web/fetch/chunked-trailing.test.js index d24cd9f77b35..9e458160ad40 100644 --- a/test/js/web/fetch/chunked-trailing.test.js +++ b/test/js/web/fetch/chunked-trailing.test.js @@ -5,6 +5,7 @@ it("handles trailing headers split across packets", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -35,6 +36,7 @@ it("handles trailing headers in a single packet", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -60,6 +62,7 @@ it("handles trailing headers with empty body", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -84,6 +87,7 @@ it("handles multiple trailing headers", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -111,6 +115,7 @@ it("handles trailing headers with very long delay", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -139,6 +144,7 @@ it("handles trailing headers with byte-by-byte transmission", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -178,6 +184,7 @@ it("handles trailing headers with malformed format (missing final CRLF)", async const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -204,6 +211,7 @@ it("handles trailing headers with extremely large values", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -229,6 +237,7 @@ it("handles connection close during trailing headers", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -254,6 +263,7 @@ it("handles trailing headers with multiple header lines", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -281,6 +291,7 @@ it("handles trailing headers with empty values", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -306,6 +317,7 @@ it("handles delayed trailing headers", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -335,6 +347,7 @@ it("handles trailing headers after the final chunk only", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -367,6 +380,7 @@ it("handles chunked extensions with empty extension", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -393,6 +407,7 @@ it("handles chunked extensions with simple key", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -419,6 +434,7 @@ it("handles chunked extensions with key-value pair", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -445,6 +461,7 @@ it("handles chunked extensions with quoted value", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -471,6 +488,7 @@ it("handles chunked extensions on multiple chunks", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -502,6 +520,7 @@ it("handles chunked extensions with trailing headers", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -532,6 +551,7 @@ it("handles chunked extensions with special characters", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -558,6 +578,7 @@ it("proper error if missing zero-length chunk", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -589,6 +610,7 @@ it("proper error if missing data in middle of chunk extension", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); @@ -622,6 +644,7 @@ it("proper error if missing CRLF after chunk data", async () => { const { promise, resolve } = Promise.withResolvers(); await using server = net .createServer(socket => { + socket.on("error", () => {}); // raw test server: tolerate client aborts (ECONNRESET) socket.once("data", () => { socket.write("HTTP/1.1 200 OK\r\n"); socket.write("Content-Type: text/plain\r\n"); diff --git a/test/js/web/fetch/fetch.test.ts b/test/js/web/fetch/fetch.test.ts index b5bbca34e6ec..363e880faf10 100644 --- a/test/js/web/fetch/fetch.test.ts +++ b/test/js/web/fetch/fetch.test.ts @@ -2447,6 +2447,10 @@ describe("fetch should allow duplex", () => { it("should allow to follow redirect if connection is closed, abort should work even if the socket was closed before the redirect", async () => { for (const type of ["normal", "delay"]) { await using server = net.createServer(socket => { + // Raw test server: tolerate client aborts, surface anything unexpected. + socket.on("error", (err: NodeJS.ErrnoException) => { + if (err.code !== "ECONNRESET" && err.code !== "EPIPE" && err.code !== "ECONNABORTED") throw err; + }); let body = ""; socket.on("data", data => { body += data.toString("utf8"); diff --git a/test/js/web/websocket/websocket-permessage-deflate-edge-cases.test.ts b/test/js/web/websocket/websocket-permessage-deflate-edge-cases.test.ts index 37a718e03465..b6645ea5ae10 100644 --- a/test/js/web/websocket/websocket-permessage-deflate-edge-cases.test.ts +++ b/test/js/web/websocket/websocket-permessage-deflate-edge-cases.test.ts @@ -221,6 +221,10 @@ test("WebSocket client rejects decompression bombs", async () => { const port = await serverReady; tcpServer.on("connection", socket => { + // Raw test server: tolerate client aborts, surface anything unexpected. + socket.on("error", (err: NodeJS.ErrnoException) => { + if (err.code !== "ECONNRESET" && err.code !== "EPIPE" && err.code !== "ECONNABORTED") throw err; + }); let buffer = Buffer.alloc(0); socket.on("data", data => { diff --git a/test/js/web/websocket/websocket-subprotocol-strict.test.ts b/test/js/web/websocket/websocket-subprotocol-strict.test.ts index b393194e160a..0cb1c35dd7c2 100644 --- a/test/js/web/websocket/websocket-subprotocol-strict.test.ts +++ b/test/js/web/websocket/websocket-subprotocol-strict.test.ts @@ -17,6 +17,10 @@ describe("WebSocket strict RFC 6455 subprotocol handling", () => { }); server.on("connection", socket => { + // Raw test server: tolerate client aborts, surface anything unexpected. + socket.on("error", (err: NodeJS.ErrnoException) => { + if (err.code !== "ECONNRESET" && err.code !== "EPIPE" && err.code !== "ECONNABORTED") throw err; + }); let requestData = ""; socket.on("data", data => { diff --git a/test/no-validate-leaksan.txt b/test/no-validate-leaksan.txt index 8dde0b650a6c..3dd7aa3dd320 100644 --- a/test/no-validate-leaksan.txt +++ b/test/no-validate-leaksan.txt @@ -216,7 +216,6 @@ test/js/node/test/parallel/test-http-dummy-characters-smuggling.js test/js/node/test/parallel/test-http-missing-header-separator-lf.js test/js/node/test/parallel/test-http-invalid-te.js test/js/node/test/parallel/test-http-missing-header-separator-cr.js -test/js/node/test/parallel/test-http-server-reject-chunked-with-content-length.js test/js/node/test/parallel/test-http-chunked-smuggling.js test/js/node/test/parallel/test-http-double-content-length.js test/js/node/test/parallel/test-http-blank-header.js @@ -386,6 +385,9 @@ test/js/node/vm/vm.test.ts # VM has terminated test/js/node/test/parallel/test-net-during-close.js +test/js/node/test/parallel/test-net-socket-reset-send.js +test/js/node/test/parallel/test-net-connect-reset-after-destroy.js +test/js/node/test/parallel/test-net-connect-reset-until-connected.js # JSC::BuiltinNames::~BuiltinNames test/js/bun/shell/shell-hang.test.ts @@ -446,4 +448,7 @@ test/js/bun/test/parallel/test-http-should-not-accept-untrusted-certificates.ts test/js/node/test/parallel/test-https-localaddress-bind-error.js test/js/node/test/parallel/test-crypto-op-during-process-exit.js -test/js/third_party/prisma/prisma.test.ts \ No newline at end of file +test/js/third_party/prisma/prisma.test.ts +# upgradeDuplexToTLS protos/server_name Box<[u8]> are owned by the JS-held +# socket cell; tests that exit before a final GC report them as indirect leaks. +test/js/node/tls/node-tls-connect.test.ts