Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/lib/server/rate-limiter.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,13 @@ export const smsGlobalDailyLimiter = new RateLimiter({
windowMs: 24 * 60 * 60 * 1000
});

export function parseTrustedProxyCount(value = process.env.TRUSTED_PROXY_COUNT) {
const raw = String(value ?? '0').trim();
if (!/^\d+$/.test(raw)) return 0;
const count = Number(raw);
return Number.isSafeInteger(count) ? count : 0;
}

/**
* Extract client IP from a Next.js Request object.
*
Expand All @@ -121,7 +128,7 @@ export const smsGlobalDailyLimiter = new RateLimiter({
* @returns {string}
*/
export function getClientIp(request) {
const trustedProxyCount = parseInt(process.env.TRUSTED_PROXY_COUNT ?? '0', 10);
const trustedProxyCount = parseTrustedProxyCount();

if (trustedProxyCount > 0) {
// Take the Nth-from-right entry in X-Forwarded-For where N = trustedProxyCount.
Expand Down
51 changes: 51 additions & 0 deletions src/lib/server/rate-limiter.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { afterEach, describe, expect, it } from 'vitest';
import { getClientIp, parseTrustedProxyCount } from './rate-limiter.js';

function requestWithHeaders(headers) {
return {
headers: {
get(name) {
return headers[name.toLowerCase()] ?? null;
}
}
};
}

describe('trusted proxy parsing', () => {
const original = process.env.TRUSTED_PROXY_COUNT;

afterEach(() => {
if (original === undefined) {
delete process.env.TRUSTED_PROXY_COUNT;
} else {
process.env.TRUSTED_PROXY_COUNT = original;
}
});

it('accepts only unsigned integer proxy counts', () => {
expect(parseTrustedProxyCount('2')).toBe(2);
expect(parseTrustedProxyCount(' 01 ')).toBe(1);
expect(parseTrustedProxyCount('1abc')).toBe(0);
expect(parseTrustedProxyCount('-1')).toBe(0);
expect(parseTrustedProxyCount('')).toBe(0);
});

it('does not trust X-Forwarded-For when the proxy count is malformed', () => {
process.env.TRUSTED_PROXY_COUNT = '1abc';
const request = requestWithHeaders({
'x-forwarded-for': '203.0.113.9, 198.51.100.10',
'x-real-ip': '192.0.2.55'
});

expect(getClientIp(request)).toBe('192.0.2.55');
});

it('uses the nth trusted hop from the right when configured', () => {
process.env.TRUSTED_PROXY_COUNT = '2';
const request = requestWithHeaders({
'x-forwarded-for': '203.0.113.9, 198.51.100.10, 192.0.2.55'
});

expect(getClientIp(request)).toBe('198.51.100.10');
});
});
24 changes: 1 addition & 23 deletions src/middleware.js
Original file line number Diff line number Diff line change
@@ -1,33 +1,11 @@
import { NextResponse } from 'next/server';
import { RateLimiter } from '@/lib/server/rate-limiter.js';
import { getClientIp, RateLimiter } from '@/lib/server/rate-limiter.js';

const authRateLimiter = new RateLimiter({ maxRequests: 10, windowMs: 60 * 1000 });
const webhookRateLimiter = new RateLimiter({ maxRequests: 100, windowMs: 60 * 1000 });
const apiRateLimiter = new RateLimiter({ maxRequests: 60, windowMs: 60 * 1000 });
const keyBackupRateLimiter = new RateLimiter({ maxRequests: 10, windowMs: 15 * 60 * 1000 });

function getClientIp(request) {
// See the detailed note in src/lib/server/rate-limiter.js.
// X-Forwarded-For is user-controllable unless a trusted proxy strips/rewrites it.
// Honour TRUSTED_PROXY_COUNT when set; otherwise fall back to X-Real-IP only.
const trustedProxyCount = parseInt(process.env.TRUSTED_PROXY_COUNT ?? '0', 10);

if (trustedProxyCount > 0) {
const xff = request.headers.get('x-forwarded-for');
if (xff) {
const parts = xff.split(',').map(s => s.trim()).filter(Boolean);
if (parts.length >= trustedProxyCount) {
return parts[parts.length - trustedProxyCount];
}
}
}

return (
request.headers.get('x-real-ip') ??
'unknown'
);
}

function rateLimitedResponse() {
return NextResponse.json(
{ error: 'Too many requests. Please try again later.' },
Expand Down
Loading