fix(http): support proxy passwords longer than 4096 characters (#25530)

## Summary
- Fixes silent 401 Unauthorized errors when using proxies with long
passwords (e.g., JWT tokens > 4096 chars)
- Bun was silently dropping proxy passwords exceeding 4095 characters,
falling through to code that only encoded the username

## Changes
- Added `PercentEncoding.decodeWithFallback` which uses a 4KB stack
buffer for the common case and falls back to heap allocation only for
larger inputs
- Updated proxy auth encoding in `AsyncHTTP.zig` to use the new fallback
method

## Test plan
- [x] Added test case that verifies passwords > 4096 chars are handled
correctly
- [x] Test fails with system bun (v1.3.3), passes with this fix
- [x] All 29 proxy tests pass

🤖 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:
robobun
2025-12-15 13:21:41 -08:00
committed by GitHub
parent d865ef41e2
commit 8dc79641c8
3 changed files with 185 additions and 70 deletions

View File

@@ -248,6 +248,144 @@ test("unsupported protocol", async () => {
);
});
/**
* Creates an HTTP proxy server that captures Proxy-Authorization headers.
* The server forwards requests to their destination and pipes responses back.
*/
async function createAuthCapturingProxy() {
const capturedAuths: string[] = [];
const server = 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:")) {
capturedAuths.push(line.substring("proxy-authorization:".length).trim());
}
}
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();
});
});
});
server.listen(0);
await once(server, "listening");
const port = (server.address() as net.AddressInfo).port;
return {
server,
port,
capturedAuths,
async close() {
server.close();
await once(server, "close");
},
};
}
test("proxy with long password (> 4096 chars) sends correct authorization", async () => {
const proxy = await createAuthCapturingProxy();
// Create a password longer than 4096 chars (e.g., simulating a JWT token)
// Use Buffer.alloc which is faster in debug JavaScriptCore builds
const longPassword = Buffer.alloc(5000, "a").toString();
const username = "testuser";
const proxyUrl = `http://${username}:${longPassword}@localhost:${proxy.port}`;
try {
const response = await fetch(httpServer.url, {
method: "GET",
proxy: proxyUrl,
keepalive: false,
});
expect(response.ok).toBe(true);
expect(response.status).toBe(200);
// Verify the auth header was sent and contains both username and password
expect(proxy.capturedAuths.length).toBeGreaterThanOrEqual(1);
const capturedAuth = proxy.capturedAuths[0];
expect(capturedAuth.startsWith("Basic ")).toBe(true);
// Decode and verify
const encoded = capturedAuth.substring("Basic ".length);
const decoded = Buffer.from(encoded, "base64url").toString();
expect(decoded).toBe(`${username}:${longPassword}`);
} finally {
await proxy.close();
}
});
test("proxy with long password (> 4096 chars) works correctly after redirect", async () => {
// This test verifies that the reset() code path (used during redirects)
// also handles long passwords correctly
const proxy = await createAuthCapturingProxy();
// Create a server that issues a redirect
using redirectServer = Bun.serve({
port: 0,
fetch(req) {
if (req.url.endsWith("/redirect")) {
return Response.redirect("/final", 302);
}
return new Response("OK", { status: 200 });
},
});
// Use Buffer.alloc which is faster in debug JavaScriptCore builds
const longPassword = Buffer.alloc(5000, "a").toString();
const username = "testuser";
const proxyUrl = `http://${username}:${longPassword}@localhost:${proxy.port}`;
try {
const response = await fetch(`${redirectServer.url.origin}/redirect`, {
method: "GET",
proxy: proxyUrl,
keepalive: false,
});
expect(response.ok).toBe(true);
expect(response.status).toBe(200);
const text = await response.text();
expect(text).toBe("OK");
// Verify auth was sent on requests. Due to connection reuse, the proxy may
// only see one request even though a redirect occurred (the redirected
// request reuses the same connection). We verify at least one auth was sent
// and that all captured auths are correct.
expect(proxy.capturedAuths.length).toBeGreaterThanOrEqual(1);
for (const capturedAuth of proxy.capturedAuths) {
expect(capturedAuth.startsWith("Basic ")).toBe(true);
const encoded = capturedAuth.substring("Basic ".length);
const decoded = Buffer.from(encoded, "base64url").toString();
expect(decoded).toBe(`${username}:${longPassword}`);
}
} finally {
await proxy.close();
}
});
test("axios with https-proxy-agent", async () => {
httpProxyServer.log.length = 0;
const httpsAgent = new HttpsProxyAgent(httpProxyServer.url, {