Skip to content

fix(relayer): resolve client IP via trusted proxy hops to stop X-Forwarded-For spoofing (#360)#361

Open
ducnmm wants to merge 1 commit into
devfrom
fix/xff-spoofing-per-ip-rate-limit
Open

fix(relayer): resolve client IP via trusted proxy hops to stop X-Forwarded-For spoofing (#360)#361
ducnmm wants to merge 1 commit into
devfrom
fix/xff-spoofing-per-ip-rate-limit

Conversation

@ducnmm

@ducnmm ducnmm commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes #360.

The MCP proxy routes (/api/mcp/sse, /api/mcp/messages, /api/mcp) intentionally skip signed-request auth and per-key rate limiting, so the per-IP session cap in the Node sidecar is the only per-source defense against an unauthenticated session flood. Both that limiter and the /sponsor IP limiter derived the client IP from the first (leftmost) value of X-Forwarded-For — which is fully client-controlled. A single host could send a fresh fake X-Forwarded-For on each request, land in a brand-new per-IP bucket every time, and sail past maxSessionsPerIp / maxNewSessionsPerIpPerMin.

This PR resolves the real client IP by counting a configured number of trusted reverse proxies in from the socket end (express-style trust proxy semantics) and never trusting the leftmost value.

Changes

  • client_ip::resolve_client_ip(xff, peer, trusted_hops) — new shared helper. Builds the address chain [socket_peer, XFF reversed…], skips trusted_hops trusted proxies, and returns the first address the client cannot forge past. Clamps a too-large hop count instead of underflowing. Unit-tested (spoof-defeat, 0/1/2 hops, IPv6, misconfig).
  • TRUSTED_PROXY_HOPS — new config (default 1 = Railway's single edge proxy; 0 = relayer directly exposed → ignore X-Forwarded-For). Documented in .env.example.
  • mcp_proxy::inject_forwarded_for — now forwards one sanitized client IP to the sidecar instead of appending the peer to a client-controlled chain. The sidecar is loopback-only from the relayer, so that single value is authoritative.
  • sponsor_rate_limit_middleware — uses the same resolver (secondary route; per-sender limit already backstops it, but fixed for consistency / defense-in-depth).
  • Sidecar clientIpFromRequest — takes the rightmost (nearest-relayer) hop as defense-in-depth and documents the trust boundary.

⚠️ Deploy note: default is TRUSTED_PROXY_HOPS=1, correct for the current Railway topology (one edge proxy). Any environment that exposes the relayer without a proxy in front must set TRUSTED_PROXY_HOPS=0; a value larger than the real hop count re-opens the spoofing hole.

Testing

  • cargo testclient_ip 10/10, mcp_proxy inject 5/5, rate_limit 26/26 pass.
  • Node sidecar — 17/17 rate-limit tests pass.
  • cargo clippy — no new warnings.

Scope is entirely relayer + sidecar; the Move contract is untouched.

…arded-For spoofing (#360)

The MCP SSE/messages/streamable proxy routes intentionally skip signed-request
auth and per-key rate limiting, so the per-IP session cap in the Node sidecar is
the only per-source defense against an unauthenticated session flood. Both the
sidecar limiter and the /sponsor IP limiter derived the client IP by taking the
FIRST (leftmost) value of X-Forwarded-For, which is fully client-controlled — a
single host could send a fresh fake XFF each request and land in a new per-IP
bucket, bypassing maxSessionsPerIp / maxNewSessionsPerIpPerMin.

Resolve the real client IP by counting a configured number of trusted reverse
proxies in from the socket end (express-style `trust proxy` semantics) and never
trusting the leftmost value:

- New shared `client_ip::resolve_client_ip(xff, peer, trusted_hops)` helper with
  unit tests (spoof-defeat, 0/1/2 hops, IPv6, misconfig clamp).
- New `TRUSTED_PROXY_HOPS` config (default 1 = Railway edge; 0 = directly
  exposed, ignore XFF). Documented in .env.example.
- mcp_proxy `inject_forwarded_for` now forwards a single sanitized client IP to
  the sidecar instead of appending peer to a client-controlled chain.
- sponsor_rate_limit_middleware uses the same resolver.
- Sidecar `clientIpFromRequest` takes the rightmost (nearest-relayer) hop as
  defense-in-depth and documents the trust boundary.
@harrymove-ctrl harrymove-ctrl self-requested a review July 9, 2026 05:14
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(relayer): X-Forwarded-For spoofing bypasses per-IP rate limiting on the unauthenticated MCP proxy routes

2 participants