Skip to content

security: trust x-real-ip over x-forwarded-for for rate-limit key#1

Open
advikdivekar wants to merge 1 commit into
security/serverless-rate-limit-bypassfrom
security/fix-xff-ip-spoofing
Open

security: trust x-real-ip over x-forwarded-for for rate-limit key#1
advikdivekar wants to merge 1 commit into
security/serverless-rate-limit-bypassfrom
security/fix-xff-ip-spoofing

Conversation

@advikdivekar

Copy link
Copy Markdown
Owner

Problem

Both app/api/contact/route.js and app/api/send-review/route.js extracted the client IP by reading the leftmost value of the X-Forwarded-For header. That value is fully client-controlled — an attacker can rotate a fake IP on every request (X-Forwarded-For: 1.1.1.1, 1.1.1.2, …) and the per-IP Redis sliding-window rate limiter never fires. Combined with a Turnstile-solving service, the Gmail account becomes an unlimited spam relay.

What changed and why

lib/getClientIp.js (new)
A single shared utility that resolves the verified client IP:

  1. Reads x-real-ip first — Vercel's edge sets this header and it cannot be forged by the client.
  2. Falls back to walking x-forwarded-for right-to-left, skipping RFC-1918 / loopback ranges, returning the rightmost public hop (the last trusted-proxy-inserted value) rather than the leftmost client-supplied one.

app/api/contact/route.js
Removed the inline getClientIp function (which read the spoofable leftmost XFF value) and imported the shared utility.

app/api/send-review/route.js
Same removal and import as above.

Why this approach fixes the root cause

x-real-ip is injected by Vercel infrastructure after the request leaves the public internet — no client header can overwrite it. The right-to-left XFF walk is the industry-standard fallback: proxies append to the right, so the rightmost non-private IP is the last hop the infrastructure itself recorded, not a value the client supplied.

Steps to test

  1. Deploy to a Vercel preview branch (or run locally with npm run dev).
  2. Send POST /api/contact six times with the header X-Forwarded-For: 1.1.1.1 — the 6th request must return 429 Too Many Requests.
  3. Change the header to X-Forwarded-For: 2.2.2.2 on the 6th attempt — it must still be blocked because the rate-limit key is now derived from x-real-ip (or the rightmost XFF hop set by the proxy), not from the spoofed leftmost value.
  4. Repeat steps 2–3 for POST /api/send-review.

Edge cases covered

  • x-real-ip present → used directly; XFF ignored.
  • x-forwarded-for contains only private IPs (e.g. internal load-balancer chain) → returns "unknown"; rate limiter key becomes contact:unknown and still enforces the limit.
  • x-forwarded-for absent and x-real-ip absent → returns "unknown".
  • Malformed or empty x-forwarded-for values are filtered before the right-to-left walk.

No regressions

  • npm run build passes without errors or warnings.
  • All existing contact and review form behaviour is unchanged — only the IP extraction logic is affected.
  • The shared utility is a pure function with no side effects; existing callers are unaffected.

Please review and merge this under GSSoC 2026.

Extract getClientIp into a shared lib/getClientIp.js that reads
x-real-ip first (set by Vercel infrastructure, not spoofable) and
falls back to walking x-forwarded-for right-to-left, skipping private
ranges, so the rightmost public hop — not the client-controlled
leftmost value — is used as the rate-limit key.

Remove the duplicate inline getClientIp from contact/route.js and
send-review/route.js and import the shared utility instead.
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.

1 participant