diff --git a/docs/guides/http/proxy.mdx b/docs/guides/http/proxy.mdx index 49268b794944..e175dcf9b6de 100644 --- a/docs/guides/http/proxy.mdx +++ b/docs/guides/http/proxy.mdx @@ -4,7 +4,7 @@ sidebarTitle: Proxy HTTP requests using fetch() mode: center --- -In Bun, `fetch` supports sending requests through an HTTP or HTTPS proxy. This is useful on corporate networks or when you need to ensure a request is sent through a specific IP address. +In Bun, `fetch` supports sending requests through an HTTP, HTTPS, or SOCKS proxy. This is useful on corporate networks or when you need to ensure a request is sent through a specific IP address. ```ts proxy.ts icon="/icons/typescript.svg" await fetch("https://example.com", { @@ -15,7 +15,17 @@ await fetch("https://example.com", { --- -The `proxy` option can be a URL string or an object with `url` and optional `headers`. The URL can include the username and password if the proxy requires authentication. It can be `http://` or `https://`. +The `proxy` option can be a URL string or an object with `url` and optional `headers`. The URL can include the username and password if the proxy requires authentication. It can be `http://`, `https://`, `socks5://`, or `socks5h://`. + +```ts socks-proxy.ts icon="/icons/typescript.svg" +await fetch("https://example.com", { + proxy: "socks5h://username:password@127.0.0.1:1080", +}); +``` + +For SOCKS proxies, credentials are sent using SOCKS username/password authentication. With `socks5://`, Bun resolves the target hostname locally and sends the resolved IP address to the proxy. With `socks5h://`, Bun sends the hostname to the proxy and lets the proxy resolve DNS. + +Custom `proxy.headers` only apply to HTTP and HTTPS proxies. --- @@ -35,7 +45,7 @@ await fetch("https://example.com", { }); ``` -The `headers` property accepts a plain object or a `Headers` instance. These headers are sent directly to the proxy server in `CONNECT` requests (for HTTPS targets) or in the proxy request (for HTTP targets). +The `headers` property accepts a plain object or a `Headers` instance. These headers are sent directly to HTTP and HTTPS proxy servers in `CONNECT` requests (for HTTPS targets) or in the proxy request (for HTTP targets). If you provide a `Proxy-Authorization` header, it will override any credentials specified in the proxy URL. @@ -48,3 +58,7 @@ You can also set the `$HTTP_PROXY` or `$HTTPS_PROXY` environment variable to the ```sh terminal icon="terminal" HTTPS_PROXY=https://username:password@proxy.example.com:8080 bun run index.ts ``` + +```sh terminal icon="terminal" +HTTPS_PROXY=socks5h://username:password@127.0.0.1:1080 bun run index.ts +``` diff --git a/docs/runtime/networking/fetch.mdx b/docs/runtime/networking/fetch.mdx index 282fa480081a..760ee24bc044 100644 --- a/docs/runtime/networking/fetch.mdx +++ b/docs/runtime/networking/fetch.mdx @@ -59,6 +59,15 @@ const response = await fetch("http://example.com", { }); ``` +The proxy URL can use `http://`, `https://`, `socks5://`, or `socks5h://`. +SOCKS proxies support username/password credentials in the proxy URL. With `socks5://`, Bun resolves the target hostname locally and sends the resolved IP address to the proxy. With `socks5h://`, Bun sends the hostname to the proxy and lets the proxy resolve DNS: + +```ts +const response = await fetch("https://example.com", { + proxy: "socks5h://username:password@127.0.0.1:1080", +}); +``` + You can also use an object format to send custom headers to the proxy server: ```ts @@ -73,7 +82,7 @@ const response = await fetch("http://example.com", { }); ``` -The `headers` are sent directly to the proxy in `CONNECT` requests (for HTTPS targets) or in the proxy request (for HTTP targets). If you provide a `Proxy-Authorization` header, it overrides any credentials in the proxy URL. +The `headers` are sent directly to HTTP and HTTPS proxies in `CONNECT` requests (for HTTPS targets) or in the proxy request (for HTTP targets). If you provide a `Proxy-Authorization` header, it overrides any credentials in the proxy URL. Custom proxy headers do not apply to SOCKS proxies. ### Custom headers diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index d341e0d3cbef..620d7ab51fac 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -4219,9 +4219,14 @@ declare module "bun" { type WebSocketOptionsProxy = { /** - * HTTP proxy to use for the WebSocket connection. + * Proxy to use for the WebSocket connection. * * Can be a string URL or an object with `url` and optional `headers`. + * Supported proxy URL schemes are `http:`, `https:`, `socks5:`, and + * `socks5h:`. SOCKS proxies support username/password credentials in the + * proxy URL. `socks5:` resolves target hostnames locally; `socks5h:` + * lets the proxy resolve target hostnames. `proxy.headers` only applies + * to HTTP(S) proxies. * * @example * ```ts @@ -4250,7 +4255,7 @@ declare module "bun" { | string | { /** - * The proxy URL (http:// or https://) + * The proxy URL (`http://`, `https://`, `socks5://`, or `socks5h://`) */ url: string; /** diff --git a/packages/bun-types/globals.d.ts b/packages/bun-types/globals.d.ts index 53072bf5ffa2..2b2ebf5fc461 100644 --- a/packages/bun-types/globals.d.ts +++ b/packages/bun-types/globals.d.ts @@ -1937,6 +1937,10 @@ interface BunFetchRequestInit extends RequestInit { * This is a custom property that is not part of the Fetch API specification. * * Can be a string URL or an object with `url` and optional `headers`. + * Supported proxy URL schemes are `http:`, `https:`, `socks5:`, and `socks5h:`. + * SOCKS proxies support username/password credentials in the proxy URL. + * `socks5:` resolves target hostnames locally; `socks5h:` lets the proxy + * resolve target hostnames. `proxy.headers` only applies to HTTP(S) proxies. * * @example * ```js diff --git a/src/http/AsyncHTTP.zig b/src/http/AsyncHTTP.zig index b58fcb040188..2b3040e245dd 100644 --- a/src/http/AsyncHTTP.zig +++ b/src/http/AsyncHTTP.zig @@ -173,7 +173,7 @@ pub fn init( .result_callback = callback, .http_proxy = options.http_proxy, .signals = options.signals orelse .{}, - .async_http_id = if (options.signals != null and options.signals.?.aborted != null) bun.http.async_http_id_monotonic.fetchAdd(1, .monotonic) else 0, + .async_http_id = if (options.signals != null and options.signals.?.aborted != null) bun.http.nextAsyncHTTPID() else 0, }; this.client = .{ @@ -213,7 +213,9 @@ pub fn init( } if (options.http_proxy) |proxy| { - if (proxy.username.len > 0) { + const is_socks = strings.eqlComptime(proxy.protocol, "socks5") or strings.eqlComptime(proxy.protocol, "socks5h"); + this.client.flags.disable_keepalive = this.client.flags.disable_keepalive or this.url.isHTTPS() or is_socks; + if (!is_socks and proxy.username.len > 0) { // Use stack fallback allocator - stack for small credentials, heap for large ones var username_sfb = std.heap.stackFallback(4096, allocator); const username_alloc = username_sfb.get(); @@ -283,14 +285,16 @@ pub fn initSync( } fn reset(this: *AsyncHTTP) !void { + const disable_keepalive = this.client.flags.disable_keepalive; const aborted = this.client.aborted; this.client = try HTTPClient.init(this.allocator, this.method, this.client.url, this.client.header_entries, this.client.header_buf, aborted); this.client.http_proxy = this.http_proxy; if (this.http_proxy) |proxy| { + const is_socks = strings.eqlComptime(proxy.protocol, "socks5") or strings.eqlComptime(proxy.protocol, "socks5h"); //TODO: need to understand how is possible to reuse Proxy with TSL, so disable keepalive if url is HTTPS - this.client.flags.disable_keepalive = this.url.isHTTPS(); - if (proxy.username.len > 0) { + this.client.flags.disable_keepalive = disable_keepalive or this.url.isHTTPS() or is_socks; + if (!is_socks and proxy.username.len > 0) { // Use stack fallback allocator - stack for small credentials, heap for large ones var username_sfb = std.heap.stackFallback(4096, this.allocator); const username_alloc = username_sfb.get(); @@ -475,6 +479,7 @@ const MutableString = bun.MutableString; const assert = bun.assert; const jsc = bun.jsc; const picohttp = bun.picohttp; +const strings = bun.strings; const Channel = bun.threading.Channel; const SSLConfig = bun.api.server.ServerConfig.SSLConfig; diff --git a/src/http/HTTPThread.zig b/src/http/HTTPThread.zig index 0a0fce8d604a..1136f6bf995e 100644 --- a/src/http/HTTPThread.zig +++ b/src/http/HTTPThread.zig @@ -273,7 +273,10 @@ pub fn connect(this: *@This(), client: *HTTPClient, comptime is_ssl: bool) !?New client.setCustomSslCtx(entry.ctx); // Keepalive is now supported for custom SSL contexts if (client.http_proxy) |url| { - return try entry.ctx.connect(client, url.hostname, url.getPortAuto()); + if (!(url.protocol.len == 0 or strings.eqlComptime(url.protocol, "https") or strings.eqlComptime(url.protocol, "http") or strings.eqlComptime(url.protocol, "socks5") or strings.eqlComptime(url.protocol, "socks5h"))) { + return error.UnsupportedProxyProtocol; + } + return try entry.ctx.connect(client, url.hostname, proxyPort(url)); } else { return try entry.ctx.connect(client, client.url.hostname, client.url.getPortAuto()); } @@ -312,8 +315,8 @@ pub fn connect(this: *@This(), client: *HTTPClient, comptime is_ssl: bool) !?New client.setCustomSslCtx(custom_context); // Keepalive is now supported for custom SSL contexts if (client.http_proxy) |url| { - if (url.protocol.len == 0 or strings.eqlComptime(url.protocol, "https") or strings.eqlComptime(url.protocol, "http")) { - return try custom_context.connect(client, url.hostname, url.getPortAuto()); + if (url.protocol.len == 0 or strings.eqlComptime(url.protocol, "https") or strings.eqlComptime(url.protocol, "http") or strings.eqlComptime(url.protocol, "socks5") or strings.eqlComptime(url.protocol, "socks5h")) { + return try custom_context.connect(client, url.hostname, proxyPort(url)); } return error.UnsupportedProxyProtocol; } @@ -323,8 +326,8 @@ pub fn connect(this: *@This(), client: *HTTPClient, comptime is_ssl: bool) !?New if (client.http_proxy) |url| { if (url.href.len > 0) { // https://github.com/oven-sh/bun/issues/11343 - if (url.protocol.len == 0 or strings.eqlComptime(url.protocol, "https") or strings.eqlComptime(url.protocol, "http")) { - return try this.context(is_ssl).connect(client, url.hostname, url.getPortAuto()); + if (url.protocol.len == 0 or strings.eqlComptime(url.protocol, "https") or strings.eqlComptime(url.protocol, "http") or strings.eqlComptime(url.protocol, "socks5") or strings.eqlComptime(url.protocol, "socks5h")) { + return try this.context(is_ssl).connect(client, url.hostname, proxyPort(url)); } return error.UnsupportedProxyProtocol; } @@ -525,6 +528,7 @@ fn drainEvents(this: *@This()) void { this.drainQueuedWrites(); this.drainQueuedShutdowns(); bun.http.H3.PendingConnect.drainResolved(); + bun.http.SocksDNSPending.drainResolved(); for (this.queued_threadlocal_proxy_derefs.items) |http| { http.deref(); @@ -729,6 +733,14 @@ pub fn schedule(this: *@This(), batch: Batch) void { pub const Queue = UnboundedQueue(AsyncHTTP, .next); +fn proxyPort(url: bun.URL) u16 { + if (strings.eqlComptime(url.protocol, "socks5") or strings.eqlComptime(url.protocol, "socks5h")) { + if (url.getPort()) |_| return url.getPortAuto(); + return 1080; + } + return url.getPortAuto(); +} + const log = Output.scoped(.HTTPThread, .visible); const stringZ = [:0]const u8; diff --git a/src/http/SocksDNSPending.zig b/src/http/SocksDNSPending.zig new file mode 100644 index 000000000000..c993ad0283f3 --- /dev/null +++ b/src/http/SocksDNSPending.zig @@ -0,0 +1,224 @@ +//! DNS-pending SOCKS5 connect. Created when `SocksProxy.receive` returns +//! `.needs_dns_resolve` (socks5:// with hostname); the global DNS cache +//! notifies via `onDNSResolved[Threadsafe]`, at which point the resolved +//! address is used to write the SOCKS5 CONNECT request. +//! +//! Lifetime: destroyed exclusively by `onDNSResolved` (from drain or sync +//! notify). Owner cancel sets `cancelled` flag only — never destroys. +//! +//! HTTP owners: pushed to `resolved_head` mutex-list, drained from +//! `HTTPThread.drainEvents`. WebSocket owners: scheduled via +//! `loop.nextTick` directly onto the JS main thread. + +const SocksDNSPending = @This(); + +pub const HTTPOwner = struct { + async_http_id: u32, + target_port: u16, +}; + +pub const OwnerKind = union(enum) { + http: HTTPOwner, + ws_non_tls: *WsUpgradeClientNonTLS, + ws_tls: *WsUpgradeClientTLS, +}; + +owner: OwnerKind, +loop_ptr: *uws.Loop, +dns_request: *dns.internal.Request, +cancelled: std.atomic.Value(bool), +next: ?*SocksDNSPending = null, + +pub fn loop(this: *SocksDNSPending) *uws.Loop { + return this.loop_ptr; +} + +// --- Thread dispatch --- + +/// Called from DNS worker thread (or inline from `registerSocksIfPending` +/// when result arrives between check and lock). Routes to the correct +/// thread for final processing. +pub fn onDNSResolvedThreadsafe(this: *SocksDNSPending) void { + switch (this.owner) { + .http => { + // Push to HTTP-thread drain list + resolved_mutex.lock(); + this.next = resolved_head; + resolved_head = this; + resolved_mutex.unlock(); + this.loop_ptr.wakeup(); + }, + .ws_non_tls, .ws_tls => { + // Schedule directly on JS main thread + this.loop_ptr.nextTick(*SocksDNSPending, this, onDNSResolved); + }, + } +} + +/// Drain all HTTP-thread pending resolves. Called from +/// `HTTPThread.drainEvents` on the HTTP thread. +pub fn drainResolved() void { + resolved_mutex.lock(); + var head = resolved_head; + resolved_head = null; + resolved_mutex.unlock(); + while (head) |pc| { + const next_pc = pc.next; + pc.onDNSResolved(); + head = next_pc; + } +} + +// --- DNS resolved callback (runs on owner's thread) --- + +pub fn onDNSResolved(this: *SocksDNSPending) void { + const req = this.dns_request; + defer { + dns.internal.freeaddrinfo(req, 0); + bun.destroy(this); + } + + // Check cancelled BEFORE touching owner + if (this.cancelled.load(.acquire)) { + this.releaseWsRef(); + return; + } + + const result = req.result orelse { + this.failOwner(error.DNSLookupFailed); + return; + }; + if (result.err != 0 or result.info == null) { + this.failOwner(error.DNSLookupFailed); + return; + } + + switch (this.owner) { + .ws_non_tls => |client| { + defer client.deref(); + client.continueSocksAfterDNSRequest(req); + return; + }, + .ws_tls => |client| { + defer client.deref(); + client.continueSocksAfterDNSRequest(req); + return; + }, + .http => {}, + } + + // HTTP SOCKS currently resumes with the first address, matching the + // surrounding HTTP client path. WebSocket keeps the full list for fallback. + const entry = &result.info.?[0]; + const address = addrFromSockaddr(&entry.addr) catch { + this.failOwner(error.DNSLookupFailed); + return; + }; + + switch (this.owner) { + .http => |http_owner| this.resumeHTTP(http_owner, address), + .ws_non_tls, .ws_tls => unreachable, + } +} + +fn getTargetPort(this: *const SocksDNSPending) u16 { + return switch (this.owner) { + .http => |h| h.target_port, + // WS owners store target port in proxy state + .ws_non_tls => |client| if (client.proxy) |*p| p.target_port else 0, + .ws_tls => |client| if (client.proxy) |*p| p.target_port else 0, + }; +} + +fn resumeHTTP(this: *SocksDNSPending, http_owner: HTTPOwner, address: std.net.Address) void { + _ = this; + // Generation check: is the HTTPClient still alive? + const any_socket = bun.http.socket_async_http_abort_tracker.get( + http_owner.async_http_id, + ) orelse return; + + // Extract HTTPClient from socket ext via ActiveSocket tagged pointer + switch (any_socket) { + inline .SocketTLS, .SocketTCP => |socket, tag| { + const is_tls = tag == .SocketTLS; + const HTTPContext = bun.http.NewHTTPContext(is_tls); + const tagged = HTTPContext.getTaggedFromSocket(socket); + const client = tagged.get(bun.http) orelse return; + // Defense-in-depth: verify same request + if (client.async_http_id != http_owner.async_http_id) return; + client.socks_dns_pending = null; + client.completeSocksWithAddress(is_tls, socket, address, http_owner.target_port); + }, + } +} + +fn failOwner(this: *SocksDNSPending, err: anyerror) void { + switch (this.owner) { + .http => |http_owner| { + const any_socket = bun.http.socket_async_http_abort_tracker.get( + http_owner.async_http_id, + ) orelse return; + switch (any_socket) { + inline .SocketTLS, .SocketTCP => |socket, tag| { + const is_tls = tag == .SocketTLS; + const HTTPContext = bun.http.NewHTTPContext(is_tls); + const tagged = HTTPContext.getTaggedFromSocket(socket); + const client = tagged.get(bun.http) orelse return; + if (client.async_http_id != http_owner.async_http_id) return; + client.socks_dns_pending = null; + client.closeAndFail(err, is_tls, socket); + }, + } + }, + .ws_non_tls => |client| { + defer client.deref(); + client.terminate(.proxy_tunnel_failed); + }, + .ws_tls => |client| { + defer client.deref(); + client.terminate(.proxy_tunnel_failed); + }, + } +} + +fn releaseWsRef(this: *SocksDNSPending) void { + switch (this.owner) { + .ws_non_tls => |client| client.deref(), + .ws_tls => |client| client.deref(), + .http => {}, + } +} + +/// Owner calls this to signal cancellation. Does NOT destroy pending. +pub fn markCancelled(this: *SocksDNSPending) void { + this.cancelled.store(true, .release); +} + +// --- Addr conversion helper --- + +pub fn addrFromSockaddr(storage: *const std.c.sockaddr.storage) !std.net.Address { + const family = storage.family; + if (family == std.posix.AF.INET) { + const addr_in: *const std.c.sockaddr.in = @ptrCast(@alignCast(storage)); + return std.net.Address{ .in = .{ .sa = addr_in.* } }; + } else if (family == std.posix.AF.INET6) { + const addr_in6: *const std.c.sockaddr.in6 = @ptrCast(@alignCast(storage)); + return std.net.Address{ .in6 = .{ .sa = addr_in6.* } }; + } + return error.SocksAddressTypeNotSupported; +} + +// --- Module-level state --- + +var resolved_mutex: bun.Mutex = .{}; +var resolved_head: ?*SocksDNSPending = null; + +// --- Imports --- + +const std = @import("std"); +const bun = @import("bun"); +const uws = bun.uws; +const dns = bun.dns; +const NewHTTPUpgradeClient = @import("../http_jsc/websocket_client/WebSocketUpgradeClient.zig").NewHTTPUpgradeClient; +const WsUpgradeClientNonTLS = NewHTTPUpgradeClient(false); +const WsUpgradeClientTLS = NewHTTPUpgradeClient(true); diff --git a/src/http/SocksProxy.zig b/src/http/SocksProxy.zig new file mode 100644 index 000000000000..967d14537e93 --- /dev/null +++ b/src/http/SocksProxy.zig @@ -0,0 +1,311 @@ +const SocksProxy = @This(); + +pub const Kind = enum { + none, + http, + https, + socks5, + socks5h, + unsupported, + + pub fn fromURL(url: URL) Kind { + if (url.protocol.len == 0 or strings.eqlComptime(url.protocol, "http")) return .http; + if (strings.eqlComptime(url.protocol, "https")) return .https; + if (strings.eqlComptime(url.protocol, "socks5")) return .socks5; + if (strings.eqlComptime(url.protocol, "socks5h")) return .socks5h; + return .unsupported; + } + + pub fn fromInt(value: u8) Kind { + return switch (value) { + 1 => .http, + 2 => .https, + 3 => .socks5, + 4 => .socks5h, + else => .none, + }; + } + + pub fn isSocks(this: Kind) bool { + return this == .socks5 or this == .socks5h; + } +}; + +pub const State = enum { + idle, + method_response, + auth_response, + connect_response, + connected, + failed, +}; + +pub const ReceiveResult = enum { + pending, + connected, + needs_dns_resolve, +}; + +allocator: std.mem.Allocator, +kind: Kind, +state: State = .idle, +read_buffer: bun.io.StreamBuffer = .{}, +write_buffer: bun.io.StreamBuffer = .{}, +resolved_address: ?std.net.Address = null, +username: []u8 = "", +password: []u8 = "", + +pub fn init(allocator: std.mem.Allocator, proxy: URL) !SocksProxy { + if ((proxy.username.len == 0) != (proxy.password.len == 0)) { + return error.SocksCredentialsIncomplete; + } + + var this = SocksProxy{ + .allocator = allocator, + .kind = Kind.fromURL(proxy), + }; + + if (proxy.username.len > 0) { + this.username = try PercentEncoding.decodeAlloc(allocator, proxy.username); + errdefer allocator.free(this.username); + if (this.username.len > 255) return error.SocksCredentialsTooLong; + + if (proxy.password.len > 0) { + this.password = try PercentEncoding.decodeAlloc(allocator, proxy.password); + errdefer allocator.free(this.password); + if (this.password.len > 255) return error.SocksCredentialsTooLong; + } + } + + return this; +} + +pub fn initWithCredentials(allocator: std.mem.Allocator, kind: Kind, username: []const u8, password: []const u8) !SocksProxy { + if ((username.len == 0) != (password.len == 0)) { + return error.SocksCredentialsIncomplete; + } + + var this = SocksProxy{ + .allocator = allocator, + .kind = kind, + }; + if (username.len > 0) { + this.username = try allocator.dupe(u8, username); + errdefer allocator.free(this.username); + if (this.username.len > 255) return error.SocksCredentialsTooLong; + if (password.len > 0) { + this.password = try allocator.dupe(u8, password); + errdefer allocator.free(this.password); + if (this.password.len > 255) return error.SocksCredentialsTooLong; + } + } + return this; +} + +pub fn deinit(this: *SocksProxy) void { + this.read_buffer.deinit(); + this.write_buffer.deinit(); + if (this.username.len > 0) { + this.allocator.free(this.username); + this.username = ""; + } + if (this.password.len > 0) { + this.allocator.free(this.password); + this.password = ""; + } +} + +pub fn defaultPort(kind: Kind) u16 { + return switch (kind) { + .https => 443, + .socks5, .socks5h => 1080, + else => 80, + }; +} + +pub fn begin(this: *SocksProxy) !void { + this.write_buffer.reset(); + if (this.username.len > 0) { + try this.write_buffer.write(&.{ 0x05, 0x02, 0x00, 0x02 }); + } else { + try this.write_buffer.write(&.{ 0x05, 0x01, 0x00 }); + } + this.state = .method_response; +} + +pub fn useResolvedAddressForNextConnect(this: *SocksProxy, address: std.net.Address) void { + this.resolved_address = address; +} + +pub fn hasPendingWrite(this: *const SocksProxy) bool { + return this.write_buffer.isNotEmpty(); +} + +pub fn flush(this: *SocksProxy, socket: anytype) !void { + const data = this.write_buffer.slice(); + if (data.len == 0) return; + const written = socket.write(data); + if (written < 0) return error.WriteFailed; + const amount: usize = @intCast(written); + this.write_buffer.cursor += amount; + if (this.write_buffer.isEmpty()) { + this.write_buffer.reset(); + } +} + +pub fn receive(this: *SocksProxy, data: []const u8, target_host: []const u8, target_port: u16) !ReceiveResult { + if (data.len > 0) { + try this.read_buffer.write(data); + } + + while (true) { + switch (this.state) { + .method_response => { + const buf = this.read_buffer.slice(); + if (buf.len < 2) return .pending; + if (buf[0] != 0x05) return error.SocksInvalidResponse; + const method = buf[1]; + this.consume(2); + switch (method) { + 0x00 => { + const connect_result = try this.writeConnect(target_host, target_port); + if (connect_result == .needs_dns_resolve) return .needs_dns_resolve; + }, + 0x02 => try this.writeAuth(), + 0xff => return error.SocksNoAcceptableAuthMethod, + else => return error.SocksNoAcceptableAuthMethod, + } + return .pending; + }, + .auth_response => { + const buf = this.read_buffer.slice(); + if (buf.len < 2) return .pending; + if (buf[0] != 0x01) return error.SocksInvalidResponse; + const status = buf[1]; + this.consume(2); + if (status != 0x00) return error.SocksAuthenticationFailed; + const connect_result = try this.writeConnect(target_host, target_port); + if (connect_result == .needs_dns_resolve) return .needs_dns_resolve; + return .pending; + }, + .connect_response => { + const buf = this.read_buffer.slice(); + if (buf.len < 5) return .pending; + if (buf[0] != 0x05 or buf[2] != 0x00) return error.SocksInvalidResponse; + if (buf[1] != 0x00) return replyError(buf[1]); + const address_len: usize = switch (buf[3]) { + 0x01 => 4, + 0x03 => buf[4], + 0x04 => 16, + else => return error.SocksInvalidResponse, + }; + const header_len: usize = if (buf[3] == 0x03) 5 else 4; + const response_len = header_len + address_len + 2; + if (buf.len < response_len) return .pending; + this.consume(response_len); + this.state = .connected; + return .connected; + }, + .connected => return .connected, + .idle, .failed => return .pending, + } + } +} + +fn consume(this: *SocksProxy, amount: usize) void { + this.read_buffer.cursor += amount; + if (this.read_buffer.isEmpty()) { + this.read_buffer.reset(); + } +} + +fn writeAuth(this: *SocksProxy) !void { + if (this.username.len > 255 or this.password.len > 255) return error.SocksCredentialsTooLong; + try this.write_buffer.ensureUnusedCapacity(3 + this.username.len + this.password.len); + this.write_buffer.writeAssumeCapacity(&.{ 0x01, @intCast(this.username.len) }); + this.write_buffer.writeAssumeCapacity(this.username); + this.write_buffer.writeAssumeCapacity(&.{@intCast(this.password.len)}); + this.write_buffer.writeAssumeCapacity(this.password); + this.state = .auth_response; +} + +const ConnectWriteResult = enum { written, needs_dns_resolve }; + +fn writeConnect(this: *SocksProxy, target_host: []const u8, target_port: u16) !ConnectWriteResult { + if (this.resolved_address) |address| { + this.resolved_address = null; + try this.write_buffer.write(&.{ 0x05, 0x01, 0x00 }); + try this.writeAddress(address, target_port); + this.state = .connect_response; + return .written; + } + + // Both socks5:// and socks5h:// should preserve numeric IP literals. + if (std.net.Address.parseIp(target_host, target_port)) |address| { + try this.write_buffer.write(&.{ 0x05, 0x01, 0x00 }); + try this.writeAddress(address, target_port); + this.state = .connect_response; + return .written; + } else |_| {} + + if (this.kind == .socks5h) { + try this.write_buffer.write(&.{ 0x05, 0x01, 0x00 }); + if (target_host.len > 255) return error.SocksDomainTooLong; + try this.write_buffer.write(&.{ 0x03, @intCast(target_host.len) }); + try this.write_buffer.write(target_host); + try writePort(&this.write_buffer, target_port); + this.state = .connect_response; + return .written; + } + + // socks5 + hostname: caller must resolve DNS asynchronously + return .needs_dns_resolve; +} + +/// Called by owner after async DNS resolves for socks5:// hostnames. +/// Writes SOCKS5 CONNECT request using the pre-resolved address. +pub fn writeConnectResolved(this: *SocksProxy, address: std.net.Address, target_port: u16) !void { + try this.write_buffer.write(&.{ 0x05, 0x01, 0x00 }); + try this.writeAddress(address, target_port); + this.state = .connect_response; +} + +fn writeAddress(this: *SocksProxy, address: std.net.Address, target_port: u16) !void { + switch (address.any.family) { + std.posix.AF.INET => { + try this.write_buffer.write(&.{0x01}); + const addr = address.in.sa.addr; + try this.write_buffer.write(std.mem.asBytes(&addr)); + }, + std.posix.AF.INET6 => { + try this.write_buffer.write(&.{0x04}); + try this.write_buffer.write(&address.in6.sa.addr); + }, + else => return error.SocksAddressTypeNotSupported, + } + try writePort(&this.write_buffer, target_port); +} + +fn writePort(buffer: *bun.io.StreamBuffer, port: u16) !void { + try buffer.write(&.{ @intCast((port >> 8) & 0xff), @intCast(port & 0xff) }); +} + +fn replyError(code: u8) anyerror { + return switch (code) { + 0x01 => error.SocksGeneralFailure, + 0x02 => error.SocksConnectionNotAllowed, + 0x03 => error.SocksNetworkUnreachable, + 0x04 => error.SocksHostUnreachable, + 0x05 => error.SocksConnectionRefused, + 0x06 => error.SocksTTLExpired, + 0x07 => error.SocksCommandNotSupported, + 0x08 => error.SocksAddressTypeNotSupported, + else => error.SocksInvalidResponse, + }; +} + +const std = @import("std"); +const URL = @import("../url/url.zig").URL; +const PercentEncoding = @import("../url/url.zig").PercentEncoding; +const bun = @import("bun"); +const strings = bun.strings; diff --git a/src/http/http.zig b/src/http/http.zig index b61092e99b7f..7801c0323636 100644 --- a/src/http/http.zig +++ b/src/http/http.zig @@ -9,6 +9,14 @@ pub var http_thread: HTTPThread = undefined; pub var socket_async_http_abort_tracker = std.AutoArrayHashMap(u32, uws.AnySocket).init(bun.default_allocator); pub var async_http_id_monotonic: std.atomic.Value(u32) = std.atomic.Value(u32).init(0); +pub fn nextAsyncHTTPID() u32 { + var id = async_http_id_monotonic.fetchAdd(1, .monotonic); + if (id == 0) { + id = async_http_id_monotonic.fetchAdd(1, .monotonic); + } + return id; +} + /// Set once at startup from `--experimental-http2-fetch` (before the HTTP /// thread spawns) and then only read on that thread, so no atomics needed. pub var experimental_http2_client_from_cli: bool = false; @@ -166,7 +174,7 @@ pub fn registerAbortTracker( pub fn unregisterAbortTracker( client: *HTTPClient, ) void { - if (client.signals.aborted != null) { + if (client.signals.aborted != null or client.async_http_id != 0) { _ = socket_async_http_abort_tracker.swapRemove(client.async_http_id); } } @@ -177,11 +185,7 @@ pub fn onOpen( socket: NewHTTPContext(is_ssl).HTTPSocket, ) !void { if (comptime Environment.allow_assert) { - if (client.http_proxy) |proxy| { - assert(is_ssl == proxy.isHTTPS()); - } else { - assert(is_ssl == client.url.isHTTPS()); - } + assert(is_ssl == client.usesTransportTLS()); } client.registerAbortTracker(is_ssl, socket); log("Connected {s} \n", .{client.url.href}); @@ -449,7 +453,7 @@ const max_tls_record_size = 16 * 1024; /// On https://, we are limited to a 16 KB TLS record size. inline fn getRequestBodySendBuffer(this: *@This()) HTTPThread.RequestBodyBuffer { const actual_estimated_size = this.state.request_body.len + this.estimatedRequestHeaderByteLength(); - const estimated_size = if (this.isHTTPS()) @min(actual_estimated_size, max_tls_record_size) else actual_estimated_size * 2; + const estimated_size = if (this.usesTransportTLS()) @min(actual_estimated_size, max_tls_record_size) else actual_estimated_size * 2; return http_thread.getRequestBodySendBuffer(estimated_size); } @@ -686,6 +690,8 @@ http_proxy: ?URL = null, proxy_headers: ?Headers = null, proxy_authorization: ?[]u8 = null, proxy_tunnel: ?*ProxyTunnel = null, +socks_proxy: ?SocksProxy = null, +socks_dns_pending: ?*SocksDNSPending = null, /// Set when this request is bound to a stream on an HTTP/2 session. /// Owned by the session; cleared by the session when the stream completes. h2: ?*H2.Stream = null, @@ -722,6 +728,14 @@ pub fn deinit(this: *HTTPClient) void { this.proxy_tunnel = null; tunnel.detachAndDeref(); } + if (this.socks_dns_pending) |pending| { + pending.markCancelled(); + this.socks_dns_pending = null; + } + if (this.socks_proxy) |*proxy| { + proxy.deinit(); + this.socks_proxy = null; + } // The session detaches `h2` before any terminal callback, so this should // be null by the time the result callback's deinit path runs. bun.debugAssert(this.h2 == null); @@ -738,6 +752,9 @@ pub fn isKeepAlivePossible(this: *HTTPClient) bool { if (comptime FeatureFlags.enable_keepalive) { // TODO keepalive for unix sockets if (this.unix_socket_path.length() > 0) return false; + if (this.http_proxy) |proxy| { + if (SocksProxy.Kind.fromURL(proxy).isSocks()) return false; + } // check state if (this.state.flags.allow_keepalive and !this.flags.disable_keepalive) return true; @@ -1176,6 +1193,14 @@ pub fn doRedirect( this.proxy_tunnel = null; tunnel.detachAndDeref(); } + if (this.socks_dns_pending) |pending| { + pending.markCancelled(); + this.socks_dns_pending = null; + } + if (this.socks_proxy) |*proxy| { + proxy.deinit(); + this.socks_proxy = null; + } this.flags.protocol = .http1_1; return this.start(.{ .bytes = request_body }, body_out_str); @@ -1183,16 +1208,15 @@ pub fn doRedirect( /// **Not thread safe while request is in-flight** pub fn isHTTPS(this: *HTTPClient) bool { + return this.url.isHTTPS(); +} + +/// **Not thread safe while request is in-flight** +pub fn usesTransportTLS(this: *HTTPClient) bool { if (this.http_proxy) |proxy| { - if (proxy.isHTTPS()) { - return true; - } - return false; - } - if (this.url.isHTTPS()) { - return true; + return SocksProxy.Kind.fromURL(proxy) == .https; } - return false; + return this.url.isHTTPS(); } pub fn start(this: *HTTPClient, body: HTTPRequestBody, body_out_str: *MutableString) void { @@ -1201,7 +1225,7 @@ pub fn start(this: *HTTPClient, body: HTTPRequestBody, body_out_str: *MutableStr assert(this.state.response_message_buffer.list.capacity == 0); this.state = InternalState.init(body, body_out_str); - if (this.isHTTPS()) { + if (this.usesTransportTLS()) { this.start_(true); } else { this.start_(false); @@ -1405,10 +1429,49 @@ noinline fn sendInitialRequestPayload(this: *HTTPClient, comptime is_first_call: const writer = &temporary_send_buffer.writer(); + if (this.http_proxy) |proxy| { + const kind = SocksProxy.Kind.fromURL(proxy); + if (kind.isSocks()) { + if (this.socks_proxy) |*socks| { + if (socks.state != .connected) { + log("continue socks proxy handshake", .{}); + this.flags.proxy_tunneling = true; + try socks.flush(socket); + const done = !socks.hasPendingWrite(); + return .{ + .has_sent_headers = done, + .has_sent_body = done, + .try_sending_more_data = false, + }; + } + } else { + log("start socks proxy handshake", .{}); + this.flags.proxy_tunneling = true; + this.socks_proxy = try SocksProxy.init(this.allocator, proxy); + try this.socks_proxy.?.begin(); + try this.socks_proxy.?.flush(socket); + const done = !this.socks_proxy.?.hasPendingWrite(); + return .{ + .has_sent_headers = done, + .has_sent_body = done, + .try_sending_more_data = false, + }; + } + } + } + const request = this.buildRequest(this.state.original_request_body.len()); - if (this.http_proxy) |_| { - if (this.url.isHTTPS()) { + if (this.http_proxy) |proxy| { + const kind = SocksProxy.Kind.fromURL(proxy); + if (kind.isSocks()) { + log("send request through socks proxy", .{}); + try writeRequest( + @TypeOf(writer), + writer, + request, + ); + } else if (this.url.isHTTPS()) { log("start proxy tunneling (https proxy)", .{}); //DO the tunneling! this.flags.proxy_tunneling = true; @@ -1621,6 +1684,16 @@ pub fn onWritable(this: *HTTPClient, comptime is_first_call: bool, comptime is_s proxy.onWritable(is_ssl, socket); } + if (this.socks_proxy) |*proxy| { + if (proxy.hasPendingWrite()) { + proxy.flush(socket) catch |err| { + this.closeAndFail(err, is_ssl, socket); + return; + }; + if (proxy.hasPendingWrite()) return; + } + } + switch (this.state.request_stage) { .pending, .headers, .opened => { log("sendInitialRequestPayload", .{}); @@ -1658,7 +1731,7 @@ pub fn onWritable(this: *HTTPClient, comptime is_first_call: bool, comptime is_s } assert( // we should have leftover data OR we use sendfile/stream - (this.state.original_request_body == .bytes and this.state.request_body.len > 0) or + (this.state.original_request_body == .bytes and (this.state.request_body.len > 0 or this.state.original_request_body.bytes.len == 0)) or this.state.original_request_body == .sendfile or this.state.original_request_body == .stream, ); @@ -1831,6 +1904,107 @@ pub fn closeAndFail(this: *HTTPClient, err: anyerror, comptime is_ssl: bool, soc this.fail(err); } +fn startSocksDnsResolve(this: *HTTPClient, comptime is_ssl: bool, socket: NewHTTPContext(is_ssl).HTTPSocket) void { + var temp_buf: [256:0]u8 = undefined; + const hostname_z: [:0]const u8 = brk: { + if (this.url.hostname.len < temp_buf.len) { + @memcpy(temp_buf[0..this.url.hostname.len], this.url.hostname); + temp_buf[this.url.hostname.len] = 0; + break :brk temp_buf[0..this.url.hostname.len :0]; + } + break :brk bun.default_allocator.dupeZ(u8, this.url.hostname) catch |e| bun.handleOom(e); + }; + const needs_free = this.url.hostname.len >= temp_buf.len; + defer if (needs_free) bun.default_allocator.free(hostname_z); + + const loop = bun.http.http_thread.loop.loop; + var is_cache_hit: bool = false; + const dns_req = bun.dns.internal.getaddrinfo( + loop, hostname_z, this.url.getPortAuto(), &is_cache_hit, + ) orelse { + this.closeAndFail(error.DNSLookupFailed, is_ssl, socket); + return; + }; + + if (is_cache_hit) { + // Synchronous path — result already available + this.completeSocksFromDnsReq(dns_req, is_ssl, socket); + return; + } + + if (this.async_http_id == 0) { + this.async_http_id = nextAsyncHTTPID(); + } + switch (is_ssl) { + true => socket_async_http_abort_tracker.put(this.async_http_id, .{ .SocketTLS = socket }) catch unreachable, + false => socket_async_http_abort_tracker.put(this.async_http_id, .{ .SocketTCP = socket }) catch unreachable, + } + + // Atomic create+register under DNS cache lock + if (bun.dns.internal.registerSocksIfPending( + dns_req, + .{ .http = .{ + .async_http_id = this.async_http_id, + .target_port = this.url.getPortAuto(), + }}, + loop, + )) |pending| { + this.socks_dns_pending = pending; + } else { + // Result arrived between getaddrinfo and lock — handle sync + this.completeSocksFromDnsReq(dns_req, is_ssl, socket); + } +} + +fn completeSocksFromDnsReq( + this: *HTTPClient, + dns_req: *bun.dns.internal.Request, + comptime is_ssl: bool, + socket: NewHTTPContext(is_ssl).HTTPSocket, +) void { + this.socks_dns_pending = null; + defer bun.dns.internal.freeaddrinfo(dns_req, 0); + + const result = dns_req.result orelse { + this.closeAndFail(error.DNSLookupFailed, is_ssl, socket); + return; + }; + if (result.err != 0 or result.info == null) { + this.closeAndFail(error.DNSLookupFailed, is_ssl, socket); + return; + } + + const entry = &result.info.?[0]; + const address = SocksDNSPending.addrFromSockaddr(&entry.addr) catch { + this.closeAndFail(error.DNSLookupFailed, is_ssl, socket); + return; + }; + + this.completeSocksWithAddress(is_ssl, socket, address, this.url.getPortAuto()); +} + +pub fn completeSocksWithAddress( + this: *HTTPClient, + comptime is_ssl: bool, + socket: NewHTTPContext(is_ssl).HTTPSocket, + address: std.net.Address, + target_port: u16, +) void { + this.socks_dns_pending = null; + const proxy = if (this.socks_proxy) |*proxy| proxy else { + this.closeAndFail(error.ProxyProtocolError, is_ssl, socket); + return; + }; + proxy.writeConnectResolved(address, target_port) catch |err| { + this.closeAndFail(err, is_ssl, socket); + return; + }; + proxy.flush(socket) catch |err| { + this.closeAndFail(err, is_ssl, socket); + return; + }; +} + fn startProxyHandshake(this: *HTTPClient, comptime is_ssl: bool, socket: NewHTTPContext(is_ssl).HTTPSocket, start_payload: []const u8) void { log("startProxyHandshake", .{}); // if we have options we pass them (ca, reject_unauthorized, etc) otherwise use the default @@ -2044,6 +2218,56 @@ pub fn onData( return; } + if (this.socks_proxy) |*proxy| { + if (proxy.state != .connected) { + this.setTimeout(socket, 5); + const result = proxy.receive(incoming_data, this.url.hostname, this.url.getPortAuto()) catch |err| { + this.closeAndFail(err, is_ssl, socket); + return; + }; + switch (result) { + .needs_dns_resolve => { + this.startSocksDnsResolve(is_ssl, socket); + return; + }, + else => {}, + } + // Flush write buffer before processing connected state. + // Required when method_response + connect_response arrive in + // the same packet: writeConnect fills the buffer, and we must + // send it before the proxy processes the CONNECT response. + proxy.flush(socket) catch |err| { + this.closeAndFail(err, is_ssl, socket); + return; + }; + switch (result) { + .connected => { + this.flags.proxy_tunneling = false; + this.state.request_sent_len = 0; + if (this.url.isHTTPS()) { + const allocator = this.allocator; + const trailing = proxy.read_buffer.slice(); + const start_payload = if (trailing.len > 0) + allocator.dupe(u8, trailing) catch |err| bun.handleOom(err) + else + ""; + defer if (start_payload.len > 0) allocator.free(start_payload); + proxy.deinit(); + this.socks_proxy = null; + this.startProxyHandshake(is_ssl, socket, start_payload); + } else { + this.state.request_stage = .headers; + this.state.response_stage = .pending; + this.onWritable(true, is_ssl, socket); + } + }, + .pending => {}, + .needs_dns_resolve => unreachable, + } + return; + } + } + switch (this.state.response_stage) { .pending, .headers => { this.handleOnDataHeaders(is_ssl, incoming_data, ctx, socket); @@ -2157,6 +2381,11 @@ fn fail(this: *HTTPClient, err: anyerror) void { this.unregisterAbortTracker(); this.resolvePendingH2(.leader_failed); + if (this.socks_dns_pending) |pending| { + pending.markCancelled(); + this.socks_dns_pending = null; + } + if (this.proxy_tunnel) |tunnel| { this.proxy_tunnel = null; tunnel.shutdown(); @@ -2897,7 +3126,7 @@ pub fn handleResponseMetadata( // Record regardless of *this* request's shape — a future // request to the same origin may be h3-eligible even if this // one was pinned/proxied/sendfile. - if (this.isHTTPS() and this.unix_socket_path.length() == 0 and h3AltSvcEnabled()) { + if (this.url.isHTTPS() and this.unix_socket_path.length() == 0 and h3AltSvcEnabled()) { H3.AltSvc.record(this.url.hostname, this.url.getPortAuto(), header.value); } }, @@ -3264,6 +3493,8 @@ const string = []const u8; const HTTPCertError = @import("./HTTPCertError.zig"); const ProxyTunnel = @import("./ProxyTunnel.zig"); +const SocksProxy = @import("./SocksProxy.zig"); +pub const SocksDNSPending = @import("./SocksDNSPending.zig"); const std = @import("std"); const URL = @import("../url/url.zig").URL; diff --git a/src/http_jsc/websocket_client/WebSocketProxy.zig b/src/http_jsc/websocket_client/WebSocketProxy.zig index 4565717919c3..7ec01111f689 100644 --- a/src/http_jsc/websocket_client/WebSocketProxy.zig +++ b/src/http_jsc/websocket_client/WebSocketProxy.zig @@ -8,8 +8,14 @@ const WebSocketProxy = @This(); #target_host: []const u8, /// Whether target uses TLS (wss://) #target_is_https: bool, +target_port: u16, +/// SOCKS proxy hostname for reconnecting when trying another resolved target address +#proxy_host: []const u8, +proxy_port: u16, /// WebSocket upgrade request to send after CONNECT succeeds #websocket_request_buf: []u8, +kind: SocksProxy.Kind = .http, +socks: ?SocksProxy = null, /// TLS tunnel for wss:// through HTTP proxy #tunnel: ?*WebSocketProxyTunnel = null, @@ -17,20 +23,38 @@ const WebSocketProxy = @This(); pub fn init( target_host: []const u8, target_is_https: bool, + target_port: u16, + proxy_host: []const u8, + proxy_port: u16, websocket_request_buf: []u8, + kind: SocksProxy.Kind, + socks: ?SocksProxy, ) WebSocketProxy { return .{ .#target_host = target_host, .#target_is_https = target_is_https, + .target_port = target_port, + .#proxy_host = proxy_host, + .proxy_port = proxy_port, .#websocket_request_buf = websocket_request_buf, + .kind = kind, + .socks = socks, }; } +pub fn isSocks(self: *const WebSocketProxy) bool { + return self.kind.isSocks(); +} + /// Get the target hostname for SNI during TLS handshake pub fn getTargetHost(self: *const WebSocketProxy) []const u8 { return self.#target_host; } +pub fn getProxyHost(self: *const WebSocketProxy) []const u8 { + return self.#proxy_host; +} + /// Check if the target uses HTTPS (wss://) pub fn isTargetHttps(self: *const WebSocketProxy) bool { return self.#target_is_https; @@ -57,9 +81,16 @@ pub fn takeWebsocketRequestBuf(self: *WebSocketProxy) []u8 { /// Clean up all allocated resources pub fn deinit(self: *WebSocketProxy) void { bun.default_allocator.free(self.#target_host); + if (self.#proxy_host.len > 0) { + bun.default_allocator.free(self.#proxy_host); + } if (self.#websocket_request_buf.len > 0) { bun.default_allocator.free(self.#websocket_request_buf); } + if (self.socks) |*socks| { + socks.deinit(); + self.socks = null; + } if (self.#tunnel) |tunnel| { self.#tunnel = null; tunnel.shutdown(); @@ -68,4 +99,5 @@ pub fn deinit(self: *WebSocketProxy) void { } const WebSocketProxyTunnel = @import("./WebSocketProxyTunnel.zig"); +const SocksProxy = @import("../../http/SocksProxy.zig"); const bun = @import("bun"); diff --git a/src/http_jsc/websocket_client/WebSocketUpgradeClient.zig b/src/http_jsc/websocket_client/WebSocketUpgradeClient.zig index 0243a5267168..262c615ecd13 100644 --- a/src/http_jsc/websocket_client/WebSocketUpgradeClient.zig +++ b/src/http_jsc/websocket_client/WebSocketUpgradeClient.zig @@ -46,6 +46,11 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type { /// Proxy state (null when not using proxy) proxy: ?WebSocketProxy = null, + /// Pending SOCKS5 DNS resolution (null when not resolving) + socks_dns_pending: ?*bun.http.SocksDNSPending = null, + socks_resolved_addresses: []std.net.Address = &.{}, + socks_resolved_address_index: usize = 0, + // TLS options (full SSLConfig for complete TLS customization) ssl_config: ?*SSLConfig = null, @@ -112,6 +117,9 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type { // Proxy parameters proxy_host: ?*const bun.String, proxy_port: u16, + proxy_kind: u8, + proxy_username: ?*const bun.String, + proxy_password: ?*const bun.String, proxy_authorization: ?*const bun.String, proxy_header_names: ?[*]const bun.String, proxy_header_values: ?[*]const bun.String, @@ -194,6 +202,7 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type { var proxy_state: ?WebSocketProxy = null; var connect_request: []u8 = &[_]u8{}; if (using_proxy) { + const kind = SocksProxy.Kind.fromInt(proxy_kind); // Parse proxy authorization (temporary, freed after building CONNECT request) var proxy_auth_slice: ?[]const u8 = null; var proxy_auth_decoded: ?jsc.ZigString.Slice = null; @@ -204,38 +213,77 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type { proxy_auth_slice = proxy_auth_decoded.?.slice(); } - // Parse proxy headers (temporary, freed after building CONNECT request) - var proxy_hdrs: ?Headers = null; - defer if (proxy_hdrs) |*hdrs| hdrs.deinit(); + var socks: ?SocksProxy = null; + if (kind.isSocks()) { + var username_slice: ?jsc.ZigString.Slice = null; + defer if (username_slice) |s| s.deinit(); + if (proxy_username) |username| username_slice = username.toUTF8(allocator); + + var password_slice: ?jsc.ZigString.Slice = null; + defer if (password_slice) |s| s.deinit(); + if (proxy_password) |password| password_slice = password.toUTF8(allocator); + + socks = SocksProxy.initWithCredentials( + allocator, + kind, + if (username_slice) |s| s.slice() else "", + if (password_slice) |s| s.slice() else "", + ) catch |err| { + switch (err) { + error.OutOfMemory => bun.outOfMemory(), + else => { + allocator.free(body); + return null; + }, + } + }; + socks.?.begin() catch |err| bun.handleOom(err); + connect_request = allocator.dupe(u8, socks.?.write_buffer.slice()) catch |err| bun.handleOom(err); + socks.?.write_buffer.cursor = socks.?.write_buffer.list.items.len; + socks.?.write_buffer.reset(); + } else { + // Parse proxy headers (temporary, freed after building CONNECT request) + var proxy_hdrs: ?Headers = null; + defer if (proxy_hdrs) |*hdrs| hdrs.deinit(); - // Headers8Bit.init / toHeaders only return Allocator.Error; - // OOM should crash, not silently become a connection failure. - const proxy_extra_headers = Headers8Bit.init(allocator, proxy_header_names, proxy_header_values, proxy_header_count) catch |err| bun.handleOom(err); - defer proxy_extra_headers.deinit(); + // Headers8Bit.init / toHeaders only return Allocator.Error; + // OOM should crash, not silently become a connection failure. + const proxy_extra_headers = Headers8Bit.init(allocator, proxy_header_names, proxy_header_values, proxy_header_count) catch |err| bun.handleOom(err); + defer proxy_extra_headers.deinit(); - if (proxy_header_count > 0) { - proxy_hdrs = proxy_extra_headers.toHeaders(allocator) catch |err| bun.handleOom(err); - } + if (proxy_header_count > 0) { + proxy_hdrs = proxy_extra_headers.toHeaders(allocator) catch |err| bun.handleOom(err); + } - // Build CONNECT request (proxy_auth and proxy_hdrs are freed by defer after this). - // buildConnectRequest only returns Allocator.Error; crash on OOM. - connect_request = buildConnectRequest( - host_slice.slice(), - port, - proxy_auth_slice, - proxy_hdrs, - ) catch |err| bun.handleOom(err); + // Build CONNECT request (proxy_auth and proxy_hdrs are freed by defer after this). + // buildConnectRequest only returns Allocator.Error; crash on OOM. + connect_request = buildConnectRequest( + host_slice.slice(), + port, + proxy_auth_slice, + proxy_hdrs, + ) catch |err| bun.handleOom(err); + } // Duplicate target_host (needed for SNI during TLS handshake). // allocator.dupe only returns Allocator.Error; crash on OOM. const target_host_dup = allocator.dupe(u8, host_slice.slice()) catch |err| bun.handleOom(err); + const proxy_host_dup = if (kind.isSocks()) + allocator.dupe(u8, proxy_host_slice.?.slice()) catch |err| bun.handleOom(err) + else + &[_]u8{}; 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, + port, + proxy_host_dup, + proxy_port, body, + kind, + socks, ); } @@ -390,6 +438,7 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type { this.subprotocols.clearAndFree(); this.clearInput(); + this.clearSocksResolvedAddresses(); this.body.clearAndFree(bun.default_allocator); if (this.hostname.len > 0) { @@ -397,6 +446,12 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type { this.hostname = ""; } + // Cancel any pending SOCKS DNS resolution + if (this.socks_dns_pending) |pending| { + pending.markCancelled(); + this.socks_dns_pending = null; + } + // Clean up proxy state. Null the field and detach the tunnel's // back-reference before deinit so that SSLWrapper shutdown callbacks // cannot re-enter clearData() while the proxy is still reachable. @@ -418,6 +473,15 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type { this.secure = null; } } + + fn clearSocksResolvedAddresses(this: *HTTPClient) void { + if (this.socks_resolved_addresses.len > 0) { + bun.default_allocator.free(this.socks_resolved_addresses); + this.socks_resolved_addresses = &.{}; + } + this.socks_resolved_address_index = 0; + } + pub fn cancel(this: *HTTPClient) callconv(.c) void { this.clearData(); @@ -578,6 +642,12 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type { // Handle proxy handshake response if (this.state == .proxy_handshake) { + if (this.proxy) |*p| { + if (p.isSocks()) { + this.handleSocksProxyResponse(socket, data); + return; + } + } this.handleProxyResponse(socket, data); return; } @@ -717,6 +787,230 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type { } } + fn handleSocksProxyResponse(this: *HTTPClient, socket: Socket, data: []const u8) void { + const p = if (this.proxy) |*proxy| proxy else { + this.terminate(ErrorCode.proxy_tunnel_failed); + return; + }; + const socks = if (p.socks) |*s| s else { + this.terminate(ErrorCode.proxy_tunnel_failed); + return; + }; + + const result = socks.receive(data, p.getTargetHost(), p.target_port) catch |err| { + switch (err) { + error.OutOfMemory => bun.outOfMemory(), + else => { + if (this.retrySocksWithNextAddress(socket, err)) { + return; + } + this.terminate(socksErrorCode(err)); + return; + }, + } + }; + switch (result) { + .needs_dns_resolve => { + this.startSocksDnsResolve(); + return; + }, + else => {}, + } + socks.flush(socket) catch { + this.terminate(ErrorCode.failed_to_write); + return; + }; + if (socks.hasPendingWrite()) { + return; + } + switch (result) { + .connected => { + this.body.clearRetainingCapacity(); + if (p.isTargetHttps()) { + this.startProxyTLSHandshake(socket, socks.read_buffer.slice()); + return; + } + + this.state = .reading; + if (this.input_body_buf.len > 0) { + bun.default_allocator.free(this.input_body_buf); + } + this.input_body_buf = p.takeWebsocketRequestBuf(); + 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))..]; + + const remain_buf = socks.read_buffer.slice(); + if (remain_buf.len > 0) { + this.handleData(socket, remain_buf); + } + }, + .pending => {}, + .needs_dns_resolve => unreachable, + } + } + + fn startSocksDnsResolve(this: *HTTPClient) void { + const p = if (this.proxy) |*proxy| proxy else { + this.terminate(ErrorCode.proxy_tunnel_failed); + return; + }; + const target_host = p.getTargetHost(); + var temp_buf: [256:0]u8 = undefined; + const hostname_z: [:0]const u8 = brk: { + if (target_host.len < temp_buf.len) { + @memcpy(temp_buf[0..target_host.len], target_host); + temp_buf[target_host.len] = 0; + break :brk temp_buf[0..target_host.len :0]; + } + break :brk bun.default_allocator.dupeZ(u8, target_host) catch |e| bun.handleOom(e); + }; + const needs_free = target_host.len >= temp_buf.len; + defer if (needs_free) bun.default_allocator.free(hostname_z); + + const loop = jsc.VirtualMachine.get().uwsLoop(); + var is_cache_hit: bool = false; + const dns_req = bun.dns.internal.getaddrinfo( + loop, hostname_z, p.target_port, &is_cache_hit, + ) orelse { + this.terminate(ErrorCode.proxy_connect_failed); + return; + }; + + if (is_cache_hit) { + this.completeSocksFromDnsReqWs(dns_req); + return; + } + + if (bun.dns.internal.registerSocksIfPending( + dns_req, + if (ssl) .{ .ws_tls = this } else .{ .ws_non_tls = this }, + loop, + )) |pending| { + this.ref(); // prevent dealloc while DNS pending + this.socks_dns_pending = pending; + } else { + this.completeSocksFromDnsReqWs(dns_req); + } + } + + fn completeSocksFromDnsReqWs(this: *HTTPClient, dns_req: *bun.dns.internal.Request) void { + defer bun.dns.internal.freeaddrinfo(dns_req, 0); + this.continueSocksAfterDNSRequest(dns_req); + } + + pub fn continueSocksAfterDNSRequest(this: *HTTPClient, dns_req: *bun.dns.internal.Request) void { + const result = dns_req.result orelse { + this.terminate(ErrorCode.proxy_connect_failed); + return; + }; + if (result.err != 0 or result.info == null) { + this.terminate(ErrorCode.proxy_connect_failed); + return; + } + const p = if (this.proxy) |*proxy| proxy else { + this.terminate(ErrorCode.proxy_tunnel_failed); + return; + }; + this.clearSocksResolvedAddresses(); + this.socks_resolved_addresses = collectSocksResolvedAddresses(result.info.?) catch |err| bun.handleOom(err); + if (this.socks_resolved_addresses.len == 0) { + this.terminate(ErrorCode.proxy_connect_failed); + return; + } + this.continueSocksAfterNextDNSAddress(p.target_port); + } + + /// Resume SOCKS5 CONNECT after DNS resolves. Called from + /// SocksDNSPending.onDNSResolved on the JS main thread. + fn continueSocksAfterNextDNSAddress(this: *HTTPClient, target_port: u16) void { + if (this.socks_resolved_address_index >= this.socks_resolved_addresses.len) { + this.terminate(ErrorCode.proxy_connect_failed); + return; + } + const address = this.socks_resolved_addresses[this.socks_resolved_address_index]; + this.socks_resolved_address_index += 1; + this.continueSocksAfterDNS(address, target_port); + } + + pub fn continueSocksAfterDNS(this: *HTTPClient, address: std.net.Address, target_port: u16) void { + this.socks_dns_pending = null; + const p = if (this.proxy) |*proxy| proxy else { + this.terminate(ErrorCode.proxy_tunnel_failed); + return; + }; + const socks = if (p.socks) |*s| s else { + this.terminate(ErrorCode.proxy_tunnel_failed); + return; + }; + socks.writeConnectResolved(address, target_port) catch |err| { + switch (err) { + error.OutOfMemory => bun.outOfMemory(), + else => { + this.terminate(ErrorCode.proxy_tunnel_failed); + return; + }, + } + }; + socks.flush(this.tcp) catch { + this.terminate(ErrorCode.failed_to_write); + return; + }; + } + + fn retrySocksWithNextAddress(this: *HTTPClient, socket: Socket, err: anyerror) bool { + if (!isRetryableSocksAddressError(err) or this.socks_resolved_address_index >= this.socks_resolved_addresses.len) { + return false; + } + const p = if (this.proxy) |*proxy| proxy else return false; + const socks = if (p.socks) |*s| s else return false; + const address = this.socks_resolved_addresses[this.socks_resolved_address_index]; + this.socks_resolved_address_index += 1; + + socks.read_buffer.reset(); + socks.write_buffer.reset(); + socks.state = .idle; + socks.useResolvedAddressForNextConnect(address); + socks.begin() catch |oom| bun.handleOom(oom); + + this.clearInput(); + this.input_body_buf = bun.default_allocator.dupe(u8, socks.write_buffer.slice()) catch |oom| bun.handleOom(oom); + socks.write_buffer.cursor = socks.write_buffer.list.items.len; + socks.write_buffer.reset(); + this.to_send = ""; + this.body.clearRetainingCapacity(); + + if (socket.ext(?*HTTPClient)) |ext| { + ext.* = null; + } + socket.close(.failure); + this.tcp = .{ .socket = .{ .detached = {} } }; + + const vm = jsc.VirtualMachine.get(); + const group = vm.rareData().wsUpgradeGroup(vm, ssl); + const kind: uws.SocketKind = if (ssl) .ws_client_upgrade_tls else .ws_client_upgrade; + if (Socket.connectGroup( + group, + kind, + null, + p.getProxyHost(), + p.proxy_port, + this, + false, + )) |new_socket| { + this.tcp = new_socket; + this.tcp.timeout(120); + this.state = .reading; + } else |_| { + this.terminate(ErrorCode.failed_to_connect); + this.deref(); + } + return true; + } + /// Start TLS handshake inside the proxy tunnel for wss:// connections fn startProxyTLSHandshake(this: *HTTPClient, socket: Socket, initial_data: []const u8) void { log("startProxyTLSHandshake", .{}); @@ -1126,6 +1420,16 @@ pub fn NewHTTPUpgradeClient(comptime ssl: bool) type { this.to_send = this.to_send[@min(wrote, this.to_send.len)..]; return; } + + if (p.socks) |*socks| { + if (socks.hasPendingWrite()) { + socks.flush(socket) catch { + this.terminate(ErrorCode.failed_to_write); + return; + }; + if (socks.hasPendingWrite()) return; + } + } } if (this.to_send.len == 0) @@ -1532,6 +1836,48 @@ pub fn freeSSLConfig(config: *SSLConfig) callconv(.c) void { bun.default_allocator.destroy(config); } +fn socksErrorCode(err: anyerror) ErrorCode { + return switch (err) { + error.SocksAuthenticationFailed, error.SocksNoAcceptableAuthMethod, error.SocksCredentialsIncomplete => .proxy_authentication_required, + error.SocksConnectionRefused => .proxy_connection_refused, + else => .proxy_connect_failed, + }; +} + +fn isRetryableSocksAddressError(err: anyerror) bool { + return switch (err) { + error.SocksGeneralFailure, + error.SocksNetworkUnreachable, + error.SocksHostUnreachable, + error.SocksConnectionRefused, + error.SocksTTLExpired, + error.SocksAddressTypeNotSupported, + => true, + else => false, + }; +} + +fn collectSocksResolvedAddresses(result_info: anytype) ![]std.net.Address { + const Entry = @TypeOf(result_info[0]); + var addresses: std.ArrayListUnmanaged(std.net.Address) = .{}; + errdefer addresses.deinit(bun.default_allocator); + + var current: ?[*]Entry = result_info; + while (current) |entries| { + const entry = &entries[0]; + if (bun.http.SocksDNSPending.addrFromSockaddr(&entry.addr)) |address| { + try addresses.append(bun.default_allocator, address); + } else |_| {} + + current = if (entry.info.next) |next_info| brk: { + const next_entry: *Entry = @fieldParentPtr("info", next_info); + break :brk @as([*]Entry, @ptrCast(next_entry)); + } else null; + } + + return addresses.toOwnedSlice(bun.default_allocator); +} + comptime { @export(&parseSSLConfig, .{ .name = "Bun__WebSocket__parseSSLConfig" }); @export(&freeSSLConfig, .{ .name = "Bun__WebSocket__freeSSLConfig" }); @@ -1540,6 +1886,7 @@ comptime { const WebSocketDeflate = @import("./WebSocketDeflate.zig"); const WebSocketProxy = @import("./WebSocketProxy.zig"); const WebSocketProxyTunnel = @import("./WebSocketProxyTunnel.zig"); +const SocksProxy = @import("../../http/SocksProxy.zig"); const std = @import("std"); const CppWebSocket = @import("./CppWebSocket.zig").CppWebSocket; diff --git a/src/jsc/bindings/headers.h b/src/jsc/bindings/headers.h index c85e73790653..43da6cd7b75d 100644 --- a/src/jsc/bindings/headers.h +++ b/src/jsc/bindings/headers.h @@ -605,7 +605,9 @@ ZIG_DECL WebSocketHTTPClient* Bun__WebSocketHTTPClient__connect( JSC::JSGlobalObject* globalObject, CppWebSocket* websocket, const BunString* host, uint16_t port, const BunString* path, const BunString* protocols, BunString* headerNames, BunString* headerValues, size_t headerCount, - const BunString* proxyHost, uint16_t proxyPort, + const BunString* proxyHost, uint16_t proxyPort, uint8_t proxyKind, + const BunString* proxyUsername, + const BunString* proxyPassword, const BunString* proxyAuthorization, BunString* proxyHeaderNames, BunString* proxyHeaderValues, size_t proxyHeaderCount, void* sslConfig, bool targetIsSecure, @@ -622,7 +624,9 @@ ZIG_DECL WebSocketHTTPSClient* Bun__WebSocketHTTPSClient__connect( JSC::JSGlobalObject* globalObject, CppWebSocket* websocket, const BunString* host, uint16_t port, const BunString* path, const BunString* protocols, BunString* headerNames, BunString* headerValues, size_t headerCount, - const BunString* proxyHost, uint16_t proxyPort, + const BunString* proxyHost, uint16_t proxyPort, uint8_t proxyKind, + const BunString* proxyUsername, + const BunString* proxyPassword, const BunString* proxyAuthorization, BunString* proxyHeaderNames, BunString* proxyHeaderValues, size_t proxyHeaderCount, void* sslConfig, bool targetIsSecure, diff --git a/src/jsc/bindings/webcore/WebSocket.cpp b/src/jsc/bindings/webcore/WebSocket.cpp index ddfbe3da8540..391c31deecd7 100644 --- a/src/jsc/bindings/webcore/WebSocket.cpp +++ b/src/jsc/bindings/webcore/WebSocket.cpp @@ -230,8 +230,11 @@ struct ProxyConfig { String host; uint16_t port { 0 }; String authorization; + String username; + String password; Vector> headers; bool isHTTPS { false }; + uint8_t kind { 0 }; // 1=http, 2=https, 3=socks5, 4=socks5h }; static ExceptionOr> setupProxy(const String& proxyUrl, std::optional&& proxyHeaders) @@ -245,11 +248,25 @@ static ExceptionOr> setupProxy(const String& proxyUrl ProxyConfig config; config.host = url.host().toString(); - config.isHTTPS = url.protocolIs("https"_s); - config.port = url.port().value_or(config.isHTTPS ? 443 : 80); + config.username = url.user(); + config.password = url.password(); + if (url.protocolIs("https"_s)) { + config.isHTTPS = true; + config.kind = 2; + config.port = url.port().value_or(443); + } else if (url.protocolIs("socks5"_s)) { + config.kind = 3; + config.port = url.port().value_or(1080); + } else if (url.protocolIs("socks5h"_s)) { + config.kind = 4; + config.port = url.port().value_or(1080); + } else { + config.kind = 1; + config.port = url.port().value_or(80); + } // Compute Basic auth from proxy URL credentials - if (!url.user().isEmpty()) { + if (!url.user().isEmpty() && config.kind != 3 && config.kind != 4) { auto credentials = makeString(url.user(), ':', url.password()); auto utf8 = credentials.utf8(); auto encoded = base64EncodeToString(std::span(reinterpret_cast(utf8.data()), utf8.length())); @@ -671,6 +688,8 @@ ExceptionOr WebSocket::connect(const String& url, const Vector& pr // and remain valid for the duration of the connect() call. BunString proxyHost = hasProxy ? Bun::toString(proxyConfig->host) : BunString { BunStringTag::Empty }; BunString proxyAuth = hasProxy ? Bun::toString(proxyConfig->authorization) : BunString { BunStringTag::Empty }; + BunString proxyUsername = hasProxy ? Bun::toString(proxyConfig->username) : BunString { BunStringTag::Empty }; + BunString proxyPassword = hasProxy ? Bun::toString(proxyConfig->password) : BunString { BunStringTag::Empty }; uint16_t proxyPort = hasProxy ? proxyConfig->port : 0; Vector proxyHeaderNames; @@ -708,7 +727,9 @@ ExceptionOr WebSocket::connect(const String& url, const Vector& pr scriptExecutionContext()->jsGlobalObject(), reinterpret_cast(this), &host, port, &path, &clientProtocolString, headerNames.begin(), headerValues.begin(), headerNames.size(), - hasProxy ? &proxyHost : nullptr, proxyPort, + hasProxy ? &proxyHost : nullptr, proxyPort, hasProxy ? proxyConfig->kind : 0, + hasProxy ? &proxyUsername : nullptr, + hasProxy ? &proxyPassword : nullptr, (hasProxy && !proxyConfig->authorization.isEmpty()) ? &proxyAuth : nullptr, proxyHeaderNames.begin(), proxyHeaderValues.begin(), proxyHeaderNames.size(), sslConfig, is_secure, @@ -720,7 +741,9 @@ ExceptionOr WebSocket::connect(const String& url, const Vector& pr scriptExecutionContext()->jsGlobalObject(), reinterpret_cast(this), &host, port, &path, &clientProtocolString, headerNames.begin(), headerValues.begin(), headerNames.size(), - hasProxy ? &proxyHost : nullptr, proxyPort, + hasProxy ? &proxyHost : nullptr, proxyPort, hasProxy ? proxyConfig->kind : 0, + hasProxy ? &proxyUsername : nullptr, + hasProxy ? &proxyPassword : nullptr, (hasProxy && !proxyConfig->authorization.isEmpty()) ? &proxyAuth : nullptr, proxyHeaderNames.begin(), proxyHeaderValues.begin(), proxyHeaderNames.size(), sslConfig, is_secure, diff --git a/src/runtime/dns_jsc/dns.zig b/src/runtime/dns_jsc/dns.zig index 9659715eb07d..31ff76843751 100644 --- a/src/runtime/dns_jsc/dns.zig +++ b/src/runtime/dns_jsc/dns.zig @@ -1437,12 +1437,14 @@ pub const internal = struct { socket: *bun.uws.ConnectingSocket, prefetch: *bun.uws.Loop, quic: *bun.http.H3.PendingConnect, + socks: *bun.http.SocksDNSPending, pub fn notifyThreadsafe(this: DNSRequestOwner, req: *Request) void { switch (this) { .socket => |socket| us_internal_dns_callback_threadsafe(socket, req), .prefetch => freeaddrinfo(req, 0), .quic => |pc| pc.onDNSResolvedThreadsafe(), + .socks => |pending| pending.onDNSResolvedThreadsafe(), } } @@ -1451,6 +1453,7 @@ pub const internal = struct { .prefetch => freeaddrinfo(req, 0), .socket => us_internal_dns_callback(this.socket, req), .quic => |pc| pc.onDNSResolved(), + .socks => |pending| pending.onDNSResolved(), } } @@ -1459,6 +1462,7 @@ pub const internal = struct { .prefetch => this.prefetch, .socket => this.socket.loop(), .quic => |pc| pc.loop(), + .socks => |pending| pending.loop(), }; } }; @@ -1480,6 +1484,33 @@ pub const internal = struct { global_cache.lock.unlock(); } + /// Atomically create a SocksDNSPending and register it on the DNS + /// request's notify list while holding the cache lock. Returns null + /// if the result is already available (caller handles sync path). + /// This prevents the sync-notify UAF: if result is null under the + /// lock, the pending cannot be notify'd until afterResult moves the + /// list out, which requires the same lock. + pub fn registerSocksIfPending( + request: *Request, + owner_data: bun.http.SocksDNSPending.OwnerKind, + l: *bun.uws.Loop, + ) ?*bun.http.SocksDNSPending { + global_cache.lock.lock(); + if (request.result != null) { + global_cache.lock.unlock(); + return null; + } + const pending = bun.new(bun.http.SocksDNSPending, .{ + .owner = owner_data, + .loop_ptr = l, + .dns_request = request, + .cancelled = std.atomic.Value(bool).init(false), + }); + bun.handleOom(request.notify.append(bun.default_allocator, .{ .socks = pending })); + global_cache.lock.unlock(); + return pending; + } + const ResultEntry = extern struct { info: std.c.addrinfo, addr: std.c.sockaddr.storage, @@ -1885,13 +1916,13 @@ pub const internal = struct { _ = request.notify.swapRemove(i); return 1; }, - .prefetch, .quic => {}, + .prefetch, .quic, .socks => {}, } } return 0; } - fn freeaddrinfo(req: *Request, err: c_int) callconv(.c) void { + pub fn freeaddrinfo(req: *Request, err: c_int) callconv(.c) void { global_cache.lock.lock(); defer global_cache.lock.unlock(); diff --git a/src/runtime/webcore/fetch/FetchTasklet.zig b/src/runtime/webcore/fetch/FetchTasklet.zig index 968177b7aee8..69f995970fa0 100644 --- a/src/runtime/webcore/fetch/FetchTasklet.zig +++ b/src/runtime/webcore/fetch/FetchTasklet.zig @@ -777,6 +777,19 @@ pub const FetchTasklet = struct { error.FailedToOpenSocket => bun.String.static("Was there a typo in the url or port?"), error.TooManyRedirects => bun.String.static("The response redirected too many times. For more information, pass `verbose: true` in the second argument to fetch()"), error.ConnectionRefused => bun.String.static("Unable to connect. Is the computer able to access the url?"), + error.SocksAuthenticationFailed, error.SocksNoAcceptableAuthMethod => bun.String.static("SOCKS proxy authentication failed."), + error.SocksCredentialsIncomplete => bun.String.static("SOCKS proxy credentials must include both username and password."), + error.SocksConnectionRefused => bun.String.static("SOCKS proxy refused the connection."), + error.SocksGeneralFailure => bun.String.static("SOCKS proxy reported a general failure."), + error.SocksConnectionNotAllowed => bun.String.static("SOCKS proxy reported that the connection is not allowed."), + error.SocksNetworkUnreachable => bun.String.static("SOCKS proxy reported the network is unreachable."), + error.SocksHostUnreachable => bun.String.static("SOCKS proxy reported the host is unreachable."), + error.SocksTTLExpired => bun.String.static("SOCKS proxy reported that the TTL expired."), + error.SocksCommandNotSupported => bun.String.static("SOCKS proxy does not support the requested command."), + error.SocksAddressTypeNotSupported => bun.String.static("SOCKS proxy does not support the requested address type."), + error.SocksCredentialsTooLong => bun.String.static("SOCKS proxy credentials are too long."), + error.SocksDomainTooLong => bun.String.static("SOCKS proxy target hostname is too long."), + error.SocksInvalidResponse => bun.String.static("SOCKS proxy returned an invalid response."), error.RedirectURLInvalid => bun.String.static("Redirect URL in Location header is invalid."), error.UNABLE_TO_GET_ISSUER_CERT => bun.String.static("unable to get issuer certificate"), diff --git a/test/js/bun/http/proxy.test.ts b/test/js/bun/http/proxy.test.ts index 38d95741c1ef..78c5886ecda7 100644 --- a/test/js/bun/http/proxy.test.ts +++ b/test/js/bun/http/proxy.test.ts @@ -6,6 +6,7 @@ import { HttpsProxyAgent } from "https-proxy-agent"; import { once } from "node:events"; import net from "node:net"; import tls from "node:tls"; +import { createSocksProxy, startProxy } from "../../web/websocket/proxy-test-utils"; async function createProxyServer(is_tls: boolean) { const serverArgs = []; if (is_tls) { @@ -240,11 +241,19 @@ for (const server_tls of [false, true]) { } test("unsupported protocol", async () => { - expect( - fetch("https://httpbin.org/get", { + const tls = { ca: tlsCert.cert }; + const doFetch = () => + fetch(httpsServer.url, { proxy: "ftp://asdf.com", + tls, + }); + + await expect(doFetch()).rejects.toThrowError( + expect.objectContaining({ + code: "UnsupportedProxyProtocol", }), - ).rejects.toThrowError( + ); + await expect(doFetch()).rejects.toThrowError( expect.objectContaining({ code: "UnsupportedProxyProtocol", }), @@ -309,6 +318,48 @@ async function createAuthCapturingProxy() { }; } +async function createConnectionCountingHttpProxy() { + let connections = 0; + const sockets = new Set(); + const upstreamSockets = new Set(); + const server = net.createServer((clientSocket: net.Socket) => { + connections++; + sockets.add(clientSocket); + clientSocket.once("data", data => { + const request = data.toString(); + const [method, path] = request.split(" "); + const url = new URL(path); + const serverSocket = net.connect(Number(url.port || "80"), url.hostname, () => { + serverSocket.write(`${method} ${url.pathname}${url.search || ""} HTTP/1.1\r\n`); + serverSocket.write(data.slice(request.indexOf("\r\n") + 2)); + serverSocket.pipe(clientSocket); + }); + upstreamSockets.add(serverSocket); + clientSocket.on("error", () => {}); + clientSocket.on("close", () => sockets.delete(clientSocket)); + serverSocket.on("close", () => upstreamSockets.delete(serverSocket)); + serverSocket.on("error", () => clientSocket.end()); + }); + }); + + server.listen(0); + await once(server, "listening"); + const port = (server.address() as net.AddressInfo).port; + + return { + port, + get connections() { + return connections; + }, + async close() { + for (const socket of sockets) socket.destroy(); + for (const socket of upstreamSockets) socket.destroy(); + server.close(); + await once(server, "close"); + }, + }; +} + test("proxy with long password (> 4096 chars) sends correct authorization", async () => { const proxy = await createAuthCapturingProxy(); @@ -419,6 +470,40 @@ test("HTTPS proxy tunnel keep-alive reuses CONNECT across sequential requests", expect(connects).toEqual([`CONNECT localhost:${httpsServer.port}`]); }); +test("HTTP proxy honors keepalive: false across sequential requests and redirects", async () => { + const proxy = await createConnectionCountingHttpProxy(); + using redirectServer = Bun.serve({ + port: 0, + fetch(req) { + if (new URL(req.url).pathname === "/redirect") { + return Response.redirect("/final", 302); + } + return new Response("ok"); + }, + }); + + try { + const targetURL = `http://127.0.0.1:${redirectServer.port}/redirect`; + const proxyURL = `http://127.0.0.1:${proxy.port}`; + + const first = await fetch(targetURL, { + proxy: proxyURL, + keepalive: false, + }); + expect(await first.text()).toBe("ok"); + + const second = await fetch(targetURL, { + proxy: proxyURL, + keepalive: false, + }); + expect(await second.text()).toBe("ok"); + + expect(proxy.connections).toBe(4); + } finally { + await proxy.close(); + } +}); + test("HTTPS proxy tunnel keep-alive does not share tunnel across different targets", async () => { // Fresh servers so prior tests' pooled tunnels can't interfere. using serverA = Bun.serve({ port: 0, tls: tlsCert, fetch: () => new Response("a") }); @@ -948,6 +1033,206 @@ describe.concurrent("proxy object format with headers", () => { }); }); +describe("fetch through SOCKS5 proxy", () => { + let socksProxy: net.Server; + let socksAuthProxy: net.Server; + let socksPort: number; + let socksAuthPort: number; + const records: { atyp: number; host: string; port: number; username?: string; password?: string }[] = []; + const authRecords: { atyp: number; host: string; port: number; username?: string; password?: string }[] = []; + + beforeAll(async () => { + socksProxy = createSocksProxy({ records }); + socksPort = await startProxy(socksProxy); + socksAuthProxy = createSocksProxy({ requireAuth: true, records: authRecords }); + socksAuthPort = await startProxy(socksAuthProxy); + }); + + afterAll(() => { + socksProxy?.close(); + socksAuthProxy?.close(); + }); + + test("HTTP fetch through socks5h sends domain to proxy", async () => { + records.length = 0; + const response = await fetch(`http://127.0.0.1:${httpServer.port}/`, { + proxy: `socks5h://127.0.0.1:${socksPort}`, + }); + + expect(response.status).toBe(200); + expect(records[0]).toMatchObject({ atyp: 0x01, host: "127.0.0.1", port: httpServer.port }); + }); + + test("HTTPS fetch through socks5h proxy", async () => { + records.length = 0; + const response = await fetch(`https://127.0.0.1:${httpsServer.port}/`, { + proxy: `socks5h://127.0.0.1:${socksPort}`, + tls: { + rejectUnauthorized: false, + }, + }); + + expect(response.status).toBe(200); + expect(records[0]).toMatchObject({ atyp: 0x01, host: "127.0.0.1", port: httpsServer.port }); + }); + + test("HTTP POST through socks5 proxy with username/password", async () => { + authRecords.length = 0; + const response = await fetch(`http://127.0.0.1:${httpServer.port}/`, { + method: "POST", + body: "hello via socks auth", + proxy: `socks5://proxy_user:proxy_pa%73s@127.0.0.1:${socksAuthPort}`, + }); + + expect(await response.text()).toBe("hello via socks auth"); + expect(authRecords[0]).toMatchObject({ + atyp: 0x01, + host: "127.0.0.1", + port: httpServer.port, + username: "proxy_user", + password: "proxy_pass", + }); + }); + + test("redirect through socks5 creates a new tunnel", async () => { + records.length = 0; + using server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url); + if (url.pathname === "/redirect") { + return Response.redirect("/done", 302); + } + return new Response("redirected through socks"); + }, + }); + + const response = await fetch(`${server.url.origin}/redirect`, { + proxy: `socks5h://127.0.0.1:${socksPort}`, + }); + + expect(await response.text()).toBe("redirected through socks"); + expect(records).toHaveLength(2); + expect(records[0]).toMatchObject({ atyp: 0x03, host: server.url.hostname, port: server.port }); + expect(records[1]).toMatchObject({ atyp: 0x03, host: server.url.hostname, port: server.port }); + }); + + test("socks5:// with localhost resolves client-side (ATYP != 0x03)", async () => { + records.length = 0; + const response = await fetch(`http://localhost:${httpServer.port}/`, { + proxy: `socks5://127.0.0.1:${socksPort}`, + }); + + expect(response.status).toBe(200); + // socks5:// must resolve DNS client-side: ATYP should be 0x01 (IPv4) + // or 0x04 (IPv6), never 0x03 (domain) + expect(records[0].atyp).not.toBe(0x03); + expect(records[0].port).toBe(httpServer.port); + }); + + test("HTTPS fetch through socks5:// with hostname resolves client-side", async () => { + records.length = 0; + const response = await fetch(`https://localhost:${httpsServer.port}/`, { + proxy: `socks5://127.0.0.1:${socksPort}`, + tls: { + rejectUnauthorized: false, + }, + }); + + expect(response.status).toBe(200); + // socks5:// must send resolved IP, not domain name + expect(records[0].atyp).not.toBe(0x03); + expect(records[0].port).toBe(httpsServer.port); + }); + + test("socks5:// with unresolvable hostname rejects request", async () => { + await expect( + fetch(`http://this-hostname-does-not-exist-bun-test.invalid:${httpServer.port}/`, { + proxy: `socks5://127.0.0.1:${socksPort}`, + }), + ).rejects.toThrow(); + }); + + test("socks5:// with password but no username rejects request", async () => { + await expect( + fetch(`http://127.0.0.1:${httpServer.port}/`, { + proxy: `socks5://:proxy_pass@127.0.0.1:${socksPort}`, + }), + ).rejects.toThrow("SOCKS proxy credentials must include both username and password."); + }); + + test("socks5:// with username but no password rejects request", async () => { + await expect( + fetch(`http://127.0.0.1:${httpServer.port}/`, { + proxy: `socks5://proxy_user@127.0.0.1:${socksPort}`, + }), + ).rejects.toThrow("SOCKS proxy credentials must include both username and password."); + }); + + test("socks5:// maps general proxy failure", async () => { + const failingProxy = createSocksProxy({ connectFailureCode: 0x01 }); + const failingPort = await startProxy(failingProxy); + try { + await expect( + fetch(`http://127.0.0.1:${httpServer.port}/`, { + proxy: `socks5://127.0.0.1:${failingPort}`, + }), + ).rejects.toThrow("SOCKS proxy reported a general failure."); + } finally { + failingProxy.close(); + } + }); + + test("socks5:// maps connection not allowed proxy failure", async () => { + const failingProxy = createSocksProxy({ connectFailureCode: 0x02 }); + const failingPort = await startProxy(failingProxy); + try { + await expect( + fetch(`http://127.0.0.1:${httpServer.port}/`, { + proxy: `socks5://127.0.0.1:${failingPort}`, + }), + ).rejects.toThrow("SOCKS proxy reported that the connection is not allowed."); + } finally { + failingProxy.close(); + } + }); + + test("socks5h:// with hostname sends domain to proxy (ATYP 0x03)", async () => { + records.length = 0; + const response = await fetch(`http://localhost:${httpServer.port}/`, { + proxy: `socks5h://127.0.0.1:${socksPort}`, + }); + + expect(response.status).toBe(200); + // socks5h sends domain name, proxy resolves + expect(records[0]).toMatchObject({ atyp: 0x03, host: "localhost", port: httpServer.port }); + }); + + test("redirect through socks5:// re-resolves DNS for new tunnel", async () => { + records.length = 0; + using server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url); + if (url.pathname === "/redirect") { + return Response.redirect("/done", 302); + } + return new Response("redirected via socks5 dns"); + }, + }); + + const response = await fetch(`http://localhost:${server.port}/redirect`, { + proxy: `socks5://127.0.0.1:${socksPort}`, + }); + + expect(await response.text()).toBe("redirected via socks5 dns"); + // Two tunnels: one for /redirect, one for /done — both client-side DNS + expect(records).toHaveLength(2); + expect(records[0].atyp).not.toBe(0x03); + expect(records[1].atyp).not.toBe(0x03); + }); +}); + describe.concurrent("NO_PROXY with explicit proxy option", () => { // These tests use subprocess spawning because NO_PROXY is read from the // process environment at startup. A dead proxy that immediately closes diff --git a/test/js/first_party/ws/ws-proxy.test.ts b/test/js/first_party/ws/ws-proxy.test.ts index a3166ec07b42..7aa576393160 100644 --- a/test/js/first_party/ws/ws-proxy.test.ts +++ b/test/js/first_party/ws/ws-proxy.test.ts @@ -4,7 +4,7 @@ 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"; +import { createConnectProxy, createSocksProxy, createTLSConnectProxy, startProxy } from "../../web/websocket/proxy-test-utils"; // Use dynamic require to avoid linter removing the import const { HttpsProxyAgent } = require("https-proxy-agent") as { @@ -14,11 +14,15 @@ const { HttpsProxyAgent } = require("https-proxy-agent") as { // HTTP CONNECT proxy server for WebSocket tunneling let proxy: net.Server; let authProxy: net.Server; +let socksProxy: net.Server; +let socksAuthProxy: net.Server; let httpsProxy: tls.Server; let wsServer: ReturnType; let wssServer: ReturnType; let proxyPort: number; let authProxyPort: number; +let socksPort: number; +let socksAuthPort: number; let httpsProxyPort: number; let wsPort: number; let wssPort: number; @@ -32,6 +36,12 @@ beforeAll(async () => { authProxy = createConnectProxy({ requireAuth: true }); authProxyPort = await startProxy(authProxy); + socksProxy = createSocksProxy(); + socksPort = await startProxy(socksProxy); + + socksAuthProxy = createSocksProxy({ requireAuth: true }); + socksAuthPort = await startProxy(socksAuthProxy); + // Create HTTPS CONNECT proxy httpsProxy = createTLSConnectProxy(); httpsProxyPort = await startProxy(httpsProxy); @@ -86,6 +96,8 @@ beforeAll(async () => { afterAll(() => { proxy?.close(); authProxy?.close(); + socksProxy?.close(); + socksAuthProxy?.close(); httpsProxy?.close(); wsServer?.stop(true); wssServer?.stop(true); @@ -143,6 +155,76 @@ describe("ws package proxy API", () => { }); }); +describe("ws package through SOCKS5 proxy", () => { + test("ws:// through socks5h proxy", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + + const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { + proxy: `socks5h://127.0.0.1:${socksPort}`, + }); + + const receivedMessages: string[] = []; + + ws.on("open", () => { + ws.send("hello through socks from ws package"); + }); + + 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 through socks from ws package"); + gc(); + }); + + test("ws:// through socks5 proxy with auth", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + + const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { + proxy: `socks5://proxy_user:proxy_pa%73s@127.0.0.1:${socksAuthPort}`, + }); + + const receivedMessages: string[] = []; + + ws.on("open", () => { + ws.send("hello through socks auth from ws package"); + }); + + 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 through socks auth from ws package"); + gc(); + }); +}); + describe("ws package through HTTP CONNECT proxy", () => { test("ws:// through HTTP proxy", async () => { const { promise, resolve, reject } = Promise.withResolvers(); diff --git a/test/js/web/websocket/proxy-test-utils.ts b/test/js/web/websocket/proxy-test-utils.ts index 315cda5e4770..1f0846eece0f 100644 --- a/test/js/web/websocket/proxy-test-utils.ts +++ b/test/js/web/websocket/proxy-test-utils.ts @@ -11,6 +11,20 @@ export interface ConnectProxyOptions { requireAuth?: boolean; } +export interface SocksProxyRecord { + atyp: number; + host: string; + port: number; + username?: string; + password?: string; +} + +export interface SocksProxyOptions { + requireAuth?: boolean; + records?: SocksProxyRecord[]; + connectFailureCode?: number; +} + /** * Create an HTTP CONNECT proxy server using Node's net module. * This proxy handles the CONNECT method to establish tunnels for WebSocket connections. @@ -184,6 +198,143 @@ export function createTLSConnectProxy(): tls.Server { ); } +export function createSocksProxy(options: SocksProxyOptions = {}): net.Server { + return net.createServer(clientSocket => { + let buffer = Buffer.alloc(0); + let stage: "method" | "auth" | "connect" | "tunnel" = "method"; + let targetSocket: net.Socket | null = null; + let auth: { username?: string; password?: string } = {}; + + const fail = (code: number) => { + clientSocket.write(Buffer.from([0x05, code, 0x00, 0x01, 0, 0, 0, 0, 0, 0])); + clientSocket.end(); + }; + + clientSocket.on("data", data => { + if (stage === "tunnel" && targetSocket) { + targetSocket.write(data); + return; + } + + buffer = Buffer.concat([buffer, data]); + + while (true) { + if (stage === "method") { + if (buffer.length < 2) return; + if (buffer[0] !== 0x05) { + clientSocket.destroy(); + return; + } + const nmethods = buffer[1]; + if (buffer.length < 2 + nmethods) return; + const methods = new Set(buffer.subarray(2, 2 + nmethods)); + const selectedMethod = options.requireAuth ? 0x02 : 0x00; + if (!methods.has(selectedMethod)) { + clientSocket.write(Buffer.from([0x05, 0xff])); + clientSocket.end(); + return; + } + buffer = buffer.subarray(2 + nmethods); + clientSocket.write(Buffer.from([0x05, selectedMethod])); + stage = options.requireAuth ? "auth" : "connect"; + continue; + } + + if (stage === "auth") { + if (buffer.length < 2) return; + if (buffer[0] !== 0x01) { + clientSocket.destroy(); + return; + } + const ulen = buffer[1]; + if (buffer.length < 2 + ulen + 1) return; + const plen = buffer[2 + ulen]; + if (buffer.length < 3 + ulen + plen) return; + const username = buffer.subarray(2, 2 + ulen).toString(); + const password = buffer.subarray(3 + ulen, 3 + ulen + plen).toString(); + buffer = buffer.subarray(3 + ulen + plen); + auth = { username, password }; + if (username !== "proxy_user" || password !== "proxy_pass") { + clientSocket.write(Buffer.from([0x01, 0x01])); + clientSocket.end(); + return; + } + clientSocket.write(Buffer.from([0x01, 0x00])); + stage = "connect"; + continue; + } + + if (stage === "connect") { + if (buffer.length < 5) return; + if (buffer[0] !== 0x05 || buffer[2] !== 0x00) { + fail(0x01); + return; + } + if (buffer[1] !== 0x01) { + fail(0x07); + return; + } + const atyp = buffer[3]; + let offset = 4; + let host = ""; + if (atyp === 0x01) { + if (buffer.length < offset + 4 + 2) return; + host = `${buffer[offset]}.${buffer[offset + 1]}.${buffer[offset + 2]}.${buffer[offset + 3]}`; + offset += 4; + } else if (atyp === 0x03) { + const len = buffer[offset++]; + if (buffer.length < offset + len + 2) return; + host = buffer.subarray(offset, offset + len).toString(); + offset += len; + } else if (atyp === 0x04) { + if (buffer.length < offset + 16 + 2) return; + const parts: string[] = []; + for (let i = 0; i < 8; i++) { + parts.push(buffer.readUInt16BE(offset + i * 2).toString(16)); + } + host = parts.join(":"); + offset += 16; + } else { + fail(0x08); + return; + } + const port = buffer.readUInt16BE(offset); + const remaining = buffer.subarray(offset + 2); + buffer = Buffer.alloc(0); + options.records?.push({ atyp, host, port, ...auth }); + + if (options.connectFailureCode != null) { + fail(options.connectFailureCode); + return; + } + + targetSocket = net.connect(port, host, () => { + clientSocket.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0])); + stage = "tunnel"; + if (remaining.length > 0) targetSocket!.write(remaining); + targetSocket!.on("data", chunk => clientSocket.write(chunk)); + }); + targetSocket.on("error", () => { + if (stage !== "tunnel") { + fail(0x05); + return; + } + targetSocket?.destroy(); + clientSocket.destroy(); + }); + targetSocket.on("close", () => clientSocket.destroy()); + clientSocket.on("close", () => targetSocket?.destroy()); + return; + } + + return; + } + }); + + clientSocket.on("error", () => targetSocket?.destroy()); + }); +} + /** * Helper to start a proxy server and get its port. */ diff --git a/test/js/web/websocket/websocket-proxy.test.ts b/test/js/web/websocket/websocket-proxy.test.ts index ae1f5b759dda..82915f319095 100644 --- a/test/js/web/websocket/websocket-proxy.test.ts +++ b/test/js/web/websocket/websocket-proxy.test.ts @@ -4,7 +4,7 @@ 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"; +import { createConnectProxy, createSocksProxy, 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; @@ -806,3 +806,167 @@ describe.concurrent("WebSocket NO_PROXY bypass", () => { expect(exitCode).toBe(0); }); }); + +describe("WebSocket through SOCKS5 proxy", () => { + let socksProxy: net.Server; + let socksAuthProxy: net.Server; + let socksPort: number; + let socksAuthPort: number; + const records: { atyp: number; host: string; port: number; username?: string; password?: string }[] = []; + const authRecords: { atyp: number; host: string; port: number; username?: string; password?: string }[] = []; + + beforeAll(async () => { + socksProxy = createSocksProxy({ records }); + socksPort = await startProxy(socksProxy); + socksAuthProxy = createSocksProxy({ requireAuth: true, records: authRecords }); + socksAuthPort = await startProxy(socksAuthProxy); + }); + + afterAll(() => { + socksProxy?.close(); + socksAuthProxy?.close(); + }); + + test("ws:// through socks5h proxy", async () => { + records.length = 0; + const { promise, resolve, reject } = Promise.withResolvers(); + const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { + proxy: `socks5h://127.0.0.1:${socksPort}`, + }); + const messages: string[] = []; + ws.onopen = () => ws.send("hello via socks"); + ws.onmessage = event => { + messages.push(String(event.data)); + if (messages.length === 2) ws.close(); + }; + ws.onclose = () => resolve(messages); + ws.onerror = reject; + + expect(await promise).toContain("hello via socks"); + expect(records[0]).toMatchObject({ atyp: 0x01, host: "127.0.0.1", port: wsPort }); + }); + + test("wss:// through socks5h proxy", async () => { + records.length = 0; + const { promise, resolve, reject } = Promise.withResolvers(); + const ws = new WebSocket(`wss://127.0.0.1:${wssPort}`, { + proxy: `socks5h://127.0.0.1:${socksPort}`, + tls: { + rejectUnauthorized: false, + }, + }); + const messages: string[] = []; + ws.onopen = () => ws.send("hello via secure socks"); + ws.onmessage = event => { + messages.push(String(event.data)); + if (messages.length === 2) ws.close(); + }; + ws.onclose = () => resolve(messages); + ws.onerror = reject; + + expect(await promise).toContain("hello via secure socks"); + expect(records[0]).toMatchObject({ atyp: 0x01, host: "127.0.0.1", port: wssPort }); + }); + + test("ws:// through socks5 proxy with username/password", async () => { + authRecords.length = 0; + const { promise, resolve, reject } = Promise.withResolvers(); + const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { + proxy: `socks5://proxy_user:proxy_pa%73s@127.0.0.1:${socksAuthPort}`, + }); + const messages: string[] = []; + ws.onopen = () => ws.send("hello via socks auth"); + ws.onmessage = event => { + messages.push(String(event.data)); + if (messages.length === 2) ws.close(); + }; + ws.onclose = () => resolve(messages); + ws.onerror = reject; + + expect(await promise).toContain("hello via socks auth"); + expect(authRecords[0]).toMatchObject({ username: "proxy_user", password: "proxy_pass" }); + }); + + test("ws:// through socks5 proxy rejects username without password", async () => { + const { promise, resolve, reject } = Promise.withResolvers(); + let sawError = false; + const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, { + proxy: `socks5://proxy_user@127.0.0.1:${socksAuthPort}`, + }); + + ws.onopen = () => reject(new Error("Expected SOCKS credential validation to fail before connect")); + ws.onerror = () => { + sawError = true; + resolve(); + }; + ws.onclose = () => { + if (sawError) { + resolve(); + } else { + reject(new Error("Expected SOCKS credential validation to emit an error event")); + } + }; + + await promise; + }); + + test("ws:// through socks5:// with hostname resolves client-side", async () => { + records.length = 0; + const { promise, resolve, reject } = Promise.withResolvers(); + const ws = new WebSocket(`ws://localhost:${wsPort}`, { + proxy: `socks5://127.0.0.1:${socksPort}`, + }); + const messages: string[] = []; + ws.onopen = () => ws.send("hello via socks5 dns"); + ws.onmessage = event => { + messages.push(String(event.data)); + if (messages.length === 2) ws.close(); + }; + ws.onclose = () => resolve(messages); + ws.onerror = reject; + + expect(await promise).toContain("hello via socks5 dns"); + // socks5:// must resolve DNS client-side: ATYP should be 0x01 or 0x04 + expect(records[0].atyp).not.toBe(0x03); + }); + + test("wss:// through socks5:// with hostname resolves client-side", async () => { + records.length = 0; + const { promise, resolve, reject } = Promise.withResolvers(); + const ws = new WebSocket(`wss://localhost:${wssPort}`, { + proxy: `socks5://127.0.0.1:${socksPort}`, + tls: { rejectUnauthorized: false }, + }); + const messages: string[] = []; + ws.onopen = () => ws.send("hello via socks5 tls dns"); + ws.onmessage = event => { + messages.push(String(event.data)); + if (messages.length === 2) ws.close(); + }; + ws.onclose = () => resolve(messages); + ws.onerror = reject; + + expect(await promise).toContain("hello via socks5 tls dns"); + expect(records[0].atyp).not.toBe(0x03); + }); + + test("socks5h:// with hostname sends domain to proxy", async () => { + records.length = 0; + const { promise, resolve, reject } = Promise.withResolvers(); + const ws = new WebSocket(`ws://localhost:${wsPort}`, { + proxy: `socks5h://127.0.0.1:${socksPort}`, + }); + const messages: string[] = []; + ws.onopen = () => ws.send("hello via socks5h"); + ws.onmessage = event => { + messages.push(String(event.data)); + if (messages.length === 2) ws.close(); + }; + ws.onclose = () => resolve(messages); + ws.onerror = reject; + + expect(await promise).toContain("hello via socks5h"); + // socks5h sends domain name, ATYP must be 0x03 + expect(records[0]).toMatchObject({ atyp: 0x03, host: "localhost", port: wsPort }); + }); +});