diff --git a/app/api/auth/route.ts b/app/api/auth/route.ts index 6202d96..068e0ce 100644 --- a/app/api/auth/route.ts +++ b/app/api/auth/route.ts @@ -5,20 +5,14 @@ import { NextRequest, NextResponse } from 'next/server'; import { authenticate, isProtected } from '@/lib/auth'; -import { checkRateLimit } from '@/lib/rate-limit'; +import { checkRateLimit, getClientIp } from '@/lib/rate-limit'; /** Tight limit for auth attempts — 10 per minute per IP. */ const AUTH_RPM = 10; export async function POST(request: NextRequest) { // ── Rate limiting (brute-force protection) ────────── - // Security: Prioritize x-real-ip (set by trusted reverse proxies like nginx/Traefik) - // over x-forwarded-for which can be spoofed by clients. - // Note: request.ip was removed in Next.js 15+. - const ip = - request.headers.get('x-real-ip') ?? - request.headers.get('x-forwarded-for')?.split(',')[0].trim() ?? - 'unknown'; + const ip = getClientIp(request); const { success } = checkRateLimit(`auth:${ip}`, AUTH_RPM); if (!success) { diff --git a/app/api/exif/[id]/route.ts b/app/api/exif/[id]/route.ts index 0b6d118..59f7ce6 100644 --- a/app/api/exif/[id]/route.ts +++ b/app/api/exif/[id]/route.ts @@ -9,7 +9,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { immich } from '@/lib/immich'; import { decodeAssetId } from '@/lib/tokens'; import { getConfig } from '@/lib/config'; -import { checkRateLimit } from '@/lib/rate-limit'; +import { checkRateLimit, getClientIp } from '@/lib/rate-limit'; export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const config = getConfig(); @@ -20,12 +20,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ } // ── Rate limiting ────────────────────────────────── - // Security: Prioritize x-real-ip (set by trusted reverse proxies like nginx/Traefik) - // over x-forwarded-for which can be spoofed by clients. - const ip = - request.headers.get('x-real-ip') ?? - request.headers.get('x-forwarded-for')?.split(',')[0].trim() ?? - 'unknown'; + const ip = getClientIp(request); const { success, resetAt } = checkRateLimit(`exif:${ip}`, config.rateLimitRpm); if (!success) { diff --git a/app/api/image/[id]/route.ts b/app/api/image/[id]/route.ts index 3214620..79115a0 100644 --- a/app/api/image/[id]/route.ts +++ b/app/api/image/[id]/route.ts @@ -11,7 +11,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { immich, ImageSize } from '@/lib/immich'; import { decodeAssetId } from '@/lib/tokens'; import { getConfig } from '@/lib/config'; -import { checkRateLimit } from '@/lib/rate-limit'; +import { checkRateLimit, getClientIp } from '@/lib/rate-limit'; const VALID_SIZES: ImageSize[] = ['thumbnail', 'preview', 'original']; @@ -26,13 +26,7 @@ function widthToSize(w: number): ImageSize { export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { // ── Rate limiting ────────────────────────────────── - // Security: Prioritize x-real-ip (set by trusted reverse proxies like nginx/Traefik) - // over x-forwarded-for which can be spoofed by clients. - // Note: request.ip was removed in Next.js 15+. - const ip = - request.headers.get('x-real-ip') ?? - request.headers.get('x-forwarded-for')?.split(',')[0].trim() ?? - 'unknown'; + const ip = getClientIp(request); const { success, remaining, resetAt } = checkRateLimit(ip, getConfig().rateLimitRpm); if (!success) { diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts index a679bc7..c27ae23 100644 --- a/lib/rate-limit.ts +++ b/lib/rate-limit.ts @@ -9,6 +9,16 @@ * multiple nodes, a persistent store like Redis would be required. */ +import { NextRequest } from 'next/server'; + +export function getClientIp(request: NextRequest): string { + return ( + request.headers.get('x-real-ip') ?? + request.headers.get('x-forwarded-for')?.split(',')[0].trim() ?? + 'unknown' + ); +} + interface RateLimitEntry { count: number; expiresAt: number; @@ -32,15 +42,6 @@ function cleanup() { } } -/** - * Evict oldest entry when store is at capacity (FIFO). - */ -function evictIfFull() { - if (store.size < MAX_STORE_ENTRIES) return; - const firstKey = store.keys().next().value; - if (firstKey) store.delete(firstKey); -} - /** * Check if a request should be allowed. * @param ip - Client IP address @@ -61,7 +62,10 @@ export function checkRateLimit( // New window or expired if (!entry || now > entry.expiresAt) { - evictIfFull(); + if (store.size >= MAX_STORE_ENTRIES) { + // Store is completely full with active IPs -> block to prevent memory exhaustion & rate limit bypass + return { success: false, remaining: 0, resetAt: now + windowMs }; + } const resetAt = now + windowMs; store.set(key, { count: 1, expiresAt: resetAt }); return { success: true, remaining: maxRpm - 1, resetAt };