ReviewOS

stacks/rpx

HTTP proxy sets x-forwarded-for to a hardcoded 127.0.0.1, discarding the real client IP

#2276
Closed glennmichael123 opened this 11 days ago ·
11 days ago

Summary

The HTTP proxy path sets x-forwarded-for to the string literal 127.0.0.1 on every request, rather than the connecting client's address. Any application behind rpx sees every visitor on the planet as localhost.

This appears to have been introduced by the fix for #2265, which asked for x-forwarded-for / -host / -proto to be set on the HTTP path (they were previously absent). The headers are now set — but x-forwarded-for carries a hardcoded value rather than a real one.

Observed on @stacksjs/rpx@0.11.18.

Where

Three call sites, all unconditional — no dev/prod branch, no fallback:

src/proxy-handler.ts:190   forwardHeaders['x-forwarded-for'] = '127.0.0.1'   // WebSocket upgrade
src/proxy-handler.ts:214   headers.set('x-forwarded-for', '127.0.0.1')       // HTTP proxy
src/start.ts:611           headers.set('x-forwarded-for', '127.0.0.1')       // HTTP proxy

Both HTTP sites build new Headers(req.headers) first and then .set(), so an inbound x-forwarded-for from a real upstream proxy is overwritten, not appended to. A CDN or load balancer in front of rpx has its value discarded.

x-forwarded-proto and x-forwarded-host next to these lines are correct — they carry real values. x-forwarded-for is the only one pinned to a constant.

Why it matters

x-forwarded-for is how an app behind a terminating proxy learns who is talking to it. With it pinned to a constant, everything downstream that reasons about client identity silently degrades — silently being the operative word, since the header is present and well-formed:

  • IP rate limiting — every client shares one bucket. Either one visitor can exhaust the limit for everyone, or the limit is never reached, depending on which way it fails.
  • Abuse controls and IP blocklists — cannot distinguish clients, cannot block one.
  • Geolocation — every request resolves to loopback.
  • Audit and access logs — no usable client attribution, and this is not reconstructable after the fact.

Anything reading request.ip() in a framework layered on rpx gets 127.0.0.1 for every request.

Suggested fix

Bun exposes the peer address via server.requestIP(req), and server is already in scope — src/proxy-handler.ts:157 takes it as server?: ProxyServer, and the WebSocket branch already uses it for server.upgrade(). rpx does not currently call requestIP anywhere.

Roughly:

const peer = server?.requestIP(req)?.address
const priorXff = req.headers.get('x-forwarded-for')
const xff = [priorXff, peer].filter(Boolean).join(', ')
if (xff)
  headers.set('x-forwarded-for', xff)

Two properties worth keeping: append to any inbound value rather than replacing it, so a CDN in front of rpx is preserved; and omit the header when no address is available, rather than substituting a placeholder — a missing header is a condition an app can detect, whereas a plausible wrong value is not.

server being optional in the signature means the no-server path needs to omit rather than invent a value.

11 days ago

Closing — already fixed, and I should have checked main before filing.

Fixed by 8c5f0ec ("fix(proxy): forward the real client IP instead of a hardcoded loopback", 2026-08-28), first released in v0.11.46. I filed this against 0.11.18 from a downstream project's node_modules, which predates the fix by roughly thirty releases. Current main resolves the peer via peerAddress() → server.requestIP(req) on all three paths, with UNKNOWN_PEER reserved for the genuine no-peer cases (Unix socket, closed connection, no server passed).

One correction worth recording, since my suggested fix was wrong. I proposed appending to any inbound x-forwarded-for so a CDN in front of rpx would be preserved. The implementation deliberately does the opposite, and the reasoning in proxy-handler.ts is right:

We do NOT append to a client-supplied chain — rpx terminates TLS and is the first hop, so there is no trusted chain to extend.

Appending at the edge would let any client prepend arbitrary addresses to its own forwarded-for chain, so anything downstream doing per-IP rate limiting or blocking could be fed a spoofed origin. Dropping client-supplied values via STRIP_REQUEST and writing only the socket peer is the correct behaviour for a first-hop proxy. Deployments that genuinely sit behind a CDN need an explicit trusted-proxy configuration, not an unconditional append.

Apologies for the noise.

Sign in to comment on this issue.