From c90c0e69cb882d143da18f2a6d2aae8428aa429e Mon Sep 17 00:00:00 2001 From: Ciro Spaciari Date: Thu, 8 Jan 2026 16:21:34 -0800 Subject: [PATCH] feat(websocket): add HTTP/HTTPS proxy support (#25614) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add `proxy` option to WebSocket constructor for connecting through HTTP CONNECT proxies. ### Features - Support for `ws://` and `wss://` through HTTP proxies - Support for `ws://` and `wss://` through HTTPS proxies (with `rejectUnauthorized: false`) - Proxy authentication via URL credentials (Basic auth) - Custom proxy headers support - Full TLS options (`ca`, `cert`, `key`, etc.) for target connections using `SSLConfig.fromJS` ### API ```javascript // String format new WebSocket("wss://example.com", { proxy: "http://proxy:8080" }) // With credentials new WebSocket("wss://example.com", { proxy: "http://user:pass@proxy:8080" }) // Object format with custom headers new WebSocket("wss://example.com", { proxy: { url: "http://proxy:8080", headers: { "X-Custom": "value" } } }) // HTTPS proxy new WebSocket("ws://example.com", { proxy: "https://proxy:8443", tls: { rejectUnauthorized: false } }) ``` ### Implementation | File | Changes | |------|---------| | `WebSocketUpgradeClient.zig` | Proxy state machine and CONNECT handling | | `WebSocketProxyTunnel.zig` | **New** - TLS tunnel inside CONNECT for wss:// through HTTP proxy | | `JSWebSocket.cpp` | Parse proxy option and TLS options using `SSLConfig.fromJS` | | `WebSocket.cpp` | Pass proxy parameters to Zig, handle HTTPS proxy socket selection | | `bun.d.ts` | Add `proxy` and full TLS options to WebSocket types | ### Supported Scenarios | Scenario | Status | |----------|--------| | ws:// through HTTP proxy | ✅ Working | | wss:// through HTTP proxy | ✅ Working (TLS tunnel) | | ws:// through HTTPS proxy | ✅ Working (with `rejectUnauthorized: false`) | | wss:// through HTTPS proxy | ✅ Working (with `rejectUnauthorized: false`) | | Proxy authentication (Basic) | ✅ Working | | Custom proxy headers | ✅ Working | | Custom CA for HTTPS proxy | ✅ Working | ## Test plan - [x] API tests verify proxy option is accepted in various formats - [x] Functional tests with local HTTP CONNECT proxy server - [x] Proxy authentication tests (Basic auth) - [x] HTTPS proxy tests with `rejectUnauthorized: false` - [x] Error handling tests (auth failures, wrong credentials) Run tests: `bun test test/js/web/websocket/websocket-proxy.test.ts` ## Changelog - Added `proxy` option to `WebSocket` constructor for HTTP/HTTPS proxy support - Added full TLS options (`ca`, `cert`, `key`, `passphrase`, etc.) to `WebSocket` constructor 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- packages/bun-types/bun.d.ts | 80 +- src/bun.js/api/bun/ssl_wrapper.zig | 6 + src/bun.js/api/server/SSLConfig.zig | 20 + src/bun.js/bindings/ObjectBindings.cpp | 15 + src/bun.js/bindings/ObjectBindings.h | 7 + src/bun.js/bindings/headers.h | 25 +- src/bun.js/bindings/webcore/JSWebSocket.cpp | 198 +++- src/bun.js/bindings/webcore/WebSocket.cpp | 282 +++++- src/bun.js/bindings/webcore/WebSocket.h | 33 +- .../bindings/webcore/WebSocketErrorCode.h | 5 + src/http.zig | 6 +- src/http/HTTPContext.zig | 4 +- src/http/ProxyTunnel.zig | 14 +- src/http/websocket_client.zig | 111 ++- src/http/websocket_client/CppWebSocket.zig | 18 +- src/http/websocket_client/WebSocketProxy.zig | 71 ++ .../websocket_client/WebSocketProxyTunnel.zig | 351 +++++++ .../WebSocketUpgradeClient.zig | 581 ++++++++++- src/js/thirdparty/ws.js | 74 +- src/sql/mysql/js/JSMySQLConnection.zig | 11 +- src/sql/postgres/PostgresSQLConnection.zig | 9 +- test/bun.lock | 70 +- test/docker/config/squid.conf | 20 + test/docker/docker-compose.yml | 19 + test/docker/index.ts | 65 +- test/js/first_party/ws/ws-proxy.test.ts | 600 ++++++++++++ test/js/web/websocket/proxy-test-utils.ts | 197 ++++ test/js/web/websocket/websocket-proxy.test.ts | 911 ++++++++++++++++++ test/package.json | 2 +- 29 files changed, 3635 insertions(+), 170 deletions(-) create mode 100644 src/http/websocket_client/WebSocketProxy.zig create mode 100644 src/http/websocket_client/WebSocketProxyTunnel.zig create mode 100644 test/docker/config/squid.conf create mode 100644 test/js/first_party/ws/ws-proxy.test.ts create mode 100644 test/js/web/websocket/proxy-test-utils.ts create mode 100644 test/js/web/websocket/websocket-proxy.test.ts diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index bf6e80c9de..0dbf3f5fa7 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -3290,16 +3290,29 @@ declare module "bun" { type WebSocketOptionsTLS = { /** - * Options for the TLS connection + * Options for the TLS connection. + * + * Supports full TLS configuration including custom CA certificates, + * client certificates, and other TLS settings (same as fetch). + * + * @example + * ```ts + * // Using BunFile for certificates + * const ws = new WebSocket("wss://example.com", { + * tls: { + * ca: Bun.file("./ca.pem") + * } + * }); + * + * // Using Buffer + * const ws = new WebSocket("wss://example.com", { + * tls: { + * ca: fs.readFileSync("./ca.pem") + * } + * }); + * ``` */ - tls?: { - /** - * Whether to reject the connection if the certificate is not valid - * - * @default true - */ - rejectUnauthorized?: boolean; - }; + tls?: TLSOptions; }; type WebSocketOptionsHeaders = { @@ -3309,10 +3322,57 @@ declare module "bun" { headers?: import("node:http").OutgoingHttpHeaders; }; + type WebSocketOptionsProxy = { + /** + * HTTP proxy to use for the WebSocket connection. + * + * Can be a string URL or an object with `url` and optional `headers`. + * + * @example + * ```ts + * // String format + * const ws = new WebSocket("wss://example.com", { + * proxy: "http://proxy.example.com:8080" + * }); + * + * // With credentials + * const ws = new WebSocket("wss://example.com", { + * proxy: "http://user:pass@proxy.example.com:8080" + * }); + * + * // Object format with custom headers + * const ws = new WebSocket("wss://example.com", { + * proxy: { + * url: "http://proxy.example.com:8080", + * headers: { + * "Proxy-Authorization": "Bearer token" + * } + * } + * }); + * ``` + */ + proxy?: + | string + | { + /** + * The proxy URL (http:// or https://) + */ + url: string; + /** + * Custom headers to send to the proxy server. + * Supports plain objects or Headers class instances. + */ + headers?: import("node:http").OutgoingHttpHeaders | Headers; + }; + }; + /** * Constructor options for the `Bun.WebSocket` client */ - type WebSocketOptions = WebSocketOptionsProtocolsOrProtocol & WebSocketOptionsTLS & WebSocketOptionsHeaders; + type WebSocketOptions = WebSocketOptionsProtocolsOrProtocol & + WebSocketOptionsTLS & + WebSocketOptionsHeaders & + WebSocketOptionsProxy; interface WebSocketEventMap { close: CloseEvent; diff --git a/src/bun.js/api/bun/ssl_wrapper.zig b/src/bun.js/api/bun/ssl_wrapper.zig index 819107fa9e..233b2db0e7 100644 --- a/src/bun.js/api/bun/ssl_wrapper.zig +++ b/src/bun.js/api/bun/ssl_wrapper.zig @@ -445,6 +445,9 @@ pub fn SSLWrapper(comptime T: type) type { log("triggering data callback (read {d}) and resetting read buffer", .{read}); // we filled the buffer this.triggerDataCallback(buffer[0..read]); + // The callback may have closed the connection - check before continuing + // Check ssl first as a proxy for whether we were deinited + if (this.ssl == null or this.flags.closed_notified) return false; read = 0; } } @@ -452,6 +455,9 @@ pub fn SSLWrapper(comptime T: type) type { if (read > 0) { log("triggering data callback (read {d})", .{read}); this.triggerDataCallback(buffer[0..read]); + // The callback may have closed the connection + // Check ssl first as a proxy for whether we were deinited + if (this.ssl == null or this.flags.closed_notified) return false; } return true; } diff --git a/src/bun.js/api/server/SSLConfig.zig b/src/bun.js/api/server/SSLConfig.zig index 764d2cc25d..05833d7eea 100644 --- a/src/bun.js/api/server/SSLConfig.zig +++ b/src/bun.js/api/server/SSLConfig.zig @@ -91,6 +91,26 @@ pub fn asUSockets(this: *const SSLConfig) uws.SocketContext.BunSocketContextOpti return ctx_opts; } +/// Returns socket options for client-side TLS with manual verification. +/// Sets request_cert=1 (to receive server cert) and reject_unauthorized=0 +/// (to handle verification manually in handshake callback). +pub fn asUSocketsForClientVerification(this: *const SSLConfig) uws.SocketContext.BunSocketContextOptions { + var opts = this.asUSockets(); + opts.request_cert = 1; + opts.reject_unauthorized = 0; + return opts; +} + +/// Returns a copy of this config for client-side TLS with manual verification. +/// Sets request_cert=1 (to receive server cert) and reject_unauthorized=0 +/// (to handle verification manually in handshake callback). +pub fn forClientVerification(this: SSLConfig) SSLConfig { + var copy = this; + copy.request_cert = 1; + copy.reject_unauthorized = 0; + return copy; +} + pub fn isSame(this: *const SSLConfig, other: *const SSLConfig) bool { inline for (comptime std.meta.fields(SSLConfig)) |field| { const first = @field(this, field.name); diff --git a/src/bun.js/bindings/ObjectBindings.cpp b/src/bun.js/bindings/ObjectBindings.cpp index 87903aa5df..bc7a751ac0 100644 --- a/src/bun.js/bindings/ObjectBindings.cpp +++ b/src/bun.js/bindings/ObjectBindings.cpp @@ -92,4 +92,19 @@ JSC::JSValue getIfPropertyExistsPrototypePollutionMitigation(JSC::VM& vm, JSC::J return value; } +JSC::JSValue getOwnPropertyIfExists(JSC::JSGlobalObject* globalObject, JSC::JSObject* object, const JSC::PropertyName& name) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + PropertySlot slot(object, PropertySlot::InternalMethodType::GetOwnProperty, nullptr); + if (!object->methodTable()->getOwnPropertySlot(object, globalObject, name, slot)) { + RETURN_IF_EXCEPTION(scope, {}); + return JSC::jsUndefined(); + } + RETURN_IF_EXCEPTION(scope, {}); + JSValue value = slot.getValue(globalObject, name); + RETURN_IF_EXCEPTION(scope, {}); + return value; +} + } diff --git a/src/bun.js/bindings/ObjectBindings.h b/src/bun.js/bindings/ObjectBindings.h index e32febca1d..28b843ff85 100644 --- a/src/bun.js/bindings/ObjectBindings.h +++ b/src/bun.js/bindings/ObjectBindings.h @@ -24,4 +24,11 @@ ALWAYS_INLINE JSC::JSValue getIfPropertyExistsPrototypePollutionMitigation(JSC:: return getIfPropertyExistsPrototypePollutionMitigation(JSC::getVM(globalObject), globalObject, object, name); } +/** + * Gets an own property only (no prototype chain lookup). + * Returns jsUndefined() if property doesn't exist as own property. + * This is the strictest form of property access - use for security-critical options. + */ +JSC::JSValue getOwnPropertyIfExists(JSC::JSGlobalObject* globalObject, JSC::JSObject* object, const JSC::PropertyName& name); + } diff --git a/src/bun.js/bindings/headers.h b/src/bun.js/bindings/headers.h index 895bb4f078..a7a87876c2 100644 --- a/src/bun.js/bindings/headers.h +++ b/src/bun.js/bindings/headers.h @@ -575,7 +575,14 @@ BUN_DECLARE_HOST_FUNCTION(NetworkSink__write); #ifdef __cplusplus ZIG_DECL void Bun__WebSocketHTTPClient__cancel(WebSocketHTTPClient* arg0); -ZIG_DECL WebSocketHTTPClient* Bun__WebSocketHTTPClient__connect(JSC::JSGlobalObject* arg0, void* arg1, CppWebSocket* arg2, const ZigString* arg3, uint16_t arg4, const ZigString* arg5, const ZigString* arg6, ZigString* arg7, ZigString* arg8, size_t arg9); +ZIG_DECL WebSocketHTTPClient* Bun__WebSocketHTTPClient__connect( + JSC::JSGlobalObject* globalObject, void* socketContext, CppWebSocket* websocket, + const ZigString* host, uint16_t port, const ZigString* path, const ZigString* protocols, + ZigString* headerNames, ZigString* headerValues, size_t headerCount, + const ZigString* proxyHost, uint16_t proxyPort, + const ZigString* proxyAuthorization, + ZigString* proxyHeaderNames, ZigString* proxyHeaderValues, size_t proxyHeaderCount, + void* sslConfig, bool targetIsSecure); ZIG_DECL void Bun__WebSocketHTTPClient__register(JSC::JSGlobalObject* arg0, void* arg1, void* arg2); ZIG_DECL size_t Bun__WebSocketHTTPClient__memoryCost(WebSocketHTTPClient* arg0); #endif @@ -583,9 +590,19 @@ ZIG_DECL size_t Bun__WebSocketHTTPClient__memoryCost(WebSocketHTTPClient* arg0); #ifdef __cplusplus ZIG_DECL void Bun__WebSocketHTTPSClient__cancel(WebSocketHTTPSClient* arg0); -ZIG_DECL WebSocketHTTPSClient* Bun__WebSocketHTTPSClient__connect(JSC::JSGlobalObject* arg0, void* arg1, CppWebSocket* arg2, const ZigString* arg3, uint16_t arg4, const ZigString* arg5, const ZigString* arg6, ZigString* arg7, ZigString* arg8, size_t arg9); +ZIG_DECL WebSocketHTTPSClient* Bun__WebSocketHTTPSClient__connect( + JSC::JSGlobalObject* globalObject, void* socketContext, CppWebSocket* websocket, + const ZigString* host, uint16_t port, const ZigString* path, const ZigString* protocols, + ZigString* headerNames, ZigString* headerValues, size_t headerCount, + const ZigString* proxyHost, uint16_t proxyPort, + const ZigString* proxyAuthorization, + ZigString* proxyHeaderNames, ZigString* proxyHeaderValues, size_t proxyHeaderCount, + void* sslConfig, bool targetIsSecure); ZIG_DECL void Bun__WebSocketHTTPSClient__register(JSC::JSGlobalObject* arg0, void* arg1, void* arg2); ZIG_DECL size_t Bun__WebSocketHTTPSClient__memoryCost(WebSocketHTTPSClient* arg0); + +// Parse TLS options from JavaScript object using SSLConfig.fromJS +ZIG_DECL void* Bun__WebSocket__parseSSLConfig(JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue tlsValue); #endif #ifdef __cplusplus @@ -593,7 +610,7 @@ ZIG_DECL size_t Bun__WebSocketHTTPSClient__memoryCost(WebSocketHTTPSClient* arg0 ZIG_DECL void Bun__WebSocketClient__cancel(WebSocketClient* arg0); ZIG_DECL void Bun__WebSocketClient__close(WebSocketClient* arg0, uint16_t arg1, const ZigString* arg2); ZIG_DECL void Bun__WebSocketClient__finalize(WebSocketClient* arg0); -ZIG_DECL void* Bun__WebSocketClient__init(CppWebSocket* arg0, void* arg1, void* arg2, JSC::JSGlobalObject* arg3, unsigned char* arg4, size_t arg5, const PerMessageDeflateParams* arg6); +ZIG_DECL void* Bun__WebSocketClient__init(CppWebSocket* arg0, void* arg1, void* arg2, JSC::JSGlobalObject* arg3, unsigned char* arg4, size_t arg5, const PerMessageDeflateParams* arg6, void* customSSLCtx); ZIG_DECL void Bun__WebSocketClient__register(JSC::JSGlobalObject* arg0, void* arg1, void* arg2); ZIG_DECL void Bun__WebSocketClient__writeBinaryData(WebSocketClient* arg0, const unsigned char* arg1, size_t arg2, unsigned char arg3); ZIG_DECL void Bun__WebSocketClient__writeString(WebSocketClient* arg0, const ZigString* arg1, unsigned char arg2); @@ -606,7 +623,7 @@ ZIG_DECL size_t Bun__WebSocketClient__memoryCost(WebSocketClient* arg0); ZIG_DECL void Bun__WebSocketClientTLS__cancel(WebSocketClientTLS* arg0); ZIG_DECL void Bun__WebSocketClientTLS__close(WebSocketClientTLS* arg0, uint16_t arg1, const ZigString* arg2); ZIG_DECL void Bun__WebSocketClientTLS__finalize(WebSocketClientTLS* arg0); -ZIG_DECL void* Bun__WebSocketClientTLS__init(CppWebSocket* arg0, void* arg1, void* arg2, JSC::JSGlobalObject* arg3, unsigned char* arg4, size_t arg5, const PerMessageDeflateParams* arg6); +ZIG_DECL void* Bun__WebSocketClientTLS__init(CppWebSocket* arg0, void* arg1, void* arg2, JSC::JSGlobalObject* arg3, unsigned char* arg4, size_t arg5, const PerMessageDeflateParams* arg6, void* customSSLCtx); ZIG_DECL void Bun__WebSocketClientTLS__register(JSC::JSGlobalObject* arg0, void* arg1, void* arg2); ZIG_DECL void Bun__WebSocketClientTLS__writeBinaryData(WebSocketClientTLS* arg0, const unsigned char* arg1, size_t arg2, unsigned char arg3); ZIG_DECL void Bun__WebSocketClientTLS__writeString(WebSocketClientTLS* arg0, const ZigString* arg1, unsigned char arg2); diff --git a/src/bun.js/bindings/webcore/JSWebSocket.cpp b/src/bun.js/bindings/webcore/JSWebSocket.cpp index db678ebc7b..43b7c9d7d6 100644 --- a/src/bun.js/bindings/webcore/JSWebSocket.cpp +++ b/src/bun.js/bindings/webcore/JSWebSocket.cpp @@ -63,6 +63,9 @@ #include #include "IDLTypes.h" #include "FetchHeaders.h" +#include "JSFetchHeaders.h" +#include "headers.h" +#include "ObjectBindings.h" namespace WebCore { using namespace JSC; @@ -210,10 +213,16 @@ static inline JSC::EncodedJSValue constructJSWebSocket3(JSGlobalObject* lexicalG Vector protocols; int rejectUnauthorized = -1; + void* sslConfig = nullptr; // SSLConfig pointer from Zig auto headersInit = std::optional>, IDLRecord>>::ReturnType>(); + + // Proxy options + String proxyUrl; + auto proxyHeadersInit = std::optional>, IDLRecord>>::ReturnType>(); + if (JSC::JSObject* options = optionsObjectValue.getObject()) { const auto& builtinnames = WebCore::builtinNames(vm); - auto headersValue = options->getIfPropertyExists(globalObject, builtinnames.headersPublicName()); + auto headersValue = Bun::getOwnPropertyIfExists(globalObject, options, builtinnames.headersPublicName()); RETURN_IF_EXCEPTION(throwScope, {}); if (headersValue) { if (!headersValue.isUndefinedOrNull()) { @@ -222,7 +231,7 @@ static inline JSC::EncodedJSValue constructJSWebSocket3(JSGlobalObject* lexicalG } } - auto protocolsValue = options->getIfPropertyExists(globalObject, PropertyName(Identifier::fromString(vm, "protocols"_s))); + auto protocolsValue = Bun::getOwnPropertyIfExists(globalObject, options, PropertyName(Identifier::fromString(vm, "protocols"_s))); RETURN_IF_EXCEPTION(throwScope, {}); if (protocolsValue) { if (!protocolsValue.isUndefinedOrNull()) { @@ -230,7 +239,7 @@ static inline JSC::EncodedJSValue constructJSWebSocket3(JSGlobalObject* lexicalG RETURN_IF_EXCEPTION(throwScope, {}); } } else { - auto protocolValue = options->getIfPropertyExists(globalObject, PropertyName(Identifier::fromString(vm, "protocol"_s))); + auto protocolValue = Bun::getOwnPropertyIfExists(globalObject, options, PropertyName(Identifier::fromString(vm, "protocol"_s))); RETURN_IF_EXCEPTION(throwScope, {}); if (protocolValue) { if (!protocolValue.isUndefinedOrNull()) { @@ -240,17 +249,179 @@ static inline JSC::EncodedJSValue constructJSWebSocket3(JSGlobalObject* lexicalG } } - auto tlsOptionsValue = options->getIfPropertyExists(globalObject, PropertyName(Identifier::fromString(vm, "tls"_s))); + // Parse TLS options using Zig's SSLConfig.fromJS for full TLS option support + JSValue tlsOptionsValue = Bun::getOwnPropertyIfExists(globalObject, options, PropertyName(Identifier::fromString(vm, "tls"_s))); RETURN_IF_EXCEPTION(throwScope, {}); - if (tlsOptionsValue) { - if (!tlsOptionsValue.isUndefinedOrNull() && tlsOptionsValue.isObject()) { - if (JSC::JSObject* tlsOptions = tlsOptionsValue.getObject()) { + if (tlsOptionsValue && !tlsOptionsValue.isUndefinedOrNull() && tlsOptionsValue.isObject()) { + // Also extract rejectUnauthorized for backwards compatibility + if (JSC::JSObject* tlsOptions = tlsOptionsValue.getObject()) { + auto rejectUnauthorizedValue = Bun::getOwnPropertyIfExists(globalObject, tlsOptions, PropertyName(Identifier::fromString(vm, "rejectUnauthorized"_s))); + RETURN_IF_EXCEPTION(throwScope, {}); + if (rejectUnauthorizedValue && !rejectUnauthorizedValue.isUndefinedOrNull() && rejectUnauthorizedValue.isBoolean()) { + rejectUnauthorized = rejectUnauthorizedValue.asBoolean() ? 1 : 0; + } + } - auto rejectUnauthorizedValue = tlsOptions->getIfPropertyExists(globalObject, PropertyName(Identifier::fromString(vm, "rejectUnauthorized"_s))); + // Parse full TLS options using Zig's SSLConfig.fromJS + sslConfig = Bun__WebSocket__parseSSLConfig(globalObject, JSValue::encode(tlsOptionsValue)); + RETURN_IF_EXCEPTION(throwScope, {}); + } + + // Parse proxy option - can be string or { url, headers } + auto proxyValue = Bun::getOwnPropertyIfExists(globalObject, options, PropertyName(Identifier::fromString(vm, "proxy"_s))); + RETURN_IF_EXCEPTION(throwScope, {}); + if (proxyValue) { + if (!proxyValue.isUndefinedOrNull()) { + if (proxyValue.isString()) { + // proxy: "http://proxy:8080" + proxyUrl = convert(*lexicalGlobalObject, proxyValue); RETURN_IF_EXCEPTION(throwScope, {}); - if (rejectUnauthorizedValue) { - if (!rejectUnauthorizedValue.isUndefinedOrNull() && rejectUnauthorizedValue.isBoolean()) { - rejectUnauthorized = rejectUnauthorizedValue.asBoolean() ? 1 : 0; + } else if (proxyValue.isObject()) { + // proxy: { url: "http://proxy:8080", headers: {...} } + if (JSC::JSObject* proxyOptions = proxyValue.getObject()) { + auto proxyUrlValue = Bun::getOwnPropertyIfExists(globalObject, proxyOptions, PropertyName(Identifier::fromString(vm, "url"_s))); + RETURN_IF_EXCEPTION(throwScope, {}); + if (proxyUrlValue && !proxyUrlValue.isUndefinedOrNull()) { + proxyUrl = convert(*lexicalGlobalObject, proxyUrlValue); + RETURN_IF_EXCEPTION(throwScope, {}); + } + + auto proxyHeadersValue = Bun::getOwnPropertyIfExists(globalObject, proxyOptions, builtinnames.headersPublicName()); + RETURN_IF_EXCEPTION(throwScope, {}); + if (proxyHeadersValue && !proxyHeadersValue.isUndefinedOrNull()) { + // Check if it's already a Headers instance (like fetch does) + if (auto* jsHeaders = jsDynamicCast(proxyHeadersValue)) { + // Convert FetchHeaders to the Init variant + auto& headers = jsHeaders->wrapped(); + Vector> pairs; + auto iterator = headers.createIterator(false); + while (auto value = iterator.next()) { + pairs.append({ value->key, value->value }); + } + proxyHeadersInit = WTF::move(pairs); + } else { + // Fall back to IDL conversion for plain objects/arrays + proxyHeadersInit = convert>, IDLRecord>>(*lexicalGlobalObject, proxyHeadersValue); + RETURN_IF_EXCEPTION(throwScope, {}); + } + } + } + } + } + } + + // Parse agent option - extract proxy from agent.proxy if no explicit proxy + // This supports HttpsProxyAgent and similar agent libraries + if (proxyUrl.isNull() || proxyUrl.isEmpty()) { + auto agentValue = Bun::getOwnPropertyIfExists(globalObject, options, PropertyName(Identifier::fromString(vm, "agent"_s))); + RETURN_IF_EXCEPTION(throwScope, {}); + if (agentValue && !agentValue.isUndefinedOrNull() && agentValue.isObject()) { + if (JSC::JSObject* agentObj = agentValue.getObject()) { + // Get agent.proxy (can be URL object or string) + auto agentProxyValue = Bun::getOwnPropertyIfExists(globalObject, agentObj, PropertyName(Identifier::fromString(vm, "proxy"_s))); + RETURN_IF_EXCEPTION(throwScope, {}); + if (agentProxyValue && !agentProxyValue.isUndefinedOrNull()) { + if (agentProxyValue.isString()) { + proxyUrl = convert(*lexicalGlobalObject, agentProxyValue); + } else if (agentProxyValue.isObject()) { + // URL object - get .href property + if (JSC::JSObject* urlObj = agentProxyValue.getObject()) { + auto hrefValue = Bun::getOwnPropertyIfExists(globalObject, urlObj, PropertyName(Identifier::fromString(vm, "href"_s))); + RETURN_IF_EXCEPTION(throwScope, {}); + if (hrefValue && hrefValue.isString()) { + proxyUrl = convert(*lexicalGlobalObject, hrefValue); + } + } + } + RETURN_IF_EXCEPTION(throwScope, {}); + } + + // Get agent.proxyHeaders + auto proxyHeadersValue = Bun::getOwnPropertyIfExists(globalObject, agentObj, PropertyName(Identifier::fromString(vm, "proxyHeaders"_s))); + RETURN_IF_EXCEPTION(throwScope, {}); + if (proxyHeadersValue && !proxyHeadersValue.isUndefinedOrNull()) { + // If it's a function, call it + if (proxyHeadersValue.isCallable()) { + auto callData = JSC::getCallData(proxyHeadersValue); + proxyHeadersValue = JSC::call(lexicalGlobalObject, proxyHeadersValue, callData, agentObj, JSC::MarkedArgumentBuffer()); + RETURN_IF_EXCEPTION(throwScope, {}); + } + if (!proxyHeadersValue.isUndefinedOrNull()) { + // Check if it's already a Headers instance (like fetch does) + if (auto* jsHeaders = jsDynamicCast(proxyHeadersValue)) { + // Convert FetchHeaders to the Init variant + auto& headers = jsHeaders->wrapped(); + Vector> pairs; + auto iterator = headers.createIterator(false); + while (auto value = iterator.next()) { + pairs.append({ value->key, value->value }); + } + proxyHeadersInit = WTF::move(pairs); + } else { + // Fall back to IDL conversion for plain objects/arrays + proxyHeadersInit = convert>, IDLRecord>>(*lexicalGlobalObject, proxyHeadersValue); + RETURN_IF_EXCEPTION(throwScope, {}); + } + } + } + + // Get TLS options from agent.connectOpts or agent.options + // We build a filtered object with only supported TLS options (ca, cert, key, passphrase, rejectUnauthorized) + // to avoid passing invalid properties like ALPNProtocols to the SSL parser + if (rejectUnauthorized == -1 && !sslConfig) { + auto connectOptsValue = Bun::getOwnPropertyIfExists(globalObject, agentObj, PropertyName(Identifier::fromString(vm, "connectOpts"_s))); + RETURN_IF_EXCEPTION(throwScope, {}); + if (!connectOptsValue || connectOptsValue.isUndefinedOrNull()) { + connectOptsValue = Bun::getOwnPropertyIfExists(globalObject, agentObj, PropertyName(Identifier::fromString(vm, "options"_s))); + RETURN_IF_EXCEPTION(throwScope, {}); + } + if (connectOptsValue && !connectOptsValue.isUndefinedOrNull() && connectOptsValue.isObject()) { + if (JSC::JSObject* connectOptsObj = connectOptsValue.getObject()) { + // Extract rejectUnauthorized + auto rejectValue = Bun::getOwnPropertyIfExists(globalObject, connectOptsObj, PropertyName(Identifier::fromString(vm, "rejectUnauthorized"_s))); + RETURN_IF_EXCEPTION(throwScope, {}); + if (rejectValue && rejectValue.isBoolean()) { + rejectUnauthorized = rejectValue.asBoolean() ? 1 : 0; + } + + // Build filtered TLS options object with only supported properties + JSC::JSObject* filteredTlsOpts = JSC::constructEmptyObject(globalObject); + bool hasTlsOpts = false; + + auto caValue = Bun::getOwnPropertyIfExists(globalObject, connectOptsObj, PropertyName(Identifier::fromString(vm, "ca"_s))); + RETURN_IF_EXCEPTION(throwScope, {}); + if (caValue && !caValue.isUndefinedOrNull()) { + filteredTlsOpts->putDirect(vm, Identifier::fromString(vm, "ca"_s), caValue); + hasTlsOpts = true; + } + + auto certValue = Bun::getOwnPropertyIfExists(globalObject, connectOptsObj, PropertyName(Identifier::fromString(vm, "cert"_s))); + RETURN_IF_EXCEPTION(throwScope, {}); + if (certValue && !certValue.isUndefinedOrNull()) { + filteredTlsOpts->putDirect(vm, Identifier::fromString(vm, "cert"_s), certValue); + hasTlsOpts = true; + } + + auto keyValue = Bun::getOwnPropertyIfExists(globalObject, connectOptsObj, PropertyName(Identifier::fromString(vm, "key"_s))); + RETURN_IF_EXCEPTION(throwScope, {}); + if (keyValue && !keyValue.isUndefinedOrNull()) { + filteredTlsOpts->putDirect(vm, Identifier::fromString(vm, "key"_s), keyValue); + hasTlsOpts = true; + } + + auto passphraseValue = Bun::getOwnPropertyIfExists(globalObject, connectOptsObj, PropertyName(Identifier::fromString(vm, "passphrase"_s))); + RETURN_IF_EXCEPTION(throwScope, {}); + if (passphraseValue && !passphraseValue.isUndefinedOrNull()) { + filteredTlsOpts->putDirect(vm, Identifier::fromString(vm, "passphrase"_s), passphraseValue); + hasTlsOpts = true; + } + + // Parse the filtered TLS options + if (hasTlsOpts) { + sslConfig = Bun__WebSocket__parseSSLConfig(globalObject, JSValue::encode(filteredTlsOpts)); + RETURN_IF_EXCEPTION(throwScope, {}); + } + } } } } @@ -258,10 +429,13 @@ static inline JSC::EncodedJSValue constructJSWebSocket3(JSGlobalObject* lexicalG } } - auto object = (rejectUnauthorized == -1) ? WebSocket::create(*context, WTF::move(url), protocols, WTF::move(headersInit)) : WebSocket::create(*context, WTF::move(url), protocols, WTF::move(headersInit), rejectUnauthorized ? true : false); + auto object = (rejectUnauthorized == -1) + ? WebSocket::create(*context, WTF::move(url), protocols, WTF::move(headersInit), WTF::move(proxyUrl), WTF::move(proxyHeadersInit), sslConfig) + : WebSocket::create(*context, WTF::move(url), protocols, WTF::move(headersInit), rejectUnauthorized ? true : false, WTF::move(proxyUrl), WTF::move(proxyHeadersInit), sslConfig); if constexpr (IsExceptionOr) RETURN_IF_EXCEPTION(throwScope, {}); + static_assert(TypeOrExceptionOrUnderlyingType::isRef); auto jsValue = toJSNewlyCreated>(*lexicalGlobalObject, *globalObject, throwScope, WTF::move(object)); if constexpr (IsExceptionOr) diff --git a/src/bun.js/bindings/webcore/WebSocket.cpp b/src/bun.js/bindings/webcore/WebSocket.cpp index 5a85a027b8..71014121aa 100644 --- a/src/bun.js/bindings/webcore/WebSocket.cpp +++ b/src/bun.js/bindings/webcore/WebSocket.cpp @@ -36,6 +36,7 @@ #include "blob.h" #include "ZigGeneratedClasses.h" #include "CloseEvent.h" +#include // #include "ContentSecurityPolicy.h" // #include "DOMWindow.h" // #include "Document.h" @@ -190,7 +191,9 @@ WebSocket::~WebSocket() { if (m_upgradeClient != nullptr) { void* upgradeClient = m_upgradeClient; - if (m_isSecure) { + // Use TLS cancel if connection type is TLS or ProxyTLS (either is a TLS socket to the remote) + bool useTLSClient = (m_connectionType == ConnectionType::TLS || m_connectionType == ConnectionType::ProxyTLS); + if (useTLSClient) { Bun__WebSocketHTTPSClient__cancel(reinterpret_cast(upgradeClient)); } else { Bun__WebSocketHTTPClient__cancel(reinterpret_cast(upgradeClient)); @@ -220,6 +223,74 @@ WebSocket::~WebSocket() } } +// Transient proxy configuration - used only during connect() and not stored as member fields +struct ProxyConfig { + String host; + uint16_t port { 0 }; + String authorization; + Vector> headers; + bool isHTTPS { false }; +}; + +static ExceptionOr> setupProxy(const String& proxyUrl, std::optional&& proxyHeaders) +{ + if (proxyUrl.isNull() || proxyUrl.isEmpty()) + return { std::nullopt }; + + URL url { proxyUrl }; + if (!url.isValid()) + return Exception { SyntaxError, makeString("Invalid proxy URL: "_s, proxyUrl) }; + + ProxyConfig config; + config.host = url.host().toString(); + config.isHTTPS = url.protocolIs("https"_s); + config.port = url.port().value_or(config.isHTTPS ? 443 : 80); + + // Compute Basic auth from proxy URL credentials + if (!url.user().isEmpty()) { + auto credentials = makeString(url.user(), ':', url.password()); + auto utf8 = credentials.utf8(); + auto encoded = base64EncodeToString(std::span(reinterpret_cast(utf8.data()), utf8.length())); + config.authorization = makeString("Basic "_s, encoded); + } + + // Store proxy headers + if (proxyHeaders) { + auto headersOrException = FetchHeaders::create(WTF::move(proxyHeaders)); + if (!headersOrException.hasException()) { + auto hdrs = headersOrException.releaseReturnValue(); + auto iterator = hdrs.get().createIterator(false); + while (auto value = iterator.next()) { + config.headers.append({ value->key, value->value }); + } + } + } + + return { WTF::move(config) }; +} + +void WebSocket::setExtensionsFromDeflateParams(const PerMessageDeflateParams* deflate_params) +{ + if (deflate_params == nullptr) + return; + + StringBuilder extensions; + extensions.append("permessage-deflate"_s); + if (deflate_params->server_no_context_takeover) + extensions.append("; server_no_context_takeover"_s); + if (deflate_params->client_no_context_takeover) + extensions.append("; client_no_context_takeover"_s); + if (deflate_params->server_max_window_bits != 15) { + extensions.append("; server_max_window_bits="_s); + extensions.append(String::number(deflate_params->server_max_window_bits)); + } + if (deflate_params->client_max_window_bits != 15) { + extensions.append("; client_max_window_bits="_s); + extensions.append(String::number(deflate_params->client_max_window_bits)); + } + m_extensions = extensions.toString(); +} + ExceptionOr> WebSocket::create(ScriptExecutionContext& context, const String& url) { return create(context, url, Vector {}, std::nullopt); @@ -264,6 +335,45 @@ ExceptionOr> WebSocket::create(ScriptExecutionContext& context, c return socket; } +ExceptionOr> WebSocket::create(ScriptExecutionContext& context, const String& url, const Vector& protocols, std::optional&& headers, const String& proxyUrl, std::optional&& proxyHeaders, void* sslConfig) +{ + if (url.isNull()) + return Exception { SyntaxError }; + + auto proxyConfigResult = setupProxy(proxyUrl, WTF::move(proxyHeaders)); + if (proxyConfigResult.hasException()) + return proxyConfigResult.releaseException(); + + auto socket = adoptRef(*new WebSocket(context)); + socket->m_sslConfig = sslConfig; // Set BEFORE connect() so it's available during connection + + auto result = socket->connect(url, protocols, WTF::move(headers), proxyConfigResult.releaseReturnValue()); + if (result.hasException()) + return result.releaseException(); + + return socket; +} + +ExceptionOr> WebSocket::create(ScriptExecutionContext& context, const String& url, const Vector& protocols, std::optional&& headers, bool rejectUnauthorized, const String& proxyUrl, std::optional&& proxyHeaders, void* sslConfig) +{ + if (url.isNull()) + return Exception { SyntaxError }; + + auto proxyConfigResult = setupProxy(proxyUrl, WTF::move(proxyHeaders)); + if (proxyConfigResult.hasException()) + return proxyConfigResult.releaseException(); + + auto socket = adoptRef(*new WebSocket(context)); + socket->setRejectUnauthorized(rejectUnauthorized); + socket->m_sslConfig = sslConfig; // Set BEFORE connect() so it's available during connection + + auto result = socket->connect(url, protocols, WTF::move(headers), proxyConfigResult.releaseReturnValue()); + if (result.hasException()) + return result.releaseException(); + + return socket; +} + ExceptionOr> WebSocket::create(ScriptExecutionContext& context, const String& url, const String& protocol) { return create(context, url, Vector { 1, protocol }); @@ -304,6 +414,11 @@ ExceptionOr WebSocket::connect(const String& url, const Vector& pr return connect(url, protocols, std::nullopt); } +ExceptionOr WebSocket::connect(const String& url, const Vector& protocols, std::optional&& headersInit) +{ + return connect(url, protocols, WTF::move(headersInit), std::nullopt); +} + size_t WebSocket::memoryCost() const { @@ -319,7 +434,9 @@ size_t WebSocket::memoryCost() const } if (m_upgradeClient) { - if (m_isSecure) { + // Use TLS cost if connection type is TLS or ProxyTLS + bool useTLSClient = (m_connectionType == ConnectionType::TLS || m_connectionType == ConnectionType::ProxyTLS); + if (useTLSClient) { cost += Bun__WebSocketHTTPSClient__memoryCost(m_upgradeClient); } else { cost += Bun__WebSocketHTTPClient__memoryCost(m_upgradeClient); @@ -329,7 +446,7 @@ size_t WebSocket::memoryCost() const return cost; } -ExceptionOr WebSocket::connect(const String& url, const Vector& protocols, std::optional&& headersInit) +ExceptionOr WebSocket::connect(const String& url, const Vector& protocols, std::optional&& headersInit, std::optional&& proxyConfig) { // LOG(Network, "WebSocket %p connect() url='%s'", this, url.utf8().data()); m_url = URL { url }; @@ -454,19 +571,73 @@ ExceptionOr WebSocket::connect(const String& url, const Vector& pr headerValues.unsafeAppendWithoutCapacityCheck(Zig::toZigString(value->value)); } - m_isSecure = is_secure; + // Determine connection type based on proxy usage and TLS requirements + bool hasProxy = proxyConfig.has_value(); + bool proxyIsHTTPS = hasProxy && proxyConfig->isHTTPS; + + // Connection type determines what kind of socket we use: + // - Plain/TLS: direct connection, socket type matches target protocol + // - ProxyPlain/ProxyTLS: through proxy, socket type matches PROXY protocol (not target) + if (hasProxy) { + m_connectionType = proxyIsHTTPS ? ConnectionType::ProxyTLS : ConnectionType::ProxyPlain; + } else { + m_connectionType = is_secure ? ConnectionType::TLS : ConnectionType::Plain; + } + this->incPendingActivityCount(); - if (is_secure) { + // Prepare proxy parameters (use local variables, not member fields) + ZigString proxyHost = hasProxy ? Zig::toZigString(proxyConfig->host) : ZigString {}; + ZigString proxyAuth = hasProxy ? Zig::toZigString(proxyConfig->authorization) : ZigString {}; + uint16_t proxyPort = hasProxy ? proxyConfig->port : 0; + + Vector proxyHeaderNames; + Vector proxyHeaderValues; + if (hasProxy) { + proxyHeaderNames.reserveInitialCapacity(proxyConfig->headers.size()); + proxyHeaderValues.reserveInitialCapacity(proxyConfig->headers.size()); + for (const auto& header : proxyConfig->headers) { + proxyHeaderNames.unsafeAppendWithoutCapacityCheck(Zig::toZigString(header.first)); + proxyHeaderValues.unsafeAppendWithoutCapacityCheck(Zig::toZigString(header.second)); + } + } + + // Pass SSLConfig pointer to Zig (ownership transferred - Zig will deinit when connection closes) + // After this call, m_sslConfig should not be used by C++ anymore + void* sslConfig = m_sslConfig; + m_sslConfig = nullptr; // Transfer ownership + + // Use TLS client based on connection type: + // - TLS/ProxyTLS: use TLS socket + // - Plain/ProxyPlain: use plain socket + bool useTLSClient = (m_connectionType == ConnectionType::TLS || m_connectionType == ConnectionType::ProxyTLS); + + if (useTLSClient) { us_socket_context_t* ctx = scriptExecutionContext()->webSocketContext(); RELEASE_ASSERT(ctx); - this->m_upgradeClient = Bun__WebSocketHTTPSClient__connect(scriptExecutionContext()->jsGlobalObject(), ctx, reinterpret_cast(this), &host, port, &path, &clientProtocolString, headerNames.begin(), headerValues.begin(), headerNames.size()); + this->m_upgradeClient = Bun__WebSocketHTTPSClient__connect( + scriptExecutionContext()->jsGlobalObject(), ctx, reinterpret_cast(this), + &host, port, &path, &clientProtocolString, + headerNames.begin(), headerValues.begin(), headerNames.size(), + hasProxy ? &proxyHost : nullptr, proxyPort, + (hasProxy && !proxyConfig->authorization.isEmpty()) ? &proxyAuth : nullptr, + proxyHeaderNames.begin(), proxyHeaderValues.begin(), proxyHeaderNames.size(), + sslConfig, is_secure); } else { us_socket_context_t* ctx = scriptExecutionContext()->webSocketContext(); RELEASE_ASSERT(ctx); - this->m_upgradeClient = Bun__WebSocketHTTPClient__connect(scriptExecutionContext()->jsGlobalObject(), ctx, reinterpret_cast(this), &host, port, &path, &clientProtocolString, headerNames.begin(), headerValues.begin(), headerNames.size()); + this->m_upgradeClient = Bun__WebSocketHTTPClient__connect( + scriptExecutionContext()->jsGlobalObject(), ctx, reinterpret_cast(this), + &host, port, &path, &clientProtocolString, + headerNames.begin(), headerValues.begin(), headerNames.size(), + hasProxy ? &proxyHost : nullptr, proxyPort, + (hasProxy && !proxyConfig->authorization.isEmpty()) ? &proxyAuth : nullptr, + proxyHeaderNames.begin(), proxyHeaderValues.begin(), proxyHeaderNames.size(), + sslConfig, is_secure); } + proxyHeaderValues.clear(); + proxyHeaderNames.clear(); headerValues.clear(); headerNames.clear(); @@ -673,7 +844,8 @@ ExceptionOr WebSocket::close(std::optional optionalCode, c if (m_upgradeClient != nullptr) { void* upgradeClient = m_upgradeClient; m_upgradeClient = nullptr; - if (m_isSecure) { + bool useTLSClient = (m_connectionType == ConnectionType::TLS || m_connectionType == ConnectionType::ProxyTLS); + if (useTLSClient) { Bun__WebSocketHTTPSClient__cancel(upgradeClient); } else { Bun__WebSocketHTTPClient__cancel(upgradeClient); @@ -728,7 +900,8 @@ ExceptionOr WebSocket::terminate() if (m_upgradeClient != nullptr) { void* upgradeClient = m_upgradeClient; m_upgradeClient = nullptr; - if (m_isSecure) { + bool useTLSClient = (m_connectionType == ConnectionType::TLS || m_connectionType == ConnectionType::ProxyTLS); + if (useTLSClient) { Bun__WebSocketHTTPSClient__cancel(upgradeClient); } else { Bun__WebSocketHTTPClient__cancel(upgradeClient); @@ -1297,43 +1470,30 @@ void WebSocket::didClose(unsigned unhandledBufferedAmount, unsigned short code, this->disablePendingActivity(); } -void WebSocket::didConnect(us_socket_t* socket, char* bufferedData, size_t bufferedDataSize, const PerMessageDeflateParams* deflate_params) +void WebSocket::didConnect(us_socket_t* socket, char* bufferedData, size_t bufferedDataSize, const PerMessageDeflateParams* deflate_params, void* customSSLCtx) { this->m_upgradeClient = nullptr; + setExtensionsFromDeflateParams(deflate_params); - // Set extensions if permessage-deflate was negotiated - if (deflate_params != nullptr) { - StringBuilder extensions; - extensions.append("permessage-deflate"_s); - if (deflate_params->server_no_context_takeover) { - extensions.append("; server_no_context_takeover"_s); - } - if (deflate_params->client_no_context_takeover) { - extensions.append("; client_no_context_takeover"_s); - } - if (deflate_params->server_max_window_bits != 15) { - extensions.append("; server_max_window_bits="_s); - extensions.append(String::number(deflate_params->server_max_window_bits)); - } - if (deflate_params->client_max_window_bits != 15) { - extensions.append("; client_max_window_bits="_s); - extensions.append(String::number(deflate_params->client_max_window_bits)); - } - this->m_extensions = extensions.toString(); - } + // Use TLS WebSocket client if connection type is TLS or ProxyTLS. + // For TLS: direct wss:// connection, socket is already TLS. + // For ProxyTLS: connected through HTTPS proxy, socket is TLS (even for ws:// target). + // For Plain/ProxyPlain: socket is not TLS. + bool useTLSSocket = (m_connectionType == ConnectionType::TLS || m_connectionType == ConnectionType::ProxyTLS); - if (m_isSecure) { + if (useTLSSocket) { us_socket_context_t* ctx = (us_socket_context_t*)this->scriptExecutionContext()->connectedWebSocketContext(); - this->m_connectedWebSocket.clientSSL = Bun__WebSocketClientTLS__init(reinterpret_cast(this), socket, ctx, this->scriptExecutionContext()->jsGlobalObject(), reinterpret_cast(bufferedData), bufferedDataSize, deflate_params); + this->m_connectedWebSocket.clientSSL = Bun__WebSocketClientTLS__init(reinterpret_cast(this), socket, ctx, this->scriptExecutionContext()->jsGlobalObject(), reinterpret_cast(bufferedData), bufferedDataSize, deflate_params, customSSLCtx); this->m_connectedWebSocketKind = ConnectedWebSocketKind::ClientSSL; } else { us_socket_context_t* ctx = (us_socket_context_t*)this->scriptExecutionContext()->connectedWebSocketContext(); - this->m_connectedWebSocket.client = Bun__WebSocketClient__init(reinterpret_cast(this), socket, ctx, this->scriptExecutionContext()->jsGlobalObject(), reinterpret_cast(bufferedData), bufferedDataSize, deflate_params); + this->m_connectedWebSocket.client = Bun__WebSocketClient__init(reinterpret_cast(this), socket, ctx, this->scriptExecutionContext()->jsGlobalObject(), reinterpret_cast(bufferedData), bufferedDataSize, deflate_params, customSSLCtx); this->m_connectedWebSocketKind = ConnectedWebSocketKind::Client; } this->didConnect(); } + void WebSocket::didFailWithErrorCode(Bun::WebSocketErrorCode code) { // from new WebSocket() -> connect() @@ -1479,6 +1639,22 @@ void WebSocket::didFailWithErrorCode(Bun::WebSocketErrorCode code) didReceiveClose(CleanStatus::NotClean, 1002, "Invalid compressed data"_s); break; } + case Bun::WebSocketErrorCode::proxy_connect_failed: { + didReceiveClose(CleanStatus::NotClean, 1006, "Proxy connection failed"_s, true); + break; + } + case Bun::WebSocketErrorCode::proxy_authentication_required: { + didReceiveClose(CleanStatus::NotClean, 1006, "Proxy authentication required"_s, true); + break; + } + case Bun::WebSocketErrorCode::proxy_connection_refused: { + didReceiveClose(CleanStatus::NotClean, 1006, "Proxy connection refused"_s, true); + break; + } + case Bun::WebSocketErrorCode::proxy_tunnel_failed: { + didReceiveClose(CleanStatus::NotClean, 1006, "Proxy tunnel failed"_s, true); + break; + } } m_state = CLOSED; @@ -1504,12 +1680,48 @@ void WebSocket::updateHasPendingActivity() !(m_state == CLOSED && m_pendingActivityCount == 0)); } +// Forward declarations for tunnel mode (defined outside namespace) +extern "C" void* Bun__WebSocketClient__initWithTunnel(CppWebSocket* ws, void* tunnel, JSC::JSGlobalObject* globalObject, unsigned char* bufferedData, size_t bufferedDataSize, const PerMessageDeflateParams* deflate_params); +extern "C" void WebSocketProxyTunnel__setConnectedWebSocket(void* tunnel, void* websocket); + +void WebSocket::didConnectWithTunnel(void* tunnel, char* bufferedData, size_t bufferedDataSize, const PerMessageDeflateParams* deflate_params) +{ + this->m_upgradeClient = nullptr; + setExtensionsFromDeflateParams(deflate_params); + + // For wss:// through HTTP proxy, we use a plain (non-TLS) WebSocket client + // because the TLS is handled by the proxy tunnel + this->m_connectedWebSocket.client = Bun__WebSocketClient__initWithTunnel( + reinterpret_cast(this), + tunnel, + this->scriptExecutionContext()->jsGlobalObject(), + reinterpret_cast(bufferedData), + bufferedDataSize, + deflate_params); + this->m_connectedWebSocketKind = ConnectedWebSocketKind::Client; + + // IMPORTANT: Call didConnect() BEFORE setting the connected websocket on the tunnel. + // didConnect() sets m_state = OPEN, and messages are dropped if state != OPEN. + // By calling didConnect() first, we ensure the state is OPEN before the tunnel + // starts forwarding messages to the WebSocket client. + this->didConnect(); + + // Now set the connected websocket on the tunnel to start forwarding data + WebSocketProxyTunnel__setConnectedWebSocket(tunnel, this->m_connectedWebSocket.client); +} + } // namespace WebCore -extern "C" void WebSocket__didConnect(WebCore::WebSocket* webSocket, us_socket_t* socket, char* bufferedData, size_t len, const PerMessageDeflateParams* deflate_params) +extern "C" void WebSocket__didConnect(WebCore::WebSocket* webSocket, us_socket_t* socket, char* bufferedData, size_t len, const PerMessageDeflateParams* deflate_params, void* customSSLCtx) { - webSocket->didConnect(socket, bufferedData, len, deflate_params); + webSocket->didConnect(socket, bufferedData, len, deflate_params, customSSLCtx); } + +extern "C" void WebSocket__didConnectWithTunnel(WebCore::WebSocket* webSocket, void* tunnel, char* bufferedData, size_t len, const PerMessageDeflateParams* deflate_params) +{ + webSocket->didConnectWithTunnel(tunnel, bufferedData, len, deflate_params); +} + extern "C" void WebSocket__didAbruptClose(WebCore::WebSocket* webSocket, Bun::WebSocketErrorCode errorCode) { webSocket->didFailWithErrorCode(errorCode); diff --git a/src/bun.js/bindings/webcore/WebSocket.h b/src/bun.js/bindings/webcore/WebSocket.h index 98111c338e..b993aab6c3 100644 --- a/src/bun.js/bindings/webcore/WebSocket.h +++ b/src/bun.js/bindings/webcore/WebSocket.h @@ -68,6 +68,9 @@ public: static ExceptionOr> create(ScriptExecutionContext&, const String& url, const Vector& protocols); static ExceptionOr> create(ScriptExecutionContext&, const String& url, const Vector& protocols, std::optional&&); static ExceptionOr> create(ScriptExecutionContext& context, const String& url, const Vector& protocols, std::optional&& headers, bool rejectUnauthorized); + // With proxy support + static ExceptionOr> create(ScriptExecutionContext&, const String& url, const Vector& protocols, std::optional&&, const String& proxyUrl, std::optional&& proxyHeaders, void* sslConfig); + static ExceptionOr> create(ScriptExecutionContext& context, const String& url, const Vector& protocols, std::optional&& headers, bool rejectUnauthorized, const String& proxyUrl, std::optional&& proxyHeaders, void* sslConfig); ~WebSocket(); enum State { @@ -91,10 +94,21 @@ public: Clean = 1, }; + // Tracks the connection type for both the upgrade client and the connected websocket. + // This replaces separate m_isSecure and m_proxyIsHTTPS bools. + enum class ConnectionType : uint8_t { + Plain, // ws:// direct connection + TLS, // wss:// direct connection + ProxyPlain, // ws:// or wss:// through HTTP proxy (plain socket to proxy) + ProxyTLS // ws:// or wss:// through HTTPS proxy (TLS socket to proxy) + }; + ExceptionOr connect(const String& url); ExceptionOr connect(const String& url, const String& protocol); ExceptionOr connect(const String& url, const Vector& protocols); ExceptionOr connect(const String& url, const Vector& protocols, std::optional&&); + // Internal connect with proxy config (used by create() with proxy support) + ExceptionOr connect(const String& url, const Vector& protocols, std::optional&&, std::optional&&); ExceptionOr send(const String& message); ExceptionOr send(JSC::ArrayBuffer&); @@ -135,7 +149,8 @@ public: void didConnect(); void disablePendingActivity(); void didClose(unsigned unhandledBufferedAmount, unsigned short code, const String& reason); - void didConnect(us_socket_t* socket, char* bufferedData, size_t bufferedDataSize, const PerMessageDeflateParams* deflate_params); + void didConnect(us_socket_t* socket, char* bufferedData, size_t bufferedDataSize, const PerMessageDeflateParams* deflate_params, void* customSSLCtx); + void didConnectWithTunnel(void* tunnel, char* bufferedData, size_t bufferedDataSize, const PerMessageDeflateParams* deflate_params); void didFailWithErrorCode(Bun::WebSocketErrorCode code); void didReceiveMessage(String&& message); @@ -158,6 +173,16 @@ public: return m_rejectUnauthorized; } + void setSSLConfig(void* config) + { + m_sslConfig = config; + } + + void* sslConfig() const + { + return m_sslConfig; + } + void incPendingActivityCount() { ASSERT(m_pendingActivityCount < std::numeric_limits::max()); @@ -203,6 +228,7 @@ private: void sendWebSocketString(const String& message, const Opcode opcode); void sendWebSocketData(const char* data, size_t length, const Opcode opcode); + void setExtensionsFromDeflateParams(const PerMessageDeflateParams* deflate_params); enum class BinaryType { Blob, ArrayBuffer, @@ -222,12 +248,15 @@ private: String m_subprotocol; String m_extensions; void* m_upgradeClient { nullptr }; - bool m_isSecure { false }; + ConnectionType m_connectionType { ConnectionType::Plain }; bool m_rejectUnauthorized { false }; AnyWebSocket m_connectedWebSocket { nullptr }; ConnectedWebSocketKind m_connectedWebSocketKind { ConnectedWebSocketKind::None }; size_t m_pendingActivityCount { 0 }; + // TLS options (SSLConfig pointer from Zig - ownership transferred to Zig) + void* m_sslConfig { nullptr }; + bool m_dispatchedErrorEvent { false }; // RefPtr> m_pendingActivity; }; diff --git a/src/bun.js/bindings/webcore/WebSocketErrorCode.h b/src/bun.js/bindings/webcore/WebSocketErrorCode.h index 93e642847b..82c64c4d63 100644 --- a/src/bun.js/bindings/webcore/WebSocketErrorCode.h +++ b/src/bun.js/bindings/webcore/WebSocketErrorCode.h @@ -37,6 +37,11 @@ enum class WebSocketErrorCode : int32_t { tls_handshake_failed = 30, message_too_big = 31, protocol_error = 32, + // Proxy error codes + proxy_connect_failed = 33, + proxy_authentication_required = 34, + proxy_connection_refused = 35, + proxy_tunnel_failed = 36, }; } diff --git a/src/http.zig b/src/http.zig index fa42aba1a3..7bf8242d41 100644 --- a/src/http.zig +++ b/src/http.zig @@ -1291,7 +1291,7 @@ pub fn onWritable(this: *HTTPClient, comptime is_first_call: bool, comptime is_s this.setTimeout(socket, 5); const to_send = this.state.request_body; - const sent = proxy.writeData(to_send) catch return; // just wait and retry when onWritable! if closed internally will call proxy.onClose + const sent = proxy.write(to_send) catch return; // just wait and retry when onWritable! if closed internally will call proxy.onClose this.state.request_sent_len += sent; this.state.request_body = this.state.request_body[sent..]; @@ -1346,7 +1346,7 @@ pub fn onWritable(this: *HTTPClient, comptime is_first_call: bool, comptime is_s assert(!socket.isShutdown()); assert(!socket.isClosed()); } - const amount = proxy.writeData(to_send) catch return; // just wait and retry when onWritable! if closed internally will call proxy.onClose + const amount = proxy.write(to_send) catch return; // just wait and retry when onWritable! if closed internally will call proxy.onClose if (comptime is_first_call) { if (amount == 0) { @@ -1606,7 +1606,7 @@ pub fn onData( if (this.proxy_tunnel) |proxy| { // if we have a tunnel we dont care about the other stages, we will just tunnel the data this.setTimeout(socket, 5); - proxy.receiveData(incoming_data); + proxy.receive(incoming_data); return; } diff --git a/src/http/HTTPContext.zig b/src/http/HTTPContext.zig index 0a48a422da..155f219e64 100644 --- a/src/http/HTTPContext.zig +++ b/src/http/HTTPContext.zig @@ -87,9 +87,7 @@ pub fn NewHTTPContext(comptime ssl: bool) type { if (!comptime ssl) { @compileError("ssl only"); } - var opts = client.tls_props.?.asUSockets(); - opts.request_cert = 1; - opts.reject_unauthorized = 0; + const opts = client.tls_props.?.asUSocketsForClientVerification(); try this.initWithOpts(&opts); } diff --git a/src/http/ProxyTunnel.zig b/src/http/ProxyTunnel.zig index de197e4047..8a5ad5bad9 100644 --- a/src/http/ProxyTunnel.zig +++ b/src/http/ProxyTunnel.zig @@ -192,7 +192,7 @@ fn onHandshake(this: *HTTPClient, handshake_success: bool, ssl_error: uws.us_bun } } -pub fn write(this: *HTTPClient, encoded_data: []const u8) void { +pub fn writeEncrypted(this: *HTTPClient, encoded_data: []const u8) void { if (this.proxy_tunnel) |proxy| { // Preserve TLS record ordering: if any encrypted bytes are buffered, // enqueue new bytes and flush them in FIFO via onWritable. @@ -272,16 +272,14 @@ pub fn start(this: *HTTPClient, comptime is_ssl: bool, socket: NewHTTPContext(is .ref_count = .init(), }); - var custom_options = ssl_options; - // we always request the cert so we can verify it and also we manually abort the connection if the hostname doesn't match - custom_options.reject_unauthorized = 0; - custom_options.request_cert = 1; + // We always request the cert so we can verify it and also we manually abort the connection if the hostname doesn't match + const custom_options = ssl_options.forClientVerification(); proxy_tunnel.wrapper = SSLWrapper(*HTTPClient).init(custom_options, true, .{ .onOpen = ProxyTunnel.onOpen, .onData = ProxyTunnel.onData, .onHandshake = ProxyTunnel.onHandshake, .onClose = ProxyTunnel.onClose, - .write = ProxyTunnel.write, + .write = ProxyTunnel.writeEncrypted, .ctx = this, }) catch |err| { if (err == error.OutOfMemory) { @@ -341,7 +339,7 @@ pub fn onWritable(this: *ProxyTunnel, comptime is_ssl: bool, socket: NewHTTPCont } } -pub fn receiveData(this: *ProxyTunnel, buf: []const u8) void { +pub fn receive(this: *ProxyTunnel, buf: []const u8) void { this.ref(); defer this.deref(); if (this.wrapper) |*wrapper| { @@ -349,7 +347,7 @@ pub fn receiveData(this: *ProxyTunnel, buf: []const u8) void { } } -pub fn writeData(this: *ProxyTunnel, buf: []const u8) !usize { +pub fn write(this: *ProxyTunnel, buf: []const u8) !usize { if (this.wrapper) |*wrapper| { return try wrapper.writeData(buf); } diff --git a/src/http/websocket_client.zig b/src/http/websocket_client.zig index 45e19a3a38..c78210efc4 100644 --- a/src/http/websocket_client.zig +++ b/src/http/websocket_client.zig @@ -54,6 +54,16 @@ pub fn NewWebSocketClient(comptime ssl: bool) type { // Track compression state of the entire message (across fragments) message_is_compressed: bool = false, + // Custom SSL context for per-connection TLS options (e.g., custom CA) + // This is set when the WebSocket is adopted from a connection that used a custom SSL context. + // Must be cleaned up when the WebSocket closes. + custom_ssl_ctx: ?*uws.SocketContext = null, + + // Proxy tunnel for wss:// through HTTP proxy. + // When set, all I/O goes through the tunnel (TLS encryption/decryption). + // The tunnel handles the TLS layer, so this is used with ssl=false. + proxy_tunnel: ?*WebSocketProxyTunnel = null, + const stack_frame_size = 1024; // Minimum message size to compress (RFC 7692 recommendation) const MIN_COMPRESS_SIZE = 860; @@ -117,6 +127,18 @@ pub fn NewWebSocketClient(comptime ssl: bool) type { this.message_is_compressed = false; if (this.deflate) |d| d.deinit(); this.deflate = null; + // Clean up custom SSL context if we own one + if (this.custom_ssl_ctx) |ctx| { + ctx.deinit(ssl); + this.custom_ssl_ctx = null; + } + // Clean up proxy tunnel if we own one + // Set to null FIRST to prevent re-entrancy (shutdown can trigger callbacks) + if (this.proxy_tunnel) |tunnel| { + this.proxy_tunnel = null; + tunnel.shutdown(); + tunnel.deref(); + } } pub fn cancel(this: *WebSocket) callconv(.c) void { @@ -713,6 +735,16 @@ pub fn NewWebSocketClient(comptime ssl: bool) type { socket: Socket, bytes: []const u8, ) bool { + // For tunnel mode, write through the tunnel instead of direct socket + if (this.proxy_tunnel) |tunnel| { + // The tunnel handles TLS encryption and buffering + _ = tunnel.write(bytes) catch { + this.terminate(ErrorCode.failed_to_write); + return false; + }; + return true; + } + // fast path: no backpressure, no queue, just send the bytes. if (!this.hasBackpressure()) { // Do not set MSG_MORE, see https://github.com/oven-sh/bun/issues/4010 @@ -987,6 +1019,8 @@ pub fn NewWebSocketClient(comptime ssl: bool) type { _ = this.sendData(bytes, !this.hasBackpressure(), opcode); } fn hasTCP(this: *WebSocket) bool { + // For tunnel mode, we have an active connection through the tunnel + if (this.proxy_tunnel != null) return true; return !this.tcp.isClosed() and !this.tcp.isShutdown(); } @@ -1134,7 +1168,9 @@ pub fn NewWebSocketClient(comptime ssl: bool) type { var ws = this.ws; defer ws.unref(); - if (this_socket.outgoing_websocket != null and !this_socket.tcp.isClosed()) { + // For tunnel mode, tcp is detached but connection is still active through the tunnel + const is_connected = !this_socket.tcp.isClosed() or this_socket.proxy_tunnel != null; + if (this_socket.outgoing_websocket != null and is_connected) { this_socket.handleData(this_socket.tcp, this.slice); } } @@ -1158,6 +1194,7 @@ pub fn NewWebSocketClient(comptime ssl: bool) type { buffered_data: [*]u8, buffered_data_len: usize, deflate_params: ?*const WebSocketDeflate.Params, + custom_ssl_ctx_ptr: ?*anyopaque, ) callconv(.c) ?*anyopaque { const tcp = @as(*uws.us_socket_t, @ptrCast(input_socket)); const ctx = @as(*uws.SocketContext, @ptrCast(socket_ctx)); @@ -1169,6 +1206,8 @@ pub fn NewWebSocketClient(comptime ssl: bool) type { .send_buffer = bun.LinearFifo(u8, .Dynamic).init(bun.default_allocator), .receive_buffer = bun.LinearFifo(u8, .Dynamic).init(bun.default_allocator), .event_loop = globalThis.bunVM().eventLoop(), + // Take ownership of custom SSL context if provided + .custom_ssl_ctx = if (custom_ssl_ctx_ptr) |ptr| @ptrCast(ptr) else null, }); if (deflate_params) |params| { @@ -1220,6 +1259,69 @@ pub fn NewWebSocketClient(comptime ssl: bool) type { ); } + /// Initialize a WebSocket client that uses a proxy tunnel for I/O. + /// Used for wss:// through HTTP proxy where TLS is handled by the tunnel. + /// The tunnel takes ownership of socket I/O, and this client reads/writes through it. + pub fn initWithTunnel( + outgoing: *CppWebSocket, + tunnel_ptr: *anyopaque, + globalThis: *jsc.JSGlobalObject, + buffered_data: [*]u8, + buffered_data_len: usize, + deflate_params: ?*const WebSocketDeflate.Params, + ) callconv(.c) ?*anyopaque { + const tunnel: *WebSocketProxyTunnel = @ptrCast(@alignCast(tunnel_ptr)); + + var ws = bun.new(WebSocket, .{ + .ref_count = .init(), + .tcp = .{ .socket = .{ .detached = {} } }, // No direct socket - using tunnel + .outgoing_websocket = outgoing, + .globalThis = globalThis, + .send_buffer = bun.LinearFifo(u8, .Dynamic).init(bun.default_allocator), + .receive_buffer = bun.LinearFifo(u8, .Dynamic).init(bun.default_allocator), + .event_loop = globalThis.bunVM().eventLoop(), + .proxy_tunnel = tunnel, + }); + + // Take ownership of the tunnel + tunnel.ref(); + + if (deflate_params) |params| { + if (WebSocketDeflate.init(bun.default_allocator, params.*, globalThis.bunVM().rareData())) |deflate| { + ws.deflate = deflate; + } else |_| { + ws.deflate = null; + } + } + + bun.handleOom(ws.send_buffer.ensureTotalCapacity(2048)); + bun.handleOom(ws.receive_buffer.ensureTotalCapacity(2048)); + ws.poll_ref.ref(globalThis.bunVM()); + + const buffered_slice: []u8 = buffered_data[0..buffered_data_len]; + if (buffered_slice.len > 0) { + const initial_data = InitialDataHandler.new(.{ + .adopted = ws, + .slice = buffered_slice, + .ws = outgoing, + }); + globalThis.queueMicrotaskCallback(initial_data, InitialDataHandler.handle); + outgoing.ref(); + } + + ws.ref(); + + return @as(*anyopaque, @ptrCast(ws)); + } + + /// Handle data received from the proxy tunnel (already decrypted). + /// Called by the WebSocketProxyTunnel when it receives and decrypts data. + pub fn handleTunnelData(this: *WebSocket, data: []const u8) void { + // Process the decrypted data as if it came from the socket + // hasTCP() now returns true for tunnel mode, so this will work correctly + this.handleData(this.tcp, data); + } + pub fn finalize(this: *WebSocket) callconv(.c) void { log("finalize", .{}); this.clearData(); @@ -1261,6 +1363,7 @@ pub fn NewWebSocketClient(comptime ssl: bool) type { @export(&close, .{ .name = "Bun__" ++ name ++ "__close" }); @export(&finalize, .{ .name = "Bun__" ++ name ++ "__finalize" }); @export(&init, .{ .name = "Bun__" ++ name ++ "__init" }); + @export(&initWithTunnel, .{ .name = "Bun__" ++ name ++ "__initWithTunnel" }); @export(&memoryCost, .{ .name = "Bun__" ++ name ++ "__memoryCost" }); @export(®ister, .{ .name = "Bun__" ++ name ++ "__register" }); @export(&writeBinaryData, .{ .name = "Bun__" ++ name ++ "__writeBinaryData" }); @@ -1304,6 +1407,11 @@ pub const ErrorCode = enum(i32) { tls_handshake_failed = 30, message_too_big = 31, protocol_error = 32, + // Proxy error codes + proxy_connect_failed = 33, + proxy_authentication_required = 34, + proxy_connection_refused = 35, + proxy_tunnel_failed = 36, }; pub const Mask = struct { @@ -1565,6 +1673,7 @@ const log = Output.scoped(.WebSocketClient, .visible); const string = []const u8; const WebSocketDeflate = @import("./websocket_client/WebSocketDeflate.zig"); +const WebSocketProxyTunnel = @import("./websocket_client/WebSocketProxyTunnel.zig"); const std = @import("std"); const CppWebSocket = @import("./websocket_client/CppWebSocket.zig").CppWebSocket; diff --git a/src/http/websocket_client/CppWebSocket.zig b/src/http/websocket_client/CppWebSocket.zig index 38123b23db..a22eb8f08b 100644 --- a/src/http/websocket_client/CppWebSocket.zig +++ b/src/http/websocket_client/CppWebSocket.zig @@ -14,6 +14,14 @@ pub const CppWebSocket = opaque { buffered_data: ?[*]u8, buffered_len: usize, deflate_params: ?*const WebSocketDeflate.Params, + custom_ssl_ctx: ?*uws.SocketContext, + ) void; + extern fn WebSocket__didConnectWithTunnel( + websocket_context: *CppWebSocket, + tunnel: *anyopaque, + buffered_data: ?[*]u8, + buffered_len: usize, + deflate_params: ?*const WebSocketDeflate.Params, ) void; extern fn WebSocket__didAbruptClose(websocket_context: *CppWebSocket, reason: ErrorCode) void; extern fn WebSocket__didClose(websocket_context: *CppWebSocket, code: u16, reason: *const bun.String) void; @@ -50,11 +58,17 @@ pub const CppWebSocket = opaque { defer loop.exit(); return WebSocket__rejectUnauthorized(this); } - pub fn didConnect(this: *CppWebSocket, socket: *uws.Socket, buffered_data: ?[*]u8, buffered_len: usize, deflate_params: ?*const WebSocketDeflate.Params) void { + pub fn didConnect(this: *CppWebSocket, socket: *uws.Socket, buffered_data: ?[*]u8, buffered_len: usize, deflate_params: ?*const WebSocketDeflate.Params, custom_ssl_ctx: ?*uws.SocketContext) void { const loop = jsc.VirtualMachine.get().eventLoop(); loop.enter(); defer loop.exit(); - WebSocket__didConnect(this, socket, buffered_data, buffered_len, deflate_params); + WebSocket__didConnect(this, socket, buffered_data, buffered_len, deflate_params, custom_ssl_ctx); + } + pub fn didConnectWithTunnel(this: *CppWebSocket, tunnel: *anyopaque, buffered_data: ?[*]u8, buffered_len: usize, deflate_params: ?*const WebSocketDeflate.Params) void { + const loop = jsc.VirtualMachine.get().eventLoop(); + loop.enter(); + defer loop.exit(); + WebSocket__didConnectWithTunnel(this, tunnel, buffered_data, buffered_len, deflate_params); } extern fn WebSocket__incrementPendingActivity(websocket_context: *CppWebSocket) void; extern fn WebSocket__decrementPendingActivity(websocket_context: *CppWebSocket) void; diff --git a/src/http/websocket_client/WebSocketProxy.zig b/src/http/websocket_client/WebSocketProxy.zig new file mode 100644 index 0000000000..4565717919 --- /dev/null +++ b/src/http/websocket_client/WebSocketProxy.zig @@ -0,0 +1,71 @@ +/// WebSocketProxy encapsulates proxy state for WebSocket connections through HTTP/HTTPS proxies. +/// This struct holds only the fields needed after the initial CONNECT request. +/// Fields like proxy_port, proxy_authorization, and proxy_headers are used +/// only during connect() and freed immediately after building the CONNECT request. +const WebSocketProxy = @This(); + +/// Target hostname for SNI during TLS handshake +#target_host: []const u8, +/// Whether target uses TLS (wss://) +#target_is_https: bool, +/// WebSocket upgrade request to send after CONNECT succeeds +#websocket_request_buf: []u8, +/// TLS tunnel for wss:// through HTTP proxy +#tunnel: ?*WebSocketProxyTunnel = null, + +/// Initialize a new WebSocketProxy +pub fn init( + target_host: []const u8, + target_is_https: bool, + websocket_request_buf: []u8, +) WebSocketProxy { + return .{ + .#target_host = target_host, + .#target_is_https = target_is_https, + .#websocket_request_buf = websocket_request_buf, + }; +} + +/// Get the target hostname for SNI during TLS handshake +pub fn getTargetHost(self: *const WebSocketProxy) []const u8 { + return self.#target_host; +} + +/// Check if the target uses HTTPS (wss://) +pub fn isTargetHttps(self: *const WebSocketProxy) bool { + return self.#target_is_https; +} + +/// Get the TLS tunnel for wss:// through HTTP proxy +pub fn getTunnel(self: *const WebSocketProxy) ?*WebSocketProxyTunnel { + return self.#tunnel; +} + +/// Set the TLS tunnel +pub fn setTunnel(self: *WebSocketProxy, new_tunnel: ?*WebSocketProxyTunnel) void { + self.#tunnel = new_tunnel; +} + +/// Take ownership of the WebSocket request buffer, clearing the internal reference. +/// The caller is responsible for freeing the returned buffer. +pub fn takeWebsocketRequestBuf(self: *WebSocketProxy) []u8 { + const buf = self.#websocket_request_buf; + self.#websocket_request_buf = &[_]u8{}; + return buf; +} + +/// Clean up all allocated resources +pub fn deinit(self: *WebSocketProxy) void { + bun.default_allocator.free(self.#target_host); + if (self.#websocket_request_buf.len > 0) { + bun.default_allocator.free(self.#websocket_request_buf); + } + if (self.#tunnel) |tunnel| { + self.#tunnel = null; + tunnel.shutdown(); + tunnel.deref(); + } +} + +const WebSocketProxyTunnel = @import("./WebSocketProxyTunnel.zig"); +const bun = @import("bun"); diff --git a/src/http/websocket_client/WebSocketProxyTunnel.zig b/src/http/websocket_client/WebSocketProxyTunnel.zig new file mode 100644 index 0000000000..18b6e8c919 --- /dev/null +++ b/src/http/websocket_client/WebSocketProxyTunnel.zig @@ -0,0 +1,351 @@ +/// WebSocketProxyTunnel handles TLS inside an HTTP CONNECT tunnel for wss:// through HTTP proxy. +/// +/// This is used when connecting to a wss:// WebSocket server through an HTTP proxy. +/// The flow is: +/// 1. HTTP CONNECT request to proxy (handled by WebSocketUpgradeClient) +/// 2. Proxy responds with 200 Connection Established +/// 3. TLS handshake inside the tunnel (handled by this module using SSLWrapper) +/// 4. WebSocket upgrade request through the TLS tunnel +/// 5. WebSocket 101 response +/// 6. Hand off to WebSocket client +const WebSocketProxyTunnel = @This(); + +const RefCount = bun.ptr.RefCount(@This(), "ref_count", deinit, .{}); +pub const ref = RefCount.ref; +pub const deref = RefCount.deref; + +/// Union type for upgrade client to maintain type safety. +/// The upgrade client can be either HTTP or HTTPS depending on the proxy connection. +pub const UpgradeClientUnion = union(enum) { + http: *NewHTTPUpgradeClient(false), + https: *NewHTTPUpgradeClient(true), + none: void, + + pub fn handleDecryptedData(self: UpgradeClientUnion, data: []const u8) void { + switch (self) { + .http => |client| client.handleDecryptedData(data), + .https => |client| client.handleDecryptedData(data), + .none => {}, + } + } + + pub fn terminate(self: UpgradeClientUnion, code: ErrorCode) void { + switch (self) { + .http => |client| client.terminate(code), + .https => |client| client.terminate(code), + .none => {}, + } + } + + pub fn onProxyTLSHandshakeComplete(self: UpgradeClientUnion) void { + switch (self) { + .http => |client| client.onProxyTLSHandshakeComplete(), + .https => |client| client.onProxyTLSHandshakeComplete(), + .none => {}, + } + } + + pub fn isNone(self: UpgradeClientUnion) bool { + return self == .none; + } +}; + +const WebSocketClient = @import("../websocket_client.zig").NewWebSocketClient(false); + +ref_count: RefCount, +/// Reference to the upgrade client (WebSocketUpgradeClient) - used during handshake phase +#upgrade_client: UpgradeClientUnion = .{ .none = {} }, +/// Reference to the connected WebSocket client - used after successful upgrade +#connected_websocket: ?*WebSocketClient = null, +/// SSL wrapper for TLS inside tunnel +#wrapper: ?SSLWrapperType = null, +/// Socket reference (the proxy connection) +#socket: SocketUnion = .{ .none = {} }, +/// Write buffer for encrypted data (maintains TLS record ordering) +#write_buffer: bun.io.StreamBuffer = .{}, +/// Hostname for SNI (Server Name Indication) +#sni_hostname: ?[]const u8 = null, +/// Whether to reject unauthorized certificates +#reject_unauthorized: bool = true, + +const SocketUnion = union(enum) { + tcp: uws.NewSocketHandler(false), + ssl: uws.NewSocketHandler(true), + none: void, + + pub fn write(self: SocketUnion, data: []const u8) c_int { + return switch (self) { + .tcp => |s| s.write(data), + .ssl => |s| s.write(data), + .none => 0, + }; + } + + pub fn isClosed(self: SocketUnion) bool { + return switch (self) { + .tcp => |s| s.isClosed(), + .ssl => |s| s.isClosed(), + .none => true, + }; + } +}; + +const SSLWrapperType = SSLWrapper(*WebSocketProxyTunnel); + +/// Initialize a new proxy tunnel with all required parameters +pub fn init( + comptime ssl: bool, + upgrade_client: *NewHTTPUpgradeClient(ssl), + socket: uws.NewSocketHandler(ssl), + sni_hostname: []const u8, + reject_unauthorized: bool, +) !*WebSocketProxyTunnel { + return bun.new(WebSocketProxyTunnel, .{ + .ref_count = .init(), + .#upgrade_client = if (comptime ssl) .{ .https = upgrade_client } else .{ .http = upgrade_client }, + .#socket = if (comptime ssl) .{ .ssl = socket } else .{ .tcp = socket }, + .#sni_hostname = try bun.default_allocator.dupe(u8, sni_hostname), + .#reject_unauthorized = reject_unauthorized, + }); +} + +fn deinit(this: *WebSocketProxyTunnel) void { + if (this.#wrapper) |*wrapper| { + wrapper.deinit(); + this.#wrapper = null; + } + this.#write_buffer.deinit(); + if (this.#sni_hostname) |hostname| { + bun.default_allocator.free(hostname); + this.#sni_hostname = null; + } + bun.destroy(this); +} + +/// Start TLS handshake inside the tunnel +/// The ssl_options should contain all TLS configuration including CA certificates. +pub fn start(this: *WebSocketProxyTunnel, ssl_options: SSLConfig, initial_data: []const u8) !void { + // Allow handshake to complete so we can access peer certificate for manual + // hostname verification in onHandshake(). The actual reject_unauthorized + // check uses this.#reject_unauthorized field. + const options = ssl_options.forClientVerification(); + + this.#wrapper = try SSLWrapperType.init(options, true, .{ + .ctx = this, + .onOpen = onOpen, + .onData = onData, + .onHandshake = onHandshake, + .onClose = onClose, + .write = writeEncrypted, + }); + + if (initial_data.len > 0) { + this.#wrapper.?.startWithPayload(initial_data); + } else { + this.#wrapper.?.start(); + } +} + +/// SSLWrapper callback: Called before TLS handshake starts +fn onOpen(this: *WebSocketProxyTunnel) void { + this.ref(); + defer this.deref(); + + log("onOpen", .{}); + // Configure SNI with hostname + if (this.#wrapper) |*wrapper| { + if (wrapper.ssl) |ssl_ptr| { + if (this.#sni_hostname) |hostname| { + if (!bun.strings.isIPAddress(hostname)) { + // Set SNI hostname + const hostname_z = bun.default_allocator.dupeZ(u8, hostname) catch return; + defer bun.default_allocator.free(hostname_z); + ssl_ptr.configureHTTPClient(hostname_z); + } + } + } + } +} + +/// SSLWrapper callback: Called with decrypted data from the network +fn onData(this: *WebSocketProxyTunnel, decrypted_data: []const u8) void { + this.ref(); + defer this.deref(); + + log("onData: {} bytes", .{decrypted_data.len}); + if (decrypted_data.len == 0) return; + + // If we have a connected WebSocket client, forward data to it + if (this.#connected_websocket) |ws| { + ws.handleTunnelData(decrypted_data); + return; + } + + // Otherwise, forward to the upgrade client for WebSocket response processing + this.#upgrade_client.handleDecryptedData(decrypted_data); +} + +/// SSLWrapper callback: Called after TLS handshake completes +fn onHandshake(this: *WebSocketProxyTunnel, success: bool, ssl_error: uws.us_bun_verify_error_t) void { + this.ref(); + defer this.deref(); + + log("onHandshake: success={}", .{success}); + + if (this.#upgrade_client.isNone()) return; + + if (!success) { + this.#upgrade_client.terminate(ErrorCode.tls_handshake_failed); + return; + } + + // Check for SSL errors if we need to reject unauthorized + if (this.#reject_unauthorized) { + if (ssl_error.error_no != 0) { + this.#upgrade_client.terminate(ErrorCode.tls_handshake_failed); + return; + } + + // Verify server identity + if (this.#wrapper) |*wrapper| { + if (wrapper.ssl) |ssl_ptr| { + if (this.#sni_hostname) |hostname| { + if (!BoringSSL.checkServerIdentity(ssl_ptr, hostname)) { + this.#upgrade_client.terminate(ErrorCode.tls_handshake_failed); + return; + } + } + } + } + } + + // TLS handshake successful - notify client to send WebSocket upgrade + this.#upgrade_client.onProxyTLSHandshakeComplete(); +} + +/// SSLWrapper callback: Called when connection is closing +fn onClose(this: *WebSocketProxyTunnel) void { + this.ref(); + defer this.deref(); + + log("onClose", .{}); + + // If we have a connected WebSocket client, notify it of the close + if (this.#connected_websocket) |ws| { + ws.fail(ErrorCode.ended); + return; + } + + // Check if upgrade client is already cleaned up (prevents re-entrancy during cleanup) + if (this.#upgrade_client.isNone()) return; + + // Otherwise notify the upgrade client + this.#upgrade_client.terminate(ErrorCode.ended); +} + +/// Set the connected WebSocket client. Called after successful WebSocket upgrade. +/// This transitions the tunnel from upgrade phase to connected phase. +/// After calling this, decrypted data will be forwarded to the WebSocket client. +pub fn setConnectedWebSocket(this: *WebSocketProxyTunnel, ws: *WebSocketClient) void { + log("setConnectedWebSocket", .{}); + this.#connected_websocket = ws; + // Clear the upgrade client reference since we're now in connected phase + this.#upgrade_client = .{ .none = {} }; +} + +/// SSLWrapper callback: Called with encrypted data to send to network +fn writeEncrypted(this: *WebSocketProxyTunnel, encrypted_data: []const u8) void { + log("writeEncrypted: {} bytes", .{encrypted_data.len}); + + // If data is already buffered, queue this to maintain TLS record ordering + if (this.#write_buffer.isNotEmpty()) { + bun.handleOom(this.#write_buffer.write(encrypted_data)); + return; + } + + // Try direct write to socket + const written = this.#socket.write(encrypted_data); + if (written < 0) { + // Write failed - buffer data for retry when socket becomes writable + bun.handleOom(this.#write_buffer.write(encrypted_data)); + return; + } + + // Buffer remaining data + const written_usize: usize = @intCast(written); + if (written_usize < encrypted_data.len) { + bun.handleOom(this.#write_buffer.write(encrypted_data[written_usize..])); + } +} + +/// Called when the socket becomes writable - flush buffered encrypted data +pub fn onWritable(this: *WebSocketProxyTunnel) void { + this.ref(); + defer this.deref(); + + // Flush the SSL state machine + if (this.#wrapper) |*wrapper| { + _ = wrapper.flush(); + } + + // Send buffered encrypted data + const to_send = this.#write_buffer.slice(); + if (to_send.len == 0) return; + + const written = this.#socket.write(to_send); + if (written < 0) return; + + const written_usize: usize = @intCast(written); + if (written_usize == to_send.len) { + this.#write_buffer.reset(); + } else { + this.#write_buffer.cursor += written_usize; + } +} + +/// Feed encrypted data from the network to the SSL wrapper for decryption +pub fn receive(this: *WebSocketProxyTunnel, data: []const u8) void { + this.ref(); + defer this.deref(); + + if (this.#wrapper) |*wrapper| { + wrapper.receiveData(data); + } +} + +/// Write application data through the tunnel (will be encrypted) +pub fn write(this: *WebSocketProxyTunnel, data: []const u8) !usize { + if (this.#wrapper) |*wrapper| { + return try wrapper.writeData(data); + } + return error.ConnectionClosed; +} + +/// Gracefully shutdown the TLS connection +pub fn shutdown(this: *WebSocketProxyTunnel) void { + if (this.#wrapper) |*wrapper| { + _ = wrapper.shutdown(true); // Fast shutdown + } +} + +/// Check if the tunnel has backpressure +pub fn hasBackpressure(this: *const WebSocketProxyTunnel) bool { + return this.#write_buffer.isNotEmpty(); +} + +/// C export for setting the connected WebSocket client from C++ +pub export fn WebSocketProxyTunnel__setConnectedWebSocket(tunnel: *WebSocketProxyTunnel, ws: *WebSocketClient) void { + tunnel.setConnectedWebSocket(ws); +} + +const log = bun.Output.scoped(.WebSocketProxyTunnel, .visible); + +const ErrorCode = @import("../websocket_client.zig").ErrorCode; +const NewHTTPUpgradeClient = @import("./WebSocketUpgradeClient.zig").NewHTTPUpgradeClient; +const SSLWrapper = @import("../../bun.js/api/bun/ssl_wrapper.zig").SSLWrapper; + +const bun = @import("bun"); +const BoringSSL = bun.BoringSSL; +const jsc = bun.jsc; +const uws = bun.uws; +const SSLConfig = jsc.API.ServerConfig.SSLConfig; diff --git a/src/http/websocket_client/WebSocketUpgradeClient.zig b/src/http/websocket_client/WebSocketUpgradeClient.zig index bace721c30..0c1061c313 100644 --- a/src/http/websocket_client/WebSocketUpgradeClient.zig +++ b/src/http/websocket_client/WebSocketUpgradeClient.zig @@ -43,10 +43,29 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type { state: State = .initializing, subprotocols: bun.StringSet, - const State = enum { initializing, reading, failed }; + /// Proxy state (null when not using proxy) + proxy: ?WebSocketProxy = null, + + // TLS options (full SSLConfig for complete TLS customization) + ssl_config: ?*SSLConfig = null, + + // Custom SSL context for per-connection TLS options (e.g., custom CA) + // This is used when ssl_config has custom options that can't be applied + // to the shared SSL context from C++. + custom_ssl_ctx: ?*uws.SocketContext = null, + + const State = enum { + initializing, + reading, + failed, + // Proxy states + proxy_handshake, // Sent CONNECT, waiting for 200 + proxy_tls_handshake, // TLS inside tunnel (for wss:// through proxy) + done, // WebSocket upgrade complete, forwarding data through tunnel + }; const HTTPClient = @This(); - pub fn register(_: *jsc.JSGlobalObject, _: *anyopaque, ctx: *uws.SocketContext) callconv(.c) void { + pub fn register(_: *jsc.JSGlobalObject, _: *uws.Loop, ctx: *uws.SocketContext) callconv(.c) void { Socket.configure( ctx, true, @@ -75,7 +94,7 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type { /// Returning null signals to the parent function that the connection failed. pub fn connect( global: *jsc.JSGlobalObject, - socket_ctx: *anyopaque, + socket_ctx: *uws.SocketContext, websocket: *CppWebSocket, host: *const jsc.ZigString, port: u16, @@ -84,12 +103,24 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type { header_names: ?[*]const jsc.ZigString, header_values: ?[*]const jsc.ZigString, header_count: usize, + // Proxy parameters + proxy_host: ?*const jsc.ZigString, + proxy_port: u16, + proxy_authorization: ?*const jsc.ZigString, + proxy_header_names: ?[*]const jsc.ZigString, + proxy_header_values: ?[*]const jsc.ZigString, + proxy_header_count: usize, + // TLS options (full SSLConfig for complete TLS customization) + ssl_config: ?*SSLConfig, + // Whether the target URL is wss:// (separate from ssl template parameter) + target_is_secure: bool, ) callconv(.c) ?*HTTPClient { const vm = global.bunVM(); bun.assert(vm.event_loop_handle != null); const extra_headers = NonUTF8Headers.init(header_names, header_values, header_count); + const using_proxy = proxy_host != null; // Check if user provided a custom protocol for subprotocols validation var protocol_for_subprotocols = client_protocol.*; @@ -110,12 +141,71 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type { extra_headers, ) catch return null; + // Build proxy state if using proxy + // The CONNECT request is built using local variables for proxy_authorization and proxy_headers + // which are freed immediately after building the request (not stored on the client). + var proxy_state: ?WebSocketProxy = null; + var connect_request: []u8 = &[_]u8{}; + if (using_proxy) { + // Parse proxy authorization (temporary, freed after building CONNECT request) + var proxy_auth_slice: ?[]const u8 = null; + var proxy_auth_owned: ?[]u8 = null; + defer if (proxy_auth_owned) |auth| bun.default_allocator.free(auth); + + if (proxy_authorization) |auth| { + proxy_auth_owned = bun.default_allocator.dupe(u8, auth.slice()) catch { + bun.default_allocator.free(body); + return null; + }; + proxy_auth_slice = proxy_auth_owned; + } + + // Parse proxy headers (temporary, freed after building CONNECT request) + var proxy_hdrs: ?Headers = null; + defer if (proxy_hdrs) |*hdrs| hdrs.deinit(); + + if (proxy_header_count > 0) { + const non_utf8_hdrs = NonUTF8Headers.init(proxy_header_names, proxy_header_values, proxy_header_count); + proxy_hdrs = non_utf8_hdrs.toHeaders(bun.default_allocator) catch { + bun.default_allocator.free(body); + return null; + }; + } + + // Build CONNECT request (proxy_auth and proxy_hdrs are freed by defer after this) + connect_request = buildConnectRequest( + host.slice(), + port, + proxy_auth_slice, + proxy_hdrs, + ) catch { + bun.default_allocator.free(body); + return null; + }; + + // Duplicate target_host (needed for SNI during TLS handshake) + const target_host_dup = bun.default_allocator.dupe(u8, host.slice()) catch { + bun.default_allocator.free(body); + bun.default_allocator.free(connect_request); + return null; + }; + + proxy_state = WebSocketProxy.init( + target_host_dup, + // Use target_is_secure from C++, not ssl template parameter + // (ssl may be true for HTTPS proxy even with ws:// target) + target_is_secure, + body, + ); + } + var client = bun.new(HTTPClient, .{ .ref_count = .init(), .tcp = .{ .socket = .{ .detached = {} } }, .outgoing_websocket = websocket, - .input_body_buf = body, + .input_body_buf = if (using_proxy) connect_request else body, .state = .initializing, + .proxy = proxy_state, .subprotocols = brk: { var subprotocols = bun.StringSet.init(bun.default_allocator); var it = bun.http.HeaderValueIterator.init(protocol_for_subprotocols.slice()); @@ -126,9 +216,14 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type { }, }); - var host_ = host.toSlice(bun.default_allocator); + // Store TLS config if provided (ownership transferred to client) + client.ssl_config = ssl_config; + + var host_ = if (using_proxy) proxy_host.?.toSlice(bun.default_allocator) else host.toSlice(bun.default_allocator); defer host_.deinit(); + const connect_port = if (using_proxy) proxy_port else port; + client.poll_ref.ref(vm); const display_host_ = host_.slice(); const display_host = if (bun.FeatureFlags.hardcode_localhost_to_127_0_0_1 and strings.eqlComptime(display_host_, "localhost")) @@ -136,10 +231,60 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type { else display_host_; + // For TLS connections with custom SSLConfig (e.g., custom CA), create a per-connection + // SSL context instead of using the shared context from C++. This is needed because: + // - The shared context is created once with default settings (no custom CA) + // - Custom CA certificates must be loaded at context creation time + // - This applies to both direct wss:// and HTTPS proxy connections + var connect_ctx: *uws.SocketContext = socket_ctx; + + log("connect: ssl={}, has_ssl_config={}, using_proxy={}", .{ ssl, ssl_config != null, using_proxy }); + + if (comptime ssl) { + if (ssl_config) |config| { + if (config.requires_custom_request_ctx) { + const ctx_opts = config.asUSocketsForClientVerification(); + + var err: uws.create_bun_socket_error_t = .none; + if (uws.SocketContext.createSSLContext( + vm.uwsLoop(), + @sizeOf(usize), + ctx_opts, + &err, + )) |custom_ctx| { + // Configure the custom context with the same callbacks as the shared context + Socket.configure( + custom_ctx, + true, + *HTTPClient, + struct { + pub const onOpen = handleOpen; + pub const onClose = handleClose; + pub const onData = handleData; + pub const onWritable = handleWritable; + pub const onTimeout = handleTimeout; + pub const onLongTimeout = handleTimeout; + pub const onConnectError = handleConnectError; + pub const onEnd = handleEnd; + pub const onHandshake = handleHandshake; + }, + ); + client.custom_ssl_ctx = custom_ctx; + connect_ctx = custom_ctx; + log("Created custom SSL context for TLS connection with custom CA", .{}); + } else { + // Failed to create custom context, fall back to shared context + // The connection may still work if the CA isn't needed + log("Failed to create custom SSL context: {s}", .{@tagName(err)}); + } + } + } + } + if (Socket.connectPtr( display_host, - port, - @as(*uws.SocketContext, @ptrCast(socket_ctx)), + connect_port, + connect_ctx, HTTPClient, client, "tcp", @@ -180,6 +325,21 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type { this.subprotocols.clearAndFree(); this.clearInput(); this.body.clearAndFree(bun.default_allocator); + + // Clean up proxy state + if (this.proxy) |*p| { + p.deinit(); + this.proxy = null; + } + if (this.ssl_config) |config| { + config.deinit(); + bun.default_allocator.destroy(config); + this.ssl_config = null; + } + if (this.custom_ssl_ctx) |ctx| { + ctx.deinit(ssl); + this.custom_ssl_ctx = null; + } } pub fn cancel(this: *HTTPClient) callconv(.c) void { this.clearData(); @@ -189,7 +349,7 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type { this.ref(); defer this.deref(); - // The C++ end of the socket is no longer holding a reference to this, sowe must clear it. + // The C++ end of the socket is no longer holding a reference to this, so we must clear it. if (this.outgoing_websocket != null) { this.outgoing_websocket = null; this.deref(); @@ -206,10 +366,6 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type { pub fn fail(this: *HTTPClient, code: ErrorCode) void { log("onFail: {s}", .{@tagName(code)}); jsc.markBinding(@src()); - - this.ref(); - defer this.deref(); - this.dispatchAbruptClose(code); if (comptime ssl) { @@ -244,7 +400,7 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type { } pub fn handleHandshake(this: *HTTPClient, socket: Socket, success: i32, ssl_error: uws.us_bun_verify_error_t) void { - log("onHandshake({d})", .{success}); + log("onHandshake({d}) ssl_error.error_no={d}", .{ success, ssl_error.error_no }); const handshake_success = if (success == 1) true else false; var reject_unauthorized = false; @@ -257,6 +413,7 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type { if (reject_unauthorized) { // only reject the connection if reject_unauthorized == true if (ssl_error.error_no != 0) { + log("TLS handshake failed: ssl_error={d}, has_custom_ctx={}", .{ ssl_error.error_no, this.custom_ssl_ctx != null }); this.fail(ErrorCode.tls_handshake_failed); return; } @@ -284,13 +441,19 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type { if (comptime ssl) { if (this.hostname.len > 0) { - socket.getNativeHandle().?.configureHTTPClient(this.hostname); + if (socket.getNativeHandle()) |handle| { + handle.configureHTTPClient(this.hostname); + } bun.default_allocator.free(this.hostname); this.hostname = ""; } } - // Do not set MSG_MORE, see https://github.com/oven-sh/bun/issues/4010 + // If using proxy, set state to proxy_handshake + if (this.proxy != null) { + this.state = .proxy_handshake; + } + const wrote = socket.write(this.input_body_buf); if (wrote < 0) { this.terminate(ErrorCode.failed_to_write); @@ -306,7 +469,24 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type { pub fn handleData(this: *HTTPClient, socket: Socket, data: []const u8) void { log("onData", .{}); + + // For tunnel mode after successful upgrade, forward all data to the tunnel + // The tunnel will decrypt and pass to the WebSocket client + if (this.state == .done) { + if (this.proxy) |*p| { + if (p.getTunnel()) |tunnel| { + // Ref the tunnel to keep it alive during this call + // (in case the WebSocket client closes during processing) + tunnel.ref(); + defer tunnel.deref(); + tunnel.receive(data); + } + } + return; + } + if (this.outgoing_websocket == null) { + this.state = .failed; this.clearData(); socket.close(.failure); return; @@ -319,6 +499,225 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type { if (comptime Environment.allow_assert) bun.assert(!socket.isShutdown()); + // Handle proxy handshake response + if (this.state == .proxy_handshake) { + this.handleProxyResponse(socket, data); + return; + } + + // Route through proxy tunnel if TLS handshake is in progress or complete + if (this.proxy) |*p| { + if (p.getTunnel()) |tunnel| { + tunnel.receive(data); + return; + } + } + + var body = data; + if (this.body.items.len > 0) { + bun.handleOom(this.body.appendSlice(bun.default_allocator, data)); + body = this.body.items; + } + + const is_first = this.body.items.len == 0; + const http_101 = "HTTP/1.1 101 "; + if (is_first and body.len > http_101.len) { + // fail early if we receive a non-101 status code + if (!strings.hasPrefixComptime(body, http_101)) { + this.terminate(ErrorCode.expected_101_status_code); + return; + } + } + + const response = PicoHTTP.Response.parse(body, &this.headers_buf) catch |err| { + switch (err) { + error.Malformed_HTTP_Response => { + this.terminate(ErrorCode.invalid_response); + return; + }, + error.ShortRead => { + if (this.body.items.len == 0) { + bun.handleOom(this.body.appendSlice(bun.default_allocator, data)); + } + return; + }, + } + }; + + this.processResponse(response, body[@as(usize, @intCast(response.bytes_read))..]); + } + + fn handleProxyResponse(this: *HTTPClient, socket: Socket, data: []const u8) void { + log("handleProxyResponse", .{}); + + var body = data; + if (this.body.items.len > 0) { + bun.handleOom(this.body.appendSlice(bun.default_allocator, data)); + body = this.body.items; + } + + // Check for HTTP 200 response from proxy + const is_first = this.body.items.len == 0; + const http_200 = "HTTP/1.1 200 "; + const http_200_alt = "HTTP/1.0 200 "; + if (is_first and body.len > http_200.len) { + if (!strings.hasPrefixComptime(body, http_200) and !strings.hasPrefixComptime(body, http_200_alt)) { + // Proxy connection failed + this.terminate(ErrorCode.proxy_connect_failed); + return; + } + } + + // Parse the response to find the end of headers + const response = PicoHTTP.Response.parse(body, &this.headers_buf) catch |err| { + switch (err) { + error.Malformed_HTTP_Response => { + this.terminate(ErrorCode.invalid_response); + return; + }, + error.ShortRead => { + if (this.body.items.len == 0) { + bun.handleOom(this.body.appendSlice(bun.default_allocator, data)); + } + return; + }, + } + }; + + // Proxy returned non-200 status + if (response.status_code != 200) { + if (response.status_code == 407) { + this.terminate(ErrorCode.proxy_authentication_required); + } else { + this.terminate(ErrorCode.proxy_connect_failed); + } + return; + } + + // Proxy tunnel established + log("Proxy tunnel established", .{}); + + // Clear the body buffer for WebSocket handshake + this.body.clearRetainingCapacity(); + + const remain_buf = body[@as(usize, @intCast(response.bytes_read))..]; + + // Safely unwrap proxy state - it must exist if we're in proxy_handshake state + const p = if (this.proxy) |*proxy| proxy else { + this.terminate(ErrorCode.proxy_tunnel_failed); + return; + }; + + // For wss:// through proxy, we need to do TLS handshake inside the tunnel + if (p.isTargetHttps()) { + this.startProxyTLSHandshake(socket, remain_buf); + return; + } + + // For ws:// through proxy, send the WebSocket upgrade request + this.state = .reading; + + // Free the CONNECT request buffer + if (this.input_body_buf.len > 0) { + bun.default_allocator.free(this.input_body_buf); + } + + // Use the WebSocket upgrade request from proxy state + this.input_body_buf = p.takeWebsocketRequestBuf(); + + // Send the WebSocket upgrade request + const wrote = socket.write(this.input_body_buf); + if (wrote < 0) { + this.terminate(ErrorCode.failed_to_write); + return; + } + + this.to_send = this.input_body_buf[@as(usize, @intCast(wrote))..]; + + // If there's remaining data after the proxy response, process it + if (remain_buf.len > 0) { + this.handleData(socket, remain_buf); + } + } + + /// Start TLS handshake inside the proxy tunnel for wss:// connections + fn startProxyTLSHandshake(this: *HTTPClient, socket: Socket, initial_data: []const u8) void { + log("startProxyTLSHandshake", .{}); + + // Safely unwrap proxy state - it must exist if we're called from handleProxyResponse + const p = if (this.proxy) |*proxy| proxy else { + this.terminate(ErrorCode.proxy_tunnel_failed); + return; + }; + + // Get certificate verification setting + const reject_unauthorized = if (this.outgoing_websocket) |ws| ws.rejectUnauthorized() else true; + + // Create proxy tunnel with all parameters + const tunnel = WebSocketProxyTunnel.init(ssl, this, socket, p.getTargetHost(), reject_unauthorized) catch { + this.terminate(ErrorCode.proxy_tunnel_failed); + return; + }; + + // Use ssl_config if available, otherwise use defaults + const ssl_options: SSLConfig = if (this.ssl_config) |config| config.* else SSLConfig{ + .reject_unauthorized = 0, // We verify manually + .request_cert = 1, + }; + + // Start TLS handshake + tunnel.start(ssl_options, initial_data) catch { + tunnel.deref(); + this.terminate(ErrorCode.proxy_tunnel_failed); + return; + }; + + p.setTunnel(tunnel); + this.state = .proxy_tls_handshake; + } + + /// Called by WebSocketProxyTunnel when TLS handshake completes successfully + pub fn onProxyTLSHandshakeComplete(this: *HTTPClient) void { + log("onProxyTLSHandshakeComplete", .{}); + + // TLS handshake done - send WebSocket upgrade request through tunnel + this.state = .reading; + + // Free the CONNECT request buffer + if (this.input_body_buf.len > 0) { + bun.default_allocator.free(this.input_body_buf); + this.input_body_buf = &[_]u8{}; + } + + // Safely unwrap proxy state and send through the tunnel + const p = if (this.proxy) |*proxy| proxy else { + this.terminate(ErrorCode.proxy_tunnel_failed); + return; + }; + + // Take the WebSocket upgrade request from proxy state (transfers ownership) + const upgrade_request = p.takeWebsocketRequestBuf(); + if (upgrade_request.len == 0) { + this.terminate(ErrorCode.failed_to_write); + return; + } + + // Send through the tunnel (will be encrypted) + if (p.getTunnel()) |tunnel| { + _ = tunnel.write(upgrade_request) catch { + this.terminate(ErrorCode.failed_to_write); + return; + }; + } else { + this.terminate(ErrorCode.proxy_tunnel_failed); + } + } + + /// Called by WebSocketProxyTunnel with decrypted data from the TLS tunnel + pub fn handleDecryptedData(this: *HTTPClient, data: []const u8) void { + log("handleDecryptedData: {} bytes", .{data.len}); + + // Process as if it came directly from the socket var body = data; if (this.body.items.len > 0) { bun.handleOom(this.body.appendSlice(bun.default_allocator, data)); @@ -533,6 +932,42 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type { @memcpy(overflow, remain_buf); } + // Check if we're using a proxy tunnel (wss:// through HTTP proxy) + if (this.proxy) |*p| { + if (p.getTunnel()) |tunnel| { + // wss:// through HTTP proxy: use tunnel mode + // For tunnel mode, the upgrade client STAYS ALIVE to forward socket data to the tunnel. + // The socket continues to call handleData on the upgrade client, which forwards to tunnel. + // The tunnel forwards decrypted data to the WebSocket client. + jsc.markBinding(@src()); + if (!this.tcp.isClosed() and this.outgoing_websocket != null) { + this.tcp.timeout(0); + log("onDidConnect (tunnel mode)", .{}); + + // Take the outgoing_websocket reference but DON'T deref the upgrade client. + // We need to keep it alive to forward socket data to the tunnel. + // The upgrade client will be cleaned up when the socket closes. + const ws = bun.take(&this.outgoing_websocket).?; + + // Create the WebSocket client with the tunnel + ws.didConnectWithTunnel(tunnel, overflow.ptr, overflow.len, if (deflate_result.enabled) &deflate_result.params else null); + + // Switch state to connected - handleData will forward to tunnel + this.state = .done; + } else if (this.tcp.isClosed()) { + this.terminate(ErrorCode.cancel); + } else if (this.outgoing_websocket == null) { + this.tcp.close(.failure); + } + return; + } + } + + // Normal (non-tunnel) mode - original code path + // Don't destroy custom SSL context yet - the socket still needs it! + // Save it before clearData() would destroy it, then transfer ownership to the WebSocket client. + const saved_custom_ssl_ctx = this.custom_ssl_ctx; + this.custom_ssl_ctx = null; // Prevent clearData from destroying it this.clearData(); jsc.markBinding(@src()); if (!this.tcp.isClosed() and this.outgoing_websocket != null) { @@ -544,11 +979,15 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type { const ws = bun.take(&this.outgoing_websocket).?; const socket = this.tcp; + // Normal mode: pass socket directly to WebSocket client this.tcp.detach(); // Once again for the TCP socket. defer this.deref(); - - ws.didConnect(socket.socket.get().?, overflow.ptr, overflow.len, if (deflate_result.enabled) &deflate_result.params else null); + if (socket.socket.get()) |native_socket| { + ws.didConnect(native_socket, overflow.ptr, overflow.len, if (deflate_result.enabled) &deflate_result.params else null, saved_custom_ssl_ctx); + } else { + this.terminate(ErrorCode.failed_to_connect); + } } else if (this.tcp.isClosed()) { this.terminate(ErrorCode.cancel); } else if (this.outgoing_websocket == null) { @@ -569,13 +1008,21 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type { ) void { bun.assert(this.isSameSocket(socket)); + // Forward to proxy tunnel if active + if (this.proxy) |*p| { + if (p.getTunnel()) |tunnel| { + tunnel.onWritable(); + // In .done state (after WebSocket upgrade), just handle tunnel writes + if (this.state == .done) return; + } + } + if (this.to_send.len == 0) return; this.ref(); defer this.deref(); - // Do not set MSG_MORE, see https://github.com/oven-sh/bun/issues/4010 const wrote = socket.write(this.to_send); if (wrote < 0) { this.terminate(ErrorCode.failed_to_write); @@ -650,8 +1097,71 @@ const NonUTF8Headers = struct { .values = values.?[0..len], }; } + + /// Convert NonUTF8Headers to bun.http.Headers + pub fn toHeaders(self: NonUTF8Headers, allocator: std.mem.Allocator) !Headers { + var headers = Headers{ + .allocator = allocator, + }; + errdefer headers.deinit(); + + for (self.names, self.values) |name, value| { + try headers.append(name.slice(), value.slice()); + } + + return headers; + } }; +/// Build HTTP CONNECT request for proxy tunneling +fn buildConnectRequest( + target_host: []const u8, + target_port: u16, + proxy_authorization: ?[]const u8, + proxy_headers: ?Headers, +) std.mem.Allocator.Error![]u8 { + const allocator = bun.default_allocator; + + // Calculate size for the CONNECT request + var buf = std.array_list.Managed(u8).init(allocator); + errdefer buf.deinit(); + const writer = buf.writer(); + + // CONNECT host:port HTTP/1.1\r\n + try writer.print("CONNECT {s}:{d} HTTP/1.1\r\n", .{ target_host, target_port }); + + // Host: host:port\r\n + try writer.print("Host: {s}:{d}\r\n", .{ target_host, target_port }); + + // Proxy-Connection: Keep-Alive\r\n + try writer.writeAll("Proxy-Connection: Keep-Alive\r\n"); + + // Proxy-Authorization if provided + if (proxy_authorization) |auth| { + try writer.print("Proxy-Authorization: {s}\r\n", .{auth}); + } + + // Custom proxy headers + if (proxy_headers) |hdrs| { + const slice = hdrs.entries.slice(); + const names = slice.items(.name); + const values = slice.items(.value); + for (names, 0..) |name_ptr, idx| { + // Skip Proxy-Authorization if user provided one (we already added it) + const name = hdrs.asStr(name_ptr); + if (proxy_authorization != null and strings.eqlCaseInsensitiveASCII(name, "proxy-authorization", true)) { + continue; + } + try writer.print("{s}: {s}\r\n", .{ name, hdrs.asStr(values[idx]) }); + } + } + + // End of headers + try writer.writeAll("\r\n"); + + return buf.toOwnedSlice(); +} + fn buildRequestBody( vm: *jsc.VirtualMachine, pathname: *const jsc.ZigString, @@ -781,7 +1291,40 @@ fn buildRequestBody( const log = Output.scoped(.WebSocketUpgradeClient, .visible); +/// Parse SSLConfig from a JavaScript TLS options object. +/// This function is exported for C++ to call from JSWebSocket.cpp. +/// Returns null if parsing fails (an exception will be set on globalThis). +/// The returned SSLConfig is heap-allocated and ownership is transferred to the caller. +pub fn parseSSLConfig( + globalThis: *jsc.JSGlobalObject, + tls_value: jsc.JSValue, +) callconv(.c) ?*SSLConfig { + const vm = globalThis.bunVM(); + + // Use SSLConfig.fromJS for clean and safe parsing + const config_opt = SSLConfig.fromJS(vm, globalThis, tls_value) catch { + // Exception is already set on globalThis + return null; + }; + + if (config_opt) |config| { + // Allocate on heap and return pointer (ownership transferred to caller) + const config_ptr = bun.handleOom(bun.default_allocator.create(SSLConfig)); + config_ptr.* = config; + return config_ptr; + } + + // No TLS options provided or all defaults, return null + return null; +} + +comptime { + @export(&parseSSLConfig, .{ .name = "Bun__WebSocket__parseSSLConfig" }); +} + const WebSocketDeflate = @import("./WebSocketDeflate.zig"); +const WebSocketProxy = @import("./WebSocketProxy.zig"); +const WebSocketProxyTunnel = @import("./WebSocketProxyTunnel.zig"); const std = @import("std"); const CppWebSocket = @import("./CppWebSocket.zig").CppWebSocket; @@ -798,3 +1341,5 @@ const default_allocator = bun.default_allocator; const jsc = bun.jsc; const strings = bun.strings; const uws = bun.uws; +const Headers = bun.http.Headers; +const SSLConfig = jsc.API.ServerConfig.SSLConfig; diff --git a/src/js/thirdparty/ws.js b/src/js/thirdparty/ws.js index 0a101e8149..21620f308e 100644 --- a/src/js/thirdparty/ws.js +++ b/src/js/thirdparty/ws.js @@ -89,9 +89,62 @@ class BunWebSocket extends EventEmitter { let headers; let method = "GET"; + let proxy; + let tlsOptions; + let agent; // https://github.com/websockets/ws/blob/0d1b5e6c4acad16a6b1a1904426eb266a5ba2f72/lib/websocket.js#L741-L747 if ($isObject(options)) { headers = options?.headers; + proxy = options?.proxy; + tlsOptions = options?.tls; + + // Extract from agent if provided (like HttpsProxyAgent) + agent = options?.agent; + if ($isObject(agent)) { + // Get proxy from agent.proxy (can be URL object or string) + if (!proxy && agent.proxy) { + const agentProxy = agent.proxy?.href || agent.proxy; + // Get proxy headers from agent.proxyHeaders + if (agent.proxyHeaders) { + const proxyHeaders = $isCallable(agent.proxyHeaders) ? agent.proxyHeaders.$call(agent) : agent.proxyHeaders; + proxy = { url: agentProxy, headers: proxyHeaders }; + } else { + proxy = agentProxy; + } + } + // Get TLS options from agent.connectOpts or agent.options + // Only extract specific TLS options we support (not ALPNProtocols, etc.) + if (!tlsOptions) { + const agentOpts = agent.connectOpts || agent.options; + if ($isObject(agentOpts)) { + const newTlsOptions = {}; + let hasTlsOptions = false; + if (agentOpts.rejectUnauthorized !== undefined) { + newTlsOptions.rejectUnauthorized = agentOpts.rejectUnauthorized; + hasTlsOptions = true; + } + if (agentOpts.ca) { + newTlsOptions.ca = agentOpts.ca; + hasTlsOptions = true; + } + if (agentOpts.cert) { + newTlsOptions.cert = agentOpts.cert; + hasTlsOptions = true; + } + if (agentOpts.key) { + newTlsOptions.key = agentOpts.key; + hasTlsOptions = true; + } + if (agentOpts.passphrase) { + newTlsOptions.passphrase = agentOpts.passphrase; + hasTlsOptions = true; + } + if (hasTlsOptions) { + tlsOptions = newTlsOptions; + } + } + } + } } const finishRequest = options?.finishRequest; @@ -131,7 +184,7 @@ class BunWebSocket extends EventEmitter { end: () => { if (!didCallEnd) { didCallEnd = true; - this.#createWebSocket(url, protocols, headers, method); + this.#createWebSocket(url, protocols, headers, method, proxy, tlsOptions, agent); } }, write() {}, @@ -160,16 +213,27 @@ class BunWebSocket extends EventEmitter { EventEmitter.$call(nodeHttpClientRequestSimulated); finishRequest(nodeHttpClientRequestSimulated); if (!didCallEnd) { - this.#createWebSocket(url, protocols, headers, method); + this.#createWebSocket(url, protocols, headers, method, proxy, tlsOptions, agent); } return; } - this.#createWebSocket(url, protocols, headers, method); + this.#createWebSocket(url, protocols, headers, method, proxy, tlsOptions, agent); } - #createWebSocket(url, protocols, headers, method) { - let ws = (this.#ws = new WebSocket(url, headers ? { headers, method, protocols } : protocols)); + #createWebSocket(url, protocols, headers, method, proxy, tls, agent) { + let wsOptions; + if (headers || proxy || tls || agent) { + wsOptions = { protocols }; + if (headers) wsOptions.headers = headers; + if (method) wsOptions.method = method; + if (proxy) wsOptions.proxy = proxy; + if (tls) wsOptions.tls = tls; + if (agent) wsOptions.agent = agent; + } else { + wsOptions = protocols; + } + let ws = (this.#ws = new WebSocket(url, wsOptions)); ws.binaryType = "nodebuffer"; return ws; diff --git a/src/sql/mysql/js/JSMySQLConnection.zig b/src/sql/mysql/js/JSMySQLConnection.zig index 0e4c9a0697..e57908e1e2 100644 --- a/src/sql/mysql/js/JSMySQLConnection.zig +++ b/src/sql/mysql/js/JSMySQLConnection.zig @@ -360,13 +360,9 @@ pub fn createInstance(globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFra return .zero; } - // we always request the cert so we can verify it and also we manually abort the connection if the hostname doesn't match - const original_reject_unauthorized = tls_config.reject_unauthorized; - tls_config.reject_unauthorized = 0; - tls_config.request_cert = 1; - + // We always request the cert so we can verify it and also we manually abort the connection if the hostname doesn't match. // We create it right here so we can throw errors early. - const context_options = tls_config.asUSockets(); + const context_options = tls_config.asUSocketsForClientVerification(); var err: uws.create_bun_socket_error_t = .none; tls_ctx = uws.SocketContext.createSSLContext(vm.uwsLoop(), @sizeOf(*@This()), context_options, &err) orelse { if (err != .none) { @@ -375,9 +371,6 @@ pub fn createInstance(globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFra return globalObject.throwValue(err.toJS(globalObject)); } }; - - // restore the original reject_unauthorized - tls_config.reject_unauthorized = original_reject_unauthorized; if (err != .none) { tls_config.deinit(); if (tls_ctx) |ctx| { diff --git a/src/sql/postgres/PostgresSQLConnection.zig b/src/sql/postgres/PostgresSQLConnection.zig index 2ec95bee8d..7cbceae629 100644 --- a/src/sql/postgres/PostgresSQLConnection.zig +++ b/src/sql/postgres/PostgresSQLConnection.zig @@ -618,12 +618,9 @@ pub fn call(globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JS return .zero; } - // we always request the cert so we can verify it and also we manually abort the connection if the hostname doesn't match - const original_reject_unauthorized = tls_config.reject_unauthorized; - tls_config.reject_unauthorized = 0; - tls_config.request_cert = 1; + // We always request the cert so we can verify it and also we manually abort the connection if the hostname doesn't match. // We create it right here so we can throw errors early. - const context_options = tls_config.asUSockets(); + const context_options = tls_config.asUSocketsForClientVerification(); var err: uws.create_bun_socket_error_t = .none; tls_ctx = uws.SocketContext.createSSLContext(vm.uwsLoop(), @sizeOf(*PostgresSQLConnection), context_options, &err) orelse { if (err != .none) { @@ -632,8 +629,6 @@ pub fn call(globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JS return globalObject.throwValue(err.toJS(globalObject)); } }; - // restore the original reject_unauthorized - tls_config.reject_unauthorized = original_reject_unauthorized; if (err != .none) { tls_config.deinit(); if (tls_ctx) |ctx| { diff --git a/test/bun.lock b/test/bun.lock index d041ff8472..be25c6e180 100644 --- a/test/bun.lock +++ b/test/bun.lock @@ -47,7 +47,7 @@ "happy-dom": "17.0.3", "hono": "4.7.2", "http2-wrapper": "2.2.1", - "https-proxy-agent": "7.0.5", + "https-proxy-agent": "7.0.6", "iconv-lite": "0.6.3", "immutable": "5.1.3", "isbot": "5.1.13", @@ -860,7 +860,7 @@ "acorn-walk": ["acorn-walk@8.3.3", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-MxXdReSRhGO7VlFe1bRG/oI7/mdLV9B9JJT0N8vZOhF7gFRR5l3M8W9G8JxmKV+JC5mGqJ0QvqfSOLsCPa4nUw=="], - "agent-base": ["agent-base@7.1.1", "", { "dependencies": { "debug": "^4.3.4" } }, "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA=="], + "agent-base": ["agent-base@7.1.3", "", {}, "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw=="], "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], @@ -1524,7 +1524,7 @@ "http2-wrapper": ["http2-wrapper@2.2.1", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.2.0" } }, "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ=="], - "https-proxy-agent": ["https-proxy-agent@7.0.5", "", { "dependencies": { "agent-base": "^7.0.2", "debug": "4" } }, "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], @@ -2834,8 +2834,6 @@ "@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.4.15", "", {}, "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg=="], - "@mapbox/node-pre-gyp/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "@mapbox/node-pre-gyp/nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="], "@mapbox/node-pre-gyp/tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="], @@ -2844,6 +2842,8 @@ "@nestjs/core/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@npmcli/agent/agent-base": ["agent-base@7.1.1", "", { "dependencies": { "debug": "^4.3.4" } }, "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA=="], + "@npmcli/agent/https-proxy-agent": ["https-proxy-agent@7.0.4", "", { "dependencies": { "agent-base": "^7.0.2", "debug": "4" } }, "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg=="], "@npmcli/agent/lru-cache": ["lru-cache@10.2.2", "", {}, "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ=="], @@ -2864,7 +2864,7 @@ "@remix-run/react/@remix-run/server-runtime": ["@remix-run/server-runtime@2.10.3", "", { "dependencies": { "@remix-run/router": "1.18.0", "@types/cookie": "^0.6.0", "@web3-storage/multipart-parser": "^1.0.0", "cookie": "^0.6.0", "set-cookie-parser": "^2.4.8", "source-map": "^0.7.3", "turbo-stream": "2.2.0" }, "peerDependencies": { "typescript": "^5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-vUl5jONUI6Lj0ICg9FSRFhoPzQdZ/7dpT1m7ID13DF5BEeF3t/9uCJS61XXWgQ/JEu7YRiwvZiwSRTrgM7zeWw=="], - "@remix-run/react/react": ["react@file:../node_modules/react", {}], + "@remix-run/react/react": ["react@file:../node_modules/react", { "dependencies": { "loose-envify": "^1.1.0" } }], "@remix-run/serve/@remix-run/node": ["@remix-run/node@2.10.3", "", { "dependencies": { "@remix-run/server-runtime": "2.10.3", "@remix-run/web-fetch": "^4.4.2", "@web3-storage/multipart-parser": "^1.0.0", "cookie-signature": "^1.1.0", "source-map-support": "^0.5.21", "stream-slice": "^0.1.2", "undici": "^6.11.1" }, "peerDependencies": { "typescript": "^5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-LBqsgADJKW7tYdJZZi2wu20gfMm6UcOXbvb5U70P2jCNxjJvuIw1gXVvNXRJKAdxPKLonjm8cSpfoI6HeQKEDg=="], @@ -2886,7 +2886,7 @@ "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], - "@testing-library/react/react": ["react@file:../node_modules/react", {}], + "@testing-library/react/react": ["react@file:../node_modules/react", { "dependencies": { "loose-envify": "^1.1.0" } }], "@types/eslint/@types/estree": ["@types/estree@1.0.5", "", {}, "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw=="], @@ -2956,8 +2956,6 @@ "acorn-walk/acorn": ["acorn@8.12.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw=="], - "agent-base/debug": ["debug@4.3.5", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg=="], - "ajv-formats/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], "ajv-keywords/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], @@ -3122,9 +3120,11 @@ "header-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "http-proxy-agent/agent-base": ["agent-base@7.1.1", "", { "dependencies": { "debug": "^4.3.4" } }, "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA=="], + "http-proxy-agent/debug": ["debug@4.3.5", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg=="], - "https-proxy-agent/debug": ["debug@4.3.5", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg=="], + "https-proxy-agent/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], @@ -3136,8 +3136,6 @@ "jsdom/form-data": ["form-data@4.0.1", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "mime-types": "^2.1.12" } }, "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw=="], - "jsdom/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "jsdom/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], "jsdom/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], @@ -3196,8 +3194,12 @@ "p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + "pac-proxy-agent/agent-base": ["agent-base@7.1.1", "", { "dependencies": { "debug": "^4.3.4" } }, "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA=="], + "pac-proxy-agent/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], + "pac-proxy-agent/https-proxy-agent": ["https-proxy-agent@7.0.5", "", { "dependencies": { "agent-base": "^7.0.2", "debug": "4" } }, "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw=="], + "pac-proxy-agent/socks-proxy-agent": ["socks-proxy-agent@8.0.4", "", { "dependencies": { "agent-base": "^7.1.1", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-GNAq/eg8Udq2x0eNiFkr9gRg5bA7PXEWagQdeRX4cPSG+X/8V38v637gim9bjFptMk1QWsCTr0ttrJEiXbNnRw=="], "param-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -3232,8 +3234,12 @@ "proxy/debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], + "proxy-agent/agent-base": ["agent-base@7.1.1", "", { "dependencies": { "debug": "^4.3.4" } }, "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA=="], + "proxy-agent/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], + "proxy-agent/https-proxy-agent": ["https-proxy-agent@7.0.5", "", { "dependencies": { "agent-base": "^7.0.2", "debug": "4" } }, "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw=="], + "proxy-agent/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], "proxy-agent/socks-proxy-agent": ["socks-proxy-agent@8.0.3", "", { "dependencies": { "agent-base": "^7.1.1", "debug": "^4.3.4", "socks": "^2.7.1" } }, "sha512-VNegTZKhuGq5vSD6XNKlbqWhyt/40CgoEw8XxD6dhnm8Jq9IEa3nIa4HwnM8XOqU0CdB0BwWVXusqiFXfHB3+A=="], @@ -3248,11 +3254,11 @@ "raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], - "react-dom/react": ["react@file:../node_modules/react", {}], + "react-dom/react": ["react@file:../node_modules/react", { "dependencies": { "loose-envify": "^1.1.0" } }], - "react-router/react": ["react@file:../node_modules/react", {}], + "react-router/react": ["react@file:../node_modules/react", { "dependencies": { "loose-envify": "^1.1.0" } }], - "react-router-dom/react": ["react@file:../node_modules/react", {}], + "react-router-dom/react": ["react@file:../node_modules/react", { "dependencies": { "loose-envify": "^1.1.0" } }], "readable-web-to-node-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], @@ -3444,6 +3450,8 @@ "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "@azure/core-rest-pipeline/https-proxy-agent/agent-base": ["agent-base@7.1.1", "", { "dependencies": { "debug": "^4.3.4" } }, "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA=="], + "@azure/core-rest-pipeline/https-proxy-agent/debug": ["debug@4.3.5", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg=="], "@cypress/request/tough-cookie/universalify": ["universalify@0.2.0", "", {}, "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="], @@ -3458,10 +3466,6 @@ "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], - "@mapbox/node-pre-gyp/https-proxy-agent/agent-base": ["agent-base@7.1.3", "", {}, "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw=="], - - "@mapbox/node-pre-gyp/https-proxy-agent/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], - "@mapbox/node-pre-gyp/nopt/abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="], "@mapbox/node-pre-gyp/tar/chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], @@ -3472,6 +3476,8 @@ "@mapbox/node-pre-gyp/tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + "@npmcli/agent/agent-base/debug": ["debug@4.3.5", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg=="], + "@npmcli/agent/https-proxy-agent/debug": ["debug@4.3.5", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg=="], "@npmcli/agent/socks-proxy-agent/debug": ["debug@4.3.5", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg=="], @@ -3604,8 +3610,6 @@ "@vitest/snapshot/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.4.15", "", {}, "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg=="], - "agent-base/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="], - "ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -3756,8 +3760,6 @@ "http-proxy-agent/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="], - "https-proxy-agent/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="], - "jest-diff/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "jest-diff/pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], @@ -3766,10 +3768,6 @@ "jest-worker/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - "jsdom/https-proxy-agent/agent-base": ["agent-base@7.1.3", "", {}, "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw=="], - - "jsdom/https-proxy-agent/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], - "jsonwebtoken/jws/jwa": ["jwa@1.4.1", "", { "dependencies": { "buffer-equal-constant-time": "1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA=="], "mongodb-connection-string-url/whatwg-url/tr46": ["tr46@3.0.0", "", { "dependencies": { "punycode": "^2.1.1" } }, "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA=="], @@ -3782,8 +3780,16 @@ "node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "pac-proxy-agent/agent-base/debug": ["debug@4.3.5", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg=="], + + "pac-proxy-agent/https-proxy-agent/debug": ["debug@4.3.5", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg=="], + "peek-stream/duplexify/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "proxy-agent/agent-base/debug": ["debug@4.3.5", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg=="], + + "proxy-agent/https-proxy-agent/debug": ["debug@4.3.5", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg=="], + "proxy-agent/socks-proxy-agent/debug": ["debug@4.3.5", "", { "dependencies": { "ms": "2.1.2" } }, "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg=="], "pumpify/duplexify/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], @@ -4022,6 +4028,8 @@ "@inquirer/core/wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "@npmcli/agent/agent-base/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="], + "@npmcli/agent/https-proxy-agent/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="], "@npmcli/agent/socks-proxy-agent/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="], @@ -4092,12 +4100,20 @@ "msw/chalk/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "pac-proxy-agent/agent-base/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="], + + "pac-proxy-agent/https-proxy-agent/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="], + "peek-stream/duplexify/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], "peek-stream/duplexify/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "peek-stream/duplexify/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "proxy-agent/agent-base/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="], + + "proxy-agent/https-proxy-agent/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="], + "proxy-agent/socks-proxy-agent/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="], "pumpify/duplexify/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], diff --git a/test/docker/config/squid.conf b/test/docker/config/squid.conf new file mode 100644 index 0000000000..a17c32d6b3 --- /dev/null +++ b/test/docker/config/squid.conf @@ -0,0 +1,20 @@ +# Squid configuration for WebSocket proxy testing +# Minimal configuration allowing CONNECT to any port + +# Allow all ports for CONNECT (needed for WebSocket on non-standard ports) +acl SSL_ports port 1-65535 +acl Safe_ports port 1-65535 +acl CONNECT method CONNECT + +# Access control - allow everything for testing +http_access allow all + +# Listen on port 3128 +http_port 3128 + +# Disable caching +cache deny all + +# Logging to stdout/stderr for Docker +access_log none +cache_log /dev/stderr diff --git a/test/docker/docker-compose.yml b/test/docker/docker-compose.yml index 465ee5a147..716b531e88 100644 --- a/test/docker/docker-compose.yml +++ b/test/docker/docker-compose.yml @@ -206,6 +206,25 @@ services: published: 0 # Dynamic port protocol: tcp + # Squid proxy for WebSocket proxy testing + squid: + image: ubuntu/squid:5.2-22.04_beta + volumes: + - ./config/squid.conf:/etc/squid/squid.conf:ro + ports: + - target: 3128 + published: 0 + protocol: tcp + # Use extra_hosts to allow connections back to host services via bridge network + extra_hosts: + - "host.docker.internal:host-gateway" + healthcheck: + test: ["CMD-SHELL", "pgrep squid > /dev/null"] + interval: 1h + timeout: 5s + retries: 30 + start_period: 10s + volumes: redis-unix: redis-data: diff --git a/test/docker/index.ts b/test/docker/index.ts index 7787c4339e..0920d42432 100644 --- a/test/docker/index.ts +++ b/test/docker/index.ts @@ -1,7 +1,7 @@ import { spawn } from "bun"; -import { join, dirname } from "path"; -import { fileURLToPath } from "url"; import * as net from "net"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -16,7 +16,8 @@ export type ServiceName = | "redis_plain" | "redis_unified" | "minio" - | "autobahn"; + | "autobahn" + | "squid"; export interface ServiceInfo { host: string; @@ -41,14 +42,14 @@ class DockerComposeHelper { private runningServices: Set = new Set(); constructor(options: DockerComposeOptions = {}) { - this.projectName = options.projectName || + this.projectName = + options.projectName || process.env.BUN_DOCKER_PROJECT_NAME || process.env.COMPOSE_PROJECT_NAME || - "bun-test-services"; // Default project name for all test services + "bun-test-services"; // Default project name for all test services - this.composeFile = options.composeFile || - process.env.BUN_DOCKER_COMPOSE_FILE || - join(__dirname, "docker-compose.yml"); + this.composeFile = + options.composeFile || process.env.BUN_DOCKER_COMPOSE_FILE || join(__dirname, "docker-compose.yml"); // Verify the compose file exists const fs = require("fs"); @@ -70,10 +71,7 @@ class DockerComposeHelper { stderr: "pipe", }); - const [stdout, stderr] = await Promise.all([ - proc.stdout.text(), - proc.stderr.text(), - ]); + const [stdout, stderr] = await Promise.all([proc.stdout.text(), proc.stderr.text()]); const exitCode = await proc.exited; @@ -151,12 +149,12 @@ class DockerComposeHelper { try { const socket = new net.Socket(); await new Promise((resolve, reject) => { - socket.once('connect', () => { + socket.once("connect", () => { socket.destroy(); resolve(); }); - socket.once('error', reject); - socket.connect(port, '127.0.0.1'); + socket.once("error", reject); + socket.connect(port, "127.0.0.1"); }); return; } catch { @@ -255,6 +253,10 @@ class DockerComposeHelper { info.ports[9002] = await this.port(service, 9002); // Docker compose --wait should handle readiness break; + + case "squid": + info.ports[3128] = await this.port(service, 3128); + break; } return info; @@ -322,6 +324,12 @@ class DockerComposeHelper { case "autobahn": env.AUTOBAHN_URL = `ws://${info.host}:${info.ports[9002]}`; break; + + case "squid": + env.HTTP_PROXY = `http://${info.host}:${info.ports[3128]}`; + env.HTTPS_PROXY = `http://${info.host}:${info.ports[3128]}`; + env.PROXY_URL = `http://${info.host}:${info.ports[3128]}`; + break; } return env; @@ -449,7 +457,7 @@ export async function prepareImages(): Promise { // Higher-level wrappers for tests export async function withPostgres( opts: { variant?: "plain" | "tls" | "auth" }, - fn: (info: ServiceInfo & { url: string }) => Promise + fn: (info: ServiceInfo & { url: string }) => Promise, ): Promise { const variant = opts.variant || "plain"; const serviceName = `postgres_${variant}` as ServiceName; @@ -467,7 +475,7 @@ export async function withPostgres( export async function withMySQL( opts: { variant?: "plain" | "native_password" | "tls" }, - fn: (info: ServiceInfo & { url: string }) => Promise + fn: (info: ServiceInfo & { url: string }) => Promise, ): Promise { const variant = opts.variant || "plain"; const serviceName = `mysql_${variant}` as ServiceName; @@ -485,7 +493,7 @@ export async function withMySQL( export async function withRedis( opts: { variant?: "plain" | "unified" }, - fn: (info: ServiceInfo & { url: string; tlsUrl?: string }) => Promise + fn: (info: ServiceInfo & { url: string; tlsUrl?: string }) => Promise, ): Promise { const variant = opts.variant || "plain"; const serviceName = `redis_${variant}` as ServiceName; @@ -502,7 +510,7 @@ export async function withRedis( } export async function withMinio( - fn: (info: ServiceInfo & { endpoint: string; accessKeyId: string; secretAccessKey: string }) => Promise + fn: (info: ServiceInfo & { endpoint: string; accessKeyId: string; secretAccessKey: string }) => Promise, ): Promise { const info = await ensure("minio"); @@ -518,9 +526,7 @@ export async function withMinio( } } -export async function withAutobahn( - fn: (info: ServiceInfo & { url: string }) => Promise -): Promise { +export async function withAutobahn(fn: (info: ServiceInfo & { url: string }) => Promise): Promise { const info = await ensure("autobahn"); try { @@ -531,4 +537,17 @@ export async function withAutobahn( } finally { // Services persist - no teardown } -} \ No newline at end of file +} + +export async function withSquid(fn: (info: ServiceInfo & { proxyUrl: string }) => Promise): Promise { + const info = await ensure("squid"); + + try { + await fn({ + ...info, + proxyUrl: `http://${info.host}:${info.ports[3128]}`, + }); + } finally { + // Services persist - no teardown + } +} diff --git a/test/js/first_party/ws/ws-proxy.test.ts b/test/js/first_party/ws/ws-proxy.test.ts new file mode 100644 index 0000000000..a3166ec07b --- /dev/null +++ b/test/js/first_party/ws/ws-proxy.test.ts @@ -0,0 +1,600 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { gc, tls as tlsCerts } from "harness"; +import type { HttpsProxyAgent as HttpsProxyAgentType } from "https-proxy-agent"; +import net from "net"; +import tls from "tls"; +import WebSocket from "ws"; +import { createConnectProxy, createTLSConnectProxy, startProxy } from "../../web/websocket/proxy-test-utils"; + +// Use dynamic require to avoid linter removing the import +const { HttpsProxyAgent } = require("https-proxy-agent") as { + HttpsProxyAgent: typeof HttpsProxyAgentType; +}; + +// HTTP CONNECT proxy server for WebSocket tunneling +let proxy: net.Server; +let authProxy: net.Server; +let httpsProxy: tls.Server; +let wsServer: ReturnType; +let wssServer: ReturnType; +let proxyPort: number; +let authProxyPort: number; +let httpsProxyPort: number; +let wsPort: number; +let wssPort: number; + +beforeAll(async () => { + // Create HTTP CONNECT proxy + proxy = createConnectProxy(); + proxyPort = await startProxy(proxy); + + // Create HTTP CONNECT proxy with auth + authProxy = createConnectProxy({ requireAuth: true }); + authProxyPort = await startProxy(authProxy); + + // Create HTTPS CONNECT proxy + httpsProxy = createTLSConnectProxy(); + httpsProxyPort = await startProxy(httpsProxy); + + // Create WebSocket echo server + wsServer = Bun.serve({ + port: 0, + fetch(req, server) { + if (server.upgrade(req)) { + return; + } + return new Response("Expected WebSocket", { status: 400 }); + }, + websocket: { + message(ws, message) { + // Echo back + ws.send(message); + }, + open(ws) { + ws.send("connected"); + }, + }, + }); + wsPort = wsServer.port; + + // Create secure WebSocket echo server (wss://) + wssServer = Bun.serve({ + port: 0, + tls: { + key: tlsCerts.key, + cert: tlsCerts.cert, + }, + fetch(req, server) { + if (server.upgrade(req)) { + return; + } + return new Response("Expected WebSocket", { status: 400 }); + }, + websocket: { + message(ws, message) { + // Echo back + ws.send(message); + }, + open(ws) { + ws.send("connected"); + }, + }, + }); + wssPort = wssServer.port; +}); + +afterAll(() => { + proxy?.close(); + authProxy?.close(); + httpsProxy?.close(); + wsServer?.stop(true); + wssServer?.stop(true); +}); + +describe("ws package proxy API", () => { + test("accepts proxy option as string (HTTP proxy)", () => { + const ws = new WebSocket("ws://example.com", { + proxy: `http://127.0.0.1:${proxyPort}`, + }); + expect(ws.readyState).toBe(WebSocket.CONNECTING); + ws.close(); + }); + + test("accepts proxy option as string (HTTPS proxy)", () => { + const ws = new WebSocket("ws://example.com", { + proxy: `https://127.0.0.1:${httpsProxyPort}`, + tls: { rejectUnauthorized: false }, + }); + expect(ws.readyState).toBe(WebSocket.CONNECTING); + ws.close(); + }); + + test("accepts proxy option with object containing url", () => { + const ws = new WebSocket("ws://example.com", { + proxy: { url: `http://127.0.0.1:${proxyPort}` }, + }); + expect(ws.readyState).toBe(WebSocket.CONNECTING); + ws.close(); + }); + + test("accepts proxy URL with credentials", () => { + const ws = new WebSocket("ws://example.com", { + proxy: `http://user:pass@127.0.0.1:${authProxyPort}`, + }); + expect(ws.readyState).toBe(WebSocket.CONNECTING); + ws.close(); + }); + + test("can combine proxy with headers and protocols", () => { + const ws = new WebSocket("ws://example.com", ["graphql-ws"], { + proxy: `http://127.0.0.1:${proxyPort}`, + headers: { Authorization: "Bearer token" }, + }); + expect(ws.readyState).toBe(WebSocket.CONNECTING); + ws.close(); + }); + + test("rejects invalid proxy URL", () => { + expect(() => { + new WebSocket("ws://example.com", { + proxy: "not-a-valid-url", + }); + }).toThrow(SyntaxError); + }); +}); + +describe("ws package through HTTP CONNECT proxy", () => { + test("ws:// through HTTP proxy", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + + const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { + proxy: `http://127.0.0.1:${proxyPort}`, + }); + + const receivedMessages: string[] = []; + + ws.on("open", () => { + ws.send("hello from ws client"); + }); + + ws.on("message", (data: Buffer) => { + receivedMessages.push(data.toString()); + if (receivedMessages.length === 2) { + ws.close(); + } + }); + + ws.on("close", () => { + resolve(receivedMessages); + }); + + ws.on("error", (err: Error) => { + reject(err); + }); + + const messages = await promise; + expect(messages).toContain("connected"); + expect(messages).toContain("hello from ws client"); + gc(); + }); + + test("ws:// through HTTP proxy with auth", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + + const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { + proxy: `http://proxy_user:proxy_pass@127.0.0.1:${authProxyPort}`, + }); + + const receivedMessages: string[] = []; + + ws.on("open", () => { + ws.send("hello with auth via ws"); + }); + + ws.on("message", (data: Buffer) => { + receivedMessages.push(data.toString()); + if (receivedMessages.length === 2) { + ws.close(); + } + }); + + ws.on("close", () => { + resolve(receivedMessages); + }); + + ws.on("error", (err: Error) => { + reject(err); + }); + + const messages = await promise; + expect(messages).toContain("connected"); + expect(messages).toContain("hello with auth via ws"); + gc(); + }); + + test("proxy auth failure returns error", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + let sawError = false; + + const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { + proxy: `http://127.0.0.1:${authProxyPort}`, // No auth provided + }); + + ws.on("open", () => { + ws.close(); + reject(new Error("Expected proxy auth failure, but connection opened")); + }); + + ws.on("error", () => { + sawError = true; + ws.close(); + }); + + ws.on("close", () => { + if (sawError) { + resolve(); + } else { + reject(new Error("Expected proxy auth failure (error event), got clean close instead")); + } + }); + + await promise; + gc(); + }); + + test("proxy wrong credentials returns error", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + let sawError = false; + + const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { + proxy: `http://wrong_user:wrong_pass@127.0.0.1:${authProxyPort}`, + }); + + ws.on("open", () => { + ws.close(); + reject(new Error("Expected proxy auth failure, but connection opened")); + }); + + ws.on("error", () => { + sawError = true; + ws.close(); + }); + + ws.on("close", () => { + if (sawError) { + resolve(); + } else { + reject(new Error("Expected proxy auth failure (error event), got clean close instead")); + } + }); + + await promise; + gc(); + }); +}); + +describe("ws package wss:// through HTTP proxy (TLS tunnel)", () => { + test("wss:// through HTTP proxy", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + + const ws = new WebSocket(`wss://127.0.0.1:${wssPort}`, { + proxy: `http://127.0.0.1:${proxyPort}`, + tls: { + rejectUnauthorized: false, // Trust self-signed cert + }, + }); + + const receivedMessages: string[] = []; + + ws.on("open", () => { + ws.send("hello via tls tunnel from ws"); + }); + + ws.on("message", (data: Buffer) => { + receivedMessages.push(data.toString()); + if (receivedMessages.length === 2) { + ws.close(); + } + }); + + ws.on("close", () => { + resolve(receivedMessages); + }); + + ws.on("error", (err: Error) => { + reject(err); + }); + + const messages = await promise; + expect(messages).toContain("connected"); + expect(messages).toContain("hello via tls tunnel from ws"); + gc(); + }); +}); + +describe("ws package through HTTPS proxy (TLS proxy)", () => { + test("ws:// through HTTPS proxy with CA certificate", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + + const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { + proxy: `https://127.0.0.1:${httpsProxyPort}`, + tls: { + ca: tlsCerts.cert, // Trust self-signed proxy cert + }, + }); + + const receivedMessages: string[] = []; + + ws.on("open", () => { + ws.send("hello via https proxy from ws"); + }); + + ws.on("message", (data: Buffer) => { + receivedMessages.push(data.toString()); + if (receivedMessages.length === 2) { + ws.close(); + } + }); + + ws.on("close", () => { + resolve(receivedMessages); + }); + + ws.on("error", (err: Error) => { + reject(err); + }); + + const messages = await promise; + expect(messages).toContain("connected"); + expect(messages).toContain("hello via https proxy from ws"); + gc(); + }); + + test("ws:// through HTTPS proxy with rejectUnauthorized: false", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + + const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { + proxy: `https://127.0.0.1:${httpsProxyPort}`, + tls: { + rejectUnauthorized: false, // Skip TLS verification for proxy + }, + }); + + const receivedMessages: string[] = []; + + ws.on("open", () => { + ws.send("hello via https proxy no verify from ws"); + }); + + ws.on("message", (data: Buffer) => { + receivedMessages.push(data.toString()); + if (receivedMessages.length === 2) { + ws.close(); + } + }); + + ws.on("close", () => { + resolve(receivedMessages); + }); + + ws.on("error", (err: Error) => { + reject(err); + }); + + const messages = await promise; + expect(messages).toContain("connected"); + expect(messages).toContain("hello via https proxy no verify from ws"); + gc(); + }); + + test("ws:// through HTTPS proxy fails without CA certificate", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + let sawError = false; + + const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { + proxy: `https://127.0.0.1:${httpsProxyPort}`, + // No CA certificate - should fail (self-signed cert not trusted) + }); + + ws.on("open", () => { + ws.close(); + reject(new Error("Expected TLS verification failure, but connection opened")); + }); + + ws.on("error", () => { + sawError = true; + ws.close(); + }); + + ws.on("close", () => { + if (sawError) { + resolve(); + } else { + reject(new Error("Expected TLS verification failure (error event), got clean close instead")); + } + }); + + await promise; + gc(); + }); +}); + +describe("ws package with HttpsProxyAgent", () => { + test("ws:// through HttpsProxyAgent", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + + const agent = new HttpsProxyAgent(`http://127.0.0.1:${proxyPort}`); + const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { agent }); + + const receivedMessages: string[] = []; + + ws.on("open", () => { + ws.send("hello from ws via HttpsProxyAgent"); + }); + + ws.on("message", (data: Buffer) => { + receivedMessages.push(data.toString()); + if (receivedMessages.length === 2) { + ws.close(); + } + }); + + ws.on("close", () => { + resolve(receivedMessages); + }); + + ws.on("error", (err: Error) => { + reject(err); + }); + + const messages = await promise; + expect(messages).toContain("connected"); + expect(messages).toContain("hello from ws via HttpsProxyAgent"); + gc(); + }); + + test("wss:// through HttpsProxyAgent with rejectUnauthorized", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + + const agent = new HttpsProxyAgent(`http://127.0.0.1:${proxyPort}`, { + rejectUnauthorized: false, + }); + const ws = new WebSocket(`wss://127.0.0.1:${wssPort}`, { agent }); + + const receivedMessages: string[] = []; + + ws.on("open", () => { + ws.send("hello from wss via HttpsProxyAgent"); + }); + + ws.on("message", (data: Buffer) => { + receivedMessages.push(data.toString()); + if (receivedMessages.length === 2) { + ws.close(); + } + }); + + ws.on("close", () => { + resolve(receivedMessages); + }); + + ws.on("error", (err: Error) => { + reject(err); + }); + + const messages = await promise; + expect(messages).toContain("connected"); + expect(messages).toContain("hello from wss via HttpsProxyAgent"); + gc(); + }); + + test("HttpsProxyAgent with authentication", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + + const agent = new HttpsProxyAgent(`http://proxy_user:proxy_pass@127.0.0.1:${authProxyPort}`); + const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { agent }); + + const receivedMessages: string[] = []; + + ws.on("open", () => { + ws.send("hello from ws with auth via HttpsProxyAgent"); + }); + + ws.on("message", (data: Buffer) => { + receivedMessages.push(data.toString()); + if (receivedMessages.length === 2) { + ws.close(); + } + }); + + ws.on("close", () => { + resolve(receivedMessages); + }); + + ws.on("error", (err: Error) => { + reject(err); + }); + + const messages = await promise; + expect(messages).toContain("connected"); + expect(messages).toContain("hello from ws with auth via HttpsProxyAgent"); + gc(); + }); + + test("HttpsProxyAgent with agent.proxy as URL object", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + + // HttpsProxyAgent stores the proxy URL as a URL object in agent.proxy + const agent = new HttpsProxyAgent(`http://127.0.0.1:${proxyPort}`); + // Verify the agent has the proxy property as a URL object + expect(agent.proxy).toBeDefined(); + expect(typeof agent.proxy).toBe("object"); + expect(agent.proxy.href).toContain(`127.0.0.1:${proxyPort}`); + + const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { agent }); + + const receivedMessages: string[] = []; + + ws.on("open", () => { + ws.send("hello via agent with URL object"); + }); + + ws.on("message", (data: Buffer) => { + receivedMessages.push(data.toString()); + if (receivedMessages.length === 2) { + ws.close(); + } + }); + + ws.on("close", () => { + resolve(receivedMessages); + }); + + ws.on("error", (err: Error) => { + reject(err); + }); + + const messages = await promise; + expect(messages).toContain("connected"); + expect(messages).toContain("hello via agent with URL object"); + gc(); + }); + + test("explicit proxy option takes precedence over agent", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + + // Create agent pointing to wrong port (that doesn't exist) + const agent = new HttpsProxyAgent(`http://127.0.0.1:1`); + // But use explicit proxy option with correct port + const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { + agent, + proxy: `http://127.0.0.1:${proxyPort}`, // This should take precedence + }); + + const receivedMessages: string[] = []; + + ws.on("open", () => { + ws.send("explicit proxy wins"); + }); + + ws.on("message", (data: Buffer) => { + receivedMessages.push(data.toString()); + if (receivedMessages.length === 2) { + ws.close(); + } + }); + + ws.on("close", () => { + resolve(receivedMessages); + }); + + ws.on("error", (err: Error) => { + reject(err); + }); + + const messages = await promise; + expect(messages).toContain("connected"); + expect(messages).toContain("explicit proxy wins"); + gc(); + }); +}); diff --git a/test/js/web/websocket/proxy-test-utils.ts b/test/js/web/websocket/proxy-test-utils.ts new file mode 100644 index 0000000000..315cda5e47 --- /dev/null +++ b/test/js/web/websocket/proxy-test-utils.ts @@ -0,0 +1,197 @@ +/** + * Shared utilities for WebSocket proxy tests. + * Used by both websocket-proxy.test.ts and ws-proxy.test.ts + */ + +import { tls as tlsCerts } from "harness"; +import net from "net"; +import tls from "tls"; + +export interface ConnectProxyOptions { + requireAuth?: boolean; +} + +/** + * Create an HTTP CONNECT proxy server using Node's net module. + * This proxy handles the CONNECT method to establish tunnels for WebSocket connections. + */ +export function createConnectProxy(options: ConnectProxyOptions = {}): net.Server { + return net.createServer(clientSocket => { + let buffer = Buffer.alloc(0); + let tunnelEstablished = false; + let targetSocket: net.Socket | null = null; + + clientSocket.on("data", data => { + // If tunnel is already established, forward data directly + if (tunnelEstablished && targetSocket) { + targetSocket.write(data); + return; + } + + buffer = Buffer.concat([buffer, data]); + const bufferStr = buffer.toString(); + + // Check if we have complete headers + const headerEnd = bufferStr.indexOf("\r\n\r\n"); + if (headerEnd === -1) return; + + const headerPart = bufferStr.substring(0, headerEnd); + const lines = headerPart.split("\r\n"); + const requestLine = lines[0]; + const headers: Record = {}; + + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + if (line === "") break; + const colonIdx = line.indexOf(": "); + if (colonIdx > 0) { + headers[line.substring(0, colonIdx).toLowerCase()] = line.substring(colonIdx + 2); + } + } + + // Check for CONNECT method + const match = requestLine.match(/^CONNECT\s+([^:]+):(\d+)\s+HTTP/); + if (!match) { + clientSocket.write("HTTP/1.1 400 Bad Request\r\n\r\n"); + clientSocket.end(); + return; + } + + const [, targetHost, targetPort] = match; + + // Check auth if required + if (options.requireAuth) { + const authHeader = headers["proxy-authorization"]; + if (!authHeader) { + clientSocket.write("HTTP/1.1 407 Proxy Authentication Required\r\n\r\n"); + clientSocket.end(); + return; + } + + const auth = Buffer.from(authHeader.replace("Basic ", "").trim(), "base64").toString("utf8"); + if (auth !== "proxy_user:proxy_pass") { + clientSocket.write("HTTP/1.1 403 Forbidden\r\n\r\n"); + clientSocket.end(); + return; + } + } + + // Get any data after the headers (shouldn't be any for CONNECT) + const remainingData = buffer.subarray(headerEnd + 4); + + // Connect to target + targetSocket = net.connect(parseInt(targetPort), targetHost, () => { + clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + tunnelEstablished = true; + + // Forward any remaining data + if (remainingData.length > 0) { + targetSocket!.write(remainingData); + } + + // Set up bidirectional piping + targetSocket!.on("data", chunk => { + clientSocket.write(chunk); + }); + }); + + targetSocket.on("error", () => { + if (!tunnelEstablished) { + clientSocket.write("HTTP/1.1 502 Bad Gateway\r\n\r\n"); + } + clientSocket.end(); + }); + + targetSocket.on("close", () => clientSocket.destroy()); + clientSocket.on("close", () => targetSocket?.destroy()); + }); + + clientSocket.on("error", () => { + targetSocket?.destroy(); + }); + }); +} + +/** + * Create an HTTPS CONNECT proxy server using Node's tls module. + * This proxy handles TLS-encrypted CONNECT tunnels. + */ +export function createTLSConnectProxy(): tls.Server { + return tls.createServer( + { + key: tlsCerts.key, + cert: tlsCerts.cert, + }, + clientSocket => { + let buffer = Buffer.alloc(0); + let tunnelEstablished = false; + let targetSocket: net.Socket | null = null; + + clientSocket.on("data", data => { + if (tunnelEstablished && targetSocket) { + targetSocket.write(data); + return; + } + + buffer = Buffer.concat([buffer, data]); + const bufferStr = buffer.toString(); + + const headerEnd = bufferStr.indexOf("\r\n\r\n"); + if (headerEnd === -1) return; + + const headerPart = bufferStr.substring(0, headerEnd); + const lines = headerPart.split("\r\n"); + const requestLine = lines[0]; + + const match = requestLine.match(/^CONNECT\s+([^:]+):(\d+)\s+HTTP/); + if (!match) { + clientSocket.write("HTTP/1.1 400 Bad Request\r\n\r\n"); + clientSocket.end(); + return; + } + + const [, targetHost, targetPort] = match; + const remainingData = buffer.subarray(headerEnd + 4); + + targetSocket = net.connect(parseInt(targetPort), targetHost, () => { + clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + tunnelEstablished = true; + + if (remainingData.length > 0) { + targetSocket!.write(remainingData); + } + + targetSocket!.on("data", chunk => { + clientSocket.write(chunk); + }); + }); + + targetSocket.on("error", () => { + if (!tunnelEstablished) { + clientSocket.write("HTTP/1.1 502 Bad Gateway\r\n\r\n"); + } + clientSocket.end(); + }); + + targetSocket.on("close", () => clientSocket.destroy()); + clientSocket.on("close", () => targetSocket?.destroy()); + }); + + clientSocket.on("error", () => { + targetSocket?.destroy(); + }); + }, + ); +} + +/** + * Helper to start a proxy server and get its port. + */ +export async function startProxy(server: net.Server | tls.Server): Promise { + return new Promise(resolve => { + server.listen(0, "127.0.0.1", () => { + const addr = server.address() as net.AddressInfo; + resolve(addr.port); + }); + }); +} diff --git a/test/js/web/websocket/websocket-proxy.test.ts b/test/js/web/websocket/websocket-proxy.test.ts new file mode 100644 index 0000000000..7a15f11294 --- /dev/null +++ b/test/js/web/websocket/websocket-proxy.test.ts @@ -0,0 +1,911 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import * as harness from "harness"; +import { tls as tlsCerts } from "harness"; +import type { HttpsProxyAgent as HttpsProxyAgentType } from "https-proxy-agent"; +import net from "net"; +import tls from "tls"; +import { createConnectProxy, createTLSConnectProxy, startProxy } from "./proxy-test-utils"; +// Use dynamic require to avoid linter removing the import +const { HttpsProxyAgent } = require("https-proxy-agent") as { + HttpsProxyAgent: typeof HttpsProxyAgentType; +}; + +// Use docker-compose infrastructure for squid proxy + +const gc = harness.gc; +const isDockerEnabled = harness.isDockerEnabled; + +// HTTP CONNECT proxy server for WebSocket tunneling +let proxy: net.Server; +let authProxy: net.Server; +let wsServer: ReturnType; +let wssServer: ReturnType; +let proxyPort: number; +let authProxyPort: number; +let wsPort: number; +let wssPort: number; + +beforeAll(async () => { + // Create HTTP CONNECT proxy + proxy = createConnectProxy(); + proxyPort = await startProxy(proxy); + + // Create HTTP CONNECT proxy with auth + authProxy = createConnectProxy({ requireAuth: true }); + authProxyPort = await startProxy(authProxy); + + // Create WebSocket echo server + wsServer = Bun.serve({ + port: 0, + fetch(req, server) { + if (server.upgrade(req)) { + return; + } + return new Response("Expected WebSocket", { status: 400 }); + }, + websocket: { + message(ws, message) { + // Echo back + ws.send(message); + }, + open(ws) { + ws.send("connected"); + }, + }, + }); + wsPort = wsServer.port; + + // Create secure WebSocket echo server (wss://) + wssServer = Bun.serve({ + port: 0, + tls: { + key: tlsCerts.key, + cert: tlsCerts.cert, + }, + fetch(req, server) { + if (server.upgrade(req)) { + return; + } + return new Response("Expected WebSocket", { status: 400 }); + }, + websocket: { + message(ws, message) { + // Echo back + ws.send(message); + }, + open(ws) { + ws.send("connected"); + }, + }, + }); + wssPort = wssServer.port; +}); + +afterAll(() => { + proxy?.close(); + authProxy?.close(); + wsServer?.stop(true); + wssServer?.stop(true); +}); + +describe("WebSocket proxy API", () => { + test("accepts proxy option as string (HTTP proxy)", () => { + const ws = new WebSocket("ws://example.com", { + proxy: `http://127.0.0.1:${proxyPort}`, + }); + expect(ws.readyState).toBe(WebSocket.CONNECTING); + ws.close(); + }); + + test("accepts proxy option as string (HTTPS proxy)", () => { + // Note: This test just checks the constructor accepts the option. + // The actual connection would fail without proper TLS setup for the proxy. + const ws = new WebSocket("ws://example.com", { + proxy: `https://127.0.0.1:${proxyPort}`, + tls: { rejectUnauthorized: false }, + }); + expect(ws.readyState).toBe(WebSocket.CONNECTING); + ws.close(); + }); + + test("accepts HTTPS proxy with wss:// target", () => { + // Note: This test just checks the constructor accepts the option. + const ws = new WebSocket("wss://example.com", { + proxy: `https://127.0.0.1:${proxyPort}`, + tls: { rejectUnauthorized: false }, + }); + expect(ws.readyState).toBe(WebSocket.CONNECTING); + ws.close(); + }); + + test("accepts proxy option as object with url", () => { + const ws = new WebSocket("ws://example.com", { + proxy: { url: `http://127.0.0.1:${proxyPort}` }, + }); + expect(ws.readyState).toBe(WebSocket.CONNECTING); + ws.close(); + }); + + test("accepts proxy option with headers", () => { + const ws = new WebSocket("ws://example.com", { + proxy: { + url: `http://127.0.0.1:${proxyPort}`, + headers: { "X-Custom-Header": "test-value" }, + }, + }); + expect(ws.readyState).toBe(WebSocket.CONNECTING); + ws.close(); + }); + + test("accepts proxy option with Headers class instance", () => { + const headers = new Headers({ "X-Custom-Header": "test-value" }); + const ws = new WebSocket("ws://example.com", { + proxy: { + url: `http://127.0.0.1:${proxyPort}`, + headers: headers, + }, + }); + expect(ws.readyState).toBe(WebSocket.CONNECTING); + ws.close(); + }); + + test("accepts proxy URL with credentials", () => { + const ws = new WebSocket("ws://example.com", { + proxy: `http://user:pass@127.0.0.1:${authProxyPort}`, + }); + expect(ws.readyState).toBe(WebSocket.CONNECTING); + ws.close(); + }); + + test("can combine proxy with other options", () => { + const ws = new WebSocket("ws://example.com", { + proxy: `http://127.0.0.1:${proxyPort}`, + headers: { Authorization: "Bearer token" }, + protocols: ["graphql-ws"], + }); + expect(ws.readyState).toBe(WebSocket.CONNECTING); + ws.close(); + }); + + test("rejects invalid proxy URL", () => { + expect(() => { + new WebSocket("ws://example.com", { + proxy: "not-a-valid-url", + }); + }).toThrow(SyntaxError); + }); +}); + +describe("WebSocket through HTTP CONNECT proxy", () => { + test("ws:// through HTTP proxy", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + + const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { + proxy: `http://127.0.0.1:${proxyPort}`, + }); + + const receivedMessages: string[] = []; + + ws.onopen = () => { + ws.send("hello from client"); + }; + + ws.onmessage = event => { + receivedMessages.push(String(event.data)); + if (receivedMessages.length === 2) { + ws.close(); + } + }; + + ws.onclose = () => { + resolve(receivedMessages); + }; + + ws.onerror = event => { + reject(event); + }; + + const messages = await promise; + expect(messages).toContain("connected"); + expect(messages).toContain("hello from client"); + gc(); + }); + + test("ws:// through HTTP proxy with auth", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + + const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { + proxy: `http://proxy_user:proxy_pass@127.0.0.1:${authProxyPort}`, + }); + + const receivedMessages: string[] = []; + + ws.onopen = () => { + ws.send("hello with auth"); + }; + + ws.onmessage = event => { + receivedMessages.push(String(event.data)); + if (receivedMessages.length === 2) { + ws.close(); + } + }; + + ws.onclose = () => { + resolve(receivedMessages); + }; + + ws.onerror = event => { + reject(event); + }; + + const messages = await promise; + expect(messages).toContain("connected"); + expect(messages).toContain("hello with auth"); + gc(); + }); + + test("ws:// through proxy with custom headers", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + + const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { + proxy: { + url: `http://127.0.0.1:${proxyPort}`, + headers: { "X-Custom-Proxy-Header": "test-value" }, + }, + }); + + ws.onopen = () => { + ws.close(); + resolve(); + }; + + ws.onerror = event => { + reject(event); + }; + + await promise; + gc(); + }); + + test("ws:// through proxy with Headers class instance", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + + const headers = new Headers({ "X-Custom-Proxy-Header": "test-value-from-headers-class" }); + const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { + proxy: { + url: `http://127.0.0.1:${proxyPort}`, + headers: headers, + }, + }); + + ws.onopen = () => { + ws.close(); + resolve(); + }; + + ws.onerror = event => { + reject(event); + }; + + await promise; + gc(); + }); + + test("proxy auth failure returns error", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + let sawError = false; + + const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { + proxy: `http://127.0.0.1:${authProxyPort}`, // No auth provided + }); + + ws.onopen = () => { + ws.close(); + reject(new Error("Expected proxy auth failure, but connection opened")); + }; + + ws.onerror = () => { + sawError = true; + ws.close(); + }; + + ws.onclose = () => { + if (sawError) { + resolve(); + } else { + reject(new Error("Expected proxy auth failure (error event), got clean close instead")); + } + }; + + await promise; + gc(); + }); + + test("proxy wrong credentials returns error", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + let sawError = false; + + const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { + proxy: `http://wrong_user:wrong_pass@127.0.0.1:${authProxyPort}`, + }); + + ws.onopen = () => { + ws.close(); + reject(new Error("Expected proxy auth failure, but connection opened")); + }; + + ws.onerror = () => { + sawError = true; + ws.close(); + }; + + ws.onclose = () => { + if (sawError) { + resolve(); + } else { + reject(new Error("Expected proxy auth failure (error event), got clean close instead")); + } + }; + + await promise; + gc(); + }); +}); + +describe("WebSocket wss:// through HTTP proxy (TLS tunnel)", () => { + // This tests the TLS tunnel: wss:// target through HTTP proxy + // The outer connection is plain TCP to the HTTP proxy, then TLS is + // negotiated inside the tunnel to the wss:// target server. + + test("wss:// through HTTP proxy", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + + // Use local wss:// server with self-signed cert + const ws = new WebSocket(`wss://127.0.0.1:${wssPort}`, { + proxy: `http://127.0.0.1:${proxyPort}`, + tls: { + // Trust the self-signed certificate used by the wss:// server + rejectUnauthorized: false, + }, + }); + + const receivedMessages: string[] = []; + + ws.onopen = () => { + ws.send("hello via tls tunnel"); + }; + + ws.onmessage = event => { + receivedMessages.push(String(event.data)); + if (receivedMessages.length === 2) { + ws.close(); + } + }; + + ws.onclose = () => { + resolve(receivedMessages); + }; + + ws.onerror = event => { + reject(event); + }; + + const messages = await promise; + expect(messages).toContain("connected"); + expect(messages).toContain("hello via tls tunnel"); + gc(); + }); +}); + +describe("WebSocket through HTTPS proxy (TLS proxy)", () => { + // These tests verify WebSocket connections through HTTPS (TLS) proxy servers + + let httpsProxy: tls.Server; + let httpsProxyPort: number; + + beforeAll(async () => { + // Create HTTPS CONNECT proxy + httpsProxy = createTLSConnectProxy(); + httpsProxyPort = await startProxy(httpsProxy); + }); + + afterAll(() => { + httpsProxy?.close(); + }); + + test("ws:// through HTTPS proxy with CA certificate", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + + const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { + proxy: `https://127.0.0.1:${httpsProxyPort}`, + tls: { + // Trust the self-signed certificate used by the proxy + ca: tlsCerts.cert, + }, + }); + + const receivedMessages: string[] = []; + + ws.onopen = () => { + ws.send("hello via https proxy"); + }; + + ws.onmessage = event => { + receivedMessages.push(String(event.data)); + if (receivedMessages.length === 2) { + ws.close(); + } + }; + + ws.onclose = () => { + resolve(receivedMessages); + }; + + ws.onerror = event => { + reject(event); + }; + + const messages = await promise; + expect(messages).toContain("connected"); + expect(messages).toContain("hello via https proxy"); + gc(); + }); + + test("ws:// through HTTPS proxy fails without CA certificate", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + let sawError = false; + + const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { + proxy: `https://127.0.0.1:${httpsProxyPort}`, + // No CA certificate - should fail (self-signed cert not trusted) + }); + + ws.onopen = () => { + ws.close(); + reject(new Error("Expected TLS verification failure, but connection opened")); + }; + + ws.onerror = () => { + sawError = true; + ws.close(); + }; + + ws.onclose = () => { + if (sawError) { + resolve(); + } else { + reject(new Error("Expected TLS verification failure (error event), got clean close instead")); + } + }; + + await promise; + gc(); + }); + + test("ws:// through HTTPS proxy with rejectUnauthorized: false", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + + const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { + proxy: `https://127.0.0.1:${httpsProxyPort}`, + tls: { + rejectUnauthorized: false, // Skip TLS verification for proxy + }, + }); + + const receivedMessages: string[] = []; + + ws.onopen = () => { + ws.send("hello via https proxy no verify"); + }; + + ws.onmessage = event => { + receivedMessages.push(String(event.data)); + if (receivedMessages.length === 2) { + ws.close(); + } + }; + + ws.onclose = () => { + resolve(receivedMessages); + }; + + ws.onerror = event => { + reject(event); + }; + + const messages = await promise; + expect(messages).toContain("connected"); + expect(messages).toContain("hello via https proxy no verify"); + gc(); + }); +}); + +// Squid proxy tests - run when Docker is enabled +// Uses docker-compose infrastructure to run squid proxy +// Import docker-compose dynamically to avoid issues when not using docker +const dockerCompose = require("../../../docker/index.ts"); + +describe.skipIf(!isDockerEnabled())("WebSocket through Squid proxy (Docker)", () => { + let squidInfo: { host: string; ports: Record; proxyUrl?: string }; + + beforeAll(async () => { + console.log("Starting squid proxy container..."); + squidInfo = await dockerCompose.ensure("squid"); + console.log(`Squid proxy ready at: ${squidInfo.host}:${squidInfo.ports[3128]}`); + }, 120_000); + + afterAll(async () => { + if (!process.env.BUN_KEEP_DOCKER) { + await dockerCompose.down(); + } + }, 30_000); + + test("ws:// through squid proxy to local server", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + const proxyUrl = `http://${squidInfo.host}:${squidInfo.ports[3128]}`; + + // Connect to our local WebSocket server through squid + const ws = new WebSocket(`ws://host.docker.internal:${wsPort}`, { + proxy: proxyUrl, + }); + + const receivedMessages: string[] = []; + + ws.onopen = () => { + ws.send("hello from bun via squid"); + }; + + ws.onmessage = event => { + receivedMessages.push(String(event.data)); + if (receivedMessages.length === 2) { + ws.close(); + } + }; + + ws.onclose = () => { + resolve(receivedMessages); + }; + + ws.onerror = event => { + reject(event); + }; + + const messages = await promise; + expect(messages).toContain("connected"); + expect(messages).toContain("hello from bun via squid"); + gc(); + }, 30_000); + + test("wss:// through squid proxy to local server", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + const proxyUrl = `http://${squidInfo.host}:${squidInfo.ports[3128]}`; + + // Connect to our local secure WebSocket server through squid + const ws = new WebSocket(`wss://host.docker.internal:${wssPort}`, { + proxy: proxyUrl, + tls: { + rejectUnauthorized: false, // Accept self-signed cert + }, + }); + + const receivedMessages: string[] = []; + + ws.onopen = () => { + ws.send("hello wss from bun via squid"); + }; + + ws.onmessage = event => { + receivedMessages.push(String(event.data)); + if (receivedMessages.length === 2) { + ws.close(); + } + }; + + ws.onclose = () => { + resolve(receivedMessages); + }; + + ws.onerror = event => { + reject(event); + }; + + const messages = await promise; + expect(messages).toContain("connected"); + expect(messages).toContain("hello wss from bun via squid"); + gc(); + }, 30_000); +}); + +describe("ws module with HttpsProxyAgent", () => { + // These tests verify that the ws module (src/js/thirdparty/ws.js) correctly + // passes the agent property to the native WebSocket + + const WS = require("ws"); + + test("ws module passes agent to native WebSocket", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + + const agent = new HttpsProxyAgent(`http://127.0.0.1:${proxyPort}`); + const ws = new WS(`ws://127.0.0.1:${wsPort}`, { agent }); + + const receivedMessages: string[] = []; + + ws.on("open", () => { + ws.send("hello from ws module via agent"); + }); + + ws.on("message", (data: Buffer) => { + receivedMessages.push(data.toString()); + if (receivedMessages.length === 2) { + ws.close(); + } + }); + + ws.on("close", () => { + resolve(receivedMessages); + }); + + ws.on("error", (err: Error) => { + reject(err); + }); + + const messages = await promise; + expect(messages).toContain("connected"); + expect(messages).toContain("hello from ws module via agent"); + gc(); + }); + + test("ws module passes agent with TLS options", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + + const agent = new HttpsProxyAgent(`http://127.0.0.1:${proxyPort}`, { + rejectUnauthorized: false, + }); + const ws = new WS(`wss://127.0.0.1:${wssPort}`, { agent }); + + const receivedMessages: string[] = []; + + ws.on("open", () => { + ws.send("hello from ws module via agent to wss"); + }); + + ws.on("message", (data: Buffer) => { + receivedMessages.push(data.toString()); + if (receivedMessages.length === 2) { + ws.close(); + } + }); + + ws.on("close", () => { + resolve(receivedMessages); + }); + + ws.on("error", (err: Error) => { + reject(err); + }); + + const messages = await promise; + expect(messages).toContain("connected"); + expect(messages).toContain("hello from ws module via agent to wss"); + gc(); + }); + + test("ws module explicit proxy takes precedence over agent", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + + // Create agent pointing to wrong port + const agent = new HttpsProxyAgent(`http://127.0.0.1:1`); + // But use explicit proxy option with correct port + const ws = new WS(`ws://127.0.0.1:${wsPort}`, { + agent, + proxy: `http://127.0.0.1:${proxyPort}`, // This should take precedence + }); + + const receivedMessages: string[] = []; + + ws.on("open", () => { + ws.send("ws module explicit proxy wins"); + }); + + ws.on("message", (data: Buffer) => { + receivedMessages.push(data.toString()); + if (receivedMessages.length === 2) { + ws.close(); + } + }); + + ws.on("close", () => { + resolve(receivedMessages); + }); + + ws.on("error", (err: Error) => { + reject(err); + }); + + const messages = await promise; + expect(messages).toContain("connected"); + expect(messages).toContain("ws module explicit proxy wins"); + gc(); + }); +}); + +describe("WebSocket with HttpsProxyAgent", () => { + test("ws:// through HttpsProxyAgent", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + + const agent = new HttpsProxyAgent(`http://127.0.0.1:${proxyPort}`); + const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { agent }); + + const receivedMessages: string[] = []; + + ws.onopen = () => { + ws.send("hello from WebSocket via HttpsProxyAgent"); + }; + + ws.onmessage = event => { + receivedMessages.push(String(event.data)); + if (receivedMessages.length === 2) { + ws.close(); + } + }; + + ws.onclose = () => { + resolve(receivedMessages); + }; + + ws.onerror = event => { + reject(event); + }; + + const messages = await promise; + expect(messages).toContain("connected"); + expect(messages).toContain("hello from WebSocket via HttpsProxyAgent"); + gc(); + }); + + test("wss:// through HttpsProxyAgent with rejectUnauthorized", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + + const agent = new HttpsProxyAgent(`http://127.0.0.1:${proxyPort}`, { + rejectUnauthorized: false, + }); + const ws = new WebSocket(`wss://127.0.0.1:${wssPort}`, { agent }); + + const receivedMessages: string[] = []; + + ws.onopen = () => { + ws.send("hello from wss via HttpsProxyAgent"); + }; + + ws.onmessage = event => { + receivedMessages.push(String(event.data)); + if (receivedMessages.length === 2) { + ws.close(); + } + }; + + ws.onclose = () => { + resolve(receivedMessages); + }; + + ws.onerror = event => { + reject(event); + }; + + const messages = await promise; + expect(messages).toContain("connected"); + expect(messages).toContain("hello from wss via HttpsProxyAgent"); + gc(); + }); + + test("HttpsProxyAgent with authentication", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + + const agent = new HttpsProxyAgent(`http://proxy_user:proxy_pass@127.0.0.1:${authProxyPort}`); + const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { agent }); + + const receivedMessages: string[] = []; + + ws.onopen = () => { + ws.send("hello from WebSocket with auth via HttpsProxyAgent"); + }; + + ws.onmessage = event => { + receivedMessages.push(String(event.data)); + if (receivedMessages.length === 2) { + ws.close(); + } + }; + + ws.onclose = () => { + resolve(receivedMessages); + }; + + ws.onerror = event => { + reject(event); + }; + + const messages = await promise; + expect(messages).toContain("connected"); + expect(messages).toContain("hello from WebSocket with auth via HttpsProxyAgent"); + gc(); + }); + + test("HttpsProxyAgent with agent.proxy as URL object", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + + // HttpsProxyAgent stores the proxy URL as a URL object in agent.proxy + const agent = new HttpsProxyAgent(`http://127.0.0.1:${proxyPort}`); + // Verify the agent has the proxy property as a URL object + expect(agent.proxy).toBeDefined(); + expect(typeof agent.proxy).toBe("object"); + expect(agent.proxy.href).toContain(`127.0.0.1:${proxyPort}`); + + const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { agent }); + + const receivedMessages: string[] = []; + + ws.onopen = () => { + ws.send("hello via agent with URL object"); + }; + + ws.onmessage = event => { + receivedMessages.push(String(event.data)); + if (receivedMessages.length === 2) { + ws.close(); + } + }; + + ws.onclose = () => { + resolve(receivedMessages); + }; + + ws.onerror = event => { + reject(event); + }; + + const messages = await promise; + expect(messages).toContain("connected"); + expect(messages).toContain("hello via agent with URL object"); + gc(); + }); + + test("explicit proxy option takes precedence over agent", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + + // Create agent pointing to wrong port (that doesn't exist) + const agent = new HttpsProxyAgent(`http://127.0.0.1:1`); + // But use explicit proxy option with correct port + const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { + agent, + proxy: `http://127.0.0.1:${proxyPort}`, // This should take precedence + }); + + const receivedMessages: string[] = []; + + ws.onopen = () => { + ws.send("explicit proxy wins"); + }; + + ws.onmessage = event => { + receivedMessages.push(String(event.data)); + if (receivedMessages.length === 2) { + ws.close(); + } + }; + + ws.onclose = () => { + resolve(receivedMessages); + }; + + ws.onerror = event => { + reject(event); + }; + + const messages = await promise; + expect(messages).toContain("connected"); + expect(messages).toContain("explicit proxy wins"); + gc(); + }); +}); diff --git a/test/package.json b/test/package.json index be6ab50928..d91bd9dce5 100644 --- a/test/package.json +++ b/test/package.json @@ -51,7 +51,7 @@ "happy-dom": "17.0.3", "hono": "4.7.2", "http2-wrapper": "2.2.1", - "https-proxy-agent": "7.0.5", + "https-proxy-agent": "7.0.6", "iconv-lite": "0.6.3", "immutable": "5.1.3", "isbot": "5.1.13",