security: default-deny /api middleware allowlist (#44)
Previously middleware.ts listed /api/ as a public prefix, so any new API route added under /api/** was served without a session check unless the developer remembered to self-authenticate it. The middleware now returns 404 for any /api path not explicitly allowlisted (auth, trpc, sse, cron, reports, health, ready, perf) — adding a new API route is a deliberate allowlist edit. verifyCronSecret was already fail-closed when CRON_SECRET is unset; added unit tests. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+39
-13
@@ -1,22 +1,39 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "./server/auth-edge.js";
|
||||
|
||||
// Paths that are accessible without a session.
|
||||
// Everything else requires a valid JWT session.
|
||||
const PUBLIC_PREFIXES = [
|
||||
"/auth/", // signin, forgot-password, reset-password
|
||||
"/api/", // tRPC, health, auth endpoints — these manage their own auth
|
||||
"/invite/", // public invite acceptance flow
|
||||
// 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/",
|
||||
];
|
||||
|
||||
function isPublicPath(pathname: string): boolean {
|
||||
return PUBLIC_PREFIXES.some((prefix) => pathname.startsWith(prefix));
|
||||
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));
|
||||
}
|
||||
|
||||
function buildCsp(nonce: string, isProd: boolean): string {
|
||||
const scriptSrc = isProd
|
||||
? `'self' 'nonce-${nonce}'`
|
||||
: `'self' 'unsafe-eval' 'unsafe-inline'`;
|
||||
const scriptSrc = isProd ? `'self' 'nonce-${nonce}'` : `'self' 'unsafe-eval' 'unsafe-inline'`;
|
||||
|
||||
const imgSrc = isProd ? "'self' data: blob:" : "'self' data: blob: https:";
|
||||
|
||||
@@ -36,8 +53,17 @@ function buildCsp(nonce: string, isProd: boolean): string {
|
||||
export default auth(function middleware(request) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// Redirect unauthenticated requests for protected routes to signin
|
||||
if (!isPublicPath(pathname) && !request.auth) {
|
||||
// /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);
|
||||
|
||||
Reference in New Issue
Block a user