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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user