mirror of
https://github.com/oven-sh/bun
synced 2026-02-02 15:08:46 +00:00
## 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>
703 lines
23 KiB
TypeScript
703 lines
23 KiB
TypeScript
import axios from "axios";
|
|
import type { Server } from "bun";
|
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
import { tls as tlsCert } from "harness";
|
|
import { HttpsProxyAgent } from "https-proxy-agent";
|
|
import { once } from "node:events";
|
|
import net from "node:net";
|
|
import tls from "node:tls";
|
|
async function createProxyServer(is_tls: boolean) {
|
|
const serverArgs = [];
|
|
if (is_tls) {
|
|
serverArgs.push({
|
|
...tlsCert,
|
|
rejectUnauthorized: false,
|
|
});
|
|
}
|
|
const log: Array<string> = [];
|
|
serverArgs.push((clientSocket: net.Socket | tls.TLSSocket) => {
|
|
clientSocket.once("data", data => {
|
|
const request = data.toString();
|
|
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 {
|
|
// Extract the host and port from the CONNECT request
|
|
[host, port] = path.split(":");
|
|
}
|
|
const destinationPort = Number.parseInt((port || (method === "CONNECT" ? "443" : "80")).toString(), 10);
|
|
const destinationHost = host || "";
|
|
log.push(`${method} ${host}:${port}${request_path}`);
|
|
|
|
// Establish a connection to the destination server
|
|
const serverSocket = net.connect(destinationPort, destinationHost, () => {
|
|
if (method === "CONNECT") {
|
|
// 220 OK with host so the client knows the connection was successful
|
|
clientSocket.write("HTTP/1.1 200 OK\r\nHost: localhost\r\n\r\n");
|
|
|
|
// Pipe data between client and server
|
|
clientSocket.pipe(serverSocket);
|
|
serverSocket.pipe(clientSocket);
|
|
} else {
|
|
serverSocket.write(`${method} ${request_path} HTTP/1.1\r\n`);
|
|
// Send the request to the destination server
|
|
serverSocket.write(data.slice(request.indexOf("\r\n") + 2));
|
|
serverSocket.pipe(clientSocket);
|
|
}
|
|
});
|
|
// ignore client errors (can happen because of happy eye balls and now we error on write when not connected for node.js compatibility)
|
|
clientSocket.on("error", () => {});
|
|
|
|
serverSocket.on("error", err => {
|
|
clientSocket.end();
|
|
});
|
|
});
|
|
});
|
|
// Create a server to listen for incoming HTTPS connections
|
|
//@ts-ignore
|
|
const server = (is_tls ? tls : net).createServer(...serverArgs);
|
|
|
|
server.listen(0);
|
|
await once(server, "listening");
|
|
const port = server.address().port;
|
|
const url = `http${is_tls ? "s" : ""}://localhost:${port}`;
|
|
return { server, url, log: log };
|
|
}
|
|
|
|
let httpServer: Server;
|
|
let httpsServer: Server;
|
|
let httpProxyServer: { server: net.Server; url: string; log: string[] };
|
|
let httpsProxyServer: { server: net.Server; url: string; log: string[] };
|
|
|
|
beforeAll(async () => {
|
|
httpServer = Bun.serve({
|
|
port: 0,
|
|
async fetch(req) {
|
|
if (req.method === "POST") {
|
|
const text = await req.text();
|
|
return new Response(text, { status: 200 });
|
|
}
|
|
return new Response("", { status: 200 });
|
|
},
|
|
});
|
|
|
|
httpsServer = Bun.serve({
|
|
port: 0,
|
|
tls: tlsCert,
|
|
async fetch(req) {
|
|
if (req.method === "POST") {
|
|
const text = await req.text();
|
|
return new Response(text, { status: 200 });
|
|
}
|
|
return new Response("", { status: 200 });
|
|
},
|
|
});
|
|
|
|
httpProxyServer = await createProxyServer(false);
|
|
httpsProxyServer = await createProxyServer(true);
|
|
});
|
|
|
|
afterAll(() => {
|
|
httpServer.stop();
|
|
httpsServer.stop();
|
|
httpProxyServer.server.close();
|
|
httpsProxyServer.server.close();
|
|
});
|
|
|
|
for (const proxy_tls of [false, true]) {
|
|
for (const target_tls of [false, true]) {
|
|
for (const body of [undefined, "Hello, World"]) {
|
|
test(`${body === undefined ? "GET" : "POST"} ${proxy_tls ? "TLS" : "non-TLS"} proxy -> ${target_tls ? "TLS" : "non-TLS"} body type ${typeof body}`, async () => {
|
|
const response = await fetch(target_tls ? httpsServer.url : httpServer.url, {
|
|
method: body === undefined ? "GET" : "POST",
|
|
proxy: proxy_tls ? httpsProxyServer.url : httpProxyServer.url,
|
|
headers: {
|
|
"Content-Type": "plain/text",
|
|
},
|
|
keepalive: false,
|
|
body: body,
|
|
tls: {
|
|
ca: tlsCert.cert,
|
|
rejectUnauthorized: false,
|
|
},
|
|
});
|
|
expect(response.ok).toBe(true);
|
|
expect(response.status).toBe(200);
|
|
expect(response.statusText).toBe("OK");
|
|
const result = await response.text();
|
|
|
|
expect(result).toBe(body || "");
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const server_tls of [false, true]) {
|
|
describe(`proxy can handle redirects with ${server_tls ? "TLS" : "non-TLS"} server`, () => {
|
|
test("with empty body #12007", async () => {
|
|
using server = Bun.serve({
|
|
tls: server_tls ? tlsCert : undefined,
|
|
port: 0,
|
|
async fetch(req) {
|
|
if (req.url.endsWith("/bunbun")) {
|
|
return Response.redirect("/bun", 302);
|
|
}
|
|
if (req.url.endsWith("/bun")) {
|
|
return Response.redirect("/", 302);
|
|
}
|
|
return new Response("", { status: 403 });
|
|
},
|
|
});
|
|
const response = await fetch(`${server.url.origin}/bunbun`, {
|
|
proxy: httpsProxyServer.url,
|
|
tls: {
|
|
cert: tlsCert.cert,
|
|
rejectUnauthorized: false,
|
|
},
|
|
});
|
|
expect(response.ok).toBe(false);
|
|
expect(response.status).toBe(403);
|
|
expect(response.statusText).toBe("Forbidden");
|
|
});
|
|
|
|
test("with body #12007", async () => {
|
|
using server = Bun.serve({
|
|
tls: server_tls ? tlsCert : undefined,
|
|
port: 0,
|
|
async fetch(req) {
|
|
if (req.url.endsWith("/bunbun")) {
|
|
return new Response("Hello, bunbun", { status: 302, headers: { Location: "/bun" } });
|
|
}
|
|
if (req.url.endsWith("/bun")) {
|
|
return new Response("Hello, bun", { status: 302, headers: { Location: "/" } });
|
|
}
|
|
return new Response("BUN!", { status: 200 });
|
|
},
|
|
});
|
|
const response = await fetch(`${server.url.origin}/bunbun`, {
|
|
proxy: httpsProxyServer.url,
|
|
tls: {
|
|
cert: tlsCert.cert,
|
|
rejectUnauthorized: false,
|
|
},
|
|
});
|
|
expect(response.ok).toBe(true);
|
|
expect(response.status).toBe(200);
|
|
expect(response.statusText).toBe("OK");
|
|
|
|
const result = await response.text();
|
|
expect(result).toBe("BUN!");
|
|
});
|
|
|
|
test("with chunked body #12007", async () => {
|
|
using server = Bun.serve({
|
|
tls: server_tls ? tlsCert : undefined,
|
|
port: 0,
|
|
async fetch(req) {
|
|
async function* body() {
|
|
await Bun.sleep(100);
|
|
yield "bun";
|
|
await Bun.sleep(100);
|
|
yield "bun";
|
|
await Bun.sleep(100);
|
|
yield "bun";
|
|
await Bun.sleep(100);
|
|
yield "bun";
|
|
}
|
|
if (req.url.endsWith("/bunbun")) {
|
|
return new Response(body, { status: 302, headers: { Location: "/bun" } });
|
|
}
|
|
if (req.url.endsWith("/bun")) {
|
|
return new Response(body, { status: 302, headers: { Location: "/" } });
|
|
}
|
|
return new Response(body, { status: 200 });
|
|
},
|
|
});
|
|
const response = await fetch(`${server.url.origin}/bunbun`, {
|
|
proxy: httpsProxyServer.url,
|
|
tls: {
|
|
cert: tlsCert.cert,
|
|
rejectUnauthorized: false,
|
|
},
|
|
});
|
|
expect(response.ok).toBe(true);
|
|
expect(response.status).toBe(200);
|
|
expect(response.statusText).toBe("OK");
|
|
|
|
const result = await response.text();
|
|
expect(result).toBe("bunbunbunbun");
|
|
});
|
|
});
|
|
}
|
|
|
|
test("unsupported protocol", async () => {
|
|
expect(
|
|
fetch("https://httpbin.org/get", {
|
|
proxy: "ftp://asdf.com",
|
|
}),
|
|
).rejects.toThrowError(
|
|
expect.objectContaining({
|
|
code: "UnsupportedProxyProtocol",
|
|
}),
|
|
);
|
|
});
|
|
|
|
test("axios with https-proxy-agent", async () => {
|
|
httpProxyServer.log.length = 0;
|
|
const httpsAgent = new HttpsProxyAgent(httpProxyServer.url, {
|
|
rejectUnauthorized: false, // this should work with self-signed certs
|
|
});
|
|
|
|
const result = await axios.get(httpsServer.url.href, {
|
|
httpsAgent,
|
|
});
|
|
expect(result.data).toBe("");
|
|
// did we got proxied?
|
|
expect(httpProxyServer.log).toEqual([`CONNECT localhost:${httpsServer.port}`]);
|
|
});
|
|
|
|
test("HTTPS over HTTP proxy preserves TLS record order with large bodies", async () => {
|
|
// Create a custom HTTPS server that returns body size for this test
|
|
using customServer = Bun.serve({
|
|
port: 0,
|
|
tls: tlsCert,
|
|
async fetch(req) {
|
|
// return the body size
|
|
const buf = await req.arrayBuffer();
|
|
return new Response(String(buf.byteLength), { status: 200 });
|
|
},
|
|
});
|
|
|
|
// Test with multiple body sizes to ensure TLS record ordering is preserved
|
|
// also testing several times because it's flaky otherwise
|
|
const testCases = [
|
|
16 * 1024 * 1024, // 16MB
|
|
32 * 1024 * 1024, // 32MB
|
|
];
|
|
|
|
for (const size of testCases) {
|
|
const body = new Uint8Array(size).fill(0x61); // 'a'
|
|
|
|
const response = await fetch(customServer.url, {
|
|
method: "POST",
|
|
proxy: httpProxyServer.url,
|
|
headers: { "Content-Type": "application/octet-stream" },
|
|
body,
|
|
keepalive: false,
|
|
tls: { ca: tlsCert.cert, rejectUnauthorized: false },
|
|
});
|
|
|
|
expect(response.ok).toBe(true);
|
|
expect(response.status).toBe(200);
|
|
const result = await response.text();
|
|
|
|
// recvd body size should exactly match the sent body size
|
|
expect(result).toBe(String(size));
|
|
}
|
|
});
|
|
|
|
test("HTTPS origin close-delimited body via HTTP proxy does not ECONNRESET", async () => {
|
|
// Inline raw HTTPS origin: 200 + no Content-Length then close
|
|
const originServer = tls.createServer(
|
|
{ ...tlsCert, rejectUnauthorized: false },
|
|
(clientSocket: net.Socket | tls.TLSSocket) => {
|
|
clientSocket.once("data", () => {
|
|
const body = "ok";
|
|
// ! Notice we are not using a Content-Length header here, this is what is causing the issue
|
|
const resp = "HTTP/1.1 200 OK\r\n" + "content-type: text/plain\r\n" + "connection: close\r\n" + "\r\n" + body;
|
|
clientSocket.write(resp);
|
|
clientSocket.end();
|
|
});
|
|
clientSocket.on("error", () => {});
|
|
},
|
|
);
|
|
originServer.listen(0);
|
|
await once(originServer, "listening");
|
|
const originURL = `https://localhost:${(originServer.address() as net.AddressInfo).port}`;
|
|
try {
|
|
const res = await fetch(originURL, {
|
|
method: "POST",
|
|
body: "x",
|
|
proxy: httpProxyServer.url,
|
|
keepalive: false,
|
|
tls: { ca: tlsCert.cert, rejectUnauthorized: false },
|
|
});
|
|
expect(res.ok).toBe(true);
|
|
expect(res.status).toBe(200);
|
|
const text = await res.text();
|
|
expect(text).toBe("ok");
|
|
} finally {
|
|
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");
|
|
}
|
|
});
|
|
});
|