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 proxyBoth 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.