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
+48 -3
View File
@@ -4,9 +4,8 @@ import { NextRequest } from "next/server";
// Simulate an authenticated session so the middleware does not redirect
// and CSP headers are set on every response.
vi.mock("./server/auth-edge.js", () => ({
auth: (handler: (req: NextRequest & { auth: object | null }) => unknown) =>
(req: NextRequest) =>
handler(Object.assign(req, { auth: { user: { id: "test-user", email: "test@test.com" } } })),
auth: (handler: (req: NextRequest & { auth: object | null }) => unknown) => (req: NextRequest) =>
handler(Object.assign(req, { auth: { user: { id: "test-user", email: "test@test.com" } } })),
}));
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);
});
});