Files
pn-new-crm/src/middleware.ts
Matt 4b9743a594 audit: 33-agent comprehensive audit + critical fixes
Full team audit run, all reports verbatim in docs/AUDIT-2026-05-12.md
(5900+ lines, 30+ critical findings). Already-fixed this commit:
- permission-overrides PUT: self-target block + RolePermissions allow-list + cross-tenant guard
- /api/auth/resolve-identifier: rate-limit + synthetic miss-email kill enumeration
- admin email-change: rotates account.accountId + revokes sessions
- middleware: token-gated email confirm/cancel routes whitelisted
- NAV_CATALOG: 10 dead-link sweeps to existing /admin/<x> targets

Feature work landing same commit: optional username sign-in
(migration 0054), per-user permission overrides (0055) with three-state
matrix tabbed inside UserForm, user disable button, role + outcome +
stage label normalisation across the platform, admin email-change
with auto-notification template.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:52:35 +02:00

134 lines
4.7 KiB
TypeScript

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
/**
* Paths that do not require an authenticated session.
* Checked with startsWith, so /auth/ covers /auth/callback etc.
*/
const PUBLIC_PATHS: string[] = [
'/login',
'/reset-password',
'/set-password',
'/auth/',
'/api/auth/',
'/api/public/',
'/api/health',
'/api/webhooks/',
'/scan',
'/portal/',
'/api/portal/',
// Token-gated email-change endpoints. The confirm/cancel links land in
// a fresh browser (the user may not be signed in on this device), so
// they need to bypass the session 401 gate. The endpoints validate a
// signed sha256-hashed token instead — that's the auth.
'/api/v1/me/email/confirm/',
'/api/v1/me/email/cancel/',
];
function isPublicPath(pathname: string): boolean {
// Per-port PWA manifests sit under `/<portSlug>/scan/manifest.webmanifest`
// and need to be fetchable without a session - browsers fetch them eagerly
// during install / first paint. The manifest only contains port name +
// icon paths, no sensitive data, so making it public is safe.
if (pathname.endsWith('/scan/manifest.webmanifest')) return true;
return PUBLIC_PATHS.some((prefix) => pathname === prefix || pathname.startsWith(prefix));
}
function isApiRoute(pathname: string): boolean {
return pathname.startsWith('/api/');
}
const STATE_CHANGING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
/**
* SameSite=Lax cookies block top-level cross-site POSTs in modern browsers,
* but defense-in-depth: every state-changing request to a session-authed
* `/api/v1/**` endpoint must originate from the same origin as the app.
* Webhooks (`/api/webhooks/**`) and public posts (`/api/public/**`) are
* exempt because they're called by external systems with no session
* cookie. Auth flows (`/api/auth/**`) and portal (`/api/portal/**`) handle
* their own origin/CSRF checks via better-auth.
*/
function isOriginCheckedPath(pathname: string): boolean {
if (!pathname.startsWith('/api/v1/')) return false;
return true;
}
function originAllowed(request: NextRequest): boolean {
const origin = request.headers.get('origin');
const referer = request.headers.get('referer');
// Same-origin fetch from the app sends both Origin AND a matching host.
// Use request.nextUrl.origin (the deployed origin) as the source of truth.
const expectedOrigin = request.nextUrl.origin;
if (origin) return origin === expectedOrigin;
if (referer) {
try {
return new URL(referer).origin === expectedOrigin;
} catch {
return false;
}
}
// Neither header present: most browser fetches always send Origin on
// POST/PUT/PATCH/DELETE, so this likely means a same-origin server-side
// call (e.g. Next.js internal fetch). Allow.
return true;
}
export function middleware(request: NextRequest): NextResponse {
const { pathname } = request.nextUrl;
// CSRF defense-in-depth: state-changing requests to authed /api/v1
// endpoints must come from the app's own origin. Skipped in dev so
// LAN testing (e.g. real iPhone hitting the Mac via 192.168.x.x while
// a Mac browser tab is loaded from localhost) doesn't trip on the
// origin mismatch. Production keeps the check.
if (
process.env.NODE_ENV !== 'development' &&
STATE_CHANGING_METHODS.has(request.method) &&
isOriginCheckedPath(pathname) &&
!originAllowed(request)
) {
return NextResponse.json(
{ error: 'Cross-origin state-changing request rejected' },
{ status: 403 },
);
}
// Always allow public paths through
if (isPublicPath(pathname)) {
return NextResponse.next();
}
const sessionToken = request.cookies.get('pn-crm.session_token');
if (!sessionToken?.value) {
if (isApiRoute(pathname)) {
// API routes return 401 JSON - never redirect
return NextResponse.json({ error: 'Authentication required' }, { status: 401 });
}
// Page routes redirect to /login, preserving the intended destination
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('redirect', pathname + request.nextUrl.search);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: [
/*
* Match all request paths except:
* - _next/static (static files)
* - _next/image (Next.js image optimisation)
* - favicon.ico (browser tab icon)
* - /images/ (public image assets)
* - manifest.json (PWA manifest — must be unauthed for installability)
* - icon-*.png (PWA + apple-touch icons referenced by manifest)
* - apple-touch-icon (iOS home-screen icon)
*/
'/((?!_next/static|_next/image|favicon\\.ico|images/|manifest\\.json|icon-|apple-touch-icon).*)',
],
};