- 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
94 lines
3.1 KiB
TypeScript
94 lines
3.1 KiB
TypeScript
import { NextResponse, type NextRequest } from "next/server";
|
|
import { extractClientIpAsync, safeRedirect } from "./security";
|
|
import { runWithStore, createStore, getRequestId } from "./request-context";
|
|
import { logger } from "@/lib/logger";
|
|
|
|
type MiddlewareHandler = (req: NextRequest) => Promise<NextResponse | null>;
|
|
|
|
export function chain(...handlers: MiddlewareHandler[]): MiddlewareHandler {
|
|
return async (req: NextRequest) => {
|
|
for (const handler of handlers) {
|
|
const result = await handler(req);
|
|
if (result) return result;
|
|
}
|
|
return NextResponse.next();
|
|
};
|
|
}
|
|
|
|
export function withRequestContext(handler: MiddlewareHandler): MiddlewareHandler {
|
|
return async (req: NextRequest) => {
|
|
const ip = await extractClientIpAsync();
|
|
const store = createStore(ip);
|
|
return runWithStore(store, async () => {
|
|
const start = Date.now();
|
|
try {
|
|
const result = await handler(req);
|
|
const duration = Date.now() - start;
|
|
logger.info("Request completed", {
|
|
method: req.method,
|
|
path: req.nextUrl.pathname,
|
|
status: result?.status ?? 200,
|
|
durationMs: duration,
|
|
requestId: getRequestId(),
|
|
});
|
|
return result;
|
|
} catch (error) {
|
|
const duration = Date.now() - start;
|
|
logger.error("Request failed", {
|
|
method: req.method,
|
|
path: req.nextUrl.pathname,
|
|
durationMs: duration,
|
|
requestId: getRequestId(),
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
const safeUrl = new URL(safeRedirect(req.nextUrl.pathname, "/"), req.url);
|
|
return NextResponse.redirect(safeUrl);
|
|
}
|
|
});
|
|
};
|
|
}
|
|
|
|
export function protectAdminRoutes(req: NextRequest): NextResponse | null {
|
|
const { pathname } = req.nextUrl;
|
|
|
|
if (!pathname.startsWith("/admin")) return null;
|
|
|
|
const authToken = req.cookies.get("next-auth.session-token")?.value
|
|
?? req.cookies.get("__Secure-next-auth.session-token")?.value;
|
|
|
|
if (!authToken) {
|
|
const loginUrl = new URL("/login", req.url);
|
|
loginUrl.searchParams.set("callbackUrl", pathname);
|
|
return NextResponse.redirect(loginUrl);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function addSecurityHeaders(req: NextRequest): NextResponse | null {
|
|
if (req.method === "OPTIONS") return null;
|
|
|
|
const response = NextResponse.next();
|
|
const csp = [
|
|
"default-src 'self'",
|
|
"script-src 'self' 'unsafe-eval' 'unsafe-inline'",
|
|
"style-src 'self' 'unsafe-inline'",
|
|
"img-src 'self' data: https: http:",
|
|
"font-src 'self' https:",
|
|
"connect-src 'self' https: wss:",
|
|
"frame-src 'self'",
|
|
"object-src 'none'",
|
|
"base-uri 'self'",
|
|
"form-action 'self'",
|
|
].join("; ");
|
|
|
|
response.headers.set("Content-Security-Policy", csp);
|
|
response.headers.set("X-Content-Type-Options", "nosniff");
|
|
response.headers.set("X-Frame-Options", "DENY");
|
|
response.headers.set("X-XSS-Protection", "0");
|
|
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
response.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
|
|
|
return response;
|
|
}
|