import { NextResponse } from "next/server"; import { withAuth, NextRequestWithAuth } from "next-auth/middleware"; import { createLogger } from "@link-stack/logger"; const logger = createLogger('link-middleware'); const rewriteURL = ( request: NextRequestWithAuth, originBaseURL: string, destinationBaseURL: string, headers: any = {}, ) => { logger.debug({ originBaseURL, destinationBaseURL, headerKeys: Object.keys(headers) }, "Rewriting URL"); let path = request.url.replace(originBaseURL, ""); if (path.startsWith("/")) { path = path.slice(1); } const destinationURL = `${destinationBaseURL}/${path}`; logger.debug({ from: request.url, to: destinationURL }, "URL rewrite"); const requestHeaders = new Headers(request.headers); requestHeaders.delete("x-forwarded-user"); requestHeaders.delete("x-forwarded-roles"); requestHeaders.delete("connection"); for (const [key, value] of Object.entries(headers)) { requestHeaders.set(key, value as string); } return NextResponse.rewrite(new URL(destinationURL), { request: { headers: requestHeaders }, }); }; const checkRewrites = async (request: NextRequestWithAuth) => { const linkBaseURL = process.env.LINK_URL ?? "http://localhost:3000"; logger.debug({ linkBaseURL }, "Link base URL"); const opensearchBaseURL = process.env.OPENSEARCH_DASHBOARDS_URL ?? "http://opensearch-dashboards:5601"; const { token } = request.nextauth; const email = token?.email?.toLowerCase() ?? "unknown"; const roles = (token?.roles as string[]) ?? []; let headers = { "x-forwarded-user": email, "x-forwarded-roles": roles.join(","), }; if (request.nextUrl.pathname.startsWith("/dashboards")) { // Extract the path after /dashboards and append to OpenSearch URL let path = request.nextUrl.pathname.slice("/dashboards".length); if (path.startsWith("/")) { path = path.slice(1); } const search = request.nextUrl.search; const destinationURL = `${opensearchBaseURL}/${path}${search}`; logger.debug({ pathname: request.nextUrl.pathname, path, search, destinationURL }, "OpenSearch proxy"); const requestHeaders = new Headers(request.headers); requestHeaders.delete("x-forwarded-user"); requestHeaders.delete("x-forwarded-roles"); requestHeaders.delete("connection"); for (const [key, value] of Object.entries(headers)) { requestHeaders.set(key, value as string); } return NextResponse.rewrite(new URL(destinationURL), { request: { headers: requestHeaders }, }); } const isDev = process.env.NODE_ENV === "development"; const nonce = Buffer.from(crypto.randomUUID()).toString("base64"); // Allow digiresilience.org for embedding documentation const frameSrcDirective = `frame-src 'self' https://digiresilience.org;`; const cspHeader = ` default-src 'self'; ${frameSrcDirective} connect-src 'self'; script-src 'self' 'nonce-${nonce}' 'strict-dynamic' ${isDev ? "'unsafe-eval'" : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; upgrade-insecure-requests; `; const contentSecurityPolicyHeaderValue = cspHeader .replace(/\s{2,}/g, " ") .trim(); const requestHeaders = new Headers(request.headers); requestHeaders.set("x-nonce", nonce); requestHeaders.set( "Content-Security-Policy", contentSecurityPolicyHeaderValue, ); const response = NextResponse.next({ request: { headers: requestHeaders, }, }); response.headers.set( "Content-Security-Policy", contentSecurityPolicyHeaderValue, ); // Additional security headers response.headers.set("X-Frame-Options", "SAMEORIGIN"); response.headers.set("X-Content-Type-Options", "nosniff"); response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); response.headers.set("X-XSS-Protection", "1; mode=block"); response.headers.set( "Permissions-Policy", "camera=(), microphone=(), geolocation=()" ); return response; } export default withAuth(checkRewrites, { callbacks: { authorized: ({ token, req }) => { if (process.env.SETUP_MODE === "true") { return true; } const path = req.nextUrl.pathname; const roles: any = token?.roles ?? []; if (path.startsWith("/login")) { return true; } if (path.startsWith("/admin") && !roles.includes("admin")) { return false; } if (roles.includes("admin") || roles.includes("agent")) { return true; } return false; }, }, }); export const config = { matcher: [ "/((?!ws|wss|api/signal|api/whatsapp|api/formstack|_next/static|_next/image|favicon.ico).*)", ], }