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