feat(web): middleware gates non-allowlisted paths on session cookie
Edge-runtime check via auth-cookie.verifySession. /api/* paths get a 401 (no body) when unauthenticated; pages get a 307 to /login with the original path encoded into ?next=. Allowlist explicitly excludes /api/events and /api/qr — both were unauthenticated in v1.1.0 and let an unauthenticated client snoop the entire SSE event stream and enumerate paired account QR codes.
This commit is contained in:
parent
5b4787d10e
commit
b77a9d106d
84
apps/web/src/middleware.test.ts
Normal file
84
apps/web/src/middleware.test.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const SECRET = "test-secret";
|
||||
beforeAll(() => {
|
||||
process.env.AUTH_SECRET = SECRET;
|
||||
process.env.OPERATOR_TOKEN_VERSION = "1";
|
||||
});
|
||||
|
||||
import { signSession } from "./lib/auth-cookie";
|
||||
import { middleware } from "./middleware";
|
||||
|
||||
async function makeReq(path: string, cookie?: string): Promise<NextRequest> {
|
||||
const url = new URL(`https://wabot.04080616.xyz${path}`);
|
||||
const headers = new Headers();
|
||||
if (cookie) headers.set("cookie", `session=${cookie}`);
|
||||
return new NextRequest(url, { headers });
|
||||
}
|
||||
|
||||
async function validCookie(): Promise<string> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return signSession(
|
||||
{
|
||||
userId: "00000000-0000-0000-0000-000000000000",
|
||||
role: "admin",
|
||||
iat: now,
|
||||
exp: now + 3600,
|
||||
v: 1,
|
||||
},
|
||||
SECRET,
|
||||
);
|
||||
}
|
||||
|
||||
describe("middleware", () => {
|
||||
it("page request without a cookie redirects to /login?next=…", async () => {
|
||||
const r = await middleware(await makeReq("/dashboard"));
|
||||
expect(r.status).toBe(307);
|
||||
expect(r.headers.get("location")).toContain("/login");
|
||||
expect(r.headers.get("location")).toContain("next=%2Fdashboard");
|
||||
});
|
||||
|
||||
it("/api/* request without a cookie returns 401 with no body", async () => {
|
||||
const r = await middleware(await makeReq("/api/events"));
|
||||
expect(r.status).toBe(401);
|
||||
});
|
||||
|
||||
it("page request with a valid cookie passes through", async () => {
|
||||
const r = await middleware(await makeReq("/dashboard", await validCookie()));
|
||||
// NextResponse.next() returns a 200 with the x-middleware-next header.
|
||||
expect(r.status).toBe(200);
|
||||
});
|
||||
|
||||
it("page request with a tampered cookie redirects to /login", async () => {
|
||||
const cookie = (await validCookie()).slice(0, -4) + "AAAA";
|
||||
const r = await middleware(await makeReq("/dashboard", cookie));
|
||||
expect(r.status).toBe(307);
|
||||
expect(r.headers.get("location")).toContain("/login");
|
||||
});
|
||||
|
||||
it("allowlisted paths bypass auth (login, logout, health, manifest, icons)", async () => {
|
||||
for (const path of [
|
||||
"/login",
|
||||
"/logout",
|
||||
"/api/health",
|
||||
"/manifest.webmanifest",
|
||||
"/icon-192.png",
|
||||
"/favicon.ico",
|
||||
]) {
|
||||
const r = await middleware(await makeReq(path));
|
||||
expect(r.status).toBe(200);
|
||||
}
|
||||
});
|
||||
|
||||
it("/api/events and /api/qr/<id> are NOT in the allowlist (regression)", async () => {
|
||||
expect((await middleware(await makeReq("/api/events"))).status).toBe(401);
|
||||
expect(
|
||||
(
|
||||
await middleware(
|
||||
await makeReq("/api/qr/11111111-1111-1111-1111-111111111111"),
|
||||
)
|
||||
).status,
|
||||
).toBe(401);
|
||||
});
|
||||
});
|
||||
@ -1,21 +1,41 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { COOKIE_NAME, verifySession } from "./lib/auth-cookie";
|
||||
|
||||
export function middleware(req: NextRequest) {
|
||||
const PUBLIC_PATHS = new Set<string>([
|
||||
"/login",
|
||||
"/logout",
|
||||
"/api/health",
|
||||
"/manifest.webmanifest",
|
||||
"/favicon.ico",
|
||||
"/robots.txt",
|
||||
]);
|
||||
|
||||
function isPublic(path: string): boolean {
|
||||
if (PUBLIC_PATHS.has(path)) return true;
|
||||
if (path.startsWith("/icon-")) return true;
|
||||
if (path.startsWith("/_next/")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function middleware(req: NextRequest): Promise<NextResponse> {
|
||||
const path = req.nextUrl.pathname;
|
||||
if (isPublic(path)) return NextResponse.next();
|
||||
|
||||
// Block all /api/* except a small set of read-only endpoints.
|
||||
// Mutations happen via Server Actions which post to page URLs, not /api/*.
|
||||
const allowed =
|
||||
path === "/api/events" ||
|
||||
path === "/api/health" ||
|
||||
path.startsWith("/api/qr/");
|
||||
if (path.startsWith("/api/") && !allowed) {
|
||||
return new NextResponse("Not Found", { status: 404 });
|
||||
const cookie = req.cookies.get(COOKIE_NAME)?.value;
|
||||
const secret = process.env.AUTH_SECRET;
|
||||
const ok =
|
||||
!!cookie && !!secret && (await verifySession(cookie, secret)) !== null;
|
||||
if (ok) return NextResponse.next();
|
||||
|
||||
if (path.startsWith("/api/")) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = "/login";
|
||||
url.searchParams.set("next", path + (req.nextUrl.search || ""));
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico|icon-).*)"],
|
||||
matcher: ["/((?!_next/static|_next/image).*)"],
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user