JavaScript 56.7%
TypeScript 42.6%
1import { NextRequest, NextResponse } from "next/server";2import { getIronSession } from "iron-session";3import { SessionData, sessionOptions } from "./lib/session";45const publicPaths = [6 "/login",7 "/api/auth/login",8 "/api/auth/2fa/verify",9 "/api/auth/otp/",10 "/shared",11 "/api/shared",12 "/api/v1/",13 "/_next",14 "/favicon.ico",15 "/manifest.webmanifest",16 "/icons/",17 "/icon.svg",18];1920// CSRF-exempt paths (public endpoints that accept POST without session)21const csrfExemptPaths = [22 "/api/auth/login",23 "/api/auth/otp/",24 "/api/shared/",25 "/api/v1/",26];2728// --- Edge-compatible rate limiter ---29const rateLimitMap = new Map<string, { count: number; resetAt: number }>();30const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute31const RATE_LIMIT_MAX = 600; // 600 requêtes/min/IP (une grille de 50 vignettes = 50 requêtes)3233function cleanupRateLimitMap() {34 const now = Date.now();35 // Periodic cleanup: only run if map is large36 if (rateLimitMap.size > 5000) {37 const keysToDelete: string[] = [];38 rateLimitMap.forEach((entry, key) => {39 if (now > entry.resetAt) {40 keysToDelete.push(key);41 }42 });43 keysToDelete.forEach((key) => rateLimitMap.delete(key));44 }45}4647function checkRateLimit(ip: string): boolean {48 const now = Date.now();49 const entry = rateLimitMap.get(ip);5051 if (!entry || now > entry.resetAt) {52 rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });53 return true;54 }5556 entry.count++;57 if (entry.count > RATE_LIMIT_MAX) {58 return false;59 }6061 return true;62}6364function addSecurityHeaders(response: NextResponse): NextResponse {65 response.headers.set("X-Content-Type-Options", "nosniff");66 response.headers.set("X-Frame-Options", "SAMEORIGIN");67 response.headers.set("X-XSS-Protection", "1; mode=block");68 return response;69}7071function getClientIp(request: NextRequest): string {72 return (73 request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||74 request.headers.get("x-real-ip") ||75 "unknown"76 );77}7879const ALLOWED_ORIGINS = (process.env.CORS_ORIGIN || "*")80 .split(",")81 .map((o) => o.trim())82 .filter(Boolean);8384function getAllowedOrigin(request: NextRequest): string {85 const requestOrigin = request.headers.get("origin") || "";86 if (ALLOWED_ORIGINS.includes("*")) return "*";87 if (ALLOWED_ORIGINS.includes(requestOrigin)) return requestOrigin;88 return ALLOWED_ORIGINS[0];89}9091function setCorsHeaders(response: NextResponse, origin: string) {92 response.headers.set("Access-Control-Allow-Origin", origin);93 response.headers.set("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS");94 response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization, x-csrf-token");95 response.headers.set("Access-Control-Allow-Credentials", "true");96 response.headers.set("Vary", "Origin");97}9899export async function middleware(request: NextRequest) {100 const { pathname } = request.nextUrl;101 const corsOrigin = getAllowedOrigin(request);102103 // Handle CORS preflight requests104 if (request.method === "OPTIONS") {105 const preflightResponse = new NextResponse(null, { status: 204 });106 setCorsHeaders(preflightResponse, corsOrigin);107 preflightResponse.headers.set("Access-Control-Max-Age", "86400");108 return preflightResponse;109 }110111 // Allow public paths112 if (publicPaths.some((path) => pathname.startsWith(path))) {113 const response = NextResponse.next();114 if (pathname.startsWith("/api/")) setCorsHeaders(response, corsOrigin);115 addSecurityHeaders(response);116 return response;117 }118119 // --- API rate limiting (les aperçus/flux de médias sont exemptés : contenu statique, coûteux en volume) ---120 const isMediaPath = /^\/api\/files\/[^/]+\/(preview|stream|content)$/.test(pathname) || pathname.startsWith("/api/users/") && pathname.endsWith("/avatar");121 if (pathname.startsWith("/api/") && !isMediaPath) {122 const ip = getClientIp(request);123 cleanupRateLimitMap();124125 if (!checkRateLimit(ip)) {126 const errorResponse = NextResponse.json(127 { error: "Trop de requetes. Reessayez dans une minute." },128 { status: 429 }129 );130 addSecurityHeaders(errorResponse);131 errorResponse.headers.set("Retry-After", "60");132 return errorResponse;133 }134 }135136 // Check session137 const response = NextResponse.next();138 const session = await getIronSession<SessionData>(request, response, sessionOptions);139140 if (!session.isLoggedIn) {141 if (pathname.startsWith("/api/")) {142 const errorResponse = NextResponse.json({ error: "Unauthorized" }, { status: 401 });143 addSecurityHeaders(errorResponse);144 return errorResponse;145 }146 const redirectResponse = NextResponse.redirect(new URL("/login", request.url));147 addSecurityHeaders(redirectResponse);148 return redirectResponse;149 }150151 // --- CSRF protection for non-GET API requests ---152 // SameSite=Lax cookies already protect against most CSRF attacks.153 // We validate the CSRF token when present, but don't block requests154 // that come from the same origin (checked via Referer/Origin header).155 if (156 pathname.startsWith("/api/") &&157 request.method !== "GET" &&158 !csrfExemptPaths.some((path) => pathname.startsWith(path))159 ) {160 const csrfHeader = request.headers.get("x-csrf-token");161 const origin = request.headers.get("origin") || "";162 const referer = request.headers.get("referer") || "";163 const host = request.headers.get("host") || "";164 const forwardedHost = request.headers.get("x-forwarded-host") || "";165 // Accept same-origin, proxy-forwarded origins (ngrok, Tailscale, custom domain)166 const isAllowedCorsOrigin = ALLOWED_ORIGINS.includes(origin);167 const isSameOrigin =168 !origin ||169 origin.includes(host) ||170 referer.includes(host) ||171 (forwardedHost && origin.includes(forwardedHost)) ||172 origin.includes(".ngrok") ||173 origin.includes("spboucher.ai") ||174 origin.includes("s-pb.ai") ||175 isAllowedCorsOrigin;176177 // If CSRF token is present, validate it. Otherwise, rely on same-origin check.178 if (csrfHeader && csrfHeader !== session.csrfToken) {179 const errorResponse = NextResponse.json(180 { error: "Jeton CSRF invalide" },181 { status: 403 }182 );183 addSecurityHeaders(errorResponse);184 return errorResponse;185 }186187 // Block requests from foreign origins without valid CSRF token188 if (!isSameOrigin && !csrfHeader) {189 const errorResponse = NextResponse.json(190 { error: "Requete cross-origin non autorisee" },191 { status: 403 }192 );193 addSecurityHeaders(errorResponse);194 return errorResponse;195 }196 }197198 if (pathname.startsWith("/api/")) setCorsHeaders(response, corsOrigin);199 addSecurityHeaders(response);200 return response;201}202203export const config = {204 matcher: [205 "/((?!_next/static|_next/image|favicon.ico).*)",206 ],207};208