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.
218 lines
7.2 KiB
TypeScript
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';
|
|
}
|