fix: harden plugin config, TOTP token exchange, and branding file serving
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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`);
|
||||||
|
|||||||
Reference in New Issue
Block a user