diff --git a/src/http/AsyncHTTP.zig b/src/http/AsyncHTTP.zig index 9361feab1c..fa6da793d0 100644 --- a/src/http/AsyncHTTP.zig +++ b/src/http/AsyncHTTP.zig @@ -213,31 +213,24 @@ pub fn init( } if (options.http_proxy) |proxy| { - // Username between 0 and 4096 chars - if (proxy.username.len > 0 and proxy.username.len < 4096) { - // Password between 0 and 4096 chars - if (proxy.password.len > 0 and proxy.password.len < 4096) { - // decode password - var password_buffer = std.mem.zeroes([4096]u8); - var password_stream = std.io.fixedBufferStream(&password_buffer); - const password_writer = password_stream.writer(); - const PassWriter = @TypeOf(password_writer); - const password_len = PercentEncoding.decode(PassWriter, password_writer, proxy.password) catch { - // Invalid proxy authorization - return this; - }; - const password = password_buffer[0..password_len]; + if (proxy.username.len > 0) { + // Use stack fallback allocator - stack for small credentials, heap for large ones + var username_sfb = std.heap.stackFallback(4096, allocator); + const username_alloc = username_sfb.get(); + const username = PercentEncoding.decodeAlloc(username_alloc, proxy.username) catch |err| { + log("failed to decode proxy username: {}", .{err}); + return this; + }; + defer username_alloc.free(username); - // Decode username - var username_buffer = std.mem.zeroes([4096]u8); - var username_stream = std.io.fixedBufferStream(&username_buffer); - const username_writer = username_stream.writer(); - const UserWriter = @TypeOf(username_writer); - const username_len = PercentEncoding.decode(UserWriter, username_writer, proxy.username) catch { - // Invalid proxy authorization + if (proxy.password.len > 0) { + var password_sfb = std.heap.stackFallback(4096, allocator); + const password_alloc = password_sfb.get(); + const password = PercentEncoding.decodeAlloc(password_alloc, proxy.password) catch |err| { + log("failed to decode proxy password: {}", .{err}); return this; }; - const username = username_buffer[0..username_len]; + defer password_alloc.free(password); // concat user and password const auth = std.fmt.allocPrint(allocator, "{s}:{s}", .{ username, password }) catch unreachable; @@ -248,19 +241,8 @@ pub fn init( buf[0.."Basic ".len].* = "Basic ".*; this.client.proxy_authorization = buf[0 .. "Basic ".len + encoded.len]; } else { - //Decode username - var username_buffer = std.mem.zeroes([4096]u8); - var username_stream = std.io.fixedBufferStream(&username_buffer); - const username_writer = username_stream.writer(); - const UserWriter = @TypeOf(username_writer); - const username_len = PercentEncoding.decode(UserWriter, username_writer, proxy.username) catch { - // Invalid proxy authorization - return this; - }; - const username = username_buffer[0..username_len]; - // only use user - const size = std.base64.standard.Encoder.calcSize(username_len); + const size = std.base64.standard.Encoder.calcSize(username.len); var buf = allocator.alloc(u8, size + "Basic ".len) catch unreachable; const encoded = std.base64.url_safe.Encoder.encode(buf["Basic ".len..], username); buf[0.."Basic ".len].* = "Basic ".*; @@ -308,32 +290,24 @@ fn reset(this: *AsyncHTTP) !void { if (this.http_proxy) |proxy| { //TODO: need to understand how is possible to reuse Proxy with TSL, so disable keepalive if url is HTTPS this.client.flags.disable_keepalive = this.url.isHTTPS(); - // Username between 0 and 4096 chars - if (proxy.username.len > 0 and proxy.username.len < 4096) { - // Password between 0 and 4096 chars - if (proxy.password.len > 0 and proxy.password.len < 4096) { - // decode password - var password_buffer = std.mem.zeroes([4096]u8); - var password_stream = std.io.fixedBufferStream(&password_buffer); - const password_writer = password_stream.writer(); - const PassWriter = @TypeOf(password_writer); - const password_len = PercentEncoding.decode(PassWriter, password_writer, proxy.password) catch { - // Invalid proxy authorization - return this; - }; - const password = password_buffer[0..password_len]; + if (proxy.username.len > 0) { + // Use stack fallback allocator - stack for small credentials, heap for large ones + var username_sfb = std.heap.stackFallback(4096, this.allocator); + const username_alloc = username_sfb.get(); + const username = PercentEncoding.decodeAlloc(username_alloc, proxy.username) catch |err| { + log("failed to decode proxy username: {}", .{err}); + return; + }; + defer username_alloc.free(username); - // Decode username - var username_buffer = std.mem.zeroes([4096]u8); - var username_stream = std.io.fixedBufferStream(&username_buffer); - const username_writer = username_stream.writer(); - const UserWriter = @TypeOf(username_writer); - const username_len = PercentEncoding.decode(UserWriter, username_writer, proxy.username) catch { - // Invalid proxy authorization - return this; + if (proxy.password.len > 0) { + var password_sfb = std.heap.stackFallback(4096, this.allocator); + const password_alloc = password_sfb.get(); + const password = PercentEncoding.decodeAlloc(password_alloc, proxy.password) catch |err| { + log("failed to decode proxy password: {}", .{err}); + return; }; - - const username = username_buffer[0..username_len]; + defer password_alloc.free(password); // concat user and password const auth = std.fmt.allocPrint(this.allocator, "{s}:{s}", .{ username, password }) catch unreachable; @@ -344,19 +318,8 @@ fn reset(this: *AsyncHTTP) !void { buf[0.."Basic ".len].* = "Basic ".*; this.client.proxy_authorization = buf[0 .. "Basic ".len + encoded.len]; } else { - //Decode username - var username_buffer = std.mem.zeroes([4096]u8); - var username_stream = std.io.fixedBufferStream(&username_buffer); - const username_writer = username_stream.writer(); - const UserWriter = @TypeOf(username_writer); - const username_len = PercentEncoding.decode(UserWriter, username_writer, proxy.username) catch { - // Invalid proxy authorization - return this; - }; - const username = username_buffer[0..username_len]; - // only use user - const size = std.base64.standard.Encoder.calcSize(username_len); + const size = std.base64.standard.Encoder.calcSize(username.len); var buf = this.allocator.alloc(u8, size + "Basic ".len) catch unreachable; const encoded = std.base64.url_safe.Encoder.encode(buf["Basic ".len..], username); buf[0.."Basic ".len].* = "Basic ".*; diff --git a/src/url.zig b/src/url.zig index a4ebd9242a..3c1a029df3 100644 --- a/src/url.zig +++ b/src/url.zig @@ -815,6 +815,20 @@ pub const PercentEncoding = struct { return @call(bun.callmod_inline, decodeFaultTolerant, .{ Writer, writer, input, null, false }); } + /// Decode percent-encoded input into allocated memory. + /// Caller owns the returned slice and must free it with the same allocator. + pub fn decodeAlloc(allocator: std.mem.Allocator, input: string) ![]u8 { + // Allocate enough space - decoded will be at most input.len bytes + const buf = try allocator.alloc(u8, input.len); + errdefer allocator.free(buf); + + var stream = std.io.fixedBufferStream(buf); + const writer = stream.writer(); + const len = try decode(@TypeOf(writer), writer, input); + + return buf[0..len]; + } + pub fn decodeFaultTolerant( comptime Writer: type, writer: Writer, diff --git a/test/js/bun/http/proxy.test.ts b/test/js/bun/http/proxy.test.ts index dc5c5ec3da..2d351c11fe 100644 --- a/test/js/bun/http/proxy.test.ts +++ b/test/js/bun/http/proxy.test.ts @@ -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, {