diff --git a/src/app/api/paste/route.ts b/src/app/api/paste/route.ts index 063695a..b345705 100644 --- a/src/app/api/paste/route.ts +++ b/src/app/api/paste/route.ts @@ -14,8 +14,8 @@ import { calculateExpiration, calculateTTL, validatePasteSize, - getClientIp, } from '@/lib/validation'; +import { getClientIp } from '@/lib/ip'; // Rate limiting: 10 pastes per hour per IP const RATE_LIMIT_MAX = 10; diff --git a/src/lib/ip.test.ts b/src/lib/ip.test.ts new file mode 100644 index 0000000..2c45b9e --- /dev/null +++ b/src/lib/ip.test.ts @@ -0,0 +1,46 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; +import { getClientIp } from './ip.ts'; + +test('getClientIp prioritizes x-real-ip', () => { + const headers = new Headers(); + headers.set('x-real-ip', '1.2.3.4'); + headers.set('x-forwarded-for', '5.6.7.8'); + const ip = getClientIp(headers); + assert.strictEqual(ip, '1.2.3.4'); +}); + +test('getClientIp takes the LAST IP in x-forwarded-for to prevent spoofing', () => { + const headers = new Headers(); + // 1.1.1.1 is spoofed by client, 2.2.2.2 is real client IP added by proxy + headers.set('x-forwarded-for', '1.1.1.1, 2.2.2.2'); + const ip = getClientIp(headers); + assert.strictEqual(ip, '2.2.2.2'); +}); + +test('getClientIp handles multiple IPs in x-forwarded-for', () => { + const headers = new Headers(); + headers.set('x-forwarded-for', '1.1.1.1, 2.2.2.2, 3.3.3.3'); + const ip = getClientIp(headers); + assert.strictEqual(ip, '3.3.3.3'); +}); + +test('getClientIp handles x-forwarded-for with whitespace', () => { + const headers = new Headers(); + headers.set('x-forwarded-for', ' 1.1.1.1 , 2.2.2.2 '); + const ip = getClientIp(headers); + assert.strictEqual(ip, '2.2.2.2'); +}); + +test('getClientIp returns unknown if no headers are present', () => { + const headers = new Headers(); + const ip = getClientIp(headers); + assert.strictEqual(ip, 'unknown'); +}); + +test('getClientIp handles empty x-forwarded-for', () => { + const headers = new Headers(); + headers.set('x-forwarded-for', ''); + const ip = getClientIp(headers); + assert.strictEqual(ip, 'unknown'); +}); diff --git a/src/lib/ip.ts b/src/lib/ip.ts new file mode 100644 index 0000000..b8a302e --- /dev/null +++ b/src/lib/ip.ts @@ -0,0 +1,28 @@ +/** + * Utility to securely retrieve client IP from request headers + */ + +/** + * Get client IP from request headers + * Handles proxy headers securely by prioritizing x-real-ip and + * taking the last entry in x-forwarded-for to prevent spoofing. + */ +export function getClientIp(headers: Headers): string { + // 1. Check x-real-ip (often set by Vercel, Nginx, etc.) + const xRealIp = headers.get('x-real-ip'); + if (xRealIp) return xRealIp; + + // 2. Check x-forwarded-for + const xForwardedFor = headers.get('x-forwarded-for'); + if (xForwardedFor) { + // We take the LAST IP in the list. + // If the client spoofs XFF, their spoofed IP will be at the beginning. + // The real client IP (seen by the proxy) will be appended to the list. + const ips = xForwardedFor.split(',').map(ip => ip.trim()).filter(Boolean); + if (ips.length > 0) { + return ips[ips.length - 1]; + } + } + + return 'unknown'; +} diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 7e0e5e8..36ffc9d 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -144,13 +144,3 @@ export function validatePasteSize(ciphertext: string): boolean { return estimatedSize <= MAX_PASTE_SIZE; } -/** - * Get client IP from request headers - */ -export function getClientIp(headers: Headers): string { - return ( - headers.get('x-forwarded-for')?.split(',')[0] || - headers.get('x-real-ip') || - 'unknown' - ); -}