fix: harden plugin config, TOTP token exchange, and branding file serving

This commit is contained in:
Linus Rath
2026-04-28 01:44:37 +02:00
parent 54af07f2af
commit 90acf181f3
6 changed files with 86 additions and 18 deletions
+2
View File
@@ -14,6 +14,7 @@ import {
KeyRound, KeyRound,
Puzzle, Puzzle,
SwatchBook, SwatchBook,
Activity,
Mail, Mail,
Calendar, Calendar,
BookUser, BookUser,
@@ -56,6 +57,7 @@ const NAV_GROUPS = [
{ {
label: 'System', label: 'System',
items: [ items: [
{ href: '/admin/telemetry', label: 'Telemetry', icon: Activity },
{ href: '/admin/logs', label: 'Audit Log', icon: ScrollText }, { href: '/admin/logs', label: 'Audit Log', icon: ScrollText },
], ],
}, },
@@ -53,11 +53,19 @@ export async function GET(
const buffer = await readFile(resolved); const buffer = await readFile(resolved);
// SVG can carry inline <script> and event handlers that execute when the
// file is fetched as a top-level document. Defense in depth on top of
// admin-only upload: nosniff blocks MIME confusion, the CSP forces a
// sandboxed unique origin so any script in an SVG is inert and cannot
// touch app cookies or storage.
return new NextResponse(buffer, { return new NextResponse(buffer, {
headers: { headers: {
'Content-Type': contentType, 'Content-Type': contentType,
'Cache-Control': 'public, max-age=3600, must-revalidate', 'Cache-Control': 'public, max-age=3600, must-revalidate',
'Content-Length': String(buffer.length), 'Content-Length': String(buffer.length),
'X-Content-Type-Options': 'nosniff',
'Content-Security-Policy':
"default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox",
}, },
}); });
} catch { } catch {
+31 -5
View File
@@ -2,15 +2,20 @@ import { NextRequest, NextResponse } from 'next/server';
import { getPlugin } from '@/lib/admin/plugin-registry'; import { getPlugin } from '@/lib/admin/plugin-registry';
import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config'; import { getPluginConfig, setPluginConfig, deletePluginConfigKey } from '@/lib/admin/plugin-config';
import { requireAdminAuth } from '@/lib/admin/session'; import { requireAdminAuth } from '@/lib/admin/session';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
/** /**
* GET /api/admin/plugins/[id]/config - Read all config for a plugin * GET /api/admin/plugins/[id]/config - Read plugin config
* *
* Returns the full config object for admin-configured plugin settings. * - Admin sessions receive every field, including those declared
* This endpoint is accessible from the client-side plugin API. * `type: 'secret'` in the plugin's configSchema.
* - Authenticated mailbox users (the plugin running in their browser)
* receive only non-secret fields.
* - Anonymous callers are rejected so unauthenticated visitors cannot
* enumerate plugin secrets.
*/ */
export async function GET( export async function GET(
_request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> }, { params }: { params: Promise<{ id: string }> },
) { ) {
try { try {
@@ -20,13 +25,34 @@ export async function GET(
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 }); return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
} }
const adminAuth = await requireAdminAuth();
const isAdmin = !('error' in adminAuth);
if (!isAdmin) {
const creds = await getStalwartCredentials(request);
if (!creds) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
}
const plugin = await getPlugin(id); const plugin = await getPlugin(id);
if (!plugin) { if (!plugin) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 }); return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
} }
const config = await getPluginConfig(id); const config = await getPluginConfig(id);
return NextResponse.json(config, {
let response: Record<string, unknown> = config;
if (!isAdmin && plugin.configSchema) {
response = {};
for (const [key, value] of Object.entries(config)) {
const field = plugin.configSchema[key];
if (field?.type === 'secret') continue;
response[key] = value;
}
}
return NextResponse.json(response, {
headers: { 'Cache-Control': 'no-store' }, headers: { 'Cache-Control': 'no-store' },
}); });
} catch { } catch {
+29 -11
View File
@@ -6,6 +6,7 @@ import { refreshTokenCookieName } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { readFileEnv } from '@/lib/read-file-env'; import { readFileEnv } from '@/lib/read-file-env';
import { configManager } from '@/lib/admin/config-manager'; import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
/** /**
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens. * Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
@@ -84,19 +85,36 @@ export async function POST(request: NextRequest) {
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0; const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0;
// Use the server-side JMAP_SERVER_URL if set (may differ from the // Pin the upstream URL to the configured JMAP server so an unauthenticated
// public URL the browser uses, e.g. inside Docker). // caller cannot point this route at internal hosts. Only when no server
const internalServerUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || serverUrl; // URL is configured (and the deployment explicitly allows custom JMAP
// endpoints) do we fall back to the user-supplied URL — and even then
// it must resolve to a public address.
await configManager.ensureLoaded();
const configuredServerUrl =
configManager.get<string>('jmapServerUrl', '') ||
process.env.JMAP_SERVER_URL ||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
'';
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
const tokenEndpoint = await findTokenEndpoint(internalServerUrl); let upstreamUrl: string;
if (!tokenEndpoint) { if (configuredServerUrl) {
// Also try with the client-provided URL in case the internal one differs upstreamUrl = configuredServerUrl;
const clientEndpoint = internalServerUrl !== serverUrl ? await findTokenEndpoint(serverUrl) : null; } else if (allowCustomEndpoint) {
if (!clientEndpoint) { if (!(await isPublicHttpUrl(serverUrl))) {
logger.warn('TOTP token exchange: no token endpoint found', { serverUrl, internalServerUrl }); logger.warn('TOTP token exchange: rejected non-public server URL');
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 }); return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 });
} }
return await attemptAllStrategies(clientEndpoint, username, password, slot); upstreamUrl = serverUrl;
} else {
return NextResponse.json({ error: 'jmap_server_not_configured' }, { status: 500 });
}
const tokenEndpoint = await findTokenEndpoint(upstreamUrl);
if (!tokenEndpoint) {
logger.warn('TOTP token exchange: no token endpoint found');
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
} }
return await attemptAllStrategies(tokenEndpoint, username, password, slot); return await attemptAllStrategies(tokenEndpoint, username, password, slot);
+2 -1
View File
@@ -52,7 +52,8 @@ configManager.load()
console.info("Admin dashboard initialized"); console.info("Admin dashboard initialized");
}) })
.then(async () => { .then(async () => {
// Anonymous telemetry - opt-in, off until admin consents. // Anonymous telemetry - on by default. Admins can disable via the
// admin UI, the BULWARK_TELEMETRY env var, or by clearing the endpoint.
// See https://bulwarkmail.org/docs/legal/privacy/telemetry // See https://bulwarkmail.org/docs/legal/privacy/telemetry
const { startScheduler, markProcessStart } = await import("./lib/telemetry"); const { startScheduler, markProcessStart } = await import("./lib/telemetry");
markProcessStart(); markProcessStart();
+14 -1
View File
@@ -7,8 +7,21 @@ export interface OAuthMetadata {
} }
const CACHE_TTL_MS = 10 * 60 * 1000; const CACHE_TTL_MS = 10 * 60 * 1000;
const CACHE_MAX_ENTRIES = 64;
const metadataCache = new Map<string, { metadata: OAuthMetadata; expiresAt: number }>(); const metadataCache = new Map<string, { metadata: OAuthMetadata; expiresAt: number }>();
function rememberMetadata(serverUrl: string, metadata: OAuthMetadata): void {
// Bound the cache so callers that can supply arbitrary serverUrl values
// (e.g. unauthenticated routes that fall back to user input) cannot
// exhaust memory. Map preserves insertion order, so the oldest entry is
// always the first one yielded by keys().
if (metadataCache.size >= CACHE_MAX_ENTRIES) {
const oldest = metadataCache.keys().next().value;
if (oldest !== undefined) metadataCache.delete(oldest);
}
metadataCache.set(serverUrl, { metadata, expiresAt: Date.now() + CACHE_TTL_MS });
}
export async function discoverOAuth(serverUrl: string): Promise<OAuthMetadata | null> { export async function discoverOAuth(serverUrl: string): Promise<OAuthMetadata | null> {
const cached = metadataCache.get(serverUrl); const cached = metadataCache.get(serverUrl);
if (cached && cached.expiresAt > Date.now()) return cached.metadata; if (cached && cached.expiresAt > Date.now()) return cached.metadata;
@@ -38,7 +51,7 @@ export async function discoverOAuth(serverUrl: string): Promise<OAuthMetadata |
revocation_endpoint: data.revocation_endpoint, revocation_endpoint: data.revocation_endpoint,
end_session_endpoint: data.end_session_endpoint, end_session_endpoint: data.end_session_endpoint,
}; };
metadataCache.set(serverUrl, { metadata, expiresAt: Date.now() + CACHE_TTL_MS }); rememberMetadata(serverUrl, metadata);
return metadata; return metadata;
} }
errors.push(`${url} response missing required endpoints`); errors.push(`${url} response missing required endpoints`);