diff --git a/src/bun.js/bindings/webcore/WebSocket.cpp b/src/bun.js/bindings/webcore/WebSocket.cpp index 67d994b077..dedc2a10be 100644 --- a/src/bun.js/bindings/webcore/WebSocket.cpp +++ b/src/bun.js/bindings/webcore/WebSocket.cpp @@ -83,6 +83,7 @@ namespace WebCore { WTF_MAKE_TZONE_ALLOCATED_IMPL(WebSocket); extern "C" int Bun__getTLSRejectUnauthorizedValue(); +extern "C" bool Bun__isNoProxy(const char* hostname, size_t hostname_len, const char* host, size_t host_len); static ErrorEvent::Init createErrorEventInit(WebSocket& webSocket, const String& reason, JSC::JSGlobalObject* globalObject) { @@ -573,6 +574,19 @@ ExceptionOr WebSocket::connect(const String& url, const Vector& pr // Determine connection type based on proxy usage and TLS requirements bool hasProxy = proxyConfig.has_value(); + + // Check NO_PROXY even for explicitly-provided proxies + if (hasProxy) { + auto hostStr = m_url.host().toString(); + auto hostWithPort = hostName(m_url, is_secure); + auto hostUtf8 = hostStr.utf8(); + auto hostWithPortUtf8 = hostWithPort.utf8(); + if (Bun__isNoProxy(hostUtf8.data(), hostUtf8.length(), hostWithPortUtf8.data(), hostWithPortUtf8.length())) { + proxyConfig = std::nullopt; + hasProxy = false; + } + } + bool proxyIsHTTPS = hasProxy && proxyConfig->isHTTPS; // Connection type determines what kind of socket we use: diff --git a/src/bun.js/virtual_machine_exports.zig b/src/bun.js/virtual_machine_exports.zig index 43a48740d6..f09ded8730 100644 --- a/src/bun.js/virtual_machine_exports.zig +++ b/src/bun.js/virtual_machine_exports.zig @@ -158,6 +158,13 @@ export fn Bun__getTLSRejectUnauthorizedValue() i32 { return if (jsc.VirtualMachine.get().getTLSRejectUnauthorized()) 1 else 0; } +export fn Bun__isNoProxy(hostname_ptr: [*]const u8, hostname_len: usize, host_ptr: [*]const u8, host_len: usize) bool { + const vm = jsc.VirtualMachine.get(); + const hostname: ?[]const u8 = if (hostname_len > 0) hostname_ptr[0..hostname_len] else null; + const host: ?[]const u8 = if (host_len > 0) host_ptr[0..host_len] else null; + return vm.transpiler.env.isNoProxy(hostname, host); +} + export fn Bun__setVerboseFetchValue(value: i32) void { VirtualMachine.get().default_verbose_fetch = if (value == 1) .headers else if (value == 2) .curl else .none; } diff --git a/src/bun.js/webcore/fetch/FetchTasklet.zig b/src/bun.js/webcore/fetch/FetchTasklet.zig index c615b9a2a5..6d5d1d2ee6 100644 --- a/src/bun.js/webcore/fetch/FetchTasklet.zig +++ b/src/bun.js/webcore/fetch/FetchTasklet.zig @@ -1036,9 +1036,14 @@ pub const FetchTasklet = struct { var proxy: ?ZigURL = null; if (fetch_options.proxy) |proxy_opt| { if (!proxy_opt.isEmpty()) { //if is empty just ignore proxy - proxy = fetch_options.proxy orelse jsc_vm.transpiler.env.getHttpProxyFor(fetch_options.url); + // Check NO_PROXY even for explicitly-provided proxies + if (!jsc_vm.transpiler.env.isNoProxy(fetch_options.url.hostname, fetch_options.url.host)) { + proxy = proxy_opt; + } } + // else: proxy: "" means explicitly no proxy (direct connection) } else { + // no proxy provided, use default proxy resolution proxy = jsc_vm.transpiler.env.getHttpProxyFor(fetch_options.url); } diff --git a/src/env_loader.zig b/src/env_loader.zig index f3ce7ecf86..39bc7c090d 100644 --- a/src/env_loader.zig +++ b/src/env_loader.zig @@ -184,71 +184,88 @@ pub const Loader = struct { } } - // NO_PROXY filter - // See the syntax at https://about.gitlab.com/blog/2021/01/27/we-need-to-talk-no-proxy/ if (http_proxy != null and hostname != null) { - if (this.get("no_proxy") orelse this.get("NO_PROXY")) |no_proxy_text| { - if (no_proxy_text.len == 0 or strings.eqlComptime(no_proxy_text, "\"\"") or strings.eqlComptime(no_proxy_text, "''")) { - return http_proxy; - } - - var no_proxy_iter = std.mem.splitScalar(u8, no_proxy_text, ','); - while (no_proxy_iter.next()) |no_proxy_item| { - var no_proxy_entry = strings.trim(no_proxy_item, &strings.whitespace_chars); - if (no_proxy_entry.len == 0) { - continue; - } - if (strings.eql(no_proxy_entry, "*")) { - return null; - } - //strips . - if (strings.startsWithChar(no_proxy_entry, '.')) { - no_proxy_entry = no_proxy_entry[1..]; - if (no_proxy_entry.len == 0) { - continue; - } - } - - // Determine if entry contains a port or is an IPv6 address - // IPv6 addresses contain multiple colons (e.g., "::1", "2001:db8::1") - // Bracketed IPv6 with port: "[::1]:8080" - // Host with port: "localhost:8080" (single colon) - const colon_count = std.mem.count(u8, no_proxy_entry, ":"); - const is_bracketed_ipv6 = strings.startsWithChar(no_proxy_entry, '['); - const has_port = blk: { - if (is_bracketed_ipv6) { - // Bracketed IPv6: check for "]:port" pattern - if (std.mem.indexOf(u8, no_proxy_entry, "]:")) |_| { - break :blk true; - } - break :blk false; - } else if (colon_count == 1) { - // Single colon means host:port (not IPv6) - break :blk true; - } - // Multiple colons without brackets = bare IPv6 literal (no port) - break :blk false; - }; - - if (has_port) { - // Entry has a port, do exact match against host:port - if (host) |h| { - if (strings.eqlCaseInsensitiveASCII(h, no_proxy_entry, true)) { - return null; - } - } - } else { - // Entry is hostname/IPv6 only, match against hostname (suffix match) - if (strings.endsWith(hostname.?, no_proxy_entry)) { - return null; - } - } - } + if (this.isNoProxy(hostname, host)) { + return null; } } return http_proxy; } + /// Returns true if the given hostname/host should bypass the proxy + /// according to the NO_PROXY / no_proxy environment variable. + pub fn isNoProxy(this: *const Loader, hostname: ?[]const u8, host: ?[]const u8) bool { + // NO_PROXY filter + // See the syntax at https://about.gitlab.com/blog/2021/01/27/we-need-to-talk-no-proxy/ + const hn = hostname orelse return false; + + const no_proxy_text = this.get("no_proxy") orelse this.get("NO_PROXY") orelse return false; + if (no_proxy_text.len == 0 or strings.eqlComptime(no_proxy_text, "\"\"") or strings.eqlComptime(no_proxy_text, "''")) { + return false; + } + + var no_proxy_iter = std.mem.splitScalar(u8, no_proxy_text, ','); + while (no_proxy_iter.next()) |no_proxy_item| { + var no_proxy_entry = strings.trim(no_proxy_item, &strings.whitespace_chars); + if (no_proxy_entry.len == 0) { + continue; + } + if (strings.eql(no_proxy_entry, "*")) { + return true; + } + //strips . + if (strings.startsWithChar(no_proxy_entry, '.')) { + no_proxy_entry = no_proxy_entry[1..]; + if (no_proxy_entry.len == 0) { + continue; + } + } + + // Determine if entry contains a port or is an IPv6 address + // IPv6 addresses contain multiple colons (e.g., "::1", "2001:db8::1") + // Bracketed IPv6 with port: "[::1]:8080" + // Host with port: "localhost:8080" (single colon) + const colon_count = std.mem.count(u8, no_proxy_entry, ":"); + const is_bracketed_ipv6 = strings.startsWithChar(no_proxy_entry, '['); + const has_port = blk: { + if (is_bracketed_ipv6) { + // Bracketed IPv6: check for "]:port" pattern + if (std.mem.indexOf(u8, no_proxy_entry, "]:")) |_| { + break :blk true; + } + break :blk false; + } else if (colon_count == 1) { + // Single colon means host:port (not IPv6) + break :blk true; + } + // Multiple colons without brackets = bare IPv6 literal (no port) + break :blk false; + }; + + if (has_port) { + // Entry has a port, do exact match against host:port + if (host) |h| { + if (strings.eqlCaseInsensitiveASCII(h, no_proxy_entry, true)) { + return true; + } + } + } else { + // Entry is hostname/IPv6 only, match exact or dot-boundary suffix (case-insensitive) + const entry_len = no_proxy_entry.len; + if (hn.len == entry_len) { + if (strings.eqlCaseInsensitiveASCII(hn, no_proxy_entry, true)) return true; + } else if (hn.len > entry_len and + hn[hn.len - entry_len - 1] == '.' and + strings.eqlCaseInsensitiveASCII(hn[hn.len - entry_len ..], no_proxy_entry, true)) + { + return true; + } + } + } + + return false; + } + var did_load_ccache_path: bool = false; pub fn loadCCachePath(this: *Loader, fs: *Fs.FileSystem) void { diff --git a/test/js/bun/http/proxy.test.ts b/test/js/bun/http/proxy.test.ts index 2d351c11fe..47c0acadc0 100644 --- a/test/js/bun/http/proxy.test.ts +++ b/test/js/bun/http/proxy.test.ts @@ -1,7 +1,7 @@ import axios from "axios"; import type { Server } from "bun"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { tls as tlsCert } from "harness"; +import { bunEnv, bunExe, tls as tlsCert } from "harness"; import { HttpsProxyAgent } from "https-proxy-agent"; import { once } from "node:events"; import net from "node:net"; @@ -859,3 +859,84 @@ describe("proxy object format with headers", () => { expect(response.status).toBe(200); }); }); + +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 + // connections is used so that if NO_PROXY doesn't work, the fetch fails + // with a connection error. + let deadProxyPort: number; + let deadProxy: ReturnType; + + beforeAll(() => { + deadProxy = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + open(socket) { + socket.end(); + }, + data() {}, + }, + }); + deadProxyPort = deadProxy.port; + }); + + afterAll(() => { + deadProxy.stop(true); + }); + + test("NO_PROXY bypasses explicit proxy for fetch", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const resp = await fetch("http://localhost:${httpServer.port}", { proxy: "http://127.0.0.1:${deadProxyPort}" }); console.log(resp.status);`, + ], + env: { ...bunEnv, NO_PROXY: "localhost" }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + if (exitCode !== 0) console.error("stderr:", stderr); + expect(stdout.trim()).toBe("200"); + expect(exitCode).toBe(0); + }); + + test("NO_PROXY with port bypasses explicit proxy for fetch", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const resp = await fetch("http://localhost:${httpServer.port}", { proxy: "http://127.0.0.1:${deadProxyPort}" }); console.log(resp.status);`, + ], + env: { ...bunEnv, NO_PROXY: `localhost:${httpServer.port}` }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + if (exitCode !== 0) console.error("stderr:", stderr); + expect(stdout.trim()).toBe("200"); + expect(exitCode).toBe(0); + }); + + test("NO_PROXY non-match does not bypass explicit proxy", async () => { + // NO_PROXY doesn't match, so fetch should try the dead proxy and fail + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `try { await fetch("http://localhost:${httpServer.port}", { proxy: "http://127.0.0.1:${deadProxyPort}" }); process.exit(1); } catch { process.exit(0); }`, + ], + env: { ...bunEnv, NO_PROXY: "other.com" }, + stdout: "pipe", + stderr: "pipe", + }); + + const exitCode = await proc.exited; + // exit(0) means fetch threw (proxy connection failed), proving proxy was used + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/web/websocket/websocket-proxy.test.ts b/test/js/web/websocket/websocket-proxy.test.ts index 09d5353263..c2b5a6e1c3 100644 --- a/test/js/web/websocket/websocket-proxy.test.ts +++ b/test/js/web/websocket/websocket-proxy.test.ts @@ -13,6 +13,8 @@ const { HttpsProxyAgent } = require("https-proxy-agent") as { // Use docker-compose infrastructure for squid proxy const gc = harness.gc; +const bunExe = harness.bunExe; +const bunEnv = harness.bunEnv; const isDockerEnabled = harness.isDockerEnabled; // HTTP CONNECT proxy server for WebSocket tunneling @@ -656,3 +658,86 @@ describe("ws module with HttpsProxyAgent", () => { gc(); }); }); + +describe.concurrent("WebSocket NO_PROXY bypass", () => { + test("NO_PROXY matching hostname bypasses explicit proxy for ws://", async () => { + // authProxy requires credentials; if NO_PROXY works, the WebSocket bypasses + // the proxy and connects directly. If NO_PROXY doesn't work, the proxy + // rejects with 407 and the WebSocket errors. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const ws = new WebSocket("ws://127.0.0.1:${wsPort}", { proxy: "http://127.0.0.1:${authProxyPort}" }); + ws.onopen = () => { ws.close(); process.exit(0); }; + ws.onerror = () => { process.exit(1); };`, + ], + env: { ...bunEnv, NO_PROXY: "127.0.0.1" }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + if (exitCode !== 0) console.error("stderr:", stderr); + expect(exitCode).toBe(0); + }); + + test("NO_PROXY matching host:port bypasses proxy for ws://", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const ws = new WebSocket("ws://127.0.0.1:${wsPort}", { proxy: "http://127.0.0.1:${authProxyPort}" }); + ws.onopen = () => { ws.close(); process.exit(0); }; + ws.onerror = () => { process.exit(1); };`, + ], + env: { ...bunEnv, NO_PROXY: `127.0.0.1:${wsPort}` }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + if (exitCode !== 0) console.error("stderr:", stderr); + expect(exitCode).toBe(0); + }); + + test("NO_PROXY not matching still uses proxy (auth fails)", async () => { + // NO_PROXY doesn't match the target, so the WebSocket should go through + // the auth proxy without credentials, which rejects with 407. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const ws = new WebSocket("ws://127.0.0.1:${wsPort}", { proxy: "http://127.0.0.1:${authProxyPort}" }); + ws.onopen = () => { process.exit(1); }; + ws.onerror = () => { process.exit(0); };`, + ], + env: { ...bunEnv, NO_PROXY: "other.host.com" }, + stdout: "pipe", + stderr: "pipe", + }); + + const exitCode = await proc.exited; + // exit(0) means onerror fired, proving the proxy was used (and auth failed) + expect(exitCode).toBe(0); + }); + + test("NO_PROXY=* bypasses all proxies", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const ws = new WebSocket("ws://127.0.0.1:${wsPort}", { proxy: "http://127.0.0.1:${authProxyPort}" }); + ws.onopen = () => { ws.close(); process.exit(0); }; + ws.onerror = () => { process.exit(1); };`, + ], + env: { ...bunEnv, NO_PROXY: "*" }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + if (exitCode !== 0) console.error("stderr:", stderr); + expect(exitCode).toBe(0); + }); +});