mirror of
https://github.com/oven-sh/bun
synced 2026-02-02 15:08:46 +00:00
feat(fetch): add proxy object format with headers support (#25090)
## Summary
- Extends `fetch()` proxy option to accept an object format: `proxy: {
url: string, headers?: Headers }`
- Allows sending custom headers to the proxy server (useful for proxy
authentication, custom routing headers, etc.)
- Headers are sent in CONNECT requests (for HTTPS targets) and direct
proxy requests (for HTTP targets)
- User-provided `Proxy-Authorization` header overrides auto-generated
credentials from URL
## Usage
```typescript
// Old format (still works)
fetch(url, { proxy: "http://proxy.example.com:8080" });
// New object format with headers
fetch(url, {
proxy: {
url: "http://proxy.example.com:8080",
headers: {
"Proxy-Authorization": "Bearer token",
"X-Custom-Proxy-Header": "value"
}
}
});
```
## Test plan
- [x] Test proxy object with url string works same as string proxy
- [x] Test proxy object with headers sends headers to proxy (HTTP
target)
- [x] Test proxy object with headers sends headers in CONNECT request
(HTTPS target)
- [x] Test proxy object with Headers instance
- [x] Test proxy object with empty headers
- [x] Test proxy object with undefined headers
- [x] Test user-provided Proxy-Authorization overrides URL credentials
- [x] All existing proxy tests pass (25 total)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
```ts proxy.ts icon="/icons/typescript.svg"
|
||||||
await fetch("https://example.com", {
|
await fetch("https://example.com", {
|
||||||
// The URL of the proxy server
|
// 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.
|
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"
|
```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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ const response = await fetch("http://example.com", {
|
|||||||
|
|
||||||
### Proxying requests
|
### 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
|
```ts
|
||||||
const response = await fetch("http://example.com", {
|
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
|
### Custom headers
|
||||||
|
|
||||||
To set custom headers, pass an object with the `headers` property set to an object.
|
To set custom headers, pass an object with the `headers` property set to an object.
|
||||||
|
|||||||
32
packages/bun-types/globals.d.ts
vendored
32
packages/bun-types/globals.d.ts
vendored
@@ -1920,14 +1920,44 @@ interface BunFetchRequestInit extends RequestInit {
|
|||||||
* Override http_proxy or HTTPS_PROXY
|
* Override http_proxy or HTTPS_PROXY
|
||||||
* This is a custom property that is not part of the Fetch API specification.
|
* 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
|
* @example
|
||||||
* ```js
|
* ```js
|
||||||
|
* // String format
|
||||||
* const response = await fetch("http://example.com", {
|
* const response = await fetch("http://example.com", {
|
||||||
* proxy: "https://username:password@127.0.0.1:8080"
|
* 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
|
* Override the default S3 options
|
||||||
|
|||||||
@@ -632,7 +632,11 @@ pub fn Bun__fetch_(
|
|||||||
break :extract_verbose verbose;
|
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: {
|
url_proxy_buffer = extract_proxy: {
|
||||||
const objects_to_try = [_]jsc.JSValue{
|
const objects_to_try = [_]jsc.JSValue{
|
||||||
options_object orelse .zero,
|
options_object orelse .zero,
|
||||||
@@ -641,6 +645,7 @@ pub fn Bun__fetch_(
|
|||||||
inline for (0..2) |i| {
|
inline for (0..2) |i| {
|
||||||
if (objects_to_try[i] != .zero) {
|
if (objects_to_try[i] != .zero) {
|
||||||
if (try objects_to_try[i].get(globalThis, "proxy")) |proxy_arg| {
|
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) {
|
if (proxy_arg.isString() and try proxy_arg.getLength(ctx) > 0) {
|
||||||
var href = try jsc.URL.hrefFromJS(proxy_arg, globalThis);
|
var href = try jsc.URL.hrefFromJS(proxy_arg, globalThis);
|
||||||
if (href.tag == .Dead) {
|
if (href.tag == .Dead) {
|
||||||
@@ -661,6 +666,54 @@ pub fn Bun__fetch_(
|
|||||||
allocator.free(url_proxy_buffer);
|
allocator.free(url_proxy_buffer);
|
||||||
break :extract_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()) {
|
if (globalThis.hasException()) {
|
||||||
@@ -1338,6 +1391,7 @@ pub fn Bun__fetch_(
|
|||||||
.redirect_type = redirect_type,
|
.redirect_type = redirect_type,
|
||||||
.verbose = verbose,
|
.verbose = verbose,
|
||||||
.proxy = proxy,
|
.proxy = proxy,
|
||||||
|
.proxy_headers = proxy_headers,
|
||||||
.url_proxy_buffer = url_proxy_buffer,
|
.url_proxy_buffer = url_proxy_buffer,
|
||||||
.signal = signal,
|
.signal = signal,
|
||||||
.globalThis = globalThis,
|
.globalThis = globalThis,
|
||||||
@@ -1372,6 +1426,7 @@ pub fn Bun__fetch_(
|
|||||||
body = FetchTasklet.HTTPRequestBody.Empty;
|
body = FetchTasklet.HTTPRequestBody.Empty;
|
||||||
}
|
}
|
||||||
proxy = null;
|
proxy = null;
|
||||||
|
proxy_headers = null;
|
||||||
url_proxy_buffer = "";
|
url_proxy_buffer = "";
|
||||||
signal = null;
|
signal = null;
|
||||||
ssl_config = null;
|
ssl_config = null;
|
||||||
|
|||||||
@@ -1049,6 +1049,7 @@ pub const FetchTasklet = struct {
|
|||||||
fetch_options.redirect_type,
|
fetch_options.redirect_type,
|
||||||
.{
|
.{
|
||||||
.http_proxy = proxy,
|
.http_proxy = proxy,
|
||||||
|
.proxy_headers = fetch_options.proxy_headers,
|
||||||
.hostname = fetch_options.hostname,
|
.hostname = fetch_options.hostname,
|
||||||
.signals = fetch_tasklet.signals,
|
.signals = fetch_tasklet.signals,
|
||||||
.unix_socket_path = fetch_options.unix_socket_path,
|
.unix_socket_path = fetch_options.unix_socket_path,
|
||||||
@@ -1222,6 +1223,7 @@ pub const FetchTasklet = struct {
|
|||||||
verbose: http.HTTPVerboseLevel = .none,
|
verbose: http.HTTPVerboseLevel = .none,
|
||||||
redirect_type: FetchRedirect = FetchRedirect.follow,
|
redirect_type: FetchRedirect = FetchRedirect.follow,
|
||||||
proxy: ?ZigURL = null,
|
proxy: ?ZigURL = null,
|
||||||
|
proxy_headers: ?Headers = null,
|
||||||
url_proxy_buffer: []const u8 = "",
|
url_proxy_buffer: []const u8 = "",
|
||||||
signal: ?*jsc.WebCore.AbortSignal = null,
|
signal: ?*jsc.WebCore.AbortSignal = null,
|
||||||
globalThis: ?*JSGlobalObject,
|
globalThis: ?*JSGlobalObject,
|
||||||
|
|||||||
56
src/http.zig
56
src/http.zig
@@ -328,10 +328,29 @@ fn writeProxyConnect(
|
|||||||
|
|
||||||
_ = writer.write("\r\nProxy-Connection: Keep-Alive\r\n") catch 0;
|
_ = 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| {
|
if (client.proxy_authorization) |auth| {
|
||||||
_ = writer.write("Proxy-Authorization: ") catch 0;
|
if (!user_provided_proxy_auth) {
|
||||||
_ = writer.write(auth) catch 0;
|
_ = writer.write("Proxy-Authorization: ") catch 0;
|
||||||
_ = writer.write("\r\n") 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;
|
_ = writer.write("\r\n") catch 0;
|
||||||
@@ -359,11 +378,31 @@ fn writeProxyRequest(
|
|||||||
_ = writer.write(request.path) catch 0;
|
_ = writer.write(request.path) catch 0;
|
||||||
_ = writer.write(" HTTP/1.1\r\nProxy-Connection: Keep-Alive\r\n") 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| {
|
if (client.proxy_authorization) |auth| {
|
||||||
_ = writer.write("Proxy-Authorization: ") catch 0;
|
if (!user_provided_proxy_auth) {
|
||||||
_ = writer.write(auth) catch 0;
|
_ = writer.write("Proxy-Authorization: ") catch 0;
|
||||||
_ = writer.write("\r\n") 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| {
|
for (request.headers) |header| {
|
||||||
_ = writer.write(header.name) catch 0;
|
_ = writer.write(header.name) catch 0;
|
||||||
_ = writer.write(": ") catch 0;
|
_ = writer.write(": ") catch 0;
|
||||||
@@ -450,6 +489,7 @@ if_modified_since: string = "",
|
|||||||
request_content_len_buf: ["-4294967295".len]u8 = undefined,
|
request_content_len_buf: ["-4294967295".len]u8 = undefined,
|
||||||
|
|
||||||
http_proxy: ?URL = null,
|
http_proxy: ?URL = null,
|
||||||
|
proxy_headers: ?Headers = null,
|
||||||
proxy_authorization: ?[]u8 = null,
|
proxy_authorization: ?[]u8 = null,
|
||||||
proxy_tunnel: ?*ProxyTunnel = null,
|
proxy_tunnel: ?*ProxyTunnel = null,
|
||||||
signals: Signals = .{},
|
signals: Signals = .{},
|
||||||
@@ -466,6 +506,10 @@ pub fn deinit(this: *HTTPClient) void {
|
|||||||
this.allocator.free(auth);
|
this.allocator.free(auth);
|
||||||
this.proxy_authorization = null;
|
this.proxy_authorization = null;
|
||||||
}
|
}
|
||||||
|
if (this.proxy_headers) |*hdrs| {
|
||||||
|
hdrs.deinit();
|
||||||
|
this.proxy_headers = null;
|
||||||
|
}
|
||||||
if (this.proxy_tunnel) |tunnel| {
|
if (this.proxy_tunnel) |tunnel| {
|
||||||
this.proxy_tunnel = null;
|
this.proxy_tunnel = null;
|
||||||
tunnel.detachAndDeref();
|
tunnel.detachAndDeref();
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ const AtomicState = std.atomic.Value(State);
|
|||||||
|
|
||||||
pub const Options = struct {
|
pub const Options = struct {
|
||||||
http_proxy: ?URL = null,
|
http_proxy: ?URL = null,
|
||||||
|
proxy_headers: ?Headers = null,
|
||||||
hostname: ?[]u8 = null,
|
hostname: ?[]u8 = null,
|
||||||
signals: ?Signals = null,
|
signals: ?Signals = null,
|
||||||
unix_socket_path: ?jsc.ZigString.Slice = null,
|
unix_socket_path: ?jsc.ZigString.Slice = null,
|
||||||
@@ -185,6 +186,7 @@ pub fn init(
|
|||||||
.signals = options.signals orelse this.signals,
|
.signals = options.signals orelse this.signals,
|
||||||
.async_http_id = this.async_http_id,
|
.async_http_id = this.async_http_id,
|
||||||
.http_proxy = this.http_proxy,
|
.http_proxy = this.http_proxy,
|
||||||
|
.proxy_headers = options.proxy_headers,
|
||||||
.redirect_type = redirect_type,
|
.redirect_type = redirect_type,
|
||||||
};
|
};
|
||||||
if (options.unix_socket_path) |val| {
|
if (options.unix_socket_path) |val| {
|
||||||
|
|||||||
@@ -246,3 +246,80 @@ if (typeof process !== "undefined") {
|
|||||||
// @ts-expect-error - -Infinity
|
// @ts-expect-error - -Infinity
|
||||||
fetch("https://example.com", { body: -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"] });
|
||||||
|
}
|
||||||
|
|||||||
@@ -337,3 +337,366 @@ test("HTTPS origin close-delimited body via HTTP proxy does not ECONNRESET", asy
|
|||||||
await once(originServer, "close");
|
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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user