Files
bun.sh/test/js/web/websocket/websocket-proxy.test.ts
Ciro Spaciari a14a89ca95 fix(proxy): respect NO_PROXY for explicit proxy options in fetch and ws (#26608)
### What does this PR do?

Extract NO_PROXY checking logic from getHttpProxyFor into a reusable
isNoProxy method on the env Loader. This allows both fetch() and
WebSocket to check NO_PROXY even when a proxy is explicitly provided via
the proxy option (not just via http_proxy env var).

Changes:
- env_loader.zig: Extract isNoProxy() from getHttpProxyFor()
- FetchTasklet.zig: Check isNoProxy() before using explicit proxy
- WebSocket.cpp: Check Bun__isNoProxy() before using explicit proxy
- virtual_machine_exports.zig: Export Bun__isNoProxy for C++ access
- Add NO_PROXY tests for both fetch and WebSocket proxy paths

### How did you verify your code works?
Tests

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-01-30 16:20:45 -08:00

744 lines
20 KiB
TypeScript

import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import * as harness from "harness";
import { tls as tlsCerts } from "harness";
import type { HttpsProxyAgent as HttpsProxyAgentType } from "https-proxy-agent";
import net from "net";
import tls from "tls";
import { createConnectProxy, createTLSConnectProxy, startProxy } from "./proxy-test-utils";
// Use dynamic require to avoid linter removing the import
const { HttpsProxyAgent } = require("https-proxy-agent") as {
HttpsProxyAgent: typeof HttpsProxyAgentType;
};
// Use docker-compose infrastructure for squid proxy
const gc = harness.gc;
const bunExe = harness.bunExe;
const bunEnv = harness.bunEnv;
const isDockerEnabled = harness.isDockerEnabled;
// HTTP CONNECT proxy server for WebSocket tunneling
let proxy: net.Server;
let authProxy: net.Server;
let wsServer: ReturnType<typeof Bun.serve>;
let wssServer: ReturnType<typeof Bun.serve>;
let proxyPort: number;
let authProxyPort: number;
let wsPort: number;
let wssPort: number;
beforeAll(async () => {
// Create HTTP CONNECT proxy
proxy = createConnectProxy();
proxyPort = await startProxy(proxy);
// Create HTTP CONNECT proxy with auth
authProxy = createConnectProxy({ requireAuth: true });
authProxyPort = await startProxy(authProxy);
// Create WebSocket echo server
wsServer = Bun.serve({
port: 0,
fetch(req, server) {
if (server.upgrade(req)) {
return;
}
return new Response("Expected WebSocket", { status: 400 });
},
websocket: {
message(ws, message) {
// Echo back
ws.send(message);
},
open(ws) {
ws.send("connected");
},
},
});
wsPort = wsServer.port;
// Create secure WebSocket echo server (wss://)
wssServer = Bun.serve({
port: 0,
tls: {
key: tlsCerts.key,
cert: tlsCerts.cert,
},
fetch(req, server) {
if (server.upgrade(req)) {
return;
}
return new Response("Expected WebSocket", { status: 400 });
},
websocket: {
message(ws, message) {
// Echo back
ws.send(message);
},
open(ws) {
ws.send("connected");
},
},
});
wssPort = wssServer.port;
});
afterAll(() => {
proxy?.close();
authProxy?.close();
wsServer?.stop(true);
wssServer?.stop(true);
});
describe("WebSocket proxy API", () => {
test("accepts proxy option as string (HTTP proxy)", () => {
const ws = new WebSocket("ws://example.com", {
proxy: `http://127.0.0.1:${proxyPort}`,
});
expect(ws.readyState).toBe(WebSocket.CONNECTING);
ws.close();
});
test("accepts proxy option as string (HTTPS proxy)", () => {
// Note: This test just checks the constructor accepts the option.
// The actual connection would fail without proper TLS setup for the proxy.
const ws = new WebSocket("ws://example.com", {
proxy: `https://127.0.0.1:${proxyPort}`,
tls: { rejectUnauthorized: false },
});
expect(ws.readyState).toBe(WebSocket.CONNECTING);
ws.close();
});
test("accepts HTTPS proxy with wss:// target", () => {
// Note: This test just checks the constructor accepts the option.
const ws = new WebSocket("wss://example.com", {
proxy: `https://127.0.0.1:${proxyPort}`,
tls: { rejectUnauthorized: false },
});
expect(ws.readyState).toBe(WebSocket.CONNECTING);
ws.close();
});
test("accepts proxy option as object with url", () => {
const ws = new WebSocket("ws://example.com", {
proxy: { url: `http://127.0.0.1:${proxyPort}` },
});
expect(ws.readyState).toBe(WebSocket.CONNECTING);
ws.close();
});
test("accepts proxy option with headers", () => {
const ws = new WebSocket("ws://example.com", {
proxy: {
url: `http://127.0.0.1:${proxyPort}`,
headers: { "X-Custom-Header": "test-value" },
},
});
expect(ws.readyState).toBe(WebSocket.CONNECTING);
ws.close();
});
test("accepts proxy option with Headers class instance", () => {
const headers = new Headers({ "X-Custom-Header": "test-value" });
const ws = new WebSocket("ws://example.com", {
proxy: {
url: `http://127.0.0.1:${proxyPort}`,
headers: headers,
},
});
expect(ws.readyState).toBe(WebSocket.CONNECTING);
ws.close();
});
test("accepts proxy URL with credentials", () => {
const ws = new WebSocket("ws://example.com", {
proxy: `http://user:pass@127.0.0.1:${authProxyPort}`,
});
expect(ws.readyState).toBe(WebSocket.CONNECTING);
ws.close();
});
test("can combine proxy with other options", () => {
const ws = new WebSocket("ws://example.com", {
proxy: `http://127.0.0.1:${proxyPort}`,
headers: { Authorization: "Bearer token" },
protocols: ["graphql-ws"],
});
expect(ws.readyState).toBe(WebSocket.CONNECTING);
ws.close();
});
test("rejects invalid proxy URL", () => {
expect(() => {
new WebSocket("ws://example.com", {
proxy: "not-a-valid-url",
});
}).toThrow(SyntaxError);
});
});
describe("WebSocket through HTTP CONNECT proxy", () => {
test("ws:// through HTTP proxy", async () => {
const { promise, resolve, reject } = Promise.withResolvers<string[]>();
const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, {
proxy: `http://127.0.0.1:${proxyPort}`,
});
const receivedMessages: string[] = [];
ws.onopen = () => {
ws.send("hello from client");
};
ws.onmessage = event => {
receivedMessages.push(String(event.data));
if (receivedMessages.length === 2) {
ws.close();
}
};
ws.onclose = () => {
resolve(receivedMessages);
};
ws.onerror = event => {
reject(event);
};
const messages = await promise;
expect(messages).toContain("connected");
expect(messages).toContain("hello from client");
gc();
});
test("ws:// through HTTP proxy with auth", async () => {
const { promise, resolve, reject } = Promise.withResolvers<string[]>();
const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, {
proxy: `http://proxy_user:proxy_pass@127.0.0.1:${authProxyPort}`,
});
const receivedMessages: string[] = [];
ws.onopen = () => {
ws.send("hello with auth");
};
ws.onmessage = event => {
receivedMessages.push(String(event.data));
if (receivedMessages.length === 2) {
ws.close();
}
};
ws.onclose = () => {
resolve(receivedMessages);
};
ws.onerror = event => {
reject(event);
};
const messages = await promise;
expect(messages).toContain("connected");
expect(messages).toContain("hello with auth");
gc();
});
test("ws:// through proxy with custom headers", async () => {
const { promise, resolve, reject } = Promise.withResolvers<void>();
const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, {
proxy: {
url: `http://127.0.0.1:${proxyPort}`,
headers: { "X-Custom-Proxy-Header": "test-value" },
},
});
ws.onopen = () => {
ws.close();
resolve();
};
ws.onerror = event => {
reject(event);
};
await promise;
gc();
});
test("ws:// through proxy with Headers class instance", async () => {
const { promise, resolve, reject } = Promise.withResolvers<void>();
const headers = new Headers({ "X-Custom-Proxy-Header": "test-value-from-headers-class" });
const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, {
proxy: {
url: `http://127.0.0.1:${proxyPort}`,
headers: headers,
},
});
ws.onopen = () => {
ws.close();
resolve();
};
ws.onerror = event => {
reject(event);
};
await promise;
gc();
});
test("proxy auth failure returns error", async () => {
const { promise, resolve, reject } = Promise.withResolvers<void>();
let sawError = false;
const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, {
proxy: `http://127.0.0.1:${authProxyPort}`, // No auth provided
});
ws.onopen = () => {
ws.close();
reject(new Error("Expected proxy auth failure, but connection opened"));
};
ws.onerror = () => {
sawError = true;
ws.close();
};
ws.onclose = () => {
if (sawError) {
resolve();
} else {
reject(new Error("Expected proxy auth failure (error event), got clean close instead"));
}
};
await promise;
gc();
});
test("proxy wrong credentials returns error", async () => {
const { promise, resolve, reject } = Promise.withResolvers<void>();
let sawError = false;
const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, {
proxy: `http://wrong_user:wrong_pass@127.0.0.1:${authProxyPort}`,
});
ws.onopen = () => {
ws.close();
reject(new Error("Expected proxy auth failure, but connection opened"));
};
ws.onerror = () => {
sawError = true;
ws.close();
};
ws.onclose = () => {
if (sawError) {
resolve();
} else {
reject(new Error("Expected proxy auth failure (error event), got clean close instead"));
}
};
await promise;
gc();
});
});
describe("WebSocket wss:// through HTTP proxy (TLS tunnel)", () => {
// This tests the TLS tunnel: wss:// target through HTTP proxy
// The outer connection is plain TCP to the HTTP proxy, then TLS is
// negotiated inside the tunnel to the wss:// target server.
test("wss:// through HTTP proxy", async () => {
const { promise, resolve, reject } = Promise.withResolvers<string[]>();
// Use local wss:// server with self-signed cert
const ws = new WebSocket(`wss://127.0.0.1:${wssPort}`, {
proxy: `http://127.0.0.1:${proxyPort}`,
tls: {
// Trust the self-signed certificate used by the wss:// server
rejectUnauthorized: false,
},
});
const receivedMessages: string[] = [];
ws.onopen = () => {
ws.send("hello via tls tunnel");
};
ws.onmessage = event => {
receivedMessages.push(String(event.data));
if (receivedMessages.length === 2) {
ws.close();
}
};
ws.onclose = () => {
resolve(receivedMessages);
};
ws.onerror = event => {
reject(event);
};
const messages = await promise;
expect(messages).toContain("connected");
expect(messages).toContain("hello via tls tunnel");
gc();
});
});
describe("WebSocket through HTTPS proxy (TLS proxy)", () => {
// These tests verify WebSocket connections through HTTPS (TLS) proxy servers
let httpsProxy: tls.Server;
let httpsProxyPort: number;
beforeAll(async () => {
// Create HTTPS CONNECT proxy
httpsProxy = createTLSConnectProxy();
httpsProxyPort = await startProxy(httpsProxy);
});
afterAll(() => {
httpsProxy?.close();
});
test("ws:// through HTTPS proxy with CA certificate", async () => {
const { promise, resolve, reject } = Promise.withResolvers<string[]>();
const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, {
proxy: `https://127.0.0.1:${httpsProxyPort}`,
tls: {
// Trust the self-signed certificate used by the proxy
ca: tlsCerts.cert,
},
});
const receivedMessages: string[] = [];
ws.onopen = () => {
ws.send("hello via https proxy");
};
ws.onmessage = event => {
receivedMessages.push(String(event.data));
if (receivedMessages.length === 2) {
ws.close();
}
};
ws.onclose = () => {
resolve(receivedMessages);
};
ws.onerror = event => {
reject(event);
};
const messages = await promise;
expect(messages).toContain("connected");
expect(messages).toContain("hello via https proxy");
gc();
});
test("ws:// through HTTPS proxy fails without CA certificate", async () => {
const { promise, resolve, reject } = Promise.withResolvers<void>();
let sawError = false;
const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, {
proxy: `https://127.0.0.1:${httpsProxyPort}`,
// No CA certificate - should fail (self-signed cert not trusted)
});
ws.onopen = () => {
ws.close();
reject(new Error("Expected TLS verification failure, but connection opened"));
};
ws.onerror = () => {
sawError = true;
ws.close();
};
ws.onclose = () => {
if (sawError) {
resolve();
} else {
reject(new Error("Expected TLS verification failure (error event), got clean close instead"));
}
};
await promise;
gc();
});
test("ws:// through HTTPS proxy with rejectUnauthorized: false", async () => {
const { promise, resolve, reject } = Promise.withResolvers<string[]>();
const ws = new WebSocket(`ws://127.0.0.1:${wsPort}`, {
proxy: `https://127.0.0.1:${httpsProxyPort}`,
tls: {
rejectUnauthorized: false, // Skip TLS verification for proxy
},
});
const receivedMessages: string[] = [];
ws.onopen = () => {
ws.send("hello via https proxy no verify");
};
ws.onmessage = event => {
receivedMessages.push(String(event.data));
if (receivedMessages.length === 2) {
ws.close();
}
};
ws.onclose = () => {
resolve(receivedMessages);
};
ws.onerror = event => {
reject(event);
};
const messages = await promise;
expect(messages).toContain("connected");
expect(messages).toContain("hello via https proxy no verify");
gc();
});
});
// Squid proxy tests - run when Docker is enabled
// Uses docker-compose infrastructure to run squid proxy
// Import docker-compose dynamically to avoid issues when not using docker
const dockerCompose = require("../../../docker/index.ts");
describe.skipIf(!isDockerEnabled())("WebSocket through Squid proxy (Docker)", () => {
let squidInfo: { host: string; ports: Record<number, number>; proxyUrl?: string };
beforeAll(async () => {
console.log("Starting squid proxy container...");
squidInfo = await dockerCompose.ensure("squid");
console.log(`Squid proxy ready at: ${squidInfo.host}:${squidInfo.ports[3128]}`);
}, 120_000);
afterAll(async () => {
if (!process.env.BUN_KEEP_DOCKER) {
await dockerCompose.down();
}
}, 30_000);
test("ws:// through squid proxy to local server", async () => {
const { promise, resolve, reject } = Promise.withResolvers<string[]>();
const proxyUrl = `http://${squidInfo.host}:${squidInfo.ports[3128]}`;
// Connect to our local WebSocket server through squid
const ws = new WebSocket(`ws://host.docker.internal:${wsPort}`, {
proxy: proxyUrl,
});
const receivedMessages: string[] = [];
ws.onopen = () => {
ws.send("hello from bun via squid");
};
ws.onmessage = event => {
receivedMessages.push(String(event.data));
if (receivedMessages.length === 2) {
ws.close();
}
};
ws.onclose = () => {
resolve(receivedMessages);
};
ws.onerror = event => {
reject(event);
};
const messages = await promise;
expect(messages).toContain("connected");
expect(messages).toContain("hello from bun via squid");
gc();
}, 30_000);
test("wss:// through squid proxy to local server", async () => {
const { promise, resolve, reject } = Promise.withResolvers<string[]>();
const proxyUrl = `http://${squidInfo.host}:${squidInfo.ports[3128]}`;
// Connect to our local secure WebSocket server through squid
const ws = new WebSocket(`wss://host.docker.internal:${wssPort}`, {
proxy: proxyUrl,
tls: {
rejectUnauthorized: false, // Accept self-signed cert
},
});
const receivedMessages: string[] = [];
ws.onopen = () => {
ws.send("hello wss from bun via squid");
};
ws.onmessage = event => {
receivedMessages.push(String(event.data));
if (receivedMessages.length === 2) {
ws.close();
}
};
ws.onclose = () => {
resolve(receivedMessages);
};
ws.onerror = event => {
reject(event);
};
const messages = await promise;
expect(messages).toContain("connected");
expect(messages).toContain("hello wss from bun via squid");
gc();
}, 30_000);
});
describe("ws module with HttpsProxyAgent", () => {
// These tests verify that the ws module (src/js/thirdparty/ws.js) correctly
// passes the agent property to the native WebSocket
const WS = require("ws");
test("ws module passes agent to native WebSocket", async () => {
const { promise, resolve, reject } = Promise.withResolvers<string[]>();
const agent = new HttpsProxyAgent(`http://127.0.0.1:${proxyPort}`);
const ws = new WS(`ws://127.0.0.1:${wsPort}`, { agent });
const receivedMessages: string[] = [];
ws.on("open", () => {
ws.send("hello from ws module via agent");
});
ws.on("message", (data: Buffer) => {
receivedMessages.push(data.toString());
if (receivedMessages.length === 2) {
ws.close();
}
});
ws.on("close", () => {
resolve(receivedMessages);
});
ws.on("error", (err: Error) => {
reject(err);
});
const messages = await promise;
expect(messages).toContain("connected");
expect(messages).toContain("hello from ws module via agent");
gc();
});
});
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);
});
});