mirror of
https://github.com/oven-sh/bun
synced 2026-02-02 15:08:46 +00:00
## Summary - Fix NO_PROXY environment variable to properly respect port numbers like Node.js and curl do - Previously `NO_PROXY=localhost:1234` would bypass proxy for all requests to localhost regardless of port - Now entries with ports (e.g., `localhost:8080`) do exact host:port matching, while entries without ports continue to use suffix matching ## Test plan - Added tests in `test/js/bun/http/proxy.test.js` covering: - [x] Bypass proxy when NO_PROXY matches host:port exactly - [x] Use proxy when NO_PROXY has different port - [x] Bypass proxy when NO_PROXY has host only (no port) - existing behavior preserved - [x] Handle NO_PROXY with multiple entries including port - Verified existing proxy tests still pass 🤖 Generated with [Claude Code](https://claude.ai/code) --------- Co-authored-by: Claude Bot <claude-bot@bun.sh> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
393 lines
12 KiB
JavaScript
393 lines
12 KiB
JavaScript
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
|
import fs from "fs";
|
|
import { bunEnv, bunExe, gc } from "harness";
|
|
import path from "path";
|
|
|
|
let proxy, auth_proxy, server;
|
|
beforeAll(() => {
|
|
proxy = Bun.serve({
|
|
port: 0,
|
|
async fetch(request) {
|
|
// if is not an proxy connection just drop it
|
|
if (!request.headers.has("proxy-connection")) {
|
|
return new Response("Bad Request", { status: 400 });
|
|
}
|
|
|
|
// simple http proxy
|
|
if (request.url.startsWith("http://")) {
|
|
const response = await fetch(request.url, {
|
|
method: request.method,
|
|
body: await request.text(),
|
|
});
|
|
// Add marker header to indicate request went through proxy
|
|
const headers = new Headers(response.headers);
|
|
headers.set("x-proxy-used", "1");
|
|
return new Response(response.body, {
|
|
status: response.status,
|
|
headers,
|
|
});
|
|
}
|
|
|
|
// no TLS support here
|
|
return new Response("Bad Request", { status: 400 });
|
|
},
|
|
});
|
|
auth_proxy = Bun.serve({
|
|
port: 0,
|
|
async fetch(request) {
|
|
// if is not an proxy connection just drop it
|
|
if (!request.headers.has("proxy-connection")) {
|
|
return new Response("Bad Request", { status: 400 });
|
|
}
|
|
|
|
if (!request.headers.has("proxy-authorization")) {
|
|
return new Response("Proxy Authentication Required", { status: 407 });
|
|
}
|
|
|
|
const auth = Buffer.from(
|
|
request.headers.get("proxy-authorization").replace("Basic ", "").trim(),
|
|
"base64",
|
|
).toString("utf8");
|
|
if (auth !== "squid_user:ASD123@123asd") {
|
|
return new Response("Forbidden", { status: 403 });
|
|
}
|
|
|
|
// simple http proxy
|
|
if (request.url.startsWith("http://")) {
|
|
return await fetch(request.url, {
|
|
method: request.method,
|
|
body: await request.text(),
|
|
});
|
|
}
|
|
|
|
// no TLS support here
|
|
return new Response("Bad Request", { status: 400 });
|
|
},
|
|
});
|
|
server = Bun.serve({
|
|
port: 0,
|
|
async fetch(request) {
|
|
if (request.method === "POST") {
|
|
const text = await request.text();
|
|
return new Response(text, { status: 200 });
|
|
}
|
|
return new Response("Hello, World", { status: 200 });
|
|
},
|
|
});
|
|
});
|
|
|
|
afterAll(() => {
|
|
server.stop(true);
|
|
proxy.stop(true);
|
|
auth_proxy.stop(true);
|
|
});
|
|
|
|
const test = process.env.PROXY_URL ? it : it.skip;
|
|
describe.concurrent(() => {
|
|
test("should be able to post on TLS", async () => {
|
|
const data = JSON.stringify({
|
|
"name": "bun",
|
|
});
|
|
|
|
const result = await fetch("https://httpbin.org/post", {
|
|
method: "POST",
|
|
proxy: process.env.PROXY_URL,
|
|
verbose: true,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: data,
|
|
}).then(res => res.json());
|
|
|
|
expect(result.data).toBe(data);
|
|
});
|
|
|
|
test("should be able to post bigger on TLS", async () => {
|
|
const data = fs.readFileSync(path.join(import.meta.dir, "fetch.json")).toString("utf8");
|
|
const result = await fetch("https://httpbin.org/post", {
|
|
method: "POST",
|
|
proxy: process.env.PROXY_URL,
|
|
verbose: true,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: data,
|
|
}).then(res => res.json());
|
|
expect(result.data).toBe(data);
|
|
});
|
|
|
|
describe("proxy non-TLS", async () => {
|
|
let url;
|
|
let auth_proxy_url;
|
|
let proxy_url;
|
|
const requests = [
|
|
() => [new Request(url), auth_proxy_url],
|
|
() => [
|
|
new Request(url, {
|
|
method: "POST",
|
|
body: "Hello, World",
|
|
}),
|
|
auth_proxy_url,
|
|
],
|
|
() => [url, auth_proxy_url],
|
|
() => [new Request(url), proxy_url],
|
|
() => [
|
|
new Request(url, {
|
|
method: "POST",
|
|
body: "Hello, World",
|
|
}),
|
|
proxy_url,
|
|
],
|
|
() => [url, proxy_url],
|
|
];
|
|
beforeAll(() => {
|
|
url = `http://localhost:${server.port}`;
|
|
auth_proxy_url = `http://squid_user:ASD123%40123asd@localhost:${auth_proxy.port}`;
|
|
proxy_url = `localhost:${proxy.port}`;
|
|
});
|
|
|
|
for (let callback of requests) {
|
|
test(async () => {
|
|
const [request, proxy] = callback();
|
|
gc();
|
|
const response = await fetch(request, { verbose: true, proxy });
|
|
gc();
|
|
const text = await response.text();
|
|
gc();
|
|
expect(text).toBe("Hello, World");
|
|
});
|
|
}
|
|
});
|
|
|
|
it("proxy non-TLS auth can fail", async () => {
|
|
const url = `http://localhost:${server.port}`;
|
|
|
|
{
|
|
try {
|
|
const response = await fetch(url, {
|
|
verbose: true,
|
|
proxy: `http://localhost:${auth_proxy.port}`,
|
|
});
|
|
expect(response.status).toBe(407);
|
|
} catch (err) {
|
|
expect(true).toBeFalsy();
|
|
}
|
|
}
|
|
|
|
{
|
|
try {
|
|
const response = await fetch(url, {
|
|
verbose: true,
|
|
proxy: `http://squid_user:asdf123@localhost:${auth_proxy.port}`,
|
|
});
|
|
expect(response.status).toBe(403);
|
|
} catch (err) {
|
|
expect(true).toBeFalsy();
|
|
}
|
|
}
|
|
});
|
|
|
|
it("simultaneous proxy auth failures should not hang", async () => {
|
|
const url = `http://localhost:${server.port}`;
|
|
const invalidProxy = `http://localhost:${auth_proxy.port}`;
|
|
|
|
// First batch: 5 simultaneous fetches with invalid credentials
|
|
const firstBatch = await Promise.all([
|
|
fetch(url, { proxy: invalidProxy }),
|
|
fetch(url, { proxy: invalidProxy }),
|
|
fetch(url, { proxy: invalidProxy }),
|
|
fetch(url, { proxy: invalidProxy }),
|
|
fetch(url, { proxy: invalidProxy }),
|
|
]);
|
|
expect(firstBatch.map(r => r.status)).toEqual([407, 407, 407, 407, 407]);
|
|
await Promise.all(firstBatch.map(r => r.text())).catch(() => {});
|
|
|
|
// Second batch: immediately send another 5
|
|
// Before the fix, these would hang due to keep-alive on failed proxy connections
|
|
const secondBatch = await Promise.all([
|
|
fetch(url, { proxy: invalidProxy }),
|
|
fetch(url, { proxy: invalidProxy }),
|
|
fetch(url, { proxy: invalidProxy }),
|
|
fetch(url, { proxy: invalidProxy }),
|
|
fetch(url, { proxy: invalidProxy }),
|
|
]);
|
|
expect(secondBatch.map(r => r.status)).toEqual([407, 407, 407, 407, 407]);
|
|
await Promise.all(secondBatch.map(r => r.text())).catch(() => {});
|
|
});
|
|
|
|
it.each([
|
|
[undefined, undefined],
|
|
["", ""],
|
|
["''", "''"],
|
|
['""', '""'],
|
|
])("test proxy env, http_proxy=%s https_proxy=%s", async (http_proxy, https_proxy) => {
|
|
const { exited, stderr: stream } = Bun.spawn({
|
|
cmd: [bunExe(), "-e", 'await fetch("https://example.com")'],
|
|
env: {
|
|
...bunEnv,
|
|
http_proxy: http_proxy,
|
|
https_proxy: https_proxy,
|
|
},
|
|
stdout: "inherit",
|
|
stderr: "pipe",
|
|
});
|
|
|
|
const [exitCode, stderr] = await Promise.all([exited, stream.text()]);
|
|
|
|
expect(stderr.includes("FailedToOpenSocket: Was there a typo in the url or port?")).toBe(false);
|
|
expect(exitCode).toBe(0);
|
|
});
|
|
|
|
it.each([
|
|
// Empty entries in NO_PROXY should not cause out-of-bounds access
|
|
["localhost, , example.com"],
|
|
[",localhost,example.com"],
|
|
["localhost,example.com,"],
|
|
[" , , "],
|
|
[",,,"],
|
|
[". , .. , ..."],
|
|
])("NO_PROXY with empty entries does not crash: %s", async no_proxy => {
|
|
// We just need to verify parsing NO_PROXY doesn't crash.
|
|
// The fetch target doesn't matter - NO_PROXY parsing happens before the connection.
|
|
const { exited, stderr: stream } = Bun.spawn({
|
|
cmd: [bunExe(), "-e", `fetch("http://localhost:1").catch(() => {})`],
|
|
env: {
|
|
...bunEnv,
|
|
http_proxy: "http://127.0.0.1:1",
|
|
NO_PROXY: no_proxy,
|
|
},
|
|
stderr: "pipe",
|
|
});
|
|
const [exitCode, stderr] = await Promise.all([exited, stream.text()]);
|
|
if (exitCode !== 0) {
|
|
console.error("stderr:", stderr);
|
|
}
|
|
expect(exitCode).toBe(0);
|
|
});
|
|
|
|
// Test that NO_PROXY respects port numbers like Node.js and curl do
|
|
describe("NO_PROXY port handling", () => {
|
|
it("should bypass proxy when NO_PROXY matches host:port exactly", async () => {
|
|
// NO_PROXY includes the exact host:port, should bypass proxy
|
|
const {
|
|
exited,
|
|
stdout,
|
|
stderr: stderrStream,
|
|
} = Bun.spawn({
|
|
cmd: [
|
|
bunExe(),
|
|
"-e",
|
|
`const resp = await fetch("http://localhost:${server.port}/test"); console.log(resp.headers.get("x-proxy-used") || "no-proxy");`,
|
|
],
|
|
env: {
|
|
...bunEnv,
|
|
http_proxy: `http://localhost:${proxy.port}`,
|
|
NO_PROXY: `localhost:${server.port}`,
|
|
},
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
|
|
const [exitCode, out, stderr] = await Promise.all([exited, stdout.text(), stderrStream.text()]);
|
|
if (exitCode !== 0) {
|
|
console.error("stderr:", stderr);
|
|
}
|
|
// Should connect directly, not through proxy (no x-proxy-used header)
|
|
expect(out.trim()).toBe("no-proxy");
|
|
expect(exitCode).toBe(0);
|
|
});
|
|
|
|
it("should use proxy when NO_PROXY has different port", async () => {
|
|
const differentPort = server.port + 1000;
|
|
// NO_PROXY includes a different port, should NOT bypass proxy
|
|
const {
|
|
exited,
|
|
stdout,
|
|
stderr: stderrStream,
|
|
} = Bun.spawn({
|
|
cmd: [
|
|
bunExe(),
|
|
"-e",
|
|
`const resp = await fetch("http://localhost:${server.port}/test"); console.log(resp.headers.get("x-proxy-used") || "no-proxy");`,
|
|
],
|
|
env: {
|
|
...bunEnv,
|
|
http_proxy: `http://localhost:${proxy.port}`,
|
|
NO_PROXY: `localhost:${differentPort}`,
|
|
},
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
|
|
const [exitCode, out, stderr] = await Promise.all([exited, stdout.text(), stderrStream.text()]);
|
|
if (exitCode !== 0) {
|
|
console.error("stderr:", stderr);
|
|
}
|
|
// The proxy adds x-proxy-used header, verify it was used
|
|
expect(out.trim()).toBe("1");
|
|
expect(exitCode).toBe(0);
|
|
});
|
|
|
|
it("should bypass proxy when NO_PROXY has host only (no port)", async () => {
|
|
// NO_PROXY includes just the host (no port), should bypass proxy for all ports
|
|
const {
|
|
exited,
|
|
stdout,
|
|
stderr: stderrStream,
|
|
} = Bun.spawn({
|
|
cmd: [
|
|
bunExe(),
|
|
"-e",
|
|
`const resp = await fetch("http://localhost:${server.port}/test"); console.log(resp.headers.get("x-proxy-used") || "no-proxy");`,
|
|
],
|
|
env: {
|
|
...bunEnv,
|
|
http_proxy: `http://localhost:${proxy.port}`,
|
|
NO_PROXY: `localhost`,
|
|
},
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
|
|
const [exitCode, out, stderr] = await Promise.all([exited, stdout.text(), stderrStream.text()]);
|
|
if (exitCode !== 0) {
|
|
console.error("stderr:", stderr);
|
|
}
|
|
// Should connect directly, not through proxy (no x-proxy-used header)
|
|
expect(out.trim()).toBe("no-proxy");
|
|
expect(exitCode).toBe(0);
|
|
});
|
|
|
|
it("should handle NO_PROXY with multiple entries including port", async () => {
|
|
const differentPort = server.port + 1000;
|
|
// NO_PROXY includes multiple entries, one of which matches exactly
|
|
const {
|
|
exited,
|
|
stdout,
|
|
stderr: stderrStream,
|
|
} = Bun.spawn({
|
|
cmd: [
|
|
bunExe(),
|
|
"-e",
|
|
`const resp = await fetch("http://localhost:${server.port}/test"); console.log(resp.headers.get("x-proxy-used") || "no-proxy");`,
|
|
],
|
|
env: {
|
|
...bunEnv,
|
|
http_proxy: `http://localhost:${proxy.port}`,
|
|
NO_PROXY: `example.com, localhost:${differentPort}, localhost:${server.port}`,
|
|
},
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
|
|
const [exitCode, out, stderr] = await Promise.all([exited, stdout.text(), stderrStream.text()]);
|
|
if (exitCode !== 0) {
|
|
console.error("stderr:", stderr);
|
|
}
|
|
// Should connect directly, not through proxy (no x-proxy-used header)
|
|
expect(out.trim()).toBe("no-proxy");
|
|
expect(exitCode).toBe(0);
|
|
});
|
|
});
|
|
});
|