import crypto from "node:crypto"; import { cookies, headers } from "next/headers"; import { redirect } from "next/navigation"; import { env } from "@/env"; import type { IpAddress } from "./types"; const CSRF_BYTES = 32; const CSRF_COOKIE_MAX_AGE = 86400; // 24h const CSRF_COOKIE_FALLBACK = "csrf-token"; const CSRF_COOKIE_HOST = "__Host-csrf-token"; const CSRF_COOKIE_NAMES = [CSRF_COOKIE_HOST, CSRF_COOKIE_FALLBACK] as const; /** HTTPS from reverse proxy (Traefik) or configured APP_URL. */ async function isRequestSecure(): Promise { try { const h = await headers(); const proto = h .get("x-forwarded-proto") ?.split(",")[0] ?.trim() .toLowerCase(); if (proto === "https") return true; if (proto === "http") return false; } catch { // headers unavailable during static analysis } try { if (env.APP_URL) return new URL(env.APP_URL).protocol === "https:"; } catch { // ignore malformed APP_URL } return process.env.NODE_ENV === "production"; } function preferredCsrfCookieName(secure: boolean): string { return secure ? CSRF_COOKIE_HOST : CSRF_COOKIE_FALLBACK; } function readExistingCsrfCookie( c: Awaited>, ): string | undefined { for (const name of CSRF_COOKIE_NAMES) { const existing = c.get(name); if (existing?.value && existing.value.length === CSRF_BYTES * 2) return existing.value; } return undefined; } const ALLOWED_HOSTS: ReadonlySet = new Set( [ env.APP_URL ? new URL(env.APP_URL).host : "", "localhost", "127.0.0.1", ].filter(Boolean), ); const SAFE_REDIRECT_PATHS = new Set([ "/login", "/register", "/forgot", "/reset", "/verify", "/banned", "/maintenance", "/", "/me", "/settings", ]); function isSafePath(path: string): boolean { if (!path.startsWith("/")) return false; if (SAFE_REDIRECT_PATHS.has(path)) return true; if (path.startsWith("/admin/") || path.startsWith("/api/")) return true; return false; } export function safeRedirect( destination: string, fallback: string = "/", ): string { try { const url = new URL(destination, env.APP_URL || "http://localhost:3000"); if (ALLOWED_HOSTS.has(url.host)) return destination; if (url.host === "localhost" || url.host === "127.0.0.1") return destination; } catch { if (isSafePath(destination)) return destination; } return fallback; } export function redirectSafe( destination: string, fallback: string = "/", ): never { redirect(safeRedirect(destination, fallback)); } function csrfCookieOpts( name: string, value: string, secure: boolean, ): { name: string; value: string; httpOnly: boolean; secure: boolean; sameSite: "lax"; path: string; maxAge: number; } { return { name, value, httpOnly: true, secure, sameSite: "lax" as const, path: "/", maxAge: CSRF_COOKIE_MAX_AGE, }; } function trySetCsrfCookie( c: Awaited>, opts: ReturnType, ): boolean { try { c.set(opts.name, opts.value, opts); return true; } catch { return false; } } /** Sets the CSRF cookie when possible; returns token or empty string (never throws). */ export async function setCsrfCookie(): Promise { try { const c = await cookies(); const existing = readExistingCsrfCookie(c); if (existing) return existing; const value = crypto.randomBytes(CSRF_BYTES).toString("hex"); const secure = await isRequestSecure(); const primary = csrfCookieOpts( preferredCsrfCookieName(secure), value, secure, ); if (trySetCsrfCookie(c, primary)) return value; const fallback = csrfCookieOpts(CSRF_COOKIE_FALLBACK, value, secure); if (trySetCsrfCookie(c, fallback)) return value; return ""; } catch { return ""; } } export async function validateCsrfToken(token: string): Promise { if (!token || token.length !== CSRF_BYTES * 2) return false; try { const c = await cookies(); let stored: string | undefined; for (const name of CSRF_COOKIE_NAMES) { const candidate = c.get(name)?.value; if (candidate && candidate.length === CSRF_BYTES * 2) { stored = candidate; break; } } if (!stored) return false; return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(stored)); } catch { return false; } } export function canonicalize(input: string): string { return input.normalize("NFC").trim(); } const INVALID_FILENAME_CHARS = /[<>:"/\\|?*]/; function removeControlChars(s: string): string { let result = ""; for (let i = 0; i < s.length; i++) { const code = s.charCodeAt(i); if (code >= 32) result += s.charAt(i); } return result; } export function sanitizeFilename(name: string): string { return removeControlChars( name .normalize("NFC") .replace(INVALID_FILENAME_CHARS, "") .replace(/\.\.(?:\/|$)/g, ""), ) .trim() .slice(0, 255); } export function canonicalizeFormValue( value: FormDataEntryValue | null, maxLen?: number, ): string { const s = canonicalize(String(value ?? "")); return maxLen ? s.slice(0, maxLen) : s; } export function canonicalizeFormData( formData: FormData, fields: Record, ): Record { return Object.fromEntries( Object.entries(fields).map(([key, maxLen]) => [ key, canonicalizeFormValue(formData.get(key), maxLen), ]), ); } export async function extractClientIpAsync(): Promise { try { const h = await headers(); return (h.get("x-real-client-ip") ?? h.get("cf-connecting-ip") ?? h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? h.get("x-real-ip") ?? "0.0.0.0") as IpAddress; } catch { return "0.0.0.0" as IpAddress; } }