diff --git a/src/bun.js/api/server/RequestContext.zig b/src/bun.js/api/server/RequestContext.zig index 8e522b9567..1b635b5144 100644 --- a/src/bun.js/api/server/RequestContext.zig +++ b/src/bun.js/api/server/RequestContext.zig @@ -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(); diff --git a/src/bun.js/webcore/Blob.zig b/src/bun.js/webcore/Blob.zig index 72b4060564..28056fd2e0 100644 --- a/src/bun.js/webcore/Blob.zig +++ b/src/bun.js/webcore/Blob.zig @@ -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]; diff --git a/src/bun.js/webcore/ReadableStream.zig b/src/bun.js/webcore/ReadableStream.zig index 5bcf0eccb7..c13e8d5495 100644 --- a/src/bun.js/webcore/ReadableStream.zig +++ b/src/bun.js/webcore/ReadableStream.zig @@ -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); diff --git a/src/bun.js/webcore/S3File.zig b/src/bun.js/webcore/S3File.zig index a9c32f112f..29d0524f7d 100644 --- a/src/bun.js/webcore/S3File.zig +++ b/src/bun.js/webcore/S3File.zig @@ -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; } diff --git a/src/bun.js/webcore/blob/Store.zig b/src/bun.js/webcore/blob/Store.zig index 0d03c339da..7de3d04c79 100644 --- a/src/bun.js/webcore/blob/Store.zig +++ b/src/bun.js/webcore/blob/Store.zig @@ -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(); diff --git a/src/env_loader.zig b/src/env_loader.zig index 03b08f5f15..f3ce7ecf86 100644 --- a/src/env_loader.zig +++ b/src/env_loader.zig @@ -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,23 +194,54 @@ 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)) { - return null; + + // 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; + } } } } diff --git a/test/js/bun/http/proxy.test.js b/test/js/bun/http/proxy.test.js index aecb0ee3ae..fcf5881034 100644 --- a/test/js/bun/http/proxy.test.js +++ b/test/js/bun/http/proxy.test.js @@ -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); + }); + }); });