mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
51 lines
2.1 KiB
TypeScript
51 lines
2.1 KiB
TypeScript
import { NextResponse } from 'next/server';
|
|
|
|
/**
|
|
* CSRF gate for cookie-writing and cookie-authed requests.
|
|
*
|
|
* Every identity cookie this app writes is `SameSite=Lax`, which still allows
|
|
* top-level cross-site POST navigations (e.g. a form auto-submitted by an
|
|
* attacker page the victim is tricked into visiting). A `text/plain` form
|
|
* body is accepted by `request.json()`, so without an origin check any
|
|
* website can drive the unauthenticated `/api/auth/*` routes in a victim's
|
|
* browser and fix their webmail session to attacker-chosen credentials
|
|
* (GHSA-qvr9-m8cq-7wvg), or trigger cookie-authed admin actions.
|
|
*
|
|
* Strategy: state-changing requests must come from the same origin. Modern
|
|
* browsers (since 2020) always send `Sec-Fetch-Site` and that header cannot
|
|
* be set by JS, so it is the authoritative signal. Older browsers fall back
|
|
* to `Origin`. Non-browser clients (curl, scripts, the native apps) send
|
|
* neither header and cannot ride a victim's cookie cross-origin, so the
|
|
* absence of both headers is allowed.
|
|
*/
|
|
export function isSameOriginRequest(request: Request): boolean {
|
|
const method = typeof request.method === 'string' ? request.method.toUpperCase() : '';
|
|
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') return true;
|
|
|
|
const fetchSite = request.headers.get('sec-fetch-site');
|
|
if (fetchSite !== null) {
|
|
return fetchSite === 'same-origin';
|
|
}
|
|
|
|
const origin = request.headers.get('origin');
|
|
if (!origin) return true;
|
|
|
|
try {
|
|
const originHost = new URL(origin).host;
|
|
const requestHost = request.headers.get('x-forwarded-host') ?? request.headers.get('host');
|
|
return !!requestHost && originHost === requestHost;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler-level guard for the mutating `/api/auth/*` routes. Returns a 403
|
|
* response for cross-origin requests and `null` when the handler may proceed.
|
|
* Runs before the body is read so nothing attacker-controlled is parsed.
|
|
*/
|
|
export function rejectCrossOriginRequest(request: Request): NextResponse | null {
|
|
if (isSameOriginRequest(request)) return null;
|
|
return NextResponse.json({ error: 'Cross-origin request rejected' }, { status: 403 });
|
|
}
|