The counts were computed inline in JSX where nothing could test them. The
sidebar matched a folder by a special-use role folders do not always carry, its
badges were fetched once and then froze - new mail did not raise them and
reading a message did not lower it - and the list header printed the size of
the loaded page, capped at 50, as the size of the folder. The rules now live in
mailCounts.ts behind tests, useFolders keeps them current from the server's
push and from this tab's own changes, and each filter chip carries its number.
Inline images never left the server: RenderMessage dropped every part a body
references as cid:, so mail written by any normal client rendered with broken
icons. They now come back as bounded data URIs, refusing image/svg+xml, and the
reader resolves them, sizes its frame from the message instead of a fixed
500px, and renders on the light surface mail assumes.
Reading mail no longer asks for a second factor - the mailbox is behind the
account's own password - while the console still requires one unconditionally,
now collected by a step-up that does not ask again for the password the session
in hand just proved. An admin sign-in issues the webmail session too, so
leaving the console is a link rather than another login.
Two defects found while testing the above:
- The admin sign-in checked the password and the second factor and never the
role, so any account that had enrolled a factor from its own settings could
obtain an admin-audience session.
- Session tokens carried nothing that varied below one second, so signing in
twice within the same second minted the identical token and collided with
the unique index on web_sessions.refresh_token_hash. A correct password
answered 500, and a double-clicked sign-in was enough to do it. Tokens now
carry a jti.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
84 lines
2.9 KiB
TypeScript
84 lines
2.9 KiB
TypeScript
import { NextResponse, type NextRequest } from "next/server";
|
|
import { surfaceGateFor } from "./lib/surfaceGate";
|
|
|
|
/**
|
|
* Gate for the two web surfaces.
|
|
*
|
|
* Without this, /admin/* rendered for anyone who typed the URL: the console
|
|
* was reachable unauthenticated and only its data calls failed. The check here
|
|
* is deliberately shallow - it reads the session cookie and its audience, and
|
|
* does not verify the signature, because the signing key belongs to the auth
|
|
* service and must not be copied into the browser-facing bundle.
|
|
*
|
|
* That is sound because this is not the security boundary: every /api/v1/*
|
|
* request is verified for real by the auth service, which owns the key. This
|
|
* only stops an unauthenticated visitor from loading a shell that cannot work,
|
|
* and sends them somewhere useful instead.
|
|
*/
|
|
|
|
const USER_COOKIE = "lm_user_session";
|
|
const ADMIN_COOKIE = "lm_admin_session";
|
|
|
|
interface JwtClaims {
|
|
aud?: string;
|
|
surface?: string;
|
|
purpose?: string;
|
|
exp?: number;
|
|
}
|
|
|
|
/** Reads the claims without verifying the signature. See the note above. */
|
|
function readClaims(token: string | undefined): JwtClaims | null {
|
|
if (!token) return null;
|
|
const parts = token.split(".");
|
|
if (parts.length !== 3) return null;
|
|
try {
|
|
const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
const json = atob(payload.padEnd(payload.length + ((4 - (payload.length % 4)) % 4), "="));
|
|
return JSON.parse(json) as JwtClaims;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isUsable(claims: JwtClaims | null, expectedAud: string): boolean {
|
|
if (!claims) return false;
|
|
// A challenge token is issued between the password and the second factor;
|
|
// it must not open either surface.
|
|
if (claims.purpose !== "session") return false;
|
|
if (claims.aud !== expectedAud) return false;
|
|
if (typeof claims.exp === "number" && claims.exp * 1000 <= Date.now()) return false;
|
|
return true;
|
|
}
|
|
|
|
export function middleware(req: NextRequest) {
|
|
const { pathname, search } = req.nextUrl;
|
|
|
|
// Which session this path needs is decided by surfaceGateFor, where it is
|
|
// tested. It is not always the surface the path sits under: /admin/step-up
|
|
// exists to turn a webmail session into an admin one, so requiring the admin
|
|
// session would make it unreachable.
|
|
const surface = surfaceGateFor(pathname);
|
|
if (!surface) {
|
|
return NextResponse.next();
|
|
}
|
|
|
|
const cookieName = surface === "admin" ? ADMIN_COOKIE : USER_COOKIE;
|
|
const claims = readClaims(req.cookies.get(cookieName)?.value);
|
|
|
|
if (isUsable(claims, `lambdamail:${surface}`)) {
|
|
return NextResponse.next();
|
|
}
|
|
|
|
// The original destination travels along so the login can return there,
|
|
// rather than always dropping the user on a default page.
|
|
const login = req.nextUrl.clone();
|
|
login.pathname = `/${surface}/login`;
|
|
login.search = "";
|
|
login.searchParams.set("next", `${pathname}${search}`);
|
|
return NextResponse.redirect(login);
|
|
}
|
|
|
|
export const config = {
|
|
matcher: ["/admin/:path*", "/user/:path*"],
|
|
};
|