SPB Git forge

spb/spb-cloud

Public
10commits 1branches 0releases
3.2 MBsize
maindefault branch
1 h agolast push
JavaScript 56.7% TypeScript 42.6%
6.7 KB · 208 lines typescript
Raw Blame History
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