From eb555b60f80aa836a75cd1f9ffee629b7b6acffe Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:12:31 +0530 Subject: [PATCH] feat(stack): forward client address headers to upstreams --- .../stack/src/HttpProxy.integration.test.ts | 63 +++++++++++++++++++ packages/stack/src/HttpProxy.ts | 16 ++++- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/packages/stack/src/HttpProxy.integration.test.ts b/packages/stack/src/HttpProxy.integration.test.ts index f193f157f2..f049201169 100644 --- a/packages/stack/src/HttpProxy.integration.test.ts +++ b/packages/stack/src/HttpProxy.integration.test.ts @@ -545,6 +545,69 @@ it.live("rewrites only the apikey query value on opted-in WebSocket routes", () ).pipe(Effect.provide(NodeServices.layer)), ); +it.live("tells upstreams the client address", () => + Effect.scoped( + Effect.gen(function* () { + const backend = createServer((incoming, response) => { + response.end( + JSON.stringify({ + forwardedFor: incoming.headers["x-forwarded-for"], + realIp: incoming.headers["x-real-ip"], + }), + ); + }); + const sockets = new WebSocketServer({ server: backend }); + const upgraded = yield* Deferred.make< + { forwardedFor?: string; realIp?: string }, + HttpProxyTestError + >(); + sockets.on("connection", (socket, incoming) => { + Deferred.doneUnsafe( + upgraded, + Effect.succeed({ + forwardedFor: headerOf(incoming.headers["x-forwarded-for"]), + realIp: headerOf(incoming.headers["x-real-ip"]), + }), + ); + socket.close(); + }); + const backendAddress = yield* listen(backend, { beforeClose: () => sockets.close() }); + const proxy = yield* makeHttpProxy({ host: "127.0.0.1", port: 0 }); + yield* proxy.setRoutes([ + { id: "functions", prefix: "/functions", target: Effect.succeed(backendAddress) }, + ]); + const send = (headers: Readonly>) => + request(proxy.port, "/functions/v1/hello", new Uint8Array(), headers, "GET").pipe( + Effect.provide(NodeHttpClient.layerNodeHttp), + Effect.map(({ body }) => JSON.parse(new TextDecoder().decode(body))), + ); + + expect(yield* send({})).toEqual({ forwardedFor: "127.0.0.1", realIp: "127.0.0.1" }); + expect(yield* send({ "x-forwarded-for": "203.0.113.7", "x-real-ip": "203.0.113.7" })).toEqual( + { forwardedFor: "203.0.113.7, 127.0.0.1", realIp: "127.0.0.1" }, + ); + + const client = new WebSocket(`ws://127.0.0.1:${proxy.port}/functions/v1/socket`, { + headers: { "x-forwarded-for": "203.0.113.7", "x-real-ip": "203.0.113.7" }, + }); + client.on("error", (cause) => + Deferred.doneUnsafe( + upgraded, + Effect.fail(new HttpProxyTestError({ message: cause.message, cause })), + ), + ); + yield* Effect.addFinalizer(() => Effect.sync(() => client.terminate())); + expect(yield* Deferred.await(upgraded).pipe(Effect.timeout("10 seconds"))).toEqual({ + forwardedFor: "203.0.113.7, 127.0.0.1", + realIp: "127.0.0.1", + }); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +const headerOf = (value: string | ReadonlyArray | undefined) => + value === undefined || typeof value === "string" ? value : value.join(", "); + it.live("returns a gateway error naming the route and cause when a target cannot wake", () => { const logs: Array = []; return Effect.scoped( diff --git a/packages/stack/src/HttpProxy.ts b/packages/stack/src/HttpProxy.ts index 803196e53c..a1394e4358 100644 --- a/packages/stack/src/HttpProxy.ts +++ b/packages/stack/src/HttpProxy.ts @@ -129,11 +129,21 @@ const bearerValueFor = (headers: IncomingMessage["headers"], keys: HttpRouteKeyR return apiKey; }; -const upstreamHeadersFor = (headers: IncomingMessage["headers"], route: HttpRoute) => { +/* Like Kong: append the client to any incoming X-Forwarded-For chain; X-Real-IP is the client. */ +const upstreamHeadersFor = (request: IncomingMessage, route: HttpRoute) => { + const headers = request.headers; + const client = request.socket.remoteAddress; + const forwardedFor = headerValue(headers["x-forwarded-for"]); const result: Record = { ...headersFor(headers), ...(route.upstreamHost === undefined ? {} : { host: route.upstreamHost }), }; + /* Without a peer address, a client-sent X-Real-IP is still dropped rather than passed through. */ + if (client === undefined) delete result["x-real-ip"]; + else { + result["x-forwarded-for"] = forwardedFor === undefined ? client : `${forwardedFor}, ${client}`; + result["x-real-ip"] = client; + } const keyRewrite = route.keyRewrite; if (keyRewrite?.policy === "bearer") { const value = bearerValueFor(headers, keyRewrite.keys); @@ -393,7 +403,7 @@ const forward = Effect.fn("HttpProxy.forward")(function* ( // Bun ends a streamed upstream response early when the request says Connection: close, // and sends a GET or DELETE body unframed unless the request says it is chunked. headers: { - ...upstreamHeadersFor(request.headers, route), + ...upstreamHeadersFor(request, route), ...(request.headers["transfer-encoding"] === undefined ? {} : { "transfer-encoding": "chunked" }), @@ -565,7 +575,7 @@ const upgrade = Effect.fn("HttpProxy.upgrade")( client.once("end", onClientEnd); upstream.once("end", onUpstreamEnd); const lines = [`${request.method ?? "GET"} ${pathFor(request, route)} HTTP/1.1`]; - for (const [name, value] of Object.entries(upstreamHeadersFor(request.headers, route))) { + for (const [name, value] of Object.entries(upstreamHeadersFor(request, route))) { if (value !== undefined) lines.push(`${name}: ${Array.isArray(value) ? value.join(", ") : value}`); }