Files
Epicnabbo-Catalogus-Updated…/src/lib/foundation/middleware.ts
T
openhands f6ad030c5b Add EpicNext CMS foundation layer and fix critical security gaps
- 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
2026-07-13 12:03:49 +02:00

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;
}