diff --git a/docs/guides/http/proxy.mdx b/docs/guides/http/proxy.mdx index c75434a3f9..49268b7949 100644 --- a/docs/guides/http/proxy.mdx +++ b/docs/guides/http/proxy.mdx @@ -9,18 +9,42 @@ In Bun, `fetch` supports sending requests through an HTTP or HTTPS proxy. This i ```ts proxy.ts icon="/icons/typescript.svg" await fetch("https://example.com", { // The URL of the proxy server - proxy: "https://usertitle:password@proxy.example.com:8080", + proxy: "https://username:password@proxy.example.com:8080", }); ``` --- -The `proxy` option is a URL string that specifies the proxy server. It can include the username and password if the proxy requires authentication. It can be `http://` or `https://`. +The `proxy` option can be a URL string or an object with `url` and optional `headers`. The URL can include the username and password if the proxy requires authentication. It can be `http://` or `https://`. --- +## Custom proxy headers + +To send custom headers to the proxy server (useful for proxy authentication tokens, custom routing, etc.), use the object format: + +```ts proxy-headers.ts icon="/icons/typescript.svg" +await fetch("https://example.com", { + proxy: { + url: "https://proxy.example.com:8080", + headers: { + "Proxy-Authorization": "Bearer my-token", + "X-Proxy-Region": "us-east-1", + }, + }, +}); +``` + +The `headers` property accepts a plain object or a `Headers` instance. These headers are sent directly to the proxy server in `CONNECT` requests (for HTTPS targets) or in the proxy request (for HTTP targets). + +If you provide a `Proxy-Authorization` header, it will override any credentials specified in the proxy URL. + +--- + +## Environment variables + You can also set the `$HTTP_PROXY` or `$HTTPS_PROXY` environment variable to the proxy URL. This is useful when you want to use the same proxy for all requests. ```sh terminal icon="terminal" -HTTPS_PROXY=https://usertitle:password@proxy.example.com:8080 bun run index.ts +HTTPS_PROXY=https://username:password@proxy.example.com:8080 bun run index.ts ``` diff --git a/docs/runtime/networking/fetch.mdx b/docs/runtime/networking/fetch.mdx index 600f7f19ac..93a59d4c59 100644 --- a/docs/runtime/networking/fetch.mdx +++ b/docs/runtime/networking/fetch.mdx @@ -51,7 +51,7 @@ const response = await fetch("http://example.com", { ### Proxying requests -To proxy a request, pass an object with the `proxy` property set to a URL. +To proxy a request, pass an object with the `proxy` property set to a URL string: ```ts const response = await fetch("http://example.com", { @@ -59,6 +59,22 @@ const response = await fetch("http://example.com", { }); ``` +You can also use an object format to send custom headers to the proxy server: + +```ts +const response = await fetch("http://example.com", { + proxy: { + url: "http://proxy.com", + headers: { + "Proxy-Authorization": "Bearer my-token", + "X-Custom-Proxy-Header": "value", + }, + }, +}); +``` + +The `headers` are sent directly to the proxy in `CONNECT` requests (for HTTPS targets) or in the proxy request (for HTTP targets). If you provide a `Proxy-Authorization` header, it overrides any credentials in the proxy URL. + ### Custom headers To set custom headers, pass an object with the `headers` property set to an object. diff --git a/packages/bun-types/globals.d.ts b/packages/bun-types/globals.d.ts index 27b07f21f9..f2d211d5ff 100644 --- a/packages/bun-types/globals.d.ts +++ b/packages/bun-types/globals.d.ts @@ -1920,14 +1920,44 @@ interface BunFetchRequestInit extends RequestInit { * Override http_proxy or HTTPS_PROXY * This is a custom property that is not part of the Fetch API specification. * + * Can be a string URL or an object with `url` and optional `headers`. + * * @example * ```js + * // String format * const response = await fetch("http://example.com", { * proxy: "https://username:password@127.0.0.1:8080" * }); + * + * // Object format with custom headers sent to the proxy + * const response = await fetch("http://example.com", { + * proxy: { + * url: "https://127.0.0.1:8080", + * headers: { + * "Proxy-Authorization": "Bearer token", + * "X-Custom-Proxy-Header": "value" + * } + * } + * }); * ``` + * + * If a `Proxy-Authorization` header is provided in `proxy.headers`, it takes + * precedence over credentials parsed from the proxy URL. */ - proxy?: string; + proxy?: + | string + | { + /** + * The proxy URL + */ + url: string; + /** + * Custom headers to send to the proxy server. + * These headers are sent in the CONNECT request (for HTTPS targets) + * or in the proxy request (for HTTP targets). + */ + headers?: Bun.HeadersInit; + }; /** * Override the default S3 options diff --git a/src/bun.js/webcore/fetch.zig b/src/bun.js/webcore/fetch.zig index 149639ce71..beb726c7c5 100644 --- a/src/bun.js/webcore/fetch.zig +++ b/src/bun.js/webcore/fetch.zig @@ -632,7 +632,11 @@ pub fn Bun__fetch_( break :extract_verbose verbose; }; - // proxy: string | undefined; + // proxy: string | { url: string, headers?: Headers } | undefined; + var proxy_headers: ?Headers = null; + defer if (proxy_headers) |*hdrs| { + hdrs.deinit(); + }; url_proxy_buffer = extract_proxy: { const objects_to_try = [_]jsc.JSValue{ options_object orelse .zero, @@ -641,6 +645,7 @@ pub fn Bun__fetch_( inline for (0..2) |i| { if (objects_to_try[i] != .zero) { if (try objects_to_try[i].get(globalThis, "proxy")) |proxy_arg| { + // Handle string format: proxy: "http://proxy.example.com:8080" if (proxy_arg.isString() and try proxy_arg.getLength(ctx) > 0) { var href = try jsc.URL.hrefFromJS(proxy_arg, globalThis); if (href.tag == .Dead) { @@ -661,6 +666,54 @@ pub fn Bun__fetch_( allocator.free(url_proxy_buffer); break :extract_proxy buffer; } + // Handle object format: proxy: { url: "http://proxy.example.com:8080", headers?: Headers } + if (proxy_arg.isObject()) { + // Get the URL from the proxy object + const proxy_url_arg = try proxy_arg.get(globalThis, "url"); + if (proxy_url_arg == null or proxy_url_arg.?.isUndefinedOrNull()) { + const err = ctx.toTypeError(.INVALID_ARG_VALUE, "fetch() proxy object requires a 'url' property", .{}); + is_error = true; + return JSPromise.dangerouslyCreateRejectedPromiseValueWithoutNotifyingVM(globalThis, err); + } + if (proxy_url_arg.?.isString() and try proxy_url_arg.?.getLength(ctx) > 0) { + var href = try jsc.URL.hrefFromJS(proxy_url_arg.?, globalThis); + if (href.tag == .Dead) { + const err = ctx.toTypeError(.INVALID_ARG_VALUE, "fetch() proxy URL is invalid", .{}); + is_error = true; + return JSPromise.dangerouslyCreateRejectedPromiseValueWithoutNotifyingVM(globalThis, err); + } + defer href.deref(); + const buffer = try std.fmt.allocPrint(allocator, "{s}{f}", .{ url_proxy_buffer, href }); + url = ZigURL.parse(buffer[0..url.href.len]); + if (url.isFile()) { + url_type = URLType.file; + } else if (url.isBlob()) { + url_type = URLType.blob; + } + + proxy = ZigURL.parse(buffer[url.href.len..]); + allocator.free(url_proxy_buffer); + url_proxy_buffer = buffer; + + // Get the headers from the proxy object (optional) + if (try proxy_arg.get(globalThis, "headers")) |headers_value| { + if (!headers_value.isUndefinedOrNull()) { + if (headers_value.as(FetchHeaders)) |fetch_hdrs| { + proxy_headers = Headers.from(fetch_hdrs, allocator, .{}) catch |err| bun.handleOom(err); + } else if (try FetchHeaders.createFromJS(ctx, headers_value)) |fetch_hdrs| { + defer fetch_hdrs.deref(); + proxy_headers = Headers.from(fetch_hdrs, allocator, .{}) catch |err| bun.handleOom(err); + } + } + } + + break :extract_proxy url_proxy_buffer; + } else { + const err = ctx.toTypeError(.INVALID_ARG_VALUE, "fetch() proxy.url must be a non-empty string", .{}); + is_error = true; + return JSPromise.dangerouslyCreateRejectedPromiseValueWithoutNotifyingVM(globalThis, err); + } + } } if (globalThis.hasException()) { @@ -1338,6 +1391,7 @@ pub fn Bun__fetch_( .redirect_type = redirect_type, .verbose = verbose, .proxy = proxy, + .proxy_headers = proxy_headers, .url_proxy_buffer = url_proxy_buffer, .signal = signal, .globalThis = globalThis, @@ -1372,6 +1426,7 @@ pub fn Bun__fetch_( body = FetchTasklet.HTTPRequestBody.Empty; } proxy = null; + proxy_headers = null; url_proxy_buffer = ""; signal = null; ssl_config = null; diff --git a/src/bun.js/webcore/fetch/FetchTasklet.zig b/src/bun.js/webcore/fetch/FetchTasklet.zig index acb147405c..bd4dc3f128 100644 --- a/src/bun.js/webcore/fetch/FetchTasklet.zig +++ b/src/bun.js/webcore/fetch/FetchTasklet.zig @@ -1049,6 +1049,7 @@ pub const FetchTasklet = struct { fetch_options.redirect_type, .{ .http_proxy = proxy, + .proxy_headers = fetch_options.proxy_headers, .hostname = fetch_options.hostname, .signals = fetch_tasklet.signals, .unix_socket_path = fetch_options.unix_socket_path, @@ -1222,6 +1223,7 @@ pub const FetchTasklet = struct { verbose: http.HTTPVerboseLevel = .none, redirect_type: FetchRedirect = FetchRedirect.follow, proxy: ?ZigURL = null, + proxy_headers: ?Headers = null, url_proxy_buffer: []const u8 = "", signal: ?*jsc.WebCore.AbortSignal = null, globalThis: ?*JSGlobalObject, diff --git a/src/http.zig b/src/http.zig index 6f66987aed..9b9ccf7bd5 100644 --- a/src/http.zig +++ b/src/http.zig @@ -328,10 +328,29 @@ fn writeProxyConnect( _ = writer.write("\r\nProxy-Connection: Keep-Alive\r\n") catch 0; + // Check if user provided Proxy-Authorization in custom headers + const user_provided_proxy_auth = if (client.proxy_headers) |hdrs| hdrs.get("proxy-authorization") != null else false; + + // Only write auto-generated proxy_authorization if user didn't provide one if (client.proxy_authorization) |auth| { - _ = writer.write("Proxy-Authorization: ") catch 0; - _ = writer.write(auth) catch 0; - _ = writer.write("\r\n") catch 0; + if (!user_provided_proxy_auth) { + _ = writer.write("Proxy-Authorization: ") catch 0; + _ = writer.write(auth) catch 0; + _ = writer.write("\r\n") catch 0; + } + } + + // Write custom proxy headers + if (client.proxy_headers) |hdrs| { + const slice = hdrs.entries.slice(); + const names = slice.items(.name); + const values = slice.items(.value); + for (names, 0..) |name_ptr, idx| { + _ = writer.write(hdrs.asStr(name_ptr)) catch 0; + _ = writer.write(": ") catch 0; + _ = writer.write(hdrs.asStr(values[idx])) catch 0; + _ = writer.write("\r\n") catch 0; + } } _ = writer.write("\r\n") catch 0; @@ -359,11 +378,31 @@ fn writeProxyRequest( _ = writer.write(request.path) catch 0; _ = writer.write(" HTTP/1.1\r\nProxy-Connection: Keep-Alive\r\n") catch 0; + // Check if user provided Proxy-Authorization in custom headers + const user_provided_proxy_auth = if (client.proxy_headers) |hdrs| hdrs.get("proxy-authorization") != null else false; + + // Only write auto-generated proxy_authorization if user didn't provide one if (client.proxy_authorization) |auth| { - _ = writer.write("Proxy-Authorization: ") catch 0; - _ = writer.write(auth) catch 0; - _ = writer.write("\r\n") catch 0; + if (!user_provided_proxy_auth) { + _ = writer.write("Proxy-Authorization: ") catch 0; + _ = writer.write(auth) catch 0; + _ = writer.write("\r\n") catch 0; + } } + + // Write custom proxy headers + if (client.proxy_headers) |hdrs| { + const slice = hdrs.entries.slice(); + const names = slice.items(.name); + const values = slice.items(.value); + for (names, 0..) |name_ptr, idx| { + _ = writer.write(hdrs.asStr(name_ptr)) catch 0; + _ = writer.write(": ") catch 0; + _ = writer.write(hdrs.asStr(values[idx])) catch 0; + _ = writer.write("\r\n") catch 0; + } + } + for (request.headers) |header| { _ = writer.write(header.name) catch 0; _ = writer.write(": ") catch 0; @@ -450,6 +489,7 @@ if_modified_since: string = "", request_content_len_buf: ["-4294967295".len]u8 = undefined, http_proxy: ?URL = null, +proxy_headers: ?Headers = null, proxy_authorization: ?[]u8 = null, proxy_tunnel: ?*ProxyTunnel = null, signals: Signals = .{}, @@ -466,6 +506,10 @@ pub fn deinit(this: *HTTPClient) void { this.allocator.free(auth); this.proxy_authorization = null; } + if (this.proxy_headers) |*hdrs| { + hdrs.deinit(); + this.proxy_headers = null; + } if (this.proxy_tunnel) |tunnel| { this.proxy_tunnel = null; tunnel.detachAndDeref(); diff --git a/src/http/AsyncHTTP.zig b/src/http/AsyncHTTP.zig index 96e399b0ee..9361feab1c 100644 --- a/src/http/AsyncHTTP.zig +++ b/src/http/AsyncHTTP.zig @@ -93,6 +93,7 @@ const AtomicState = std.atomic.Value(State); pub const Options = struct { http_proxy: ?URL = null, + proxy_headers: ?Headers = null, hostname: ?[]u8 = null, signals: ?Signals = null, unix_socket_path: ?jsc.ZigString.Slice = null, @@ -185,6 +186,7 @@ pub fn init( .signals = options.signals orelse this.signals, .async_http_id = this.async_http_id, .http_proxy = this.http_proxy, + .proxy_headers = options.proxy_headers, .redirect_type = redirect_type, }; if (options.unix_socket_path) |val| { diff --git a/test/integration/bun-types/fixture/fetch.ts b/test/integration/bun-types/fixture/fetch.ts index 32197ce37c..31eb400d15 100644 --- a/test/integration/bun-types/fixture/fetch.ts +++ b/test/integration/bun-types/fixture/fetch.ts @@ -246,3 +246,80 @@ if (typeof process !== "undefined") { // @ts-expect-error - -Infinity fetch("https://example.com", { body: -Infinity }); } + +// Proxy option types +{ + // String proxy URL is valid + fetch("https://example.com", { proxy: "http://proxy.example.com:8080" }); + fetch("https://example.com", { proxy: "https://user:pass@proxy.example.com:8080" }); +} + +{ + // Object proxy with url is valid + fetch("https://example.com", { + proxy: { + url: "http://proxy.example.com:8080", + }, + }); +} + +{ + // Object proxy with url and headers (plain object) is valid + fetch("https://example.com", { + proxy: { + url: "http://proxy.example.com:8080", + headers: { + "Proxy-Authorization": "Bearer token", + "X-Custom-Header": "value", + }, + }, + }); +} + +{ + // Object proxy with url and headers (Headers instance) is valid + fetch("https://example.com", { + proxy: { + url: "http://proxy.example.com:8080", + headers: new Headers({ "Proxy-Authorization": "Bearer token" }), + }, + }); +} + +{ + // Object proxy with url and headers (array of tuples) is valid + fetch("https://example.com", { + proxy: { + url: "http://proxy.example.com:8080", + headers: [ + ["Proxy-Authorization", "Bearer token"], + ["X-Custom", "value"], + ], + }, + }); +} + +{ + // @ts-expect-error - Proxy object without url is invalid + fetch("https://example.com", { proxy: { headers: { "X-Custom": "value" } } }); +} + +{ + // @ts-expect-error - Proxy url must be string, not number + fetch("https://example.com", { proxy: { url: 8080 } }); +} + +{ + // @ts-expect-error - Proxy must be string or object, not number + fetch("https://example.com", { proxy: 8080 }); +} + +{ + // @ts-expect-error - Proxy must be string or object, not boolean + fetch("https://example.com", { proxy: true }); +} + +{ + // @ts-expect-error - Proxy must be string or object, not array + fetch("https://example.com", { proxy: ["http://proxy.example.com"] }); +} diff --git a/test/js/bun/http/proxy.test.ts b/test/js/bun/http/proxy.test.ts index 02f088a7f1..703c189085 100644 --- a/test/js/bun/http/proxy.test.ts +++ b/test/js/bun/http/proxy.test.ts @@ -337,3 +337,366 @@ test("HTTPS origin close-delimited body via HTTP proxy does not ECONNRESET", asy await once(originServer, "close"); } }); + +describe("proxy object format with headers", () => { + test("proxy object with url string works same as string proxy", async () => { + const response = await fetch(httpServer.url, { + method: "GET", + proxy: { + url: httpProxyServer.url, + }, + keepalive: false, + }); + expect(response.ok).toBe(true); + expect(response.status).toBe(200); + }); + + test("proxy object with url and headers sends headers to proxy (HTTP proxy)", async () => { + // Create a proxy server that captures headers + const capturedHeaders: string[] = []; + const proxyServerWithCapture = net.createServer((clientSocket: net.Socket) => { + clientSocket.once("data", data => { + const request = data.toString(); + // Capture headers + const lines = request.split("\r\n"); + for (const line of lines) { + if (line.toLowerCase().startsWith("x-proxy-")) { + capturedHeaders.push(line.toLowerCase()); + } + } + + const [method, path] = request.split(" "); + let host: string; + let port: number | string = 0; + let request_path = ""; + if (path.indexOf("http") !== -1) { + const url = new URL(path); + host = url.hostname; + port = url.port; + request_path = url.pathname + (url.search || ""); + } else { + [host, port] = path.split(":"); + } + const destinationPort = Number.parseInt((port || (method === "CONNECT" ? "443" : "80")).toString(), 10); + const destinationHost = host || ""; + + const serverSocket = net.connect(destinationPort, destinationHost, () => { + if (method === "CONNECT") { + clientSocket.write("HTTP/1.1 200 OK\r\nHost: localhost\r\n\r\n"); + clientSocket.pipe(serverSocket); + serverSocket.pipe(clientSocket); + } else { + serverSocket.write(`${method} ${request_path} HTTP/1.1\r\n`); + serverSocket.write(data.slice(request.indexOf("\r\n") + 2)); + serverSocket.pipe(clientSocket); + } + }); + clientSocket.on("error", () => {}); + serverSocket.on("error", () => { + clientSocket.end(); + }); + }); + }); + + proxyServerWithCapture.listen(0); + await once(proxyServerWithCapture, "listening"); + const proxyPort = (proxyServerWithCapture.address() as net.AddressInfo).port; + const proxyUrl = `http://localhost:${proxyPort}`; + + try { + const response = await fetch(httpServer.url, { + method: "GET", + proxy: { + url: proxyUrl, + headers: { + "X-Proxy-Custom-Header": "custom-value", + "X-Proxy-Another": "another-value", + }, + }, + keepalive: false, + }); + expect(response.ok).toBe(true); + expect(response.status).toBe(200); + // Verify the custom headers were sent to the proxy (case-insensitive check) + expect(capturedHeaders).toContainEqual(expect.stringContaining("x-proxy-custom-header: custom-value")); + expect(capturedHeaders).toContainEqual(expect.stringContaining("x-proxy-another: another-value")); + } finally { + proxyServerWithCapture.close(); + await once(proxyServerWithCapture, "close"); + } + }); + + test("proxy object with url and headers sends headers in CONNECT request (HTTPS target)", async () => { + // Create a proxy server that captures headers + const capturedHeaders: string[] = []; + const proxyServerWithCapture = net.createServer((clientSocket: net.Socket) => { + clientSocket.once("data", data => { + const request = data.toString(); + // Capture headers + const lines = request.split("\r\n"); + for (const line of lines) { + if (line.toLowerCase().startsWith("x-proxy-")) { + capturedHeaders.push(line.toLowerCase()); + } + } + + const [method, path] = request.split(" "); + let host: string; + let port: number | string = 0; + if (path.indexOf("http") !== -1) { + const url = new URL(path); + host = url.hostname; + port = url.port; + } else { + [host, port] = path.split(":"); + } + const destinationPort = Number.parseInt((port || (method === "CONNECT" ? "443" : "80")).toString(), 10); + const destinationHost = host || ""; + + const serverSocket = net.connect(destinationPort, destinationHost, () => { + if (method === "CONNECT") { + clientSocket.write("HTTP/1.1 200 OK\r\nHost: localhost\r\n\r\n"); + clientSocket.pipe(serverSocket); + serverSocket.pipe(clientSocket); + } else { + clientSocket.write("HTTP/1.1 502 Bad Gateway\r\n\r\n"); + clientSocket.end(); + } + }); + clientSocket.on("error", () => {}); + serverSocket.on("error", () => { + clientSocket.end(); + }); + }); + }); + + proxyServerWithCapture.listen(0); + await once(proxyServerWithCapture, "listening"); + const proxyPort = (proxyServerWithCapture.address() as net.AddressInfo).port; + const proxyUrl = `http://localhost:${proxyPort}`; + + try { + const response = await fetch(httpsServer.url, { + method: "GET", + proxy: { + url: proxyUrl, + headers: new Headers({ + "X-Proxy-Auth-Token": "secret-token-123", + }), + }, + keepalive: false, + tls: { + ca: tlsCert.cert, + rejectUnauthorized: false, + }, + }); + expect(response.ok).toBe(true); + expect(response.status).toBe(200); + // Verify the custom headers were sent in the CONNECT request (case-insensitive check) + expect(capturedHeaders).toContainEqual(expect.stringContaining("x-proxy-auth-token: secret-token-123")); + } finally { + proxyServerWithCapture.close(); + await once(proxyServerWithCapture, "close"); + } + }); + + test("proxy object without url throws error", async () => { + await expect( + fetch(httpServer.url, { + method: "GET", + proxy: { + headers: { "X-Test": "value" }, + } as any, + keepalive: false, + }), + ).rejects.toThrow("fetch() proxy object requires a 'url' property"); + }); + + test("proxy object with null url throws error", async () => { + await expect( + fetch(httpServer.url, { + method: "GET", + proxy: { + url: null, + headers: { "X-Test": "value" }, + } as any, + keepalive: false, + }), + ).rejects.toThrow("fetch() proxy object requires a 'url' property"); + }); + + test("proxy object with empty string url throws error", async () => { + await expect( + fetch(httpServer.url, { + method: "GET", + proxy: { + url: "", + headers: { "X-Test": "value" }, + } as any, + keepalive: false, + }), + ).rejects.toThrow("fetch() proxy.url must be a non-empty string"); + }); + + test("proxy object with empty headers object works", async () => { + const response = await fetch(httpServer.url, { + method: "GET", + proxy: { + url: httpProxyServer.url, + headers: {}, + }, + keepalive: false, + }); + expect(response.ok).toBe(true); + expect(response.status).toBe(200); + }); + + test("proxy object with undefined headers works", async () => { + const response = await fetch(httpServer.url, { + method: "GET", + proxy: { + url: httpProxyServer.url, + headers: undefined, + }, + keepalive: false, + }); + expect(response.ok).toBe(true); + expect(response.status).toBe(200); + }); + + test("proxy object with headers as Headers instance", async () => { + const capturedHeaders: string[] = []; + const proxyServerWithCapture = net.createServer((clientSocket: net.Socket) => { + clientSocket.once("data", data => { + const request = data.toString(); + const lines = request.split("\r\n"); + for (const line of lines) { + if (line.toLowerCase().startsWith("x-custom-")) { + capturedHeaders.push(line.toLowerCase()); + } + } + + const [method, path] = request.split(" "); + let host: string; + let port: number | string = 0; + let request_path = ""; + if (path.indexOf("http") !== -1) { + const url = new URL(path); + host = url.hostname; + port = url.port; + request_path = url.pathname + (url.search || ""); + } else { + [host, port] = path.split(":"); + } + const destinationPort = Number.parseInt((port || "80").toString(), 10); + const destinationHost = host || ""; + + const serverSocket = net.connect(destinationPort, destinationHost, () => { + serverSocket.write(`${method} ${request_path} HTTP/1.1\r\n`); + serverSocket.write(data.slice(request.indexOf("\r\n") + 2)); + serverSocket.pipe(clientSocket); + }); + clientSocket.on("error", () => {}); + serverSocket.on("error", () => { + clientSocket.end(); + }); + }); + }); + + proxyServerWithCapture.listen(0); + await once(proxyServerWithCapture, "listening"); + const proxyPort = (proxyServerWithCapture.address() as net.AddressInfo).port; + const proxyUrl = `http://localhost:${proxyPort}`; + + try { + const headers = new Headers(); + headers.set("X-Custom-Header-1", "value1"); + headers.set("X-Custom-Header-2", "value2"); + + const response = await fetch(httpServer.url, { + method: "GET", + proxy: { + url: proxyUrl, + headers: headers, + }, + keepalive: false, + }); + expect(response.ok).toBe(true); + expect(response.status).toBe(200); + // Case-insensitive check + expect(capturedHeaders).toContainEqual(expect.stringContaining("x-custom-header-1: value1")); + expect(capturedHeaders).toContainEqual(expect.stringContaining("x-custom-header-2: value2")); + } finally { + proxyServerWithCapture.close(); + await once(proxyServerWithCapture, "close"); + } + }); + + test("user-provided Proxy-Authorization header overrides URL credentials", async () => { + const capturedHeaders: string[] = []; + const proxyServerWithCapture = net.createServer((clientSocket: net.Socket) => { + clientSocket.once("data", data => { + const request = data.toString(); + const lines = request.split("\r\n"); + for (const line of lines) { + if (line.toLowerCase().startsWith("proxy-authorization:")) { + capturedHeaders.push(line.toLowerCase()); + } + } + + const [method, path] = request.split(" "); + let host: string; + let port: number | string = 0; + let request_path = ""; + if (path.indexOf("http") !== -1) { + const url = new URL(path); + host = url.hostname; + port = url.port; + request_path = url.pathname + (url.search || ""); + } else { + [host, port] = path.split(":"); + } + const destinationPort = Number.parseInt((port || "80").toString(), 10); + const destinationHost = host || ""; + + const serverSocket = net.connect(destinationPort, destinationHost, () => { + serverSocket.write(`${method} ${request_path} HTTP/1.1\r\n`); + serverSocket.write(data.slice(request.indexOf("\r\n") + 2)); + serverSocket.pipe(clientSocket); + }); + clientSocket.on("error", () => {}); + serverSocket.on("error", () => { + clientSocket.end(); + }); + }); + }); + + proxyServerWithCapture.listen(0); + await once(proxyServerWithCapture, "listening"); + const proxyPort = (proxyServerWithCapture.address() as net.AddressInfo).port; + // Proxy URL with credentials that would generate Basic auth + const proxyUrl = `http://urluser:urlpass@localhost:${proxyPort}`; + + try { + const response = await fetch(httpServer.url, { + method: "GET", + proxy: { + url: proxyUrl, + headers: { + // User-provided Proxy-Authorization should override the URL-based one + "Proxy-Authorization": "Bearer custom-token-12345", + }, + }, + keepalive: false, + }); + expect(response.ok).toBe(true); + expect(response.status).toBe(200); + // Should only have one Proxy-Authorization header (the user-provided one) + expect(capturedHeaders.length).toBe(1); + expect(capturedHeaders[0]).toBe("proxy-authorization: bearer custom-token-12345"); + } finally { + proxyServerWithCapture.close(); + await once(proxyServerWithCapture, "close"); + } + }); +});