diff --git a/src/bun.js/api/server/SSLConfig.bindv2.ts b/src/bun.js/api/server/SSLConfig.bindv2.ts index 04a3f0b0f1e1b3..47178af3f0abc7 100644 --- a/src/bun.js/api/server/SSLConfig.bindv2.ts +++ b/src/bun.js/api/server/SSLConfig.bindv2.ts @@ -18,6 +18,7 @@ export const ALPNProtocols = b.union("ALPNProtocols", { none: b.null, string: b.String, buffer: b.ArrayBuffer, + array: b.Array(b.String), }); export const SSLConfig = b.dictionary( diff --git a/src/bun.js/api/server/SSLConfig.zig b/src/bun.js/api/server/SSLConfig.zig index 05833d7eeafaf6..86e2f1d250500b 100644 --- a/src/bun.js/api/server/SSLConfig.zig +++ b/src/bun.js/api/server/SSLConfig.zig @@ -291,6 +291,55 @@ pub fn fromGenerated( const buffer: jsc.ArrayBuffer = ref.get().asArrayBuffer(); break :blk try bun.default_allocator.dupeZ(u8, buffer.byteSlice()); }, + .array => |*arr| blk: { + // Convert array of protocol strings to ALPN wire format: + // Each protocol is prefixed with its length byte + const protocol_strings = arr.items(); + if (protocol_strings.len == 0) break :blk null; + + // First pass: convert all strings to UTF-8 and calculate total size + // Use stack fallback allocator - ALPN typically has 1-3 protocols + var stack_fallback = std.heap.stackFallback(8 * @sizeOf(jsc.ZigString.Slice), bun.default_allocator); + const slice_allocator = stack_fallback.get(); + var utf8_slices = try slice_allocator.alloc(jsc.ZigString.Slice, protocol_strings.len); + defer slice_allocator.free(utf8_slices); + + var total_len: usize = 0; + for (protocol_strings, 0..) |*str_ref, i| { + const s = str_ref.get(); + utf8_slices[i] = s.toUTF8(bun.default_allocator); + const len = utf8_slices[i].len; + if (len > 255) { + // Clean up already converted slices + for (utf8_slices[0 .. i + 1]) |*slice| { + slice.deinit(); + } + return global.throw("ALPN protocol name exceeds maximum length of 255 bytes", .{}); + } + total_len += 1 + len; // 1 byte for length + protocol string + } + defer { + for (utf8_slices) |*slice| { + slice.deinit(); + } + } + + // Allocate buffer with null terminator + const buf = try bun.default_allocator.allocSentinel(u8, total_len, 0); + errdefer bun.default_allocator.free(buf); + + // Fill buffer with length-prefixed protocols + var offset: usize = 0; + for (utf8_slices) |slice| { + const protocol_bytes = slice.slice(); + buf[offset] = @intCast(protocol_bytes.len); + offset += 1; + @memcpy(buf[offset..][0..protocol_bytes.len], protocol_bytes); + offset += protocol_bytes.len; + } + + break :blk buf; + }, }; if (protocols) |some_protocols| { result.protos = some_protocols; diff --git a/src/bun.js/bindings/JSGlobalObject.zig b/src/bun.js/bindings/JSGlobalObject.zig index fa2a09201889b9..f8063b1269b43f 100644 --- a/src/bun.js/bindings/JSGlobalObject.zig +++ b/src/bun.js/bindings/JSGlobalObject.zig @@ -800,6 +800,16 @@ pub const JSGlobalObject = opaque { extern fn JSGlobalObject__tryTakeException(*JSGlobalObject) JSValue; extern fn JSGlobalObject__requestTermination(this: *JSGlobalObject) void; + extern fn JSC__JSGlobalObject__getHttpsGlobalAgent(*JSGlobalObject) JSValue; + /// Gets `https.globalAgent` from the node:https module. + /// Returns undefined if not available. + pub fn getHttpsGlobalAgent(this: *JSGlobalObject) bun.JSError!JSValue { + jsc.markBinding(@src()); + const result = JSC__JSGlobalObject__getHttpsGlobalAgent(this); + if (result == .zero) return error.JSError; + return result; + } + extern fn Zig__GlobalObject__create(*anyopaque, i32, bool, bool, ?*anyopaque) *JSGlobalObject; pub fn create( v: *jsc.VirtualMachine, diff --git a/src/bun.js/bindings/bindings.cpp b/src/bun.js/bindings/bindings.cpp index 3a4130c9838b4f..6ae3d44e10d654 100644 --- a/src/bun.js/bindings/bindings.cpp +++ b/src/bun.js/bindings/bindings.cpp @@ -6121,3 +6121,24 @@ extern "C" void JSC__ArrayBuffer__asBunArrayBuffer(JSC::ArrayBuffer* self, Bun__ out->cell_type = JSC::JSType::ArrayBufferType; out->shared = self->isShared(); } + +extern "C" [[ZIG_EXPORT(zero_is_throw)]] JSC::EncodedJSValue JSC__JSGlobalObject__getHttpsGlobalAgent(Zig::GlobalObject* globalObject) +{ + auto& vm = globalObject->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + + // Get node:https module from internal registry + JSValue httpsModule = globalObject->internalModuleRegistry()->requireId( + globalObject, vm, Bun::InternalModuleRegistry::Field::NodeHttps); + RETURN_IF_EXCEPTION(scope, {}); + + if (!httpsModule.isObject()) + return JSValue::encode(jsUndefined()); + + // Get globalAgent from the module (reuse identifier) + auto globalAgentId = Identifier::fromString(vm, "globalAgent"_s); + JSValue globalAgent = httpsModule.getObject()->get(globalObject, globalAgentId); + RETURN_IF_EXCEPTION(scope, {}); + + return JSValue::encode(globalAgent); +} diff --git a/src/bun.js/bindings/webcore/JSWebSocket.cpp b/src/bun.js/bindings/webcore/JSWebSocket.cpp index 43b7c9d7d68463..85cc620a093cb9 100644 --- a/src/bun.js/bindings/webcore/JSWebSocket.cpp +++ b/src/bun.js/bindings/webcore/JSWebSocket.cpp @@ -67,6 +67,9 @@ #include "headers.h" #include "ObjectBindings.h" +// Extern declaration for getting https.globalAgent from Zig +extern "C" JSC::EncodedJSValue JSC__JSGlobalObject__getHttpsGlobalAgent(Zig::GlobalObject* globalObject); + namespace WebCore { using namespace JSC; @@ -146,26 +149,129 @@ static_assert(WebSocket::OPEN == 1, "OPEN in WebSocket does not match value from static_assert(WebSocket::CLOSING == 2, "CLOSING in WebSocket does not match value from IDL"); static_assert(WebSocket::CLOSED == 3, "CLOSED in WebSocket does not match value from IDL"); +// Helper function to apply https.globalAgent fallback for proxy and TLS options. +// Modifies proxyUrl, rejectUnauthorized, and sslConfig by reference if globalAgent has values set. +// Returns true on success, false if an exception was thrown. +static inline bool applyGlobalAgentFallback( + Zig::GlobalObject* globalObject, + JSC::VM& vm, + JSC::ThrowScope& throwScope, + String& proxyUrl, + int& rejectUnauthorized, + void*& sslConfig) +{ + JSValue httpsGlobalAgent = JSValue::decode(JSC__JSGlobalObject__getHttpsGlobalAgent(globalObject)); + RETURN_IF_EXCEPTION(throwScope, false); + if (!httpsGlobalAgent.isObject()) + return true; + + JSC::JSObject* agentObj = httpsGlobalAgent.getObject(); + if (!agentObj) + return true; + + // Fallback to globalAgent.proxy if no proxy was provided + if (proxyUrl.isNull() || proxyUrl.isEmpty()) { + auto agentProxyValue = Bun::getOwnPropertyIfExists(globalObject, agentObj, PropertyName(Identifier::fromString(vm, "proxy"_s))); + RETURN_IF_EXCEPTION(throwScope, false); + if (agentProxyValue && !agentProxyValue.isUndefinedOrNull()) { + if (agentProxyValue.isString()) { + proxyUrl = convert(*globalObject, 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, false); + if (hrefValue && hrefValue.isString()) { + proxyUrl = convert(*globalObject, hrefValue); + } + } + } + RETURN_IF_EXCEPTION(throwScope, false); + } + } + + // Fallback to globalAgent.options/connectOpts/connect for TLS options + // Only proceed if we need either rejectUnauthorized OR sslConfig + if (rejectUnauthorized == -1 || !sslConfig) { + // Try globalAgent.options first, then globalAgent.connectOpts, then globalAgent.connect + JSValue tlsSourceValue; + auto optionsValue = Bun::getOwnPropertyIfExists(globalObject, agentObj, PropertyName(Identifier::fromString(vm, "options"_s))); + RETURN_IF_EXCEPTION(throwScope, false); + if (optionsValue && !optionsValue.isUndefinedOrNull() && optionsValue.isObject()) { + tlsSourceValue = optionsValue; + } else { + auto connectOptsValue = Bun::getOwnPropertyIfExists(globalObject, agentObj, PropertyName(Identifier::fromString(vm, "connectOpts"_s))); + RETURN_IF_EXCEPTION(throwScope, false); + if (connectOptsValue && !connectOptsValue.isUndefinedOrNull() && connectOptsValue.isObject()) { + tlsSourceValue = connectOptsValue; + } else { + // Also check "connect" for undici.Agent compatibility + auto connectValue = Bun::getOwnPropertyIfExists(globalObject, agentObj, PropertyName(Identifier::fromString(vm, "connect"_s))); + RETURN_IF_EXCEPTION(throwScope, false); + if (connectValue && !connectValue.isUndefinedOrNull() && connectValue.isObject()) { + tlsSourceValue = connectValue; + } + } + } + + if (tlsSourceValue && tlsSourceValue.isObject()) { + if (JSC::JSObject* tlsSourceObj = tlsSourceValue.getObject()) { + // Extract rejectUnauthorized ONLY when not already set + if (rejectUnauthorized == -1) { + auto rejectValue = Bun::getOwnPropertyIfExists(globalObject, tlsSourceObj, PropertyName(Identifier::fromString(vm, "rejectUnauthorized"_s))); + RETURN_IF_EXCEPTION(throwScope, false); + if (rejectValue && rejectValue.isBoolean()) { + rejectUnauthorized = rejectValue.asBoolean() ? 1 : 0; + } + } + + // Parse sslConfig ONLY when not already set + // Pass the full object - SSLConfig.fromJS extracts only the properties it needs + if (!sslConfig) { + sslConfig = Bun__WebSocket__parseSSLConfig(globalObject, JSValue::encode(tlsSourceValue)); + RETURN_IF_EXCEPTION(throwScope, false); + } + } + } + } + + return true; +} + static inline JSC::EncodedJSValue constructJSWebSocket1(JSGlobalObject* lexicalGlobalObject, CallFrame* callFrame) { auto& vm = JSC::getVM(lexicalGlobalObject); auto throwScope = DECLARE_THROW_SCOPE(vm); - auto* castedThis = jsCast(callFrame->jsCallee()); - ASSERT(castedThis); - auto* context = castedThis->scriptExecutionContext(); + auto* globalObject = jsCast(lexicalGlobalObject); + auto* context = globalObject->scriptExecutionContext(); if (!context) [[unlikely]] return throwConstructorScriptExecutionContextUnavailableError(*lexicalGlobalObject, throwScope, "WebSocket"_s); + EnsureStillAliveScope argument0 = callFrame->uncheckedArgument(0); auto url = convert(*lexicalGlobalObject, argument0.value()); RETURN_IF_EXCEPTION(throwScope, {}); + EnsureStillAliveScope argument1 = callFrame->argument(1); auto protocols = argument1.value().isUndefined() ? Converter>::ReturnType {} : convert>(*lexicalGlobalObject, argument1.value()); RETURN_IF_EXCEPTION(throwScope, {}); - auto object = WebSocket::create(*context, WTF::move(url), WTF::move(protocols)); + + // Apply globalAgent fallback for proxy and TLS options + String proxyUrl; + int rejectUnauthorized = -1; + void* sslConfig = nullptr; + + if (!applyGlobalAgentFallback(globalObject, vm, throwScope, proxyUrl, rejectUnauthorized, sslConfig)) + return {}; + + auto object = (rejectUnauthorized == -1) + ? WebSocket::create(*context, WTF::move(url), protocols, std::nullopt, WTF::move(proxyUrl), std::nullopt, sslConfig) + : WebSocket::create(*context, WTF::move(url), protocols, std::nullopt, rejectUnauthorized ? true : false, WTF::move(proxyUrl), std::nullopt, sslConfig); + if constexpr (IsExceptionOr) RETURN_IF_EXCEPTION(throwScope, {}); + static_assert(TypeOrExceptionOrUnderlyingType::isRef); - auto jsValue = toJSNewlyCreated>(*lexicalGlobalObject, *castedThis->globalObject(), throwScope, WTF::move(object)); + auto jsValue = toJSNewlyCreated>(*lexicalGlobalObject, *globalObject, throwScope, WTF::move(object)); if constexpr (IsExceptionOr) RETURN_IF_EXCEPTION(throwScope, {}); setSubclassStructureIfNeeded(lexicalGlobalObject, callFrame, asObject(jsValue)); @@ -177,22 +283,39 @@ static inline JSC::EncodedJSValue constructJSWebSocket2(JSGlobalObject* lexicalG { auto& vm = JSC::getVM(lexicalGlobalObject); auto throwScope = DECLARE_THROW_SCOPE(vm); - auto* castedThis = jsCast(callFrame->jsCallee()); - ASSERT(castedThis); - auto* context = castedThis->scriptExecutionContext(); + auto* globalObject = jsCast(lexicalGlobalObject); + auto* context = globalObject->scriptExecutionContext(); if (!context) [[unlikely]] return throwConstructorScriptExecutionContextUnavailableError(*lexicalGlobalObject, throwScope, "WebSocket"_s); + EnsureStillAliveScope argument0 = callFrame->uncheckedArgument(0); auto url = convert(*lexicalGlobalObject, argument0.value()); RETURN_IF_EXCEPTION(throwScope, {}); + EnsureStillAliveScope argument1 = callFrame->uncheckedArgument(1); auto protocol = convert(*lexicalGlobalObject, argument1.value()); RETURN_IF_EXCEPTION(throwScope, {}); - auto object = WebSocket::create(*context, WTF::move(url), WTF::move(protocol)); + + // Apply globalAgent fallback for proxy and TLS options + String proxyUrl; + int rejectUnauthorized = -1; + void* sslConfig = nullptr; + + if (!applyGlobalAgentFallback(globalObject, vm, throwScope, proxyUrl, rejectUnauthorized, sslConfig)) + return {}; + + // Convert single protocol to Vector for the create overload that supports TLS options + Vector protocols { WTF::move(protocol) }; + + auto object = (rejectUnauthorized == -1) + ? WebSocket::create(*context, WTF::move(url), protocols, std::nullopt, WTF::move(proxyUrl), std::nullopt, sslConfig) + : WebSocket::create(*context, WTF::move(url), protocols, std::nullopt, rejectUnauthorized ? true : false, WTF::move(proxyUrl), std::nullopt, sslConfig); + if constexpr (IsExceptionOr) RETURN_IF_EXCEPTION(throwScope, {}); + static_assert(TypeOrExceptionOrUnderlyingType::isRef); - auto jsValue = toJSNewlyCreated>(*lexicalGlobalObject, *castedThis->globalObject(), throwScope, WTF::move(object)); + auto jsValue = toJSNewlyCreated>(*lexicalGlobalObject, *globalObject, throwScope, WTF::move(object)); if constexpr (IsExceptionOr) RETURN_IF_EXCEPTION(throwScope, {}); setSubclassStructureIfNeeded(lexicalGlobalObject, callFrame, asObject(jsValue)); @@ -366,9 +489,7 @@ static inline JSC::EncodedJSValue constructJSWebSocket3(JSGlobalObject* lexicalG } // 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) { + if (rejectUnauthorized == -1 || !sslConfig) { auto connectOptsValue = Bun::getOwnPropertyIfExists(globalObject, agentObj, PropertyName(Identifier::fromString(vm, "connectOpts"_s))); RETURN_IF_EXCEPTION(throwScope, {}); if (!connectOptsValue || connectOptsValue.isUndefinedOrNull()) { @@ -377,48 +498,19 @@ static inline JSC::EncodedJSValue constructJSWebSocket3(JSGlobalObject* lexicalG } 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; + // Extract rejectUnauthorized ONLY when not already set + if (rejectUnauthorized == -1) { + 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; + } } - // Parse the filtered TLS options - if (hasTlsOpts) { - sslConfig = Bun__WebSocket__parseSSLConfig(globalObject, JSValue::encode(filteredTlsOpts)); + // Parse sslConfig ONLY when not already set + // Pass the full object - SSLConfig.fromJS extracts only the properties it needs + if (!sslConfig) { + sslConfig = Bun__WebSocket__parseSSLConfig(globalObject, JSValue::encode(connectOptsValue)); RETURN_IF_EXCEPTION(throwScope, {}); } } @@ -429,6 +521,10 @@ static inline JSC::EncodedJSValue constructJSWebSocket3(JSGlobalObject* lexicalG } } + // Fallback to https.globalAgent for proxy and TLS options + if (!applyGlobalAgentFallback(globalObject, vm, throwScope, proxyUrl, rejectUnauthorized, sslConfig)) + return {}; + 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); diff --git a/src/bun.js/webcore/fetch.zig b/src/bun.js/webcore/fetch.zig index e2dba55b9e5109..0f64b52b865454 100644 --- a/src/bun.js/webcore/fetch.zig +++ b/src/bun.js/webcore/fetch.zig @@ -156,6 +156,163 @@ const StringOrURL = struct { } }; +/// Checks if an object has TLS-related properties (rejectUnauthorized, ca, cert, key, checkServerIdentity). +fn hasTLSProperties(globalThis: *JSGlobalObject, obj: JSValue) bun.JSError!bool { + const props = [_][]const u8{ "rejectUnauthorized", "ca", "cert", "key", "checkServerIdentity" }; + inline for (props) |prop| { + if (try obj.get(globalThis, prop)) |val| { + if (!val.isUndefinedOrNull()) { + return true; + } + } + } + return false; +} + +/// Gets TLS options from an agent by checking options, connectOpts, or connect properties. +fn getAgentTLSOptions(globalThis: *JSGlobalObject, agent: JSValue) bun.JSError!?JSValue { + // Try agent.options first (if it has TLS properties) + if (try agent.get(globalThis, "options")) |opts| { + if (opts.isObject()) { + if (try hasTLSProperties(globalThis, opts)) { + return opts; + } + } + } + // Fall back to connectOpts (used by https-proxy-agent) + if (try agent.get(globalThis, "connectOpts")) |connect_opts| { + if (connect_opts.isObject()) { + if (try hasTLSProperties(globalThis, connect_opts)) { + return connect_opts; + } + } + } + // Fall back to connect (used by undici.Agent) + if (try agent.get(globalThis, "connect")) |connect| { + if (connect.isObject()) { + if (try hasTLSProperties(globalThis, connect)) { + return connect; + } + } + } + return null; +} + +/// Finds agent/dispatcher from options or falls back to https.globalAgent. +/// Returns null if no agent is found. +fn findAgentOrDispatcher( + globalThis: *JSGlobalObject, + options_object: ?JSValue, + request_init_object: ?JSValue, +) bun.JSError!?JSValue { + const objects_to_try = [_]JSValue{ + options_object orelse .zero, + request_init_object orelse .zero, + }; + inline for (0..2) |i| { + if (objects_to_try[i] != .zero) { + if (try objects_to_try[i].get(globalThis, "agent")) |agent| { + if (agent.isObject()) return agent; + } + if (try objects_to_try[i].get(globalThis, "dispatcher")) |dispatcher| { + if (dispatcher.isObject()) return dispatcher; + } + } + } + const global_agent = try globalThis.getHttpsGlobalAgent(); + if (global_agent.isObject()) return global_agent; + return null; +} + +/// Finds agent/dispatcher with a proxy property from options or falls back to https.globalAgent. +/// Returns the agent and its proxy value as a tuple, or null if no agent with proxy is found. +fn findAgentWithProxy( + globalThis: *JSGlobalObject, + options_object: ?JSValue, + request_init_object: ?JSValue, +) bun.JSError!?struct { JSValue, JSValue } { + const objects_to_try = [_]JSValue{ + options_object orelse .zero, + request_init_object orelse .zero, + }; + inline for (0..2) |i| { + if (objects_to_try[i] != .zero) { + if (try objects_to_try[i].get(globalThis, "agent")) |agent| { + if (agent.isObject()) { + if (try agent.get(globalThis, "proxy")) |proxy_val| { + if (!proxy_val.isUndefinedOrNull()) { + return .{ agent, proxy_val }; + } + } + } + } + if (try objects_to_try[i].get(globalThis, "dispatcher")) |dispatcher| { + if (dispatcher.isObject()) { + if (try dispatcher.get(globalThis, "proxy")) |proxy_val| { + if (!proxy_val.isUndefinedOrNull()) { + return .{ dispatcher, proxy_val }; + } + } + } + } + } + } + const global_agent = try globalThis.getHttpsGlobalAgent(); + if (global_agent.isObject()) { + if (try global_agent.get(globalThis, "proxy")) |proxy_val| { + if (!proxy_val.isUndefinedOrNull()) { + return .{ global_agent, proxy_val }; + } + } + } + return null; +} + +/// Output struct for extractTLSSettings helper. +/// Note: check_server_identity stores an unprotected raw JSValue, but this is safe because agent_opts remain live on the JS stack for the synchronous Bun__fetch_ call, so no GC can run before the value is promoted to a Strong reference. +const TLSSettings = struct { + reject_unauthorized: ?bool = null, + check_server_identity: ?JSValue = null, + ssl_config: ?*SSLConfig = null, +}; + +/// Extracts TLS settings from agent options object. +/// Returns extracted settings on success, or null if no TLS settings found. +fn extractTLSSettings( + vm: *VirtualMachine, + globalThis: *JSGlobalObject, + agent_opts: JSValue, +) bun.JSError!TLSSettings { + var settings: TLSSettings = .{}; + + // Extract rejectUnauthorized + if (try agent_opts.get(globalThis, "rejectUnauthorized")) |reject| { + if (reject.isBoolean()) { + settings.reject_unauthorized = reject.asBoolean(); + } else if (reject.isNumber()) { + settings.reject_unauthorized = reject.to(i32) != 0; + } + } + + // Extract checkServerIdentity + if (try agent_opts.get(globalThis, "checkServerIdentity")) |csi| { + if (csi.isCell() and csi.isCallable()) { + settings.check_server_identity = csi; + } + } + + // Extract SSL config + if (SSLConfig.fromJS(vm, globalThis, agent_opts) catch { + return error.JSError; + }) |config| { + const ssl_config_object = bun.handleOom(bun.default_allocator.create(SSLConfig)); + ssl_config_object.* = config; + settings.ssl_config = ssl_config_object; + } + + return settings; +} + comptime { const Bun__fetch = jsc.toJSHostFn(Bun__fetch_); @export(&Bun__fetch, .{ .name = "Bun__fetch" }); @@ -479,6 +636,29 @@ pub fn Bun__fetch_( return .zero; } + // Fallback to agent.options/connectOpts or https.globalAgent.options/connectOpts if no TLS config was provided + // This also supports HttpsProxyAgent which uses connectOpts + if (ssl_config == null) fallback_to_agent: { + // First check for per-request agent/dispatcher option, then fall back to globalAgent + const agent = try findAgentOrDispatcher(globalThis, options_object, request_init_object) orelse break :fallback_to_agent; + const agent_opts = try getAgentTLSOptions(globalThis, agent) orelse break :fallback_to_agent; + + // Extract TLS settings from agent options using shared helper + const tls_settings = extractTLSSettings(vm, globalThis, agent_opts) catch { + is_error = true; + return .zero; + }; + if (tls_settings.reject_unauthorized) |reject| { + reject_unauthorized = reject; + } + if (tls_settings.check_server_identity) |csi| { + check_server_identity = csi; + } + if (tls_settings.ssl_config) |config| { + ssl_config = config; + } + } + // unix: string | undefined unix_socket_path = extract_unix_socket_path: { const objects_to_try = [_]JSValue{ @@ -731,6 +911,68 @@ pub fn Bun__fetch_( return .zero; } + // Fallback to agent.proxy or https.globalAgent.proxy if no proxy was provided + // This enables compatibility with https-proxy-agent and similar libraries + if (proxy == null) fallback_to_agent_proxy: { + // First check for per-request agent/dispatcher option, then fall back to globalAgent + const agent_and_proxy = try findAgentWithProxy(globalThis, options_object, request_init_object) orelse break :fallback_to_agent_proxy; + const current_agent = agent_and_proxy[0]; + const proxy_value = agent_and_proxy[1]; + + // Try to get the proxy URL - could be a string or URL object with href + var proxy_href: ?bun.String = null; + if (proxy_value.isString()) { + if ((try proxy_value.getLength(ctx)) > 0) { + proxy_href = try jsc.URL.hrefFromJS(proxy_value, globalThis); + } + } else if (proxy_value.isObject()) { + // URL object - get href property + if (try proxy_value.get(globalThis, "href")) |href_value| { + if (href_value.isString() and (try href_value.getLength(ctx)) > 0) { + proxy_href = try jsc.URL.hrefFromJS(href_value, globalThis); + } + } + } + + if (proxy_href) |href| { + if (href.tag == .Dead) { + break :fallback_to_agent_proxy; + } + defer href.deref(); + const buffer = try std.fmt.allocPrint(allocator, "{s}{f}", .{ url_proxy_buffer, href }); + url = ZigURL.parse(buffer[0..url.href.len]); + if (url.isFile()) { + url_type = URLType.file; + } else if (url.isBlob()) { + url_type = URLType.blob; + } + + proxy = ZigURL.parse(buffer[url.href.len..]); + allocator.free(url_proxy_buffer); + url_proxy_buffer = buffer; + + // When proxy is sourced from an agent/dispatcher, inherit TLS/connect options from that agent + // so per-agent TLS overrides (reject_unauthorized, check_server_identity, ssl_config) are respected. + if (ssl_config == null) { + if (try getAgentTLSOptions(globalThis, current_agent)) |connect_opts| { + const tls_settings = extractTLSSettings(vm, globalThis, connect_opts) catch { + is_error = true; + return .zero; + }; + if (tls_settings.reject_unauthorized) |reject| { + reject_unauthorized = reject; + } + if (tls_settings.check_server_identity) |csi| { + check_server_identity = csi; + } + if (tls_settings.ssl_config) |config| { + ssl_config = config; + } + } + } + } + } + // signal: AbortSignal | undefined; signal = extract_signal: { if (options_object) |options| { diff --git a/src/js/node/_http_client.ts b/src/js/node/_http_client.ts index 6e0ced4dda9b79..5e3aa4d87c5055 100644 --- a/src/js/node/_http_client.ts +++ b/src/js/node/_http_client.ts @@ -60,6 +60,19 @@ const ObjectAssign = Object.assign; const RegExpPrototypeExec = RegExp.prototype.exec; const StringPrototypeToUpperCase = String.prototype.toUpperCase; +// Helper to get TLS option from agent with fallback chain: +// agent.options -> agent.connectOpts (https-proxy-agent) -> agent.connect (undici.Agent) +// Treats null as unset to continue the fallback chain +function getAgentTlsOption(agent, key) { + const fromOptions = agent?.options?.[key]; + if (fromOptions != null) return fromOptions; + const fromConnectOpts = agent?.connectOpts?.[key]; + if (fromConnectOpts != null) return fromConnectOpts; + const fromConnect = agent?.connect?.[key]; + if (fromConnect != null) return fromConnect; + return undefined; +} + function emitErrorEventNT(self, err) { if (self.destroyed) return; if (self.listenerCount("error") > 0) { @@ -728,14 +741,18 @@ function ClientRequest(input, options, cb) { throw new Error("pfx is not supported"); } - if (options.rejectUnauthorized !== undefined) this._ensureTls().rejectUnauthorized = options.rejectUnauthorized; - else { - let agentRejectUnauthorized = agent?.options?.rejectUnauthorized; - if (agentRejectUnauthorized !== undefined) this._ensureTls().rejectUnauthorized = agentRejectUnauthorized; - else { - // popular https-proxy-agent uses connectOpts - agentRejectUnauthorized = agent?.connectOpts?.rejectUnauthorized; - if (agentRejectUnauthorized !== undefined) this._ensureTls().rejectUnauthorized = agentRejectUnauthorized; + if (options.rejectUnauthorized !== undefined) { + if (typeof options.rejectUnauthorized !== "boolean") { + throw new TypeError("rejectUnauthorized argument must be a boolean"); + } + this._ensureTls().rejectUnauthorized = options.rejectUnauthorized; + } else { + const agentRejectUnauthorized = getAgentTlsOption(agent, "rejectUnauthorized"); + if (agentRejectUnauthorized !== undefined) { + if (typeof agentRejectUnauthorized !== "boolean") { + throw new TypeError("agent TLS rejectUnauthorized option must be a boolean"); + } + this._ensureTls().rejectUnauthorized = agentRejectUnauthorized; } } if (options.ca) { @@ -744,6 +761,15 @@ function ClientRequest(input, options, cb) { "ca argument must be an string, Buffer, TypedArray, BunFile or an array containing string, Buffer, TypedArray or BunFile", ); this._ensureTls().ca = options.ca; + } else { + const agentCa = getAgentTlsOption(agent, "ca"); + if (agentCa !== undefined) { + if (!isValidTLSArray(agentCa)) + throw new TypeError( + "agent TLS ca option must be a string, Buffer, TypedArray, BunFile or an array containing string, Buffer, TypedArray or BunFile", + ); + this._ensureTls().ca = agentCa; + } } if (options.cert) { if (!isValidTLSArray(options.cert)) @@ -751,6 +777,15 @@ function ClientRequest(input, options, cb) { "cert argument must be an string, Buffer, TypedArray, BunFile or an array containing string, Buffer, TypedArray or BunFile", ); this._ensureTls().cert = options.cert; + } else { + const agentCert = getAgentTlsOption(agent, "cert"); + if (agentCert !== undefined) { + if (!isValidTLSArray(agentCert)) + throw new TypeError( + "agent TLS cert option must be a string, Buffer, TypedArray, BunFile or an array containing string, Buffer, TypedArray or BunFile", + ); + this._ensureTls().cert = agentCert; + } } if (options.key) { if (!isValidTLSArray(options.key)) @@ -758,23 +793,56 @@ function ClientRequest(input, options, cb) { "key argument must be an string, Buffer, TypedArray, BunFile or an array containing string, Buffer, TypedArray or BunFile", ); this._ensureTls().key = options.key; + } else { + const agentKey = getAgentTlsOption(agent, "key"); + if (agentKey !== undefined) { + if (!isValidTLSArray(agentKey)) + throw new TypeError( + "agent TLS key option must be a string, Buffer, TypedArray, BunFile or an array containing string, Buffer, TypedArray or BunFile", + ); + this._ensureTls().key = agentKey; + } } if (options.passphrase) { if (typeof options.passphrase !== "string") throw new TypeError("passphrase argument must be a string"); this._ensureTls().passphrase = options.passphrase; + } else { + const agentPassphrase = getAgentTlsOption(agent, "passphrase"); + if (agentPassphrase !== undefined) { + if (typeof agentPassphrase !== "string") throw new TypeError("agent TLS passphrase option must be a string"); + this._ensureTls().passphrase = agentPassphrase; + } } if (options.ciphers) { if (typeof options.ciphers !== "string") throw new TypeError("ciphers argument must be a string"); this._ensureTls().ciphers = options.ciphers; + } else { + const agentCiphers = getAgentTlsOption(agent, "ciphers"); + if (agentCiphers !== undefined) { + if (typeof agentCiphers !== "string") throw new TypeError("agent TLS ciphers option must be a string"); + this._ensureTls().ciphers = agentCiphers; + } } if (options.servername) { if (typeof options.servername !== "string") throw new TypeError("servername argument must be a string"); this._ensureTls().servername = options.servername; + } else { + const agentServername = getAgentTlsOption(agent, "servername"); + if (agentServername !== undefined) { + if (typeof agentServername !== "string") throw new TypeError("agent TLS servername option must be a string"); + this._ensureTls().servername = agentServername; + } } - - if (options.secureOptions) { - if (typeof options.secureOptions !== "number") throw new TypeError("secureOptions argument must be a string"); + if (options.secureOptions !== undefined) { + if (typeof options.secureOptions !== "number") throw new TypeError("secureOptions argument must be a number"); this._ensureTls().secureOptions = options.secureOptions; + } else { + const agentSecureOptions = getAgentTlsOption(agent, "secureOptions"); + if (agentSecureOptions !== undefined) { + if (typeof agentSecureOptions !== "number") + throw new TypeError("agent TLS secureOptions option must be a number"); + this._ensureTls().secureOptions = agentSecureOptions; + } } this[kPath] = options.path || "/"; if (cb) { diff --git a/src/js/thirdparty/undici.js b/src/js/thirdparty/undici.js index 3567f35c41ecd7..e93df24c9a72aa 100644 --- a/src/js/thirdparty/undici.js +++ b/src/js/thirdparty/undici.js @@ -4,9 +4,19 @@ const { Readable } = StreamModule; const { _ReadableFromWeb: ReadableFromWeb } = require("internal/webstreams_adapters"); const ObjectCreate = Object.create; +const ObjectDefineProperty = Object.defineProperty; const kEmptyObject = ObjectCreate(null); -var fetch = Bun.fetch; +const bunFetch = Bun.fetch; + +// Wrapper fetch that uses globalDispatcher when set +function fetch(input, init) { + // If no dispatcher/agent is provided and globalDispatcher is set, use it + if (globalDispatcher && init?.dispatcher === undefined && init?.agent === undefined) { + return bunFetch(input, { ...init, dispatcher: globalDispatcher }); + } + return bunFetch(input, init); +} const bindings = $cpp("Undici.cpp", "createUndiciInternalBinding"); const Response = bindings[0]; const Request = bindings[1]; @@ -262,33 +272,80 @@ class MockAgent { function mockErrors() {} -class Dispatcher extends EventEmitter {} -class Agent extends Dispatcher {} +class Dispatcher extends EventEmitter { + constructor(options) { + super(); + // Bun TLS fallback shim: Store options non-enumerably for fetch() to extract + // TLS settings (rejectUnauthorized, ca, cert, key, etc.). This differs from + // upstream undici which doesn't expose these properties. + if (options) { + ObjectDefineProperty(this, "options", { + value: options, + writable: true, + enumerable: false, + configurable: true, + }); + if (options.connect) { + ObjectDefineProperty(this, "connect", { + value: options.connect, + writable: true, + enumerable: false, + configurable: true, + }); + } + } + } +} + +class Agent extends Dispatcher { + constructor(options) { + super(options); + } +} + class Pool extends Dispatcher { + constructor(url, options) { + super(options); + this.origin = url; + } request() {} } -class BalancedPool extends Dispatcher {} + +class BalancedPool extends Dispatcher { + constructor(upstreams, options) { + super(options); + this.upstreams = upstreams; + } +} + class Client extends Dispatcher { + constructor(url, options) { + super(options); + this.url = url; + } request() {} } -class DispatcherBase extends EventEmitter {} +class DispatcherBase extends Dispatcher { + // Inherits options/connect handling from Dispatcher +} class ProxyAgent extends DispatcherBase { - constructor() { - super(); + constructor(options) { + super(options); } } class EnvHttpProxyAgent extends DispatcherBase { - constructor() { - super(); + constructor(options) { + super(options); } } class RetryAgent extends Dispatcher { - constructor() { - super(); + constructor(dispatcher, options) { + super(options); + this.dispatcher = dispatcher; } } diff --git a/test/js/first_party/undici/undici-properties.test.ts b/test/js/first_party/undici/undici-properties.test.ts new file mode 100644 index 00000000000000..147aed9ff50fbd --- /dev/null +++ b/test/js/first_party/undici/undici-properties.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; + +describe("undici class properties", () => { + test("undici classes store options and connect properties correctly", async () => { + const script = ` + const { Agent, Dispatcher, Pool, Client, ProxyAgent, EnvHttpProxyAgent, RetryAgent } = require('undici'); + + // Test Agent + const agent = new Agent({ + connect: { + rejectUnauthorized: false, + ca: 'test-ca', + }, + }); + + if (!agent.options) { + console.error('Agent.options is missing'); + process.exit(1); + } + if (!agent.connect) { + console.error('Agent.connect is missing'); + process.exit(1); + } + if (agent.connect.rejectUnauthorized !== false) { + console.error('Agent.connect.rejectUnauthorized is not false'); + process.exit(1); + } + if (agent.connect.ca !== 'test-ca') { + console.error('Agent.connect.ca is not test-ca'); + process.exit(1); + } + + // Test Dispatcher + const dispatcher = new Dispatcher({ + connect: { + rejectUnauthorized: false, + }, + }); + if (!dispatcher.options || !dispatcher.connect) { + console.error('Dispatcher options/connect missing'); + process.exit(1); + } + + // Test Pool + const pool = new Pool('http://localhost', { + connect: { + rejectUnauthorized: false, + }, + }); + if (!pool.options || !pool.connect) { + console.error('Pool options/connect missing'); + process.exit(1); + } + + // Test Client + const client = new Client('http://localhost', { + connect: { + rejectUnauthorized: false, + }, + }); + if (!client.options || !client.connect) { + console.error('Client options/connect missing'); + process.exit(1); + } + + // Test ProxyAgent + const proxyAgent = new ProxyAgent({ + connect: { + rejectUnauthorized: false, + }, + }); + if (!proxyAgent.options || !proxyAgent.connect) { + console.error('ProxyAgent options/connect missing'); + process.exit(1); + } + + // Test EnvHttpProxyAgent + const envAgent = new EnvHttpProxyAgent({ + connect: { + rejectUnauthorized: false, + }, + }); + if (!envAgent.options || !envAgent.connect) { + console.error('EnvHttpProxyAgent options/connect missing'); + process.exit(1); + } + + // Test RetryAgent - also test that dispatcher is stored + const retryAgent = new RetryAgent(dispatcher, { + connect: { + rejectUnauthorized: false, + }, + }); + if (!retryAgent.options || !retryAgent.connect) { + console.error('RetryAgent options/connect missing'); + process.exit(1); + } + if (retryAgent.dispatcher !== dispatcher) { + console.error('RetryAgent.dispatcher should reference the passed dispatcher'); + process.exit(1); + } + + // Test empty constructor + const emptyAgent = new Agent(); + if (emptyAgent.options !== undefined || emptyAgent.connect !== undefined) { + console.error('Empty Agent should have undefined options/connect'); + process.exit(1); + } + + console.log('All undici classes store options correctly'); + `; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("All undici classes store options correctly"); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/first_party/undici/undici.test.ts b/test/js/first_party/undici/undici.test.ts index 6619c45092201e..3f8585e1f2f175 100644 --- a/test/js/first_party/undici/undici.test.ts +++ b/test/js/first_party/undici/undici.test.ts @@ -155,4 +155,123 @@ describe("undici", () => { // expect(json.form.foo).toBe("bar"); // }); }); + + describe("dispatcher TLS options", () => { + // Import once at describe level + const { + Agent, + Dispatcher, + Pool, + BalancedPool, + Client, + ProxyAgent, + EnvHttpProxyAgent, + RetryAgent, + } = require("undici"); + + // Shared connect options for tests that verify ca + const connectOpts = { rejectUnauthorized: false, ca: "test-ca" }; + + it("Agent should store connect options", () => { + const agent = new Agent({ connect: connectOpts }); + + expect(agent.options).toBeDefined(); + expect(agent.connect).toBeDefined(); + expect(agent.connect.rejectUnauthorized).toBe(false); + expect(agent.connect.ca).toBe("test-ca"); + }); + + it("Dispatcher should store connect options", () => { + const dispatcher = new Dispatcher({ + connect: { + rejectUnauthorized: false, + }, + }); + + expect(dispatcher.options).toBeDefined(); + expect(dispatcher.connect).toBeDefined(); + expect(dispatcher.connect.rejectUnauthorized).toBe(false); + }); + + it("Pool should store connect options", () => { + const pool = new Pool(hostUrl, { connect: connectOpts }); + + expect(pool.options).toBeDefined(); + expect(pool.connect).toBeDefined(); + expect(pool.connect.rejectUnauthorized).toBe(false); + expect(pool.connect.ca).toBe("test-ca"); + }); + + it("BalancedPool should store connect options", () => { + const balancedPool = new BalancedPool([hostUrl], { + connect: { + rejectUnauthorized: false, + }, + }); + + expect(balancedPool.options).toBeDefined(); + expect(balancedPool.connect).toBeDefined(); + expect(balancedPool.connect.rejectUnauthorized).toBe(false); + }); + + it("Client should store connect options", () => { + const client = new Client(hostUrl, { connect: connectOpts }); + + expect(client.options).toBeDefined(); + expect(client.connect).toBeDefined(); + expect(client.connect.rejectUnauthorized).toBe(false); + expect(client.connect.ca).toBe("test-ca"); + }); + + it("ProxyAgent should store connect options", () => { + const proxyAgent = new ProxyAgent({ + connect: { + rejectUnauthorized: false, + }, + }); + + expect(proxyAgent.options).toBeDefined(); + expect(proxyAgent.connect).toBeDefined(); + expect(proxyAgent.connect.rejectUnauthorized).toBe(false); + }); + + it("EnvHttpProxyAgent should store connect options", () => { + const envAgent = new EnvHttpProxyAgent({ + connect: { + rejectUnauthorized: false, + }, + }); + + expect(envAgent.options).toBeDefined(); + expect(envAgent.connect).toBeDefined(); + expect(envAgent.connect.rejectUnauthorized).toBe(false); + }); + + it("RetryAgent should store connect options", () => { + const baseDispatcher = new Dispatcher(); + const retryAgent = new RetryAgent(baseDispatcher, { + connect: { + rejectUnauthorized: false, + }, + }); + + expect(retryAgent.options).toBeDefined(); + expect(retryAgent.connect).toBeDefined(); + expect(retryAgent.connect.rejectUnauthorized).toBe(false); + }); + + it("Agent without options should have undefined connect", () => { + const agent = new Agent(); + + expect(agent.options).toBeUndefined(); + expect(agent.connect).toBeUndefined(); + }); + + it("Agent with options but no connect should not have connect", () => { + const agent = new Agent({ someOtherOption: true }); + + expect(agent.options).toBeDefined(); + expect(agent.connect).toBeUndefined(); + }); + }); }); diff --git a/test/js/node/tls/globalAgent-fetch.test.ts b/test/js/node/tls/globalAgent-fetch.test.ts new file mode 100644 index 00000000000000..ddb862d06ad139 --- /dev/null +++ b/test/js/node/tls/globalAgent-fetch.test.ts @@ -0,0 +1,357 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "fs"; +import { bunRun, tempDir } from "harness"; +import { join } from "path"; + +// Server certificates +const serverKey = readFileSync(join(import.meta.dir, "fixtures", "agent1-key.pem"), "utf8"); +const serverCert = readFileSync(join(import.meta.dir, "fixtures", "agent1-cert.pem"), "utf8"); + +// CA that signed the server cert +const ca1 = readFileSync(join(import.meta.dir, "fixtures", "ca1-cert.pem"), "utf8"); + +describe.concurrent("fetch uses globalAgent.options as fallback", () => { + test("uses globalAgent.options.rejectUnauthorized for fetch", () => { + using dir = tempDir("test-fetch-reject", { + "key.pem": serverKey, + "cert.pem": serverCert, + "test.js": ` + const https = require('https'); + const fs = require('fs'); + + const serverTls = { + key: fs.readFileSync('./key.pem', 'utf8'), + cert: fs.readFileSync('./cert.pem', 'utf8'), + }; + + const server = https.createServer(serverTls, (req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('Hello from fetch'); + }); + + server.listen(0, '127.0.0.1', async () => { + const port = server.address().port; + + // Set globalAgent.options.rejectUnauthorized to false + https.globalAgent.options.rejectUnauthorized = false; + + try { + const response = await fetch(\`https://127.0.0.1:\${port}/\`); + const text = await response.text(); + console.log(text); + server.close(); + process.exit(text === 'Hello from fetch' ? 0 : 1); + } catch (err) { + console.error(err.message); + server.close(); + process.exit(1); + } + }); + `, + }); + + const { stdout } = bunRun(join(String(dir), "test.js")); + expect(stdout).toBe("Hello from fetch"); + }); + + test("uses globalAgent.options.ca for fetch requests", () => { + using dir = tempDir("test-fetch-ca", { + "key.pem": serverKey, + "cert.pem": serverCert, + "ca.pem": ca1, + "test.js": ` + const https = require('https'); + const fs = require('fs'); + + const serverTls = { + key: fs.readFileSync('./key.pem', 'utf8'), + cert: fs.readFileSync('./cert.pem', 'utf8'), + }; + const ca = fs.readFileSync('./ca.pem', 'utf8'); + + const server = https.createServer(serverTls, (req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('Hello with CA'); + }); + + server.listen(0, '127.0.0.1', async () => { + const port = server.address().port; + + // Set globalAgent.options with CA and checkServerIdentity + https.globalAgent.options.ca = ca; + https.globalAgent.options.rejectUnauthorized = true; + https.globalAgent.options.checkServerIdentity = () => {}; + + try { + const response = await fetch(\`https://127.0.0.1:\${port}/\`); + const text = await response.text(); + console.log(text); + server.close(); + process.exit(text === 'Hello with CA' ? 0 : 1); + } catch (err) { + console.error(err.message); + server.close(); + process.exit(1); + } + }); + `, + }); + + const { stdout } = bunRun(join(String(dir), "test.js")); + expect(stdout).toBe("Hello with CA"); + }); + + test("per-request tls options override globalAgent.options in fetch", () => { + using dir = tempDir("test-fetch-override", { + "key.pem": serverKey, + "cert.pem": serverCert, + "test.js": ` + const https = require('https'); + const fs = require('fs'); + + const serverTls = { + key: fs.readFileSync('./key.pem', 'utf8'), + cert: fs.readFileSync('./cert.pem', 'utf8'), + }; + + const server = https.createServer(serverTls, (req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('Hello override'); + }); + + server.listen(0, '127.0.0.1', async () => { + const port = server.address().port; + + // Set globalAgent.options.rejectUnauthorized to true (would fail without CA) + https.globalAgent.options.rejectUnauthorized = true; + + try { + // Override per-request + const response = await fetch(\`https://127.0.0.1:\${port}/\`, { + tls: { + rejectUnauthorized: false, + }, + }); + const text = await response.text(); + console.log(text); + server.close(); + process.exit(text === 'Hello override' ? 0 : 1); + } catch (err) { + console.error(err.message); + server.close(); + process.exit(1); + } + }); + `, + }); + + const { stdout } = bunRun(join(String(dir), "test.js")); + expect(stdout).toBe("Hello override"); + }); + + test("uses globalAgent.connectOpts for fetch (HttpsProxyAgent compatibility)", () => { + using dir = tempDir("test-fetch-connectOpts", { + "key.pem": serverKey, + "cert.pem": serverCert, + "test.js": ` + const https = require('https'); + const fs = require('fs'); + + const serverTls = { + key: fs.readFileSync('./key.pem', 'utf8'), + cert: fs.readFileSync('./cert.pem', 'utf8'), + }; + + const server = https.createServer(serverTls, (req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('Hello from connectOpts'); + }); + + server.listen(0, '127.0.0.1', async () => { + const port = server.address().port; + + // Set connectOpts on globalAgent (like HttpsProxyAgent does) + https.globalAgent.connectOpts = { + rejectUnauthorized: false, + }; + + try { + const response = await fetch(\`https://127.0.0.1:\${port}/\`); + const text = await response.text(); + console.log(text); + server.close(); + process.exit(text === 'Hello from connectOpts' ? 0 : 1); + } catch (err) { + console.error(err.message); + server.close(); + process.exit(1); + } + }); + `, + }); + + const { stdout } = bunRun(join(String(dir), "test.js")); + expect(stdout).toBe("Hello from connectOpts"); + }); +}); + +describe.concurrent("fetch uses agent/dispatcher option for TLS fallback", () => { + test("per-request agent.connectOpts takes precedence over globalAgent.options", () => { + using dir = tempDir("test-fetch-agent-connectOpts", { + "key.pem": serverKey, + "cert.pem": serverCert, + "test.js": ` + const https = require('https'); + const fs = require('fs'); + + const serverTls = { + key: fs.readFileSync('./key.pem', 'utf8'), + cert: fs.readFileSync('./cert.pem', 'utf8'), + }; + + const server = https.createServer(serverTls, (req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('Hello with agent TLS'); + }); + + server.listen(0, '127.0.0.1', async () => { + const port = server.address().port; + + // globalAgent.options has rejectUnauthorized: true (would fail without CA) + https.globalAgent.options.rejectUnauthorized = true; + + // Create an agent with connectOpts that allows self-signed certs + const myAgent = { + connectOpts: { + rejectUnauthorized: false, + }, + }; + + try { + // Pass agent option - should use myAgent.connectOpts instead of globalAgent.options + const response = await fetch(\`https://127.0.0.1:\${port}/\`, { + agent: myAgent, + }); + const text = await response.text(); + console.log(text); + server.close(); + process.exit(text === 'Hello with agent TLS' ? 0 : 1); + } catch (err) { + console.error(err.message); + server.close(); + process.exit(1); + } + }); + `, + }); + + const { stdout } = bunRun(join(String(dir), "test.js")); + expect(stdout).toBe("Hello with agent TLS"); + }); + + test("dispatcher option works for TLS fallback (undici compatibility)", () => { + using dir = tempDir("test-fetch-dispatcher", { + "key.pem": serverKey, + "cert.pem": serverCert, + "test.js": ` + const https = require('https'); + const fs = require('fs'); + + const serverTls = { + key: fs.readFileSync('./key.pem', 'utf8'), + cert: fs.readFileSync('./cert.pem', 'utf8'), + }; + + const server = https.createServer(serverTls, (req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('Hello with dispatcher TLS'); + }); + + server.listen(0, '127.0.0.1', async () => { + const port = server.address().port; + + // globalAgent.options has rejectUnauthorized: true (would fail without CA) + https.globalAgent.options.rejectUnauthorized = true; + + // Create a dispatcher (undici-style) with connectOpts + const myDispatcher = { + connectOpts: { + rejectUnauthorized: false, + }, + }; + + try { + // Pass dispatcher option - should use myDispatcher.connectOpts + const response = await fetch(\`https://127.0.0.1:\${port}/\`, { + dispatcher: myDispatcher, + }); + const text = await response.text(); + console.log(text); + server.close(); + process.exit(text === 'Hello with dispatcher TLS' ? 0 : 1); + } catch (err) { + console.error(err.message); + server.close(); + process.exit(1); + } + }); + `, + }); + + const { stdout } = bunRun(join(String(dir), "test.js")); + expect(stdout).toBe("Hello with dispatcher TLS"); + }); + + test("dispatcher.connect option works for TLS fallback (undici.Agent compatibility)", () => { + using dir = tempDir("test-fetch-dispatcher-connect", { + "key.pem": serverKey, + "cert.pem": serverCert, + "test.js": ` + const https = require('https'); + const fs = require('fs'); + + const serverTls = { + key: fs.readFileSync('./key.pem', 'utf8'), + cert: fs.readFileSync('./cert.pem', 'utf8'), + }; + + const server = https.createServer(serverTls, (req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('Hello with undici connect'); + }); + + server.listen(0, '127.0.0.1', async () => { + const port = server.address().port; + + // globalAgent.options has rejectUnauthorized: true (would fail without CA) + https.globalAgent.options.rejectUnauthorized = true; + + // Create a dispatcher using undici.Agent style with connect property + const myDispatcher = { + connect: { + rejectUnauthorized: false, + }, + }; + + try { + // Pass dispatcher option - should use myDispatcher.connect + const response = await fetch(\`https://127.0.0.1:\${port}/\`, { + dispatcher: myDispatcher, + }); + const text = await response.text(); + console.log(text); + server.close(); + process.exit(text === 'Hello with undici connect' ? 0 : 1); + } catch (err) { + console.error(err.message); + server.close(); + process.exit(1); + } + }); + `, + }); + + const { stdout } = bunRun(join(String(dir), "test.js")); + expect(stdout).toBe("Hello with undici connect"); + }); +}); diff --git a/test/js/node/tls/globalAgent-https-request.test.ts b/test/js/node/tls/globalAgent-https-request.test.ts new file mode 100644 index 00000000000000..098cbcb77b7011 --- /dev/null +++ b/test/js/node/tls/globalAgent-https-request.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "fs"; +import { bunRun, tempDir } from "harness"; +import { join } from "path"; + +// Server certificates +const serverKey = readFileSync(join(import.meta.dir, "fixtures", "agent1-key.pem"), "utf8"); +const serverCert = readFileSync(join(import.meta.dir, "fixtures", "agent1-cert.pem"), "utf8"); + +describe.concurrent("https.request uses globalAgent.options", () => { + test("uses globalAgent.options.rejectUnauthorized when no per-request option is provided", () => { + using dir = tempDir("test-globalAgent-reject", { + "key.pem": serverKey, + "cert.pem": serverCert, + "test.js": ` + const https = require('https'); + const fs = require('fs'); + + const serverTls = { + key: fs.readFileSync('./key.pem', 'utf8'), + cert: fs.readFileSync('./cert.pem', 'utf8'), + }; + + const server = https.createServer(serverTls, (req, res) => { + res.writeHead(200); + res.end('Hello'); + }); + + server.listen(0, '127.0.0.1', () => { + const port = server.address().port; + + // Set globalAgent.options.rejectUnauthorized to false + // This allows the request to succeed without CA verification + https.globalAgent.options.rejectUnauthorized = false; + + https.get({ + hostname: '127.0.0.1', + port, + path: '/', + }, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + console.log(data); + server.close(); + process.exit(data === 'Hello' ? 0 : 1); + }); + }).on('error', (err) => { + console.error(err.message); + server.close(); + process.exit(1); + }); + }); + `, + }); + + const { stdout } = bunRun(join(String(dir), "test.js")); + expect(stdout).toBe("Hello"); + }); + + test("per-request rejectUnauthorized overrides globalAgent.options", () => { + using dir = tempDir("test-globalAgent-override-reject", { + "key.pem": serverKey, + "cert.pem": serverCert, + "test.js": ` + const https = require('https'); + const fs = require('fs'); + + const serverTls = { + key: fs.readFileSync('./key.pem', 'utf8'), + cert: fs.readFileSync('./cert.pem', 'utf8'), + }; + + const server = https.createServer(serverTls, (req, res) => { + res.writeHead(200); + res.end('Hello'); + }); + + server.listen(0, '127.0.0.1', () => { + const port = server.address().port; + + // Set globalAgent.options.rejectUnauthorized to true (would fail) + https.globalAgent.options.rejectUnauthorized = true; + + // Override per-request with false (should succeed) + https.get({ + hostname: '127.0.0.1', + port, + path: '/', + rejectUnauthorized: false, // Override + }, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + console.log(data); + server.close(); + process.exit(data === 'Hello' ? 0 : 1); + }); + }).on('error', (err) => { + console.error(err.message); + server.close(); + process.exit(1); + }); + }); + `, + }); + + const { stdout } = bunRun(join(String(dir), "test.js")); + expect(stdout).toBe("Hello"); + }); + + test("uses agent.connectOpts.rejectUnauthorized as fallback", () => { + using dir = tempDir("test-connectOpts-reject", { + "key.pem": serverKey, + "cert.pem": serverCert, + "test.js": ` + const https = require('https'); + const fs = require('fs'); + + const serverTls = { + key: fs.readFileSync('./key.pem', 'utf8'), + cert: fs.readFileSync('./cert.pem', 'utf8'), + }; + + const server = https.createServer(serverTls, (req, res) => { + res.writeHead(200); + res.end('Hello'); + }); + + server.listen(0, '127.0.0.1', () => { + const port = server.address().port; + + // Use connectOpts instead of options (used by https-proxy-agent) + const agent = new https.Agent(); + agent.connectOpts = { + rejectUnauthorized: false, + }; + + https.get({ + hostname: '127.0.0.1', + port, + path: '/', + agent, + }, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + console.log(data); + server.close(); + process.exit(data === 'Hello' ? 0 : 1); + }); + }).on('error', (err) => { + console.error(err.message); + server.close(); + process.exit(1); + }); + }); + `, + }); + + const { stdout } = bunRun(join(String(dir), "test.js")); + expect(stdout).toBe("Hello"); + }); + + test("uses agent.connect.rejectUnauthorized (undici.Agent compatibility)", () => { + using dir = tempDir("test-https-agent-connect", { + "key.pem": serverKey, + "cert.pem": serverCert, + "test.js": ` + const https = require('https'); + const fs = require('fs'); + + const serverTls = { + key: fs.readFileSync('./key.pem', 'utf8'), + cert: fs.readFileSync('./cert.pem', 'utf8'), + }; + + const server = https.createServer(serverTls, (req, res) => { + res.writeHead(200); + res.end('Hello from connect'); + }); + + server.listen(0, '127.0.0.1', () => { + const port = server.address().port; + + // Use connect (undici.Agent style) instead of connectOpts + const agent = new https.Agent(); + agent.connect = { + rejectUnauthorized: false, + }; + + https.get({ + hostname: '127.0.0.1', + port, + path: '/', + agent, + }, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + console.log(data); + server.close(); + process.exit(data === 'Hello from connect' ? 0 : 1); + }); + }).on('error', (err) => { + console.error(err.message); + server.close(); + process.exit(1); + }); + }); + `, + }); + + const { stdout } = bunRun(join(String(dir), "test.js")); + expect(stdout).toBe("Hello from connect"); + }); +}); diff --git a/test/js/node/tls/globalAgent-undici.test.ts b/test/js/node/tls/globalAgent-undici.test.ts new file mode 100644 index 00000000000000..20b9c18334291d --- /dev/null +++ b/test/js/node/tls/globalAgent-undici.test.ts @@ -0,0 +1,408 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "fs"; +import { bunRun, tempDir } from "harness"; +import { join } from "path"; + +// Server certificates +const serverKey = readFileSync(join(import.meta.dir, "fixtures", "agent1-key.pem"), "utf8"); +const serverCert = readFileSync(join(import.meta.dir, "fixtures", "agent1-cert.pem"), "utf8"); + +describe.concurrent("undici module integration", () => { + test("undici.Agent with connect options works with Bun's fetch", () => { + using dir = tempDir("test-undici-agent-fetch", { + "key.pem": serverKey, + "cert.pem": serverCert, + "test.js": ` + const https = require('https'); + const fs = require('fs'); + const { Agent } = require('undici'); + + const serverTls = { + key: fs.readFileSync('./key.pem', 'utf8'), + cert: fs.readFileSync('./cert.pem', 'utf8'), + }; + + const server = https.createServer(serverTls, (req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('Hello from undici.Agent'); + }); + + server.listen(0, '127.0.0.1', async () => { + const port = server.address().port; + + // Create undici.Agent with connect options + const agent = new Agent({ + connect: { + rejectUnauthorized: false, + }, + }); + + try { + // Use Bun's fetch with undici.Agent as dispatcher + const response = await fetch(\`https://127.0.0.1:\${port}/\`, { + dispatcher: agent, + }); + const text = await response.text(); + console.log(text); + server.close(); + process.exit(text === 'Hello from undici.Agent' ? 0 : 1); + } catch (err) { + console.error(err.message); + server.close(); + process.exit(1); + } + }); + `, + }); + + const { stdout } = bunRun(join(String(dir), "test.js")); + expect(stdout).toBe("Hello from undici.Agent"); + }); + + test("undici.Pool with connect options works with Bun's fetch", () => { + using dir = tempDir("test-undici-pool-fetch", { + "key.pem": serverKey, + "cert.pem": serverCert, + "test.js": ` + const https = require('https'); + const fs = require('fs'); + const { Pool } = require('undici'); + + const serverTls = { + key: fs.readFileSync('./key.pem', 'utf8'), + cert: fs.readFileSync('./cert.pem', 'utf8'), + }; + + const server = https.createServer(serverTls, (req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('Hello from undici.Pool'); + }); + + server.listen(0, '127.0.0.1', async () => { + const port = server.address().port; + + // Create undici.Pool with connect options + const pool = new Pool(\`https://127.0.0.1:\${port}\`, { + connect: { + rejectUnauthorized: false, + }, + }); + + try { + // Use Bun's fetch with undici.Pool as dispatcher + const response = await fetch(\`https://127.0.0.1:\${port}/\`, { + dispatcher: pool, + }); + const text = await response.text(); + console.log(text); + server.close(); + process.exit(text === 'Hello from undici.Pool' ? 0 : 1); + } catch (err) { + console.error(err.message); + server.close(); + process.exit(1); + } + }); + `, + }); + + const { stdout } = bunRun(join(String(dir), "test.js")); + expect(stdout).toBe("Hello from undici.Pool"); + }); + + test("undici.Client with connect options works with Bun's fetch", () => { + using dir = tempDir("test-undici-client-fetch", { + "key.pem": serverKey, + "cert.pem": serverCert, + "test.js": ` + const https = require('https'); + const fs = require('fs'); + const { Client } = require('undici'); + + const serverTls = { + key: fs.readFileSync('./key.pem', 'utf8'), + cert: fs.readFileSync('./cert.pem', 'utf8'), + }; + + const server = https.createServer(serverTls, (req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('Hello from undici.Client'); + }); + + server.listen(0, '127.0.0.1', async () => { + const port = server.address().port; + + // Create undici.Client with connect options + const client = new Client(\`https://127.0.0.1:\${port}\`, { + connect: { + rejectUnauthorized: false, + }, + }); + + try { + // Use Bun's fetch with undici.Client as dispatcher + const response = await fetch(\`https://127.0.0.1:\${port}/\`, { + dispatcher: client, + }); + const text = await response.text(); + console.log(text); + server.close(); + process.exit(text === 'Hello from undici.Client' ? 0 : 1); + } catch (err) { + console.error(err.message); + server.close(); + process.exit(1); + } + }); + `, + }); + + const { stdout } = bunRun(join(String(dir), "test.js")); + expect(stdout).toBe("Hello from undici.Client"); + }); + + test("undici.ProxyAgent with connect options works with Bun's fetch", () => { + using dir = tempDir("test-undici-proxyagent-fetch", { + "key.pem": serverKey, + "cert.pem": serverCert, + "test.js": ` + const https = require('https'); + const fs = require('fs'); + const { ProxyAgent } = require('undici'); + + const serverTls = { + key: fs.readFileSync('./key.pem', 'utf8'), + cert: fs.readFileSync('./cert.pem', 'utf8'), + }; + + const server = https.createServer(serverTls, (req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('Hello from undici.ProxyAgent'); + }); + + server.listen(0, '127.0.0.1', async () => { + const port = server.address().port; + + // Create undici.ProxyAgent with connect options + const proxyAgent = new ProxyAgent({ + connect: { + rejectUnauthorized: false, + }, + }); + + try { + // Use Bun's fetch with undici.ProxyAgent as dispatcher + const response = await fetch(\`https://127.0.0.1:\${port}/\`, { + dispatcher: proxyAgent, + }); + const text = await response.text(); + console.log(text); + server.close(); + process.exit(text === 'Hello from undici.ProxyAgent' ? 0 : 1); + } catch (err) { + console.error(err.message); + server.close(); + process.exit(1); + } + }); + `, + }); + + const { stdout } = bunRun(join(String(dir), "test.js")); + expect(stdout).toBe("Hello from undici.ProxyAgent"); + }); + + test("undici.fetch uses https.globalAgent.options as fallback", () => { + using dir = tempDir("test-undici-fetch-globalagent", { + "key.pem": serverKey, + "cert.pem": serverCert, + "test.js": ` + const https = require('https'); + const fs = require('fs'); + const { fetch: undiciFetch } = require('undici'); + + const serverTls = { + key: fs.readFileSync('./key.pem', 'utf8'), + cert: fs.readFileSync('./cert.pem', 'utf8'), + }; + + const server = https.createServer(serverTls, (req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('Hello from undici.fetch'); + }); + + server.listen(0, '127.0.0.1', async () => { + const port = server.address().port; + + // Set globalAgent.options.rejectUnauthorized to false + https.globalAgent.options.rejectUnauthorized = false; + + try { + // Use undici.fetch - should use globalAgent.options as fallback + const response = await undiciFetch(\`https://127.0.0.1:\${port}/\`); + const text = await response.text(); + console.log(text); + server.close(); + process.exit(text === 'Hello from undici.fetch' ? 0 : 1); + } catch (err) { + console.error(err.message); + server.close(); + process.exit(1); + } + }); + `, + }); + + const { stdout } = bunRun(join(String(dir), "test.js")); + expect(stdout).toBe("Hello from undici.fetch"); + }); + + test("undici.fetch with dispatcher uses dispatcher.connect for TLS", () => { + using dir = tempDir("test-undici-fetch-dispatcher", { + "key.pem": serverKey, + "cert.pem": serverCert, + "test.js": ` + const https = require('https'); + const fs = require('fs'); + const { fetch: undiciFetch, Agent } = require('undici'); + + const serverTls = { + key: fs.readFileSync('./key.pem', 'utf8'), + cert: fs.readFileSync('./cert.pem', 'utf8'), + }; + + const server = https.createServer(serverTls, (req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('Hello from undici.fetch with Agent'); + }); + + server.listen(0, '127.0.0.1', async () => { + const port = server.address().port; + + // globalAgent.options has rejectUnauthorized: true (would fail) + https.globalAgent.options.rejectUnauthorized = true; + + // Create undici.Agent with connect options + const agent = new Agent({ + connect: { + rejectUnauthorized: false, + }, + }); + + try { + // Use undici.fetch with dispatcher + const response = await undiciFetch(\`https://127.0.0.1:\${port}/\`, { + dispatcher: agent, + }); + const text = await response.text(); + console.log(text); + server.close(); + process.exit(text === 'Hello from undici.fetch with Agent' ? 0 : 1); + } catch (err) { + console.error(err.message); + server.close(); + process.exit(1); + } + }); + `, + }); + + const { stdout } = bunRun(join(String(dir), "test.js")); + expect(stdout).toBe("Hello from undici.fetch with Agent"); + }); + + test("undici.setGlobalDispatcher affects fetch TLS options", () => { + using dir = tempDir("test-undici-setglobaldispatcher", { + "key.pem": serverKey, + "cert.pem": serverCert, + "test.js": ` + const https = require('https'); + const fs = require('fs'); + const { Agent, setGlobalDispatcher, fetch: undiciFetch } = require('undici'); + + const serverTls = { + key: fs.readFileSync('./key.pem', 'utf8'), + cert: fs.readFileSync('./cert.pem', 'utf8'), + }; + + const server = https.createServer(serverTls, (req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('Hello from global dispatcher'); + }); + + server.listen(0, '127.0.0.1', async () => { + const port = server.address().port; + + // Create undici.Agent with connect options and set as global dispatcher + const agent = new Agent({ + connect: { + rejectUnauthorized: false, + }, + }); + setGlobalDispatcher(agent); + + try { + // Use undici.fetch - should use global dispatcher's connect options + const response = await undiciFetch(\`https://127.0.0.1:\${port}/\`); + const text = await response.text(); + console.log(text); + server.close(); + process.exit(text === 'Hello from global dispatcher' ? 0 : 1); + } catch (err) { + console.error(err.message); + server.close(); + process.exit(1); + } + }); + `, + }); + + const { stdout } = bunRun(join(String(dir), "test.js")); + expect(stdout).toBe("Hello from global dispatcher"); + }); + + test("undici.request uses https.globalAgent.options as fallback", () => { + using dir = tempDir("test-undici-request-globalagent", { + "key.pem": serverKey, + "cert.pem": serverCert, + "test.js": ` + const https = require('https'); + const fs = require('fs'); + const { request } = require('undici'); + + const serverTls = { + key: fs.readFileSync('./key.pem', 'utf8'), + cert: fs.readFileSync('./cert.pem', 'utf8'), + }; + + const server = https.createServer(serverTls, (req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('Hello from undici.request'); + }); + + server.listen(0, '127.0.0.1', async () => { + const port = server.address().port; + + // Set globalAgent.options.rejectUnauthorized to false + https.globalAgent.options.rejectUnauthorized = false; + + try { + // Use undici.request - should use globalAgent.options as fallback + const { body } = await request(\`https://127.0.0.1:\${port}/\`); + const text = await body.text(); + console.log(text); + server.close(); + process.exit(text === 'Hello from undici.request' ? 0 : 1); + } catch (err) { + console.error(err.message); + server.close(); + process.exit(1); + } + }); + `, + }); + + const { stdout } = bunRun(join(String(dir), "test.js")); + expect(stdout).toBe("Hello from undici.request"); + }); +}); diff --git a/test/js/web/fetch/fetch-tls-abortsignal-timeout.test.ts b/test/js/web/fetch/fetch-tls-abortsignal-timeout.test.ts index 67eaeb02d77e6d..bf0790ca54b6ad 100644 --- a/test/js/web/fetch/fetch-tls-abortsignal-timeout.test.ts +++ b/test/js/web/fetch/fetch-tls-abortsignal-timeout.test.ts @@ -13,7 +13,7 @@ for (const timeout of [0, 1, 10, 20, 100, 300]) { return new Response("Hello World"); }, }); - const THRESHOLD = 50; + const THRESHOLD = 60; const time = performance.now(); try { diff --git a/test/js/web/websocket/websocket-proxy.test.ts b/test/js/web/websocket/websocket-proxy.test.ts index 7a15f112945a7a..547c7f26817d8a 100644 --- a/test/js/web/websocket/websocket-proxy.test.ts +++ b/test/js/web/websocket/websocket-proxy.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, 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"; @@ -909,3 +909,240 @@ describe("WebSocket with HttpsProxyAgent", () => { gc(); }); }); + +describe("WebSocket with https.globalAgent fallback", () => { + // Save original globalAgent state + // eslint-disable-next-line @typescript-eslint/no-require-imports + const https = require("https"); + let originalProxy: unknown; + let originalOptions: Record; + let originalConnectOpts: unknown; + let originalConnect: unknown; + + beforeEach(() => { + originalProxy = https.globalAgent.proxy; + originalOptions = { ...https.globalAgent.options }; + originalConnectOpts = https.globalAgent.connectOpts; + originalConnect = https.globalAgent.connect; + }); + + afterEach(() => { + https.globalAgent.proxy = originalProxy; + // Mutate rather than replace to preserve object reference + Object.keys(https.globalAgent.options).forEach((k: string) => delete https.globalAgent.options[k]); + Object.assign(https.globalAgent.options, originalOptions); + https.globalAgent.connectOpts = originalConnectOpts; + https.globalAgent.connect = originalConnect; + }); + + test("wss:// uses https.globalAgent.options.rejectUnauthorized", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + + // Set globalAgent to skip TLS verification + https.globalAgent.options.rejectUnauthorized = false; + + // Connect to wss server with self-signed cert - no explicit tls options + const ws = new WebSocket(`wss://127.0.0.1:${wssPort}`); + const receivedMessages: string[] = []; + + ws.onopen = () => { + ws.send("hello via globalAgent"); + }; + + 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 globalAgent"); + gc(); + }); + + test("wss:// uses https.globalAgent.options.ca", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + + // Set globalAgent to trust the test cert + https.globalAgent.options.ca = tlsCerts.cert; + + const ws = new WebSocket(`wss://127.0.0.1:${wssPort}`); + const receivedMessages: string[] = []; + + ws.onopen = () => { + ws.send("hello with globalAgent CA"); + }; + + 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 globalAgent CA"); + gc(); + }); + + test("wss:// fails without globalAgent TLS options (self-signed cert)", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + let sawError = false; + + // globalAgent defaults - should reject self-signed cert + https.globalAgent.options.rejectUnauthorized = true; + delete https.globalAgent.options.ca; + + const ws = new WebSocket(`wss://127.0.0.1:${wssPort}`); + + ws.onopen = () => { + ws.close(); + reject(new Error("Expected TLS error, but connection opened")); + }; + + ws.onerror = () => { + sawError = true; + ws.close(); + }; + + ws.onclose = () => { + if (sawError) { + resolve(); + } else { + reject(new Error("Expected TLS error, got clean close")); + } + }; + + await promise; + gc(); + }); + + test("ws:// uses https.globalAgent.proxy", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + + // Set globalAgent proxy + https.globalAgent.proxy = new URL(`http://127.0.0.1:${proxyPort}`); + + // Connect without explicit proxy option - should use globalAgent.proxy + const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`); + const receivedMessages: string[] = []; + + ws.onopen = () => { + ws.send("hello via globalAgent.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 globalAgent.proxy"); + gc(); + }); + + test("explicit tls option takes precedence over globalAgent", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + + // Set globalAgent to reject unauthorized (strict) + https.globalAgent.options.rejectUnauthorized = true; + delete https.globalAgent.options.ca; + + // But use explicit tls option to allow self-signed + const ws = new WebSocket(`wss://127.0.0.1:${wssPort}`, { + tls: { rejectUnauthorized: false }, + }); + const receivedMessages: string[] = []; + + ws.onopen = () => { + ws.send("explicit tls 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 tls wins"); + gc(); + }); + + test("explicit proxy option takes precedence over globalAgent.proxy", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + + // Set globalAgent proxy to wrong port + https.globalAgent.proxy = new URL("http://127.0.0.1:1"); + + // But use explicit proxy option with correct port + const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { + proxy: `http://127.0.0.1:${proxyPort}`, + }); + 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(); + }); +});