mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
Three files carried a literal NUL byte inside a string: the admin check's cache key in `app/api/admin/auth/route.ts`, the global search hit key in `lib/global-search/types.ts` and two TNEF fixtures in `lib/__tests__/tnef.test.ts`. Git treats any file containing a NUL as binary, so it showed no diffs for them, never normalised their line endings, and reported every pull request that touched the admin route as a conflict even when a text merge was clean. The escape produces the same string at runtime. The admin route was stored with CRLF while git saw it as binary; it is now stored with LF like the rest of the tree, so its diff covers the whole file (the only content change is on line 70).
277 lines
11 KiB
TypeScript
277 lines
11 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { createHash } from 'crypto';
|
|
import { initAdminPassword, verifyAdminPassword, updateLastLogin, isAdminEnabled, getAdminMeta } from '@/lib/admin/password';
|
|
import { setAdminSessionCookie, clearAdminSessionCookie, requireAdminAuth, getClientIP, isSameOriginRequest } from '@/lib/admin/session';
|
|
import { checkRateLimit } from '@/lib/admin/rate-limit';
|
|
import { auditLog } from '@/lib/admin/audit';
|
|
import { logger } from '@/lib/logger';
|
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
|
import { configManager } from '@/lib/admin/config-manager';
|
|
import { getStalwartAdminAccessMode, type StalwartAdminAccessMode } from '@/lib/admin/stalwart-admin-access';
|
|
|
|
/**
|
|
* Check if the current user is a Stalwart admin by attempting an
|
|
* admin-only JMAP method call. Stalwart v0.16 removed the REST management
|
|
* API (including the /api/account permissions endpoint) and exposes all
|
|
* administration through JMAP under the `urn:stalwart:jmap` capability.
|
|
*
|
|
* `x:Tenant/query` requires `sysTenantQuery`, which Stalwart's default roles
|
|
* grant to the server-wide superuser only. The dashboard configures the
|
|
* whole instance, and Bulwark has no notion of tenants, so an admin of one
|
|
* tenant must not pass: `x:Account/query` (the probe this replaced) also
|
|
* answers for tenant admins, whose role carries `sysAccount*`
|
|
* (GHSA-v6hr). A result means superuser; a method error (typically
|
|
* `forbidden`) means anyone else.
|
|
*
|
|
* Results are cached briefly per (server, user) — the admin panel's
|
|
* layout and dashboard page between them trigger this three times on
|
|
* every navigation, and admin status does not change on that timescale.
|
|
*/
|
|
const ADMIN_CHECK_CACHE_MS = 60_000;
|
|
const ADMIN_CHECK_TIMEOUT_MS = 10_000;
|
|
const adminCheckCache = new Map<string, { value: boolean; expires: number }>();
|
|
|
|
async function fetchWithTimeout(url: string, init: Parameters<typeof fetch>[1]): Promise<Response> {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), ADMIN_CHECK_TIMEOUT_MS);
|
|
try {
|
|
return await fetch(url, { ...init, signal: controller.signal });
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
async function checkStalwartAdmin(request: NextRequest, mode: StalwartAdminAccessMode): Promise<boolean> {
|
|
// Operators who keep Stalwart and Bulwark administration separate (#870)
|
|
// turn the check off entirely: no shield, no auto-login, and nothing
|
|
// about the Stalwart account influences the Bulwark admin dashboard.
|
|
if (mode === 'off') return false;
|
|
|
|
// When users may connect to arbitrary JMAP servers, the cookie-stored
|
|
// serverUrl is attacker-controllable — trusting an admin-status response
|
|
// from such a server would let anyone mint an admin session by standing
|
|
// up a JMAP-shaped endpoint. Require the password login in that mode.
|
|
await configManager.ensureLoaded();
|
|
if (configManager.get<boolean>('allowCustomJmapEndpoint', false)) return false;
|
|
|
|
const creds = await getStalwartCredentials(request);
|
|
if (!creds) return false;
|
|
// The switch above only reflects today's config. A context cookie minted
|
|
// while custom endpoints were on keeps naming the server it was minted
|
|
// for, so ask only a server the admin configured.
|
|
if (!creds.trusted) return false;
|
|
|
|
// Key on a hash of the actual authHeader, not the cookie-claimed username.
|
|
// The username field is caller-supplied in /api/auth/stalwart-context and
|
|
// can be spoofed independently of the credential; keying on it would let
|
|
// any user ride an admin's recent cache entry by sending a cookie with
|
|
// username="admin@host" plus their own authHeader.
|
|
const credHash = createHash('sha256').update(creds.authHeader).digest('base64url');
|
|
const cacheKey = `${creds.serverUrl}\0${credHash}`;
|
|
const cached = adminCheckCache.get(cacheKey);
|
|
if (cached && cached.expires > Date.now()) return cached.value;
|
|
|
|
const isAdmin = await runStalwartAdminCheck(creds);
|
|
adminCheckCache.set(cacheKey, { value: isAdmin, expires: Date.now() + ADMIN_CHECK_CACHE_MS });
|
|
return isAdmin;
|
|
}
|
|
|
|
async function runStalwartAdminCheck(
|
|
creds: { serverUrl: string; authHeader: string; username: string },
|
|
): Promise<boolean> {
|
|
try {
|
|
const sessionRes = await fetchWithTimeout(`${creds.serverUrl}/.well-known/jmap`, {
|
|
method: 'GET',
|
|
headers: { 'Authorization': creds.authHeader },
|
|
});
|
|
if (!sessionRes.ok) {
|
|
logger.info('Stalwart admin check (session)', { username: creds.username, status: sessionRes.status, isAdmin: false });
|
|
return false;
|
|
}
|
|
|
|
const session = await sessionRes.json() as {
|
|
primaryAccounts?: Record<string, string>;
|
|
};
|
|
const accountId = session.primaryAccounts?.['urn:stalwart:jmap']
|
|
?? session.primaryAccounts?.['urn:ietf:params:jmap:mail']
|
|
?? (session.primaryAccounts ? Object.values(session.primaryAccounts)[0] : undefined);
|
|
if (!accountId) {
|
|
logger.info('Stalwart admin check (no account)', { username: creds.username, isAdmin: false });
|
|
return false;
|
|
}
|
|
|
|
// Hit the externally-reachable URL, not session.apiUrl — Stalwart
|
|
// advertises its internal bind address (e.g. http://host:8080/jmap/)
|
|
// which may not be routable from this process.
|
|
const jmapRes = await fetchWithTimeout(`${creds.serverUrl}/jmap/`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': creds.authHeader,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
|
|
methodCalls: [['x:Tenant/query', { accountId, limit: 1 }, '0']],
|
|
}),
|
|
});
|
|
|
|
if (!jmapRes.ok) {
|
|
logger.info('Stalwart admin check (jmap)', { username: creds.username, status: jmapRes.status, isAdmin: false });
|
|
return false;
|
|
}
|
|
|
|
const data = await jmapRes.json() as {
|
|
methodResponses?: Array<[string, Record<string, unknown>, string]>;
|
|
};
|
|
const first = data.methodResponses?.[0];
|
|
const isAdmin = !!first && first[0] === 'x:Tenant/query';
|
|
logger.info('Stalwart admin check', { username: creds.username, isAdmin });
|
|
return isAdmin;
|
|
} catch (error) {
|
|
logger.debug('Stalwart admin check error', { error: error instanceof Error ? error.message : 'Unknown' });
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* POST /api/admin/auth - Login
|
|
*/
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
if (!isSameOriginRequest(request)) {
|
|
return NextResponse.json({ error: 'Cross-origin request rejected' }, { status: 403 });
|
|
}
|
|
const ip = getClientIP(request);
|
|
const body = await request.json();
|
|
|
|
// Stalwart-based admin authentication. Only honoured in "auto" mode:
|
|
// "password" keeps the shield but routes through the password form,
|
|
// "off" ignores Stalwart admin status altogether (#870).
|
|
if (body.stalwartAuth === true) {
|
|
const mode = await getStalwartAdminAccessMode();
|
|
if (mode !== 'auto') {
|
|
await auditLog('admin.login_failed', { method: 'stalwart', reason: 'auto_login_disabled', mode }, ip);
|
|
return NextResponse.json({ error: 'Stalwart admin sign-in is disabled; use the admin password' }, { status: 403 });
|
|
}
|
|
const isStalwartAdmin = await checkStalwartAdmin(request, mode);
|
|
if (!isStalwartAdmin) {
|
|
await auditLog('admin.login_failed', { method: 'stalwart' }, ip);
|
|
return NextResponse.json({ error: 'Not a Stalwart admin' }, { status: 403 });
|
|
}
|
|
|
|
await setAdminSessionCookie(request);
|
|
await auditLog('admin.login', { method: 'stalwart' }, ip);
|
|
return NextResponse.json({ ok: true });
|
|
}
|
|
|
|
// Password-based admin authentication
|
|
await initAdminPassword();
|
|
if (!isAdminEnabled()) {
|
|
return NextResponse.json({ error: 'Admin dashboard is not configured' }, { status: 404 });
|
|
}
|
|
|
|
// Rate limit check
|
|
const limit = checkRateLimit(ip);
|
|
if (!limit.allowed) {
|
|
const retryAfter = Math.ceil(limit.retryAfterMs / 1000);
|
|
await auditLog('admin.login_blocked', { reason: 'rate_limit' }, ip);
|
|
return NextResponse.json(
|
|
{ error: 'Too many login attempts. Try again later.' },
|
|
{ status: 429, headers: { 'Retry-After': String(retryAfter) } }
|
|
);
|
|
}
|
|
|
|
const { password } = body;
|
|
|
|
if (!password || typeof password !== 'string') {
|
|
return NextResponse.json({ error: 'Password is required' }, { status: 400 });
|
|
}
|
|
|
|
const valid = await verifyAdminPassword(password);
|
|
if (!valid) {
|
|
await auditLog('admin.login_failed', {}, ip);
|
|
logger.warn('Admin login failed', { ip });
|
|
return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
|
|
}
|
|
|
|
await setAdminSessionCookie(request);
|
|
await updateLastLogin();
|
|
await auditLog('admin.login', {}, ip);
|
|
|
|
return NextResponse.json({ ok: true });
|
|
} catch (error) {
|
|
logger.error('Admin login error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GET /api/admin/auth - Check session status
|
|
*
|
|
* `stalwartAdmin` tells the client whether to show the admin shield;
|
|
* `stalwartAutoLogin` whether clicking it may mint an admin session without
|
|
* the password (only in "auto" mode). In "auto" mode a Stalwart admin keeps
|
|
* the panel reachable even without a configured admin password.
|
|
*/
|
|
export async function GET(request: NextRequest) {
|
|
try {
|
|
await initAdminPassword();
|
|
const adminEnabled = isAdminEnabled();
|
|
const mode = await getStalwartAdminAccessMode();
|
|
const isStalwartAdmin = await checkStalwartAdmin(request, mode);
|
|
const stalwartAutoLogin = isStalwartAdmin && mode === 'auto';
|
|
|
|
// If neither password-based admin nor Stalwart auto-login, admin is disabled
|
|
if (!adminEnabled && !stalwartAutoLogin) {
|
|
return NextResponse.json({ enabled: false, authenticated: false, stalwartAdmin: false, stalwartAutoLogin: false }, {
|
|
headers: { 'Cache-Control': 'no-store' },
|
|
});
|
|
}
|
|
|
|
const result = await requireAdminAuth(request);
|
|
if ('error' in result) {
|
|
return NextResponse.json({
|
|
enabled: adminEnabled,
|
|
authenticated: false,
|
|
stalwartAdmin: isStalwartAdmin,
|
|
stalwartAutoLogin,
|
|
}, {
|
|
headers: { 'Cache-Control': 'no-store' },
|
|
});
|
|
}
|
|
|
|
const meta = getAdminMeta();
|
|
return NextResponse.json({
|
|
enabled: adminEnabled,
|
|
authenticated: true,
|
|
stalwartAdmin: isStalwartAdmin,
|
|
stalwartAutoLogin,
|
|
lastLogin: meta?.lastLogin,
|
|
passwordChangedAt: meta?.passwordChangedAt,
|
|
}, {
|
|
headers: { 'Cache-Control': 'no-store' },
|
|
});
|
|
} catch (error) {
|
|
logger.error('Admin status error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* DELETE /api/admin/auth - Logout
|
|
*/
|
|
export async function DELETE(request: NextRequest) {
|
|
try {
|
|
if (!isSameOriginRequest(request)) {
|
|
return NextResponse.json({ error: 'Cross-origin request rejected' }, { status: 403 });
|
|
}
|
|
const ip = getClientIP(request);
|
|
await clearAdminSessionCookie();
|
|
await auditLog('admin.logout', {}, ip);
|
|
return NextResponse.json({ ok: true });
|
|
} catch (error) {
|
|
logger.error('Admin logout error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
}
|
|
}
|