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:
2026-04-17 09:03:24 +02:00
parent d45cc00f2f
commit b32160d546
3 changed files with 142 additions and 16 deletions
+55
View File
@@ -0,0 +1,55 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { verifyCronSecret } from "./cron-auth.js";
describe("verifyCronSecret — fail-closed when CRON_SECRET missing", () => {
const original = process.env["CRON_SECRET"];
afterEach(() => {
if (original === undefined) delete process.env["CRON_SECRET"];
else process.env["CRON_SECRET"] = original;
});
it("returns 401 when CRON_SECRET is unset", async () => {
delete process.env["CRON_SECRET"];
const req = new Request("http://localhost/api/cron/x", {
headers: { Authorization: "Bearer whatever" },
});
const res = verifyCronSecret(req);
expect(res).not.toBeNull();
expect(res?.status).toBe(401);
});
it("returns 401 when CRON_SECRET is empty string", async () => {
process.env["CRON_SECRET"] = "";
const req = new Request("http://localhost/api/cron/x", {
headers: { Authorization: "Bearer whatever" },
});
const res = verifyCronSecret(req);
expect(res).not.toBeNull();
expect(res?.status).toBe(401);
});
it("returns 401 when Authorization header is missing", () => {
process.env["CRON_SECRET"] = "real-secret";
const req = new Request("http://localhost/api/cron/x");
const res = verifyCronSecret(req);
expect(res?.status).toBe(401);
});
it("returns 401 when Authorization header mismatches", () => {
process.env["CRON_SECRET"] = "real-secret";
const req = new Request("http://localhost/api/cron/x", {
headers: { Authorization: "Bearer wrong-secret" },
});
const res = verifyCronSecret(req);
expect(res?.status).toBe(401);
});
it("returns null (allow) when Authorization header matches", () => {
process.env["CRON_SECRET"] = "real-secret";
const req = new Request("http://localhost/api/cron/x", {
headers: { Authorization: "Bearer real-secret" },
});
expect(verifyCronSecret(req)).toBeNull();
});
});
+48 -3
View File
@@ -4,9 +4,8 @@ import { NextRequest } from "next/server";
// Simulate an authenticated session so the middleware does not redirect // Simulate an authenticated session so the middleware does not redirect
// and CSP headers are set on every response. // and CSP headers are set on every response.
vi.mock("./server/auth-edge.js", () => ({ vi.mock("./server/auth-edge.js", () => ({
auth: (handler: (req: NextRequest & { auth: object | null }) => unknown) => auth: (handler: (req: NextRequest & { auth: object | null }) => unknown) => (req: NextRequest) =>
(req: NextRequest) => handler(Object.assign(req, { auth: { user: { id: "test-user", email: "test@test.com" } } })),
handler(Object.assign(req, { auth: { user: { id: "test-user", email: "test@test.com" } } })),
})); }));
async function importMiddleware(nodeEnv: string) { async function importMiddleware(nodeEnv: string) {
@@ -82,3 +81,49 @@ describe("middleware — Content-Security-Policy", () => {
} }
}); });
}); });
describe("middleware — API allowlist (default-deny)", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
});
it("allows allowlisted API routes through", async () => {
const middleware = await importMiddleware("production");
for (const url of [
"http://localhost:3100/api/trpc/project.list",
"http://localhost:3100/api/auth/signin",
"http://localhost:3100/api/sse/timeline",
"http://localhost:3100/api/cron/health-check",
"http://localhost:3100/api/reports/allocations",
"http://localhost:3100/api/health",
"http://localhost:3100/api/ready",
"http://localhost:3100/api/perf",
]) {
const res = await middleware(new NextRequest(url));
expect(res.status).not.toBe(404);
}
});
it("returns 404 for non-allowlisted /api/* routes", async () => {
const middleware = await importMiddleware("production");
for (const url of [
"http://localhost:3100/api/debug",
"http://localhost:3100/api/internal/secret",
"http://localhost:3100/api/admin/users",
]) {
const res = await middleware(new NextRequest(url));
expect(res.status).toBe(404);
}
});
});
describe("isApiAllowlisted helper", () => {
it("exported via module for testing", async () => {
const { isApiAllowlisted } = await import("./middleware.js");
expect(isApiAllowlisted("/api/trpc/foo")).toBe(true);
expect(isApiAllowlisted("/api/debug")).toBe(false);
expect(isApiAllowlisted("/api/healthz")).toBe(false);
expect(isApiAllowlisted("/api/health")).toBe(true);
});
});
+39 -13
View File
@@ -1,22 +1,39 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "./server/auth-edge.js"; import { auth } from "./server/auth-edge.js";
// Paths that are accessible without a session. // UI routes that are accessible without a session (login page, reset flow,
// Everything else requires a valid JWT session. // public invite acceptance). All other UI routes redirect unauthenticated
const PUBLIC_PREFIXES = [ // visitors to /auth/signin.
"/auth/", // signin, forgot-password, reset-password const PUBLIC_UI_PREFIXES = ["/auth/", "/invite/"];
"/api/", // tRPC, health, auth endpoints — these manage their own auth
"/invite/", // public invite acceptance flow // 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 { export const SELF_AUTH_API_EXACT = ["/api/health", "/api/ready", "/api/perf"];
return PUBLIC_PREFIXES.some((prefix) => pathname.startsWith(prefix));
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 { function buildCsp(nonce: string, isProd: boolean): string {
const scriptSrc = isProd const scriptSrc = isProd ? `'self' 'nonce-${nonce}'` : `'self' 'unsafe-eval' 'unsafe-inline'`;
? `'self' 'nonce-${nonce}'`
: `'self' 'unsafe-eval' 'unsafe-inline'`;
const imgSrc = isProd ? "'self' data: blob:" : "'self' data: blob: https:"; 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) { export default auth(function middleware(request) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
// Redirect unauthenticated requests for protected routes to signin // /api/* — default-deny. Only allowlisted routes pass; everything else 404s.
if (!isPublicPath(pathname) && !request.auth) { // 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); const signInUrl = new URL("/auth/signin", request.url);
signInUrl.searchParams.set("callbackUrl", request.url); signInUrl.searchParams.set("callbackUrl", request.url);
return NextResponse.redirect(signInUrl); return NextResponse.redirect(signInUrl);