From b77a9d106db82c6b7ab2b2ef424d245f3818a45e Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 17:57:07 +0800 Subject: [PATCH] feat(web): middleware gates non-allowlisted paths on session cookie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/web/src/middleware.test.ts | 84 +++++++++++++++++++++++++++++++++ apps/web/src/middleware.ts | 44 ++++++++++++----- 2 files changed, 116 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/middleware.test.ts diff --git a/apps/web/src/middleware.test.ts b/apps/web/src/middleware.test.ts new file mode 100644 index 0000000..5f2ad9e --- /dev/null +++ b/apps/web/src/middleware.test.ts @@ -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 { + 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 { + 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/ 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); + }); +}); diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index b30af82..21360a3 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -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([ + "/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 { 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).*)"], };