import { NextRequest, NextResponse } from "next/server"; import { getIronSession } from "iron-session"; import { SessionData, sessionOptions } from "./lib/session"; const publicPaths = [ "/login", "/api/auth/login", "/api/auth/2fa/verify", "/api/auth/otp/", "/shared", "/api/shared", "/api/v1/", "/_next", "/favicon.ico", "/manifest.webmanifest", "/icons/", "/icon.svg", ]; // CSRF-exempt paths (public endpoints that accept POST without session) const csrfExemptPaths = [ "/api/auth/login", "/api/auth/otp/", "/api/shared/", "/api/v1/", ]; // --- Edge-compatible rate limiter --- const rateLimitMap = new Map(); const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute const RATE_LIMIT_MAX = 600; // 600 requêtes/min/IP (une grille de 50 vignettes = 50 requêtes) function cleanupRateLimitMap() { const now = Date.now(); // Periodic cleanup: only run if map is large if (rateLimitMap.size > 5000) { const keysToDelete: string[] = []; rateLimitMap.forEach((entry, key) => { if (now > entry.resetAt) { keysToDelete.push(key); } }); keysToDelete.forEach((key) => rateLimitMap.delete(key)); } } function checkRateLimit(ip: string): boolean { const now = Date.now(); const entry = rateLimitMap.get(ip); if (!entry || now > entry.resetAt) { rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS }); return true; } entry.count++; if (entry.count > RATE_LIMIT_MAX) { return false; } return true; } function addSecurityHeaders(response: NextResponse): NextResponse { response.headers.set("X-Content-Type-Options", "nosniff"); response.headers.set("X-Frame-Options", "SAMEORIGIN"); response.headers.set("X-XSS-Protection", "1; mode=block"); return response; } function getClientIp(request: NextRequest): string { return ( request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || request.headers.get("x-real-ip") || "unknown" ); } const ALLOWED_ORIGINS = (process.env.CORS_ORIGIN || "*") .split(",") .map((o) => o.trim()) .filter(Boolean); function getAllowedOrigin(request: NextRequest): string { const requestOrigin = request.headers.get("origin") || ""; if (ALLOWED_ORIGINS.includes("*")) return "*"; if (ALLOWED_ORIGINS.includes(requestOrigin)) return requestOrigin; return ALLOWED_ORIGINS[0]; } function setCorsHeaders(response: NextResponse, origin: string) { response.headers.set("Access-Control-Allow-Origin", origin); response.headers.set("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS"); response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization, x-csrf-token"); response.headers.set("Access-Control-Allow-Credentials", "true"); response.headers.set("Vary", "Origin"); } export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl; const corsOrigin = getAllowedOrigin(request); // Handle CORS preflight requests if (request.method === "OPTIONS") { const preflightResponse = new NextResponse(null, { status: 204 }); setCorsHeaders(preflightResponse, corsOrigin); preflightResponse.headers.set("Access-Control-Max-Age", "86400"); return preflightResponse; } // Allow public paths if (publicPaths.some((path) => pathname.startsWith(path))) { const response = NextResponse.next(); if (pathname.startsWith("/api/")) setCorsHeaders(response, corsOrigin); addSecurityHeaders(response); return response; } // --- API rate limiting (les aperçus/flux de médias sont exemptés : contenu statique, coûteux en volume) --- const isMediaPath = /^\/api\/files\/[^/]+\/(preview|stream|content)$/.test(pathname) || pathname.startsWith("/api/users/") && pathname.endsWith("/avatar"); if (pathname.startsWith("/api/") && !isMediaPath) { const ip = getClientIp(request); cleanupRateLimitMap(); if (!checkRateLimit(ip)) { const errorResponse = NextResponse.json( { error: "Trop de requetes. Reessayez dans une minute." }, { status: 429 } ); addSecurityHeaders(errorResponse); errorResponse.headers.set("Retry-After", "60"); return errorResponse; } } // Check session const response = NextResponse.next(); const session = await getIronSession(request, response, sessionOptions); if (!session.isLoggedIn) { if (pathname.startsWith("/api/")) { const errorResponse = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); addSecurityHeaders(errorResponse); return errorResponse; } const redirectResponse = NextResponse.redirect(new URL("/login", request.url)); addSecurityHeaders(redirectResponse); return redirectResponse; } // --- CSRF protection for non-GET API requests --- // SameSite=Lax cookies already protect against most CSRF attacks. // We validate the CSRF token when present, but don't block requests // that come from the same origin (checked via Referer/Origin header). if ( pathname.startsWith("/api/") && request.method !== "GET" && !csrfExemptPaths.some((path) => pathname.startsWith(path)) ) { const csrfHeader = request.headers.get("x-csrf-token"); const origin = request.headers.get("origin") || ""; const referer = request.headers.get("referer") || ""; const host = request.headers.get("host") || ""; const forwardedHost = request.headers.get("x-forwarded-host") || ""; // Accept same-origin, proxy-forwarded origins (ngrok, Tailscale, custom domain) const isAllowedCorsOrigin = ALLOWED_ORIGINS.includes(origin); const isSameOrigin = !origin || origin.includes(host) || referer.includes(host) || (forwardedHost && origin.includes(forwardedHost)) || origin.includes(".ngrok") || origin.includes("spboucher.ai") || origin.includes("s-pb.ai") || isAllowedCorsOrigin; // If CSRF token is present, validate it. Otherwise, rely on same-origin check. if (csrfHeader && csrfHeader !== session.csrfToken) { const errorResponse = NextResponse.json( { error: "Jeton CSRF invalide" }, { status: 403 } ); addSecurityHeaders(errorResponse); return errorResponse; } // Block requests from foreign origins without valid CSRF token if (!isSameOrigin && !csrfHeader) { const errorResponse = NextResponse.json( { error: "Requete cross-origin non autorisee" }, { status: 403 } ); addSecurityHeaders(errorResponse); return errorResponse; } } if (pathname.startsWith("/api/")) setCorsHeaders(response, corsOrigin); addSecurityHeaders(response); return response; } export const config = { matcher: [ "/((?!_next/static|_next/image|favicon.ico).*)", ], };