d1075af77d
Browser code never calls OpenAI/Azure/Gemini directly; all AI traffic is server-side tRPC. connect-src is now locked to 'self'. Added object-src 'none', frame-src 'none', media-src 'self', and worker-src 'self' blob:. style-src keeps 'unsafe-inline' for React + @react-pdf/renderer (documented residual risk — script-src is nonce-based so CSS injection cannot escalate to JS). Added three regression tests covering connect-src no-wildcards, object/frame-src 'none', and worker-src scope. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
109 lines
4.2 KiB
TypeScript
109 lines
4.2 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import { auth } from "./server/auth-edge.js";
|
|
|
|
// UI routes that are accessible without a session (login page, reset flow,
|
|
// public invite acceptance). All other UI routes redirect unauthenticated
|
|
// visitors to /auth/signin.
|
|
const PUBLIC_UI_PREFIXES = ["/auth/", "/invite/"];
|
|
|
|
// API allowlist — only routes listed here are served. Everything else under
|
|
// `/api/*` returns 404. Each allowlisted route MUST perform its own
|
|
// authentication (session check via auth(), CRON_SECRET bearer header, etc.)
|
|
// because the edge middleware cannot do Node-only work like Prisma queries.
|
|
// Prefix entries must end with `/`; exact entries match only the literal
|
|
// pathname. A new /api route therefore requires a deliberate allowlist edit,
|
|
// preventing accidental default-public exposure (security ticket #44).
|
|
export const SELF_AUTH_API_PREFIXES = [
|
|
"/api/auth/",
|
|
"/api/trpc/",
|
|
"/api/sse/",
|
|
"/api/cron/",
|
|
"/api/reports/",
|
|
];
|
|
|
|
export const SELF_AUTH_API_EXACT = ["/api/health", "/api/ready", "/api/perf"];
|
|
|
|
export function isApiAllowlisted(pathname: string): boolean {
|
|
if (SELF_AUTH_API_EXACT.includes(pathname)) return true;
|
|
return SELF_AUTH_API_PREFIXES.some((p) => pathname.startsWith(p));
|
|
}
|
|
|
|
function isPublicUiPath(pathname: string): boolean {
|
|
return PUBLIC_UI_PREFIXES.some((prefix) => pathname.startsWith(prefix));
|
|
}
|
|
|
|
// Browser-side code never talks to AI providers directly — every OpenAI /
|
|
// Azure / Gemini call goes through a server tRPC route. Therefore connect-src
|
|
// is locked to 'self' with no wildcards (ticket #45). If a future feature
|
|
// needs a browser-originated cross-origin request, add it explicitly here.
|
|
function buildCsp(nonce: string, isProd: boolean): string {
|
|
const scriptSrc = isProd ? `'self' 'nonce-${nonce}'` : `'self' 'unsafe-eval' 'unsafe-inline'`;
|
|
|
|
const imgSrc = isProd ? "'self' data: blob:" : "'self' data: blob: https:";
|
|
|
|
return [
|
|
"default-src 'self'",
|
|
`script-src ${scriptSrc}`,
|
|
// style-src keeps 'unsafe-inline' because React inlines styles from
|
|
// component-scoped CSS and @react-pdf/renderer emits inline style blocks.
|
|
// A nonce-based style-src-elem breaks both. This is an accepted residual
|
|
// risk documented in docs/security-architecture.md §5.
|
|
"style-src 'self' 'unsafe-inline'",
|
|
`img-src ${imgSrc}`,
|
|
"font-src 'self' data:",
|
|
"connect-src 'self'",
|
|
"frame-ancestors 'none'",
|
|
"frame-src 'none'",
|
|
"object-src 'none'",
|
|
"media-src 'self'",
|
|
"worker-src 'self' blob:",
|
|
"base-uri 'self'",
|
|
"form-action 'self'",
|
|
].join("; ");
|
|
}
|
|
|
|
export default auth(function middleware(request) {
|
|
const { pathname } = request.nextUrl;
|
|
|
|
// /api/* — default-deny. Only allowlisted routes pass; everything else 404s.
|
|
// Allowlisted routes are responsible for their own auth check (they are
|
|
// reached in the route handler, not here, because edge middleware cannot do
|
|
// Prisma queries).
|
|
if (pathname.startsWith("/api/")) {
|
|
if (!isApiAllowlisted(pathname)) {
|
|
return NextResponse.json({ error: "Not Found" }, { status: 404 });
|
|
}
|
|
// fall through — continue to add CSP headers
|
|
} else if (!isPublicUiPath(pathname) && !request.auth) {
|
|
// UI route requires a session. Redirect to signin.
|
|
const signInUrl = new URL("/auth/signin", request.url);
|
|
signInUrl.searchParams.set("callbackUrl", request.url);
|
|
return NextResponse.redirect(signInUrl);
|
|
}
|
|
|
|
// Generate a cryptographically random nonce for this request
|
|
const nonceBytes = new Uint8Array(16);
|
|
crypto.getRandomValues(nonceBytes);
|
|
const nonce = btoa(String.fromCharCode(...nonceBytes));
|
|
|
|
const isProd = process.env.NODE_ENV === "production";
|
|
const csp = buildCsp(nonce, isProd);
|
|
|
|
// Forward nonce to server components via request header
|
|
const requestHeaders = new Headers(request.headers);
|
|
requestHeaders.set("x-nonce", nonce);
|
|
requestHeaders.set("Content-Security-Policy", csp);
|
|
|
|
const response = NextResponse.next({ request: { headers: requestHeaders } });
|
|
response.headers.set("Content-Security-Policy", csp);
|
|
|
|
return response;
|
|
});
|
|
|
|
export const config = {
|
|
matcher: [
|
|
// Apply to all routes except Next.js internals and static assets
|
|
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
|
|
],
|
|
};
|