Files
SRCmail/lib/admin/session.ts
T
Bernd Rodler 47b9ab4398 fix: Phase 1 critical+high fixes (17/18 items)
CRITICAL fixes:
- C1: Error swallowing - throw TransportError on network failure in getEmails/searchEmails
- C2: Recurrence expansion ID delimiter changed from ':' to '::occurrence::'
- C3: Cross-account calendar event UID dedup after multi-account aggregation
- C4: Admin session token revocation via JTI blacklist on logout
- C6: FTS5 schema-drop - add warning log for automatic reindex trigger
- C7: Settings lock - gate updateSetting() with isSettingLocked() check
- C8: Offline push pause - add offline event handler that closes push transports

HIGH fixes:
- H1: Push handler - add ContactCard and FileNode branches
- H2: WS fallback - await state snapshot before reconcileAfterWebSocketFallback
- H3: Auth rate limiting - add checkUserAuthRateLimit to session and token routes
- H4: OAuth logs - strip access_token from error log context
- H7: Template XSS - apply DOMPurify to HTML template body on import
- H8: Secure cookie - derive from x-forwarded-proto, not NODE_ENV
- H9: bcrypt fix - remove bcrypt prefixes from isHashed() so scrypt-only
- H13: calendarTasksEnabled - apply admin gate at runtime in calendar page
- H14: Task mutations - add try/catch error handling to update/delete/toggle
- H18: autoSelectReplyIdentity default changed from false to true

Deferred: P1.3 (C5 auth localStorage encryption) - requires custom Zustand persist adapter.
2026-08-07 12:40:32 +02:00

218 lines
7.2 KiB
TypeScript

import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
import { getSessionSecret } from '@/lib/auth/session-secret';
import { ADMIN_SESSION_COOKIE, DEFAULT_ADMIN_SESSION_TTL } from './types';
import type { AdminSessionPayload } from './types';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const TAG_LENGTH = 16;
const MIN_SECRET_LENGTH = 32;
const revokedTokens = new Map<string, number>(); // jti → expiry timestamp
function isHttpsRequest(req: { headers: Headers }): boolean {
const proto = req.headers.get('x-forwarded-proto');
return proto === 'https';
}
function getKey(): Buffer {
const secret = getSessionSecret();
if (!secret) throw new Error('SESSION_SECRET not configured');
if (secret.length < MIN_SECRET_LENGTH) {
throw new Error(
`SESSION_SECRET must be at least ${MIN_SECRET_LENGTH} characters (got ${secret.length}). ` +
`Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`
);
}
return createHash('sha256').update(secret).digest();
}
function getSessionTTL(): number {
const ttl = parseInt(process.env.ADMIN_SESSION_TTL || '', 10);
return isNaN(ttl) || ttl <= 0 ? DEFAULT_ADMIN_SESSION_TTL : ttl;
}
/**
* Create an encrypted admin session token.
*/
export function createAdminSession(): string {
const key = getKey();
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
const now = Math.floor(Date.now() / 1000);
const exp = now + getSessionTTL();
const payload: AdminSessionPayload = {
role: 'admin',
iat: now,
exp,
jti: randomBytes(16).toString('hex'),
};
const json = JSON.stringify(payload);
const encrypted = Buffer.concat([cipher.update(json, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, tag, encrypted]).toString('base64');
}
/**
* Verify and decode an admin session token. Returns null if invalid or expired.
*/
export function verifyAdminSession(token: string): AdminSessionPayload | null {
try {
const key = getKey();
const data = Buffer.from(token, 'base64');
if (data.length < IV_LENGTH + TAG_LENGTH) return null;
const iv = data.subarray(0, IV_LENGTH);
const tag = data.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
const encrypted = data.subarray(IV_LENGTH + TAG_LENGTH);
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(tag);
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
const payload = JSON.parse(decrypted.toString('utf8')) as AdminSessionPayload;
if (payload.role !== 'admin') return null;
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) return null;
// Clean up expired revocations while we're here
for (const [jti, expiry] of revokedTokens) {
if (expiry < now) revokedTokens.delete(jti);
}
if (payload.jti && revokedTokens.has(payload.jti)) return null;
return payload;
} catch {
return null;
}
}
/**
* Revoke an admin session token so it cannot be used again.
*/
export function revokeAdminSession(token: string): void {
const payload = verifyAdminSession(token);
if (payload?.jti) {
revokedTokens.set(payload.jti, payload.exp);
}
}
/**
* CSRF gate for cookie-authed admin requests.
*
* The admin session cookie is `SameSite=Lax`, which still allows top-level
* cross-site POST navigations (e.g. a form auto-submitted by an attacker
* page the admin is tricked into visiting). Without a CSRF check, any such
* page can trigger arbitrary state changes carrying the admin cookie.
*
* 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) 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 = 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;
}
}
/**
* Validate the admin session from cookies. Returns the payload or a 401 response.
*
* Also rejects cross-origin state-changing requests with 403 to prevent CSRF
* against cookie-authenticated admin actions.
*/
export async function requireAdminAuth(request: Request): Promise<{ payload: AdminSessionPayload } | { error: NextResponse }> {
if (!isSameOriginRequest(request)) {
return { error: NextResponse.json({ error: 'Cross-origin request rejected' }, { status: 403 }) };
}
const cookieStore = await cookies();
const token = cookieStore.get(ADMIN_SESSION_COOKIE)?.value;
if (!token) {
return { error: NextResponse.json({ error: 'Not authenticated' }, { status: 401 }) };
}
const payload = verifyAdminSession(token);
if (!payload) {
cookieStore.delete(ADMIN_SESSION_COOKIE);
return { error: NextResponse.json({ error: 'Session expired' }, { status: 401 }) };
}
return { payload };
}
/**
* Set the admin session cookie.
*/
export async function setAdminSessionCookie(request?: { headers: Headers }): Promise<void> {
const token = createAdminSession();
const cookieStore = await cookies();
cookieStore.set(ADMIN_SESSION_COOKIE, token, {
httpOnly: true,
secure: request ? isHttpsRequest(request) : process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: getSessionTTL(),
});
}
/**
* Clear the admin session cookie.
*/
export async function clearAdminSessionCookie(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(ADMIN_SESSION_COOKIE);
}
/**
* Get the client IP from the request headers.
*
* Proxies typically *append* to X-Forwarded-For, so the last entry
* before our trusted proxy is the most reliable client IP. When a
* single reverse proxy sits in front of the app the rightmost entry
* is the one added by that proxy. We take the rightmost entry to
* avoid trusting attacker-controlled values prepended to the header.
*
* If you run behind multiple trusted proxies, set TRUSTED_PROXY_DEPTH
* to the number of trusted proxies (default 1).
*/
export function getClientIP(request: Request): string {
const forwarded = request.headers.get('x-forwarded-for');
if (forwarded) {
const parts = forwarded.split(',').map(s => s.trim()).filter(Boolean);
const depth = Math.max(1, parseInt(process.env.TRUSTED_PROXY_DEPTH || '1', 10));
// Take the entry at position (length - depth), clamped to 0
const index = Math.max(0, parts.length - depth);
return parts[index] || '0.0.0.0';
}
return request.headers.get('x-real-ip') || '0.0.0.0';
}