Skip to content

Commit 7b4e51d

Browse files
committed
fix: prevent rate limit bypass via spoofed X-Forwarded-For headers
1 parent e6b5723 commit 7b4e51d

8 files changed

Lines changed: 417 additions & 24 deletions

File tree

app/api/track-user/route.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,13 @@ import { NextResponse } from 'next/server';
22
import dbConnect from '@/lib/mongodb';
33
import { User } from '@/models/User';
44
import { trackUserRateLimiter } from '@/lib/rate-limit';
5+
import { getClientIp } from '@/utils/getClientIp';
56

67
export async function POST(req: Request) {
7-
// Get IP for rate limiting.
8-
// x-real-ip is provided by Vercel/Nginx as the true client IP.
9-
// We fall back to the LAST IP in the x-forwarded-for chain, which is appended by the Vercel proxy.
10-
const forwardedFor = req.headers.get('x-forwarded-for');
11-
const fallbackIp = forwardedFor ? forwardedFor.split(',').pop()?.trim() : 'unknown';
12-
const ip = req.headers.get('x-real-ip') || fallbackIp || 'unknown';
8+
// Get IP for rate limiting securely
9+
const ip = getClientIp(req);
1310

14-
if (ip !== 'unknown' && !(await trackUserRateLimiter.check(ip))) {
11+
if (ip !== '127.0.0.1' && ip !== 'unknown' && !(await trackUserRateLimiter.check(ip))) {
1512
return NextResponse.json(
1613
{ success: false, error: 'Too many requests, please try again later.' },
1714
{ status: 429 }

middleware.test.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { beforeEach, describe, expect, it, vi } from 'vitest';
1+
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
22
import { NextRequest, NextResponse } from 'next/server';
33
import { middleware } from './middleware';
44
import { rateLimit } from '@/lib/rate-limit';
@@ -8,8 +8,17 @@ vi.mock('@/lib/rate-limit', () => ({
88
}));
99

1010
describe('middleware', () => {
11+
let originalEnv: string | undefined;
12+
1113
beforeEach(() => {
1214
vi.clearAllMocks();
15+
originalEnv = process.env.TRUSTED_PROXIES;
16+
// Set trusted proxies to make standard multi-hop tests pass as trusted proxy chains
17+
process.env.TRUSTED_PROXIES = '5.6.7.8, 9.10.11.12';
18+
});
19+
20+
afterEach(() => {
21+
process.env.TRUSTED_PROXIES = originalEnv;
1322
});
1423

1524
it('calls NextResponse.next when rate limit succeeds', async () => {
@@ -86,7 +95,7 @@ describe('middleware', () => {
8695
expect(response.headers.get('X-RateLimit-Remaining')).toBe('59');
8796
});
8897

89-
it('uses first IP from x-forwarded-for', async () => {
98+
it('uses first IP from x-forwarded-for when subsequent hops are trusted', async () => {
9099
vi.mocked(rateLimit).mockResolvedValue({
91100
success: true,
92101
limit: 60,
@@ -105,6 +114,29 @@ describe('middleware', () => {
105114
expect(rateLimit).toHaveBeenCalledWith('1.2.3.4', 60, 60000);
106115
});
107116

117+
it('ignores spoofed x-forwarded-for when subsequent hops are untrusted', async () => {
118+
vi.mocked(rateLimit).mockResolvedValue({
119+
success: true,
120+
limit: 60,
121+
remaining: 59,
122+
reset: 123456789,
123+
});
124+
125+
// Clear trusted proxies so 5.6.7.8 is untrusted
126+
process.env.TRUSTED_PROXIES = '';
127+
128+
const request = new NextRequest('http://localhost:3000/api/streak?user=octocat', {
129+
headers: {
130+
'x-forwarded-for': '1.2.3.4, 5.6.7.8',
131+
},
132+
});
133+
134+
await middleware(request);
135+
136+
// Should resolve to the untrusted proxy IP (5.6.7.8) instead of the spoofed client IP (1.2.3.4)
137+
expect(rateLimit).toHaveBeenCalledWith('5.6.7.8', 60, 60000);
138+
});
139+
108140
it('uses x-real-ip if x-forwarded-for is missing', async () => {
109141
vi.mocked(rateLimit).mockResolvedValue({
110142
success: true,

middleware.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { NextResponse } from 'next/server';
22
import type { NextRequest } from 'next/server';
33
import { rateLimit } from './lib/rate-limit';
4+
import { getClientIp } from './utils/getClientIp';
45

56
/**
67
* Middleware to enforce rate limiting on specific API routes.
@@ -16,11 +17,8 @@ import { rateLimit } from './lib/rate-limit';
1617
* Limit: 60 requests per minute per IP.
1718
*/
1819
export async function middleware(request: NextRequest) {
19-
// Use Vercel's ip property if available, fallback to headers, then localhost
20-
const ip =
21-
request.headers.get('x-forwarded-for')?.split(',')[0] ??
22-
request.headers.get('x-real-ip') ??
23-
'127.0.0.1';
20+
// Secure client IP extraction
21+
const ip = getClientIp(request);
2422

2523
// Apply rate limiting
2624
// 60 requests per 60,000ms (1 minute)

package-lock.json

Lines changed: 3 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

types/network.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
export interface TrustedProxyConfig {
2+
/**
3+
* List of trusted proxy IP addresses or CIDR blocks.
4+
* If '*' is present, all proxies are trusted (similar to express trust proxy = true).
5+
*/
6+
trustedProxies: string[];
7+
8+
/**
9+
* Whether to trust default loopback/private IP addresses:
10+
* 127.0.0.1, ::1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7
11+
*/
12+
trustPrivateRanges?: boolean;
13+
}
14+
15+
export interface GetClientIpOptions {
16+
/**
17+
* Trusted proxy configuration.
18+
*/
19+
proxyConfig?: TrustedProxyConfig;
20+
21+
/**
22+
* Custom request headers to check first, in order of priority.
23+
* E.g., ['x-vercel-forwarded-for', 'cf-connecting-ip', 'x-real-ip']
24+
*/
25+
headersPriority?: string[];
26+
}

utils/getClientIp.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { NextRequest } from 'next/server';
3+
import { getClientIp } from './getClientIp';
4+
5+
describe('getClientIp', () => {
6+
it('prefers request.ip on NextRequest if available', () => {
7+
const req = new NextRequest('http://localhost:3000/api/streak');
8+
Object.defineProperty(req, 'ip', { value: '203.0.113.10', writable: true });
9+
10+
const ip = getClientIp(req);
11+
expect(ip).toBe('203.0.113.10');
12+
});
13+
14+
it('ignores spoofed X-Forwarded-For when request.ip is present', () => {
15+
const req = new NextRequest('http://localhost:3000/api/streak', {
16+
headers: {
17+
'x-forwarded-for': '198.51.100.5, 203.0.113.10',
18+
},
19+
});
20+
Object.defineProperty(req, 'ip', { value: '203.0.113.10', writable: true });
21+
22+
const ip = getClientIp(req);
23+
expect(ip).toBe('203.0.113.10');
24+
});
25+
26+
it('uses direct client-supplied custom priority headers if available', () => {
27+
const req = new Request('http://localhost:3000/api/streak', {
28+
headers: {
29+
'cf-connecting-ip': '198.51.100.99',
30+
},
31+
});
32+
33+
const ip = getClientIp(req);
34+
expect(ip).toBe('198.51.100.99');
35+
});
36+
37+
it('ignores x-forwarded-for entirely when no trusted proxies are configured', () => {
38+
const req = new Request('http://localhost:3000/api/streak', {
39+
headers: {
40+
'x-forwarded-for': '198.51.100.5, 203.0.113.10',
41+
},
42+
});
43+
44+
const ip = getClientIp(req, {
45+
proxyConfig: { trustedProxies: [], trustPrivateRanges: false },
46+
});
47+
expect(ip).toBe('127.0.0.1'); // Default fallback because x-real-ip and others are missing
48+
});
49+
50+
it('extracts correct IP through a trusted proxy chain', () => {
51+
const req = new Request('http://localhost:3000/api/streak', {
52+
headers: {
53+
'x-forwarded-for': '198.51.100.5, 203.0.113.10, 127.0.0.1',
54+
},
55+
});
56+
57+
// We trust localhost (127.0.0.1) and 203.0.113.10 as proxies, so the true client is 198.51.100.5
58+
const ip = getClientIp(req, {
59+
proxyConfig: {
60+
trustedProxies: ['127.0.0.1', '203.0.113.10'],
61+
trustPrivateRanges: true,
62+
},
63+
});
64+
expect(ip).toBe('198.51.100.5');
65+
});
66+
67+
it('stops at the first untrusted proxy in the chain', () => {
68+
const req = new Request('http://localhost:3000/api/streak', {
69+
headers: {
70+
'x-forwarded-for': '198.51.100.5, 8.8.8.8, 127.0.0.1',
71+
},
72+
});
73+
74+
// 127.0.0.1 is trusted. 8.8.8.8 is not. So the resolved IP must be 8.8.8.8.
75+
const ip = getClientIp(req, {
76+
proxyConfig: {
77+
trustedProxies: ['127.0.0.1'],
78+
trustPrivateRanges: true,
79+
},
80+
});
81+
expect(ip).toBe('8.8.8.8');
82+
});
83+
84+
it('supports wildcards to trust all proxies (trust-all behavior)', () => {
85+
const req = new Request('http://localhost:3000/api/streak', {
86+
headers: {
87+
'x-forwarded-for': '198.51.100.5, 203.0.113.10',
88+
},
89+
});
90+
91+
const ip = getClientIp(req, {
92+
proxyConfig: {
93+
trustedProxies: ['*'],
94+
trustPrivateRanges: false,
95+
},
96+
});
97+
expect(ip).toBe('198.51.100.5');
98+
});
99+
});

0 commit comments

Comments
 (0)