Skip to content

fix: isolate clients behind trusted reverse proxies - #248

Open
EvilG-MC wants to merge 6 commits into
PerformanC:devfrom
EvilG-MC:fix/proxy-client-isolation
Open

EvilG-MC wants to merge 6 commits into
PerformanC:devfrom
EvilG-MC:fix/proxy-client-isolation

Conversation

@EvilG-MC

@EvilG-MC EvilG-MC commented Oct 10, 2026 •

Copy link
Copy Markdown

Changes

Fixes #247.

  • Client IP is only taken from X-Forwarded-For when the TCP peer is in admission.trustedProxies (IPs or CIDRs). XFF is walked right to left skipping trusted hops, X-Real-IP is only used when there's no XFF. Untrusted peers always get their socket address.
  • Auth bans and the REST / WS upgrade limits now use that resolved client instead of the proxy IP.
  • Trusted proxies get their own aggregate TCP pool (admission.ip.maxProxySockets, default 1024) instead of the per-IP 25, and per-client limits are applied at the upgrade.
  • Socket slots are released when a websocket closes. PWSL calls removeAllListeners() on the TCP socket right after ending/destroying it, so the close handler in httpServer.ts never ran for upgraded sockets. That one was already there before this PR: every closed WS kept its per-IP slot, so a bot that reconnects a lot ends up locked out after ~25 reconnects until restart.
  • Bun now reserves/releases peer and per-client capacity on upgrade (it had no socket limits at all).
  • Profiler / workers loopback check: a localhost peer that sends forwarding headers is treated as external.

Why

Behind nginx/caddy/whatever, one client failing auth 5 times jails the proxy IP for 15 min and every other client starts getting 502s. Details and repro in #247.

Checkmarks

  • The modified endpoints have been tested.
  • Used the same indentation as the rest of the project.
  • Still compatible with LavaLink clients.

Additional information

To enable it:

admission: { trustProxy: true, trustedProxies: ['127.0.0.1', '10.0.0.0/8'] }

or NODELINK_ADMISSION_TRUSTPROXY=true + NODELINK_ADMISSION_TRUSTEDPROXIES=127.0.0.1,10.0.0.0/8.

Behavior change: trustProxy: true with an empty list used to trust headers from anyone, now it ignores them and logs a warning on startup.

Tested:

  • node --test src/utils/clientAddress.test.ts src/server/clientIsolation.test.ts
    • clientAddress: XFF walking, trusted proxy matching (uses net.BlockList, IPv4/IPv6 rules kept separate), loopback check
    • clientIsolation: spins up the real http server + PWSL. Auth failures are sent through REST and the WS upgrade (only the bad client gets banned), and socket slots come back after client close / server destroy. All of these fail on the old code
  • bun test src/server/bunServer.test.ts (Bun only, node skips it): same limits and release through the real Bun.serve. Fails without the Bun changes
  • ran the real server behind a fake proxy with curl: the client that fails auth gets 403, everyone else keeps 200 (before: everyone got connection resets)

dist is recompiled in the update: commits, only the files that actually changed.

Authentication bans and connection limits keyed on the TCP peer, so
behind a reverse proxy every client shared the proxy's address. One
client failing auth jailed the proxy for 15 minutes and NodeLink reset
all of its connections, which the proxy surfaced as HTTP 502. Enabling
trustProxy did not help: the socket guards ignored it, and when enabled
it trusted forwarding headers from any peer, so identities were
spoofable.

- Resolve the client address only through admission.trustedProxies
  (IPs/CIDRs), walking X-Forwarded-For from the nearest hop and
  ignoring forwarded identities from untrusted peers
- Apply auth bans and REST/WebSocket upgrade limits per client
- Give trusted proxies a separate aggregate TCP pool
  (admission.ip.maxProxySockets) and release capacity on close
- Treat loopback requests carrying forwarding headers as external in
  the profiler and workers endpoints
PWSL calls removeAllListeners() on the TCP socket right after ending or
destroying it, so 'close' listeners never ran for upgraded sockets.
Every closed WebSocket kept its per-IP slot, and with this branch also
its per-client and trusted proxy slots, until clients were refused.

- Track releases per socket and run them once, from the WebSocket
  'close' event, its destroy() (protocol errors and server teardown
  emit no 'close') and TCP 'close' as a fallback
- Reserve peer and per-client capacity for Bun upgrades, which have no
  TCP connection hook, and release it on failed upgrade or close
@github-actions

github-actions Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

All Contributors have signed the CLA. The PR is now allowed to be merged.
Posted by the CLA Assistant Lite bot.

@EvilG-MC

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

performanc-bot added a commit to PerformanC/CLA-Signatures that referenced this pull request Oct 10, 2026
- Match trusted proxies with net.BlockList instead of a hand-rolled
  CIDR parser, keeping IPv4 and IPv6 rules in separate lists so an
  address only matches rules of its own family
- Keep releaseSocket private to socketRelease.ts
- Drive the auth isolation tests through the real REST handler and
  WebSocket upgrade instead of feeding admission the client IP
- Drop the duplicated aggregate pool unit test and the unused session
  handlers from the transport harness, and destroy upgraded sockets on
  teardown so a failing test cannot hang
- Add a Bun-only regression for upgrade capacity (bun test)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG]: behind a reverse proxy, one client failing auth gets the whole proxy banned (502 for everyone)

1 participant