Files
webmail/app/api/settings/route.ts
T

259 lines
10 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { decryptSession } from '@/lib/auth/crypto';
import { sessionCookieName } from '@/lib/auth/session-cookie';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync';
import { configManager } from '@/lib/admin/config-manager';
import { resolvePolicy } from '@/lib/admin/policy-scopes';
import { hasSessionSecret } from '@/lib/auth/session-secret';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { mergeStoredTemplates } from '@/lib/template-merge';
import { mergeStoredSubscriptions } from '@/lib/calendar-subscription-sync';
/** Largest settings blob a client may store (templates and signatures included). */
const SETTINGS_MAX_BYTES = 2 * 1024 * 1024;
function classifyError(error: unknown): { message: string; status: number } {
const code = (error as NodeJS.ErrnoException).code;
const msg = error instanceof Error ? error.message : 'Unknown error';
switch (code) {
case 'EACCES':
case 'EPERM':
return {
message: 'Write permission denied on settings data directory. Check filesystem permissions for the SETTINGS_DATA_DIR (or data/settings/).',
status: 500,
};
case 'EROFS':
return {
message: 'Filesystem is read-only. Settings cannot be saved. Ensure the data directory is on a writable volume.',
status: 500,
};
case 'ENOSPC':
return {
message: 'No disk space available to save settings.',
status: 507,
};
case 'ENOENT':
return {
message: 'Settings data directory does not exist and could not be created. Check SETTINGS_DATA_DIR configuration.',
status: 500,
};
default:
if (msg.includes('SESSION_SECRET')) {
return {
message: 'Server configuration error: SESSION_SECRET is not set.',
status: 500,
};
}
return {
message: `Internal server error: ${msg}`,
status: 500,
};
}
}
function isEnabled(): boolean {
const flagOn =
process.env.SETTINGS_SYNC_ENABLED === 'true' ||
configManager.get<boolean>('settingsSyncEnabled', false);
return flagOn && hasSessionSecret();
}
/** Strip trailing slashes so differently-formatted URLs still match. */
function normalizeUrl(url: string): string {
return url.replace(/\/+$/, '');
}
/**
* The server address settings are stored under. Identity matching ignores
* trailing slashes, so every extra one would key another blob of up to
* SETTINGS_MAX_BYTES on disk. Clients send the address with or without a
* single slash, and both keep the key they have always had.
*/
function storageServerUrl(url: string): string {
return url.replace(/\/{2,}$/, '/');
}
/**
* Verify identity against session cookies across all account slots.
* With multi-account, the requesting account may be on any slot.
* Checks both basic-auth session cookies and stalwart auth context cookies
* (used by OAuth/SSO and TOTP-upgraded sessions).
* Returns the account name to key the settings on, or null when no cookie
* matches. That is the cookie's `accountName` when it has one: a Bearer
* login claimed with a bare local part must not share `john`'s settings
* with every other `john` on a multi-domain server.
*/
async function verifyIdentity(username: string, serverUrl: string): Promise<string | null> {
const cookieStore = await cookies();
const normalizedServerUrl = normalizeUrl(serverUrl);
for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) {
// The context cookie first: it is the one that knows the account name,
// and every sign-in path that writes the session cookie writes it too.
const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
if (ctx && ctx.username === username && normalizeUrl(ctx.serverUrl) === normalizedServerUrl) {
return ctx.accountName ?? username;
}
// Remember-me session cookie without a context (older sign-ins). A
// password login proves exactly the claimed name; a token login may have
// claimed a bare local part that only the context could resolve, so it
// does not key anything on its own.
const token = cookieStore.get(sessionCookieName(slot))?.value;
if (token) {
const session = decryptSession(token);
if (session && session.username === username && normalizeUrl(session.serverUrl) === normalizedServerUrl) {
if ('token' in session && session.token && !username.includes('@')) continue;
return username;
}
}
}
// No matching session found (or no cookies at all) → reject
return null;
}
export async function GET(request: NextRequest) {
if (!isEnabled()) {
return NextResponse.json({ error: 'Settings sync is disabled' }, { status: 404 });
}
const username = request.headers.get('x-settings-username');
const serverUrl = request.headers.get('x-settings-server');
if (!username || !serverUrl) {
return NextResponse.json({ error: 'Missing identity headers' }, { status: 400 });
}
const accountName = await verifyIdentity(username, serverUrl);
if (!accountName) {
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
}
try {
const settings = await loadUserSettings(accountName, storageServerUrl(serverUrl));
return NextResponse.json({ settings: settings || null });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
const code = (error as NodeJS.ErrnoException).code;
logger.error('Settings load error', { error: message, code });
const classified = classifyError(error);
return NextResponse.json({ error: classified.message }, { status: classified.status });
}
}
export async function POST(request: NextRequest) {
// CSRF gate (GHSA-9mvj-98f5-9q6g): this handler acts with the caller's
// session cookie, which SameSite=Lax still sends from a same-site page.
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
if (!isEnabled()) {
return NextResponse.json({ error: 'Settings sync is disabled' }, { status: 404 });
}
try {
// One settings blob per account, read back on every sync: bound it
// before parsing, the way the account archive is.
if (Number(request.headers.get('content-length') ?? 0) > SETTINGS_MAX_BYTES) {
return NextResponse.json({ error: 'Settings are too large' }, { status: 413 });
}
const text = await request.text();
if (text.length > SETTINGS_MAX_BYTES) {
return NextResponse.json({ error: 'Settings are too large' }, { status: 413 });
}
const { username, serverUrl, settings } = JSON.parse(text);
if (!username || !serverUrl || !settings) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
if (typeof settings !== 'object' || settings === null || Array.isArray(settings)) {
return NextResponse.json({ error: 'Settings must be an object' }, { status: 400 });
}
const accountName = await verifyIdentity(username, serverUrl);
if (!accountName) {
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
}
// Enforce admin policy - strip locked settings so users can't override them
await configManager.ensureLoaded();
// With the account's domain scopes applied; group scopes need the
// account's session, which only the client has, so the client enforces those.
const policy = resolvePolicy(configManager.getPolicy(), { email: accountName, groups: [] }).policy;
const filteredSettings = { ...settings };
for (const key of Object.keys(filteredSettings)) {
const restriction = policy.restrictions[key];
if (!restriction) continue;
if (restriction.locked) {
delete filteredSettings[key];
continue;
}
const value = filteredSettings[key];
if (restriction.allowedValues && restriction.allowedValues.length > 0) {
if (!restriction.allowedValues.includes(value)) {
delete filteredSettings[key];
}
}
if (typeof value === 'number') {
if (restriction.min !== undefined && value < restriction.min) {
delete filteredSettings[key];
}
if (restriction.max !== undefined && value > restriction.max) {
delete filteredSettings[key];
}
}
}
// Templates and iCal subscriptions are merged into the stored blob rather
// than replaced, so a stale tab or device cannot drop ones another added.
const stored = await loadUserSettings(accountName, storageServerUrl(serverUrl));
await saveUserSettings(
accountName,
storageServerUrl(serverUrl),
mergeStoredSubscriptions(stored, mergeStoredTemplates(stored, filteredSettings)),
);
return NextResponse.json({ ok: true });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
const code = (error as NodeJS.ErrnoException).code;
logger.error('Settings save error', { error: message, code });
const classified = classifyError(error);
return NextResponse.json({ error: classified.message }, { status: classified.status });
}
}
export async function DELETE(request: NextRequest) {
// CSRF gate (GHSA-9mvj-98f5-9q6g): this handler acts with the caller's
// session cookie, which SameSite=Lax still sends from a same-site page.
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
if (!isEnabled()) {
return NextResponse.json({ error: 'Settings sync is disabled' }, { status: 404 });
}
try {
const { username, serverUrl } = await request.json();
if (!username || !serverUrl) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
const accountName = await verifyIdentity(username, serverUrl);
if (!accountName) {
return NextResponse.json({ error: 'Identity mismatch' }, { status: 403 });
}
await deleteUserSettings(accountName, storageServerUrl(serverUrl));
return NextResponse.json({ ok: true });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
const code = (error as NodeJS.ErrnoException).code;
logger.error('Settings delete error', { error: message, code });
const classified = classifyError(error);
return NextResponse.json({ error: classified.message }, { status: classified.status });
}
}