- Create src/lib/foundation/ (860 LOC, 9 files): typed action wrappers, DbService with health checks, CSRF validation, safe redirects, AsyncLocalStorage request tracing, branded types, reusable Zod schemas - Migrate moderation.ts and user-settings.ts to foundation patterns - Fix abuse-guard.ts: bound in-memory Maps with LRU eviction (was unbounded) - Fix access-guard.ts: separate try/catch per check, log degradation instead of blanket fail-open - Replace raw redirect() calls with safeRedirect() in guard.ts and permissions.ts to prevent open-redirect attacks - Add CSRF validation to api-handler.ts for mutating methods - Add canonicalizeFormData() utility for FormData input sanitization
129 lines
3.8 KiB
TypeScript
129 lines
3.8 KiB
TypeScript
import { headers } from "next/headers";
|
|
import { cookies } from "next/headers";
|
|
import { redirect } from "next/navigation";
|
|
import crypto from "node:crypto";
|
|
import { env } from "@/env";
|
|
import type { IpAddress } from "./types";
|
|
|
|
const CSRF_BYTES = 32;
|
|
const CSRF_COOKIE = "__Host-csrf-token";
|
|
const CSRF_COOKIE_MAX_AGE = 86400; // 24h
|
|
|
|
const ALLOWED_HOSTS: ReadonlySet<string> = 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; httpOnly: boolean; secure: boolean; sameSite: "lax"; path: string; maxAge: number } {
|
|
return {
|
|
name: CSRF_COOKIE,
|
|
value: crypto.randomBytes(CSRF_BYTES).toString("hex"),
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: "lax" as const,
|
|
path: "/",
|
|
maxAge: CSRF_COOKIE_MAX_AGE,
|
|
};
|
|
}
|
|
|
|
export async function setCsrfCookie(): Promise<string> {
|
|
const c = await cookies();
|
|
const existing = c.get(CSRF_COOKIE);
|
|
if (existing?.value && existing.value.length === CSRF_BYTES * 2) return existing.value;
|
|
const opts = csrfCookieOpts();
|
|
c.set(opts.name, opts.value, opts);
|
|
return opts.value;
|
|
}
|
|
|
|
export async function validateCsrfToken(token: string): Promise<boolean> {
|
|
if (!token || token.length !== CSRF_BYTES * 2) return false;
|
|
try {
|
|
const c = await cookies();
|
|
const stored = c.get(CSRF_COOKIE)?.value;
|
|
if (!stored || stored.length !== CSRF_BYTES * 2) 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<string, number | undefined>): Record<string, string> {
|
|
return Object.fromEntries(
|
|
Object.entries(fields).map(([key, maxLen]) => [key, canonicalizeFormValue(formData.get(key), maxLen)]),
|
|
);
|
|
}
|
|
|
|
export async function extractClientIpAsync(): Promise<IpAddress> {
|
|
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;
|
|
}
|
|
}
|