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 { 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).*)"],
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user