mirror of
https://github.com/oven-sh/bun
synced 2026-02-02 15:08:46 +00:00
fix(http): respect port numbers in NO_PROXY environment variable (#26347)
## 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>
This commit is contained in:
@@ -1489,7 +1489,7 @@ pub fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool,
|
||||
const path = blob.store.?.data.s3.path();
|
||||
const env = globalThis.bunVM().transpiler.env;
|
||||
|
||||
S3.stat(credentials, path, @ptrCast(&onS3SizeResolved), this, if (env.getHttpProxy(true, null)) |proxy| proxy.href else null, blob.store.?.data.s3.request_payer) catch {}; // TODO: properly propagate exception upwards
|
||||
S3.stat(credentials, path, @ptrCast(&onS3SizeResolved), this, if (env.getHttpProxy(true, null, null)) |proxy| proxy.href else null, blob.store.?.data.s3.request_payer) catch {}; // TODO: properly propagate exception upwards
|
||||
return;
|
||||
}
|
||||
this.renderMetadata();
|
||||
|
||||
@@ -960,7 +960,7 @@ fn writeFileWithEmptySourceToDestination(ctx: *jsc.JSGlobalObject, destination_b
|
||||
|
||||
const promise = jsc.JSPromise.Strong.init(ctx);
|
||||
const promise_value = promise.value();
|
||||
const proxy = ctx.bunVM().transpiler.env.getHttpProxy(true, null);
|
||||
const proxy = ctx.bunVM().transpiler.env.getHttpProxy(true, null, null);
|
||||
const proxy_url = if (proxy) |p| p.href else null;
|
||||
destination_store.ref();
|
||||
try S3.upload(
|
||||
@@ -1102,7 +1102,7 @@ pub fn writeFileWithSourceDestination(ctx: *jsc.JSGlobalObject, source_blob: *Bl
|
||||
return jsc.JSPromise.dangerouslyCreateRejectedPromiseValueWithoutNotifyingVM(ctx, ctx.takeException(err));
|
||||
};
|
||||
defer aws_options.deinit();
|
||||
const proxy = ctx.bunVM().transpiler.env.getHttpProxy(true, null);
|
||||
const proxy = ctx.bunVM().transpiler.env.getHttpProxy(true, null, null);
|
||||
const proxy_url = if (proxy) |p| p.href else null;
|
||||
switch (source_store.data) {
|
||||
.bytes => |bytes| {
|
||||
@@ -1390,7 +1390,7 @@ pub fn writeFileInternal(globalThis: *jsc.JSGlobalObject, path_or_blob_: *PathOr
|
||||
destination_blob.detach();
|
||||
return globalThis.throwInvalidArguments("ReadableStream has already been used", .{});
|
||||
}
|
||||
const proxy = globalThis.bunVM().transpiler.env.getHttpProxy(true, null);
|
||||
const proxy = globalThis.bunVM().transpiler.env.getHttpProxy(true, null, null);
|
||||
const proxy_url = if (proxy) |p| p.href else null;
|
||||
|
||||
return S3.uploadStream(
|
||||
@@ -1454,7 +1454,7 @@ pub fn writeFileInternal(globalThis: *jsc.JSGlobalObject, path_or_blob_: *PathOr
|
||||
destination_blob.detach();
|
||||
return globalThis.throwInvalidArguments("ReadableStream has already been used", .{});
|
||||
}
|
||||
const proxy = globalThis.bunVM().transpiler.env.getHttpProxy(true, null);
|
||||
const proxy = globalThis.bunVM().transpiler.env.getHttpProxy(true, null, null);
|
||||
const proxy_url = if (proxy) |p| p.href else null;
|
||||
return S3.uploadStream(
|
||||
(if (options.extra_options != null) aws_options.credentials.dupe() else s3.getCredentials()),
|
||||
@@ -2266,13 +2266,13 @@ const S3BlobDownloadTask = struct {
|
||||
if (blob.offset > 0) {
|
||||
const len: ?usize = if (blob.size != Blob.max_size) @intCast(blob.size) else null;
|
||||
const offset: usize = @intCast(blob.offset);
|
||||
try S3.downloadSlice(credentials, path, offset, len, @ptrCast(&S3BlobDownloadTask.onS3DownloadResolved), this, if (env.getHttpProxy(true, null)) |proxy| proxy.href else null, s3_store.request_payer);
|
||||
try S3.downloadSlice(credentials, path, offset, len, @ptrCast(&S3BlobDownloadTask.onS3DownloadResolved), this, if (env.getHttpProxy(true, null, null)) |proxy| proxy.href else null, s3_store.request_payer);
|
||||
} else if (blob.size == Blob.max_size) {
|
||||
try S3.download(credentials, path, @ptrCast(&S3BlobDownloadTask.onS3DownloadResolved), this, if (env.getHttpProxy(true, null)) |proxy| proxy.href else null, s3_store.request_payer);
|
||||
try S3.download(credentials, path, @ptrCast(&S3BlobDownloadTask.onS3DownloadResolved), this, if (env.getHttpProxy(true, null, null)) |proxy| proxy.href else null, s3_store.request_payer);
|
||||
} else {
|
||||
const len: usize = @intCast(blob.size);
|
||||
const offset: usize = @intCast(blob.offset);
|
||||
try S3.downloadSlice(credentials, path, offset, len, @ptrCast(&S3BlobDownloadTask.onS3DownloadResolved), this, if (env.getHttpProxy(true, null)) |proxy| proxy.href else null, s3_store.request_payer);
|
||||
try S3.downloadSlice(credentials, path, offset, len, @ptrCast(&S3BlobDownloadTask.onS3DownloadResolved), this, if (env.getHttpProxy(true, null, null)) |proxy| proxy.href else null, s3_store.request_payer);
|
||||
}
|
||||
return promise;
|
||||
}
|
||||
@@ -2432,7 +2432,7 @@ pub fn pipeReadableStreamToBlob(this: *Blob, globalThis: *jsc.JSGlobalObject, re
|
||||
defer aws_options.deinit();
|
||||
|
||||
const path = s3.path();
|
||||
const proxy = globalThis.bunVM().transpiler.env.getHttpProxy(true, null);
|
||||
const proxy = globalThis.bunVM().transpiler.env.getHttpProxy(true, null, null);
|
||||
const proxy_url = if (proxy) |p| p.href else null;
|
||||
|
||||
return S3.uploadStream(
|
||||
@@ -2646,7 +2646,7 @@ pub fn getWriter(
|
||||
if (this.isS3()) {
|
||||
const s3 = &this.store.?.data.s3;
|
||||
const path = s3.path();
|
||||
const proxy = globalThis.bunVM().transpiler.env.getHttpProxy(true, null);
|
||||
const proxy = globalThis.bunVM().transpiler.env.getHttpProxy(true, null, null);
|
||||
const proxy_url = if (proxy) |p| p.href else null;
|
||||
if (arguments.len > 0) {
|
||||
const options = arguments.ptr[0];
|
||||
|
||||
@@ -332,7 +332,7 @@ pub fn fromBlobCopyRef(globalThis: *JSGlobalObject, blob: *const Blob, recommend
|
||||
.s3 => |*s3| {
|
||||
const credentials = s3.getCredentials();
|
||||
const path = s3.path();
|
||||
const proxy = globalThis.bunVM().transpiler.env.getHttpProxy(true, null);
|
||||
const proxy = globalThis.bunVM().transpiler.env.getHttpProxy(true, null, null);
|
||||
const proxy_url = if (proxy) |p| p.href else null;
|
||||
|
||||
return bun.S3.readableStream(credentials, path, blob.offset, if (blob.size != Blob.max_size) blob.size else null, proxy_url, s3.request_payer, globalThis);
|
||||
|
||||
@@ -421,7 +421,7 @@ pub const S3BlobStatTask = struct {
|
||||
const path = s3_store.path();
|
||||
const env = globalThis.bunVM().transpiler.env;
|
||||
|
||||
try S3.stat(credentials, path, @ptrCast(&S3BlobStatTask.onS3ExistsResolved), this, if (env.getHttpProxy(true, null)) |proxy| proxy.href else null, s3_store.request_payer);
|
||||
try S3.stat(credentials, path, @ptrCast(&S3BlobStatTask.onS3ExistsResolved), this, if (env.getHttpProxy(true, null, null)) |proxy| proxy.href else null, s3_store.request_payer);
|
||||
return promise;
|
||||
}
|
||||
pub fn stat(globalThis: *jsc.JSGlobalObject, blob: *Blob) bun.JSTerminated!JSValue {
|
||||
@@ -437,7 +437,7 @@ pub const S3BlobStatTask = struct {
|
||||
const path = s3_store.path();
|
||||
const env = globalThis.bunVM().transpiler.env;
|
||||
|
||||
try S3.stat(credentials, path, @ptrCast(&S3BlobStatTask.onS3StatResolved), this, if (env.getHttpProxy(true, null)) |proxy| proxy.href else null, s3_store.request_payer);
|
||||
try S3.stat(credentials, path, @ptrCast(&S3BlobStatTask.onS3StatResolved), this, if (env.getHttpProxy(true, null, null)) |proxy| proxy.href else null, s3_store.request_payer);
|
||||
return promise;
|
||||
}
|
||||
pub fn size(globalThis: *jsc.JSGlobalObject, blob: *Blob) bun.JSTerminated!JSValue {
|
||||
@@ -453,7 +453,7 @@ pub const S3BlobStatTask = struct {
|
||||
const path = s3_store.path();
|
||||
const env = globalThis.bunVM().transpiler.env;
|
||||
|
||||
try S3.stat(credentials, path, @ptrCast(&S3BlobStatTask.onS3SizeResolved), this, if (env.getHttpProxy(true, null)) |proxy| proxy.href else null, s3_store.request_payer);
|
||||
try S3.stat(credentials, path, @ptrCast(&S3BlobStatTask.onS3SizeResolved), this, if (env.getHttpProxy(true, null, null)) |proxy| proxy.href else null, s3_store.request_payer);
|
||||
return promise;
|
||||
}
|
||||
|
||||
|
||||
@@ -356,7 +356,7 @@ pub const S3 = struct {
|
||||
};
|
||||
const promise = jsc.JSPromise.Strong.init(globalThis);
|
||||
const value = promise.value();
|
||||
const proxy_url = globalThis.bunVM().transpiler.env.getHttpProxy(true, null);
|
||||
const proxy_url = globalThis.bunVM().transpiler.env.getHttpProxy(true, null, null);
|
||||
const proxy = if (proxy_url) |url| url.href else null;
|
||||
var aws_options = try this.getCredentialsWithOptions(extra_options, globalThis);
|
||||
defer aws_options.deinit();
|
||||
@@ -414,7 +414,7 @@ pub const S3 = struct {
|
||||
|
||||
const promise = jsc.JSPromise.Strong.init(globalThis);
|
||||
const value = promise.value();
|
||||
const proxy_url = globalThis.bunVM().transpiler.env.getHttpProxy(true, null);
|
||||
const proxy_url = globalThis.bunVM().transpiler.env.getHttpProxy(true, null, null);
|
||||
const proxy = if (proxy_url) |url| url.href else null;
|
||||
var aws_options = try this.getCredentialsWithOptions(extra_options, globalThis);
|
||||
defer aws_options.deinit();
|
||||
|
||||
@@ -156,14 +156,17 @@ pub const Loader = struct {
|
||||
}
|
||||
|
||||
pub fn getHttpProxyFor(this: *Loader, url: URL) ?URL {
|
||||
return this.getHttpProxy(url.isHTTP(), url.hostname);
|
||||
return this.getHttpProxy(url.isHTTP(), url.hostname, url.host);
|
||||
}
|
||||
|
||||
pub fn hasHTTPProxy(this: *const Loader) bool {
|
||||
return this.has("http_proxy") or this.has("HTTP_PROXY") or this.has("https_proxy") or this.has("HTTPS_PROXY");
|
||||
}
|
||||
|
||||
pub fn getHttpProxy(this: *Loader, is_http: bool, hostname: ?[]const u8) ?URL {
|
||||
/// Get proxy URL for HTTP/HTTPS requests, respecting NO_PROXY.
|
||||
/// `hostname` is the host without port (e.g., "localhost")
|
||||
/// `host` is the host with port if present (e.g., "localhost:3000")
|
||||
pub fn getHttpProxy(this: *Loader, is_http: bool, hostname: ?[]const u8, host: ?[]const u8) ?URL {
|
||||
// TODO: When Web Worker support is added, make sure to intern these strings
|
||||
var http_proxy: ?URL = null;
|
||||
|
||||
@@ -191,25 +194,56 @@ pub const Loader = struct {
|
||||
|
||||
var no_proxy_iter = std.mem.splitScalar(u8, no_proxy_text, ',');
|
||||
while (no_proxy_iter.next()) |no_proxy_item| {
|
||||
var host = strings.trim(no_proxy_item, &strings.whitespace_chars);
|
||||
if (host.len == 0) {
|
||||
var no_proxy_entry = strings.trim(no_proxy_item, &strings.whitespace_chars);
|
||||
if (no_proxy_entry.len == 0) {
|
||||
continue;
|
||||
}
|
||||
if (strings.eql(host, "*")) {
|
||||
if (strings.eql(no_proxy_entry, "*")) {
|
||||
return null;
|
||||
}
|
||||
//strips .
|
||||
if (strings.startsWithChar(host, '.')) {
|
||||
host = host[1..];
|
||||
if (host.len == 0) {
|
||||
if (strings.startsWithChar(no_proxy_entry, '.')) {
|
||||
no_proxy_entry = no_proxy_entry[1..];
|
||||
if (no_proxy_entry.len == 0) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
//hostname ends with suffix
|
||||
if (strings.endsWith(hostname.?, host)) {
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return http_proxy;
|
||||
|
||||
@@ -15,10 +15,17 @@ beforeAll(() => {
|
||||
|
||||
// simple http proxy
|
||||
if (request.url.startsWith("http://")) {
|
||||
return await fetch(request.url, {
|
||||
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
|
||||
@@ -257,4 +264,129 @@ describe.concurrent(() => {
|
||||
}
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user