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:
yiekheng 2026-05-10 17:57:07 +08:00
parent 5b4787d10e
commit b77a9d106d
2 changed files with 116 additions and 12 deletions

View 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);
});
});

View File

@ -1,21 +1,41 @@
import { NextRequest, NextResponse } from "next/server"; 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; const path = req.nextUrl.pathname;
if (isPublic(path)) return NextResponse.next();
// Block all /api/* except a small set of read-only endpoints. const cookie = req.cookies.get(COOKIE_NAME)?.value;
// Mutations happen via Server Actions which post to page URLs, not /api/*. const secret = process.env.AUTH_SECRET;
const allowed = const ok =
path === "/api/events" || !!cookie && !!secret && (await verifySession(cookie, secret)) !== null;
path === "/api/health" || if (ok) return NextResponse.next();
path.startsWith("/api/qr/");
if (path.startsWith("/api/") && !allowed) { if (path.startsWith("/api/")) {
return new NextResponse("Not Found", { status: 404 }); return new NextResponse("Unauthorized", { status: 401 });
} }
const url = req.nextUrl.clone();
return NextResponse.next(); url.pathname = "/login";
url.searchParams.set("next", path + (req.nextUrl.search || ""));
return NextResponse.redirect(url);
} }
export const config = { export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|icon-).*)"], matcher: ["/((?!_next/static|_next/image).*)"],
}; };