mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
Plugins that talk to Nextcloud, Paperless or an AI provider used to need the password or key in their own settings, readable by the plugin and stored in the browser. A plugin can now declare credentials in its manifest and send requests "with credential X" through the server: - `credentials` in the manifest: admin scope (secret and server from the admin config) or user scope (the user connects an account; the server address is fixed or chosen by the user) - `/api/plugin-secrets`: the user's connected accounts, encrypted per mail account under `PLUGIN_SECRETS_DIR`; listing never returns secrets - `/api/plugin-proxy`: adds the secret and forwards only to the credential's own origin, through the SSRF guard unless the admin set the server up or listed it in `PLUGIN_PROXY_PRIVATE_ORIGINS`; no redirects or WebDAV moves to other servers, streamed bodies capped at 512 MB up and 2 GB down, 300 requests a minute per plugin, 8 at once per user, and one audit line per request without paths or bodies - a capture mode that stores a secret from a sign-in response (Nextcloud Login Flow v2) and removes it from what the plugin gets back
101 lines
4.7 KiB
TypeScript
101 lines
4.7 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { configManager } from '@/lib/admin/config-manager';
|
|
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
|
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
|
|
import { CREDENTIAL_ID_RE, checkInject, credentialOrigin } from '@/lib/plugin-secrets/credentials';
|
|
import {
|
|
VaultFullError,
|
|
deleteUserCredentials,
|
|
listUserCredentials,
|
|
putUserCredential,
|
|
type VaultUser,
|
|
} from '@/lib/plugin-secrets/vault';
|
|
|
|
const PLUGIN_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
|
|
const MAX_SECRET_LENGTH = 4096;
|
|
const NO_STORE = { 'cache-control': 'no-store' };
|
|
|
|
async function currentUser(request: NextRequest): Promise<VaultUser | NextResponse> {
|
|
await configManager.ensureLoaded();
|
|
if (!configManager.getPolicy().features.pluginsEnabled) {
|
|
return NextResponse.json({ error: 'Plugins are turned off' }, { status: 403 });
|
|
}
|
|
const creds = await getStalwartCredentials(request);
|
|
if (!creds) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
|
return { username: creds.username, serverUrl: creds.serverUrl };
|
|
}
|
|
|
|
/** GET /api/plugin-secrets[?plugin=<id>] - the user's connected accounts, without secrets. */
|
|
export async function GET(request: NextRequest) {
|
|
const user = await currentUser(request);
|
|
if (user instanceof NextResponse) return user;
|
|
const pluginId = request.nextUrl.searchParams.get('plugin') ?? undefined;
|
|
if (pluginId !== undefined && !PLUGIN_ID_RE.test(pluginId)) {
|
|
return NextResponse.json({ error: 'Invalid plugin id' }, { status: 400 });
|
|
}
|
|
return NextResponse.json({ credentials: await listUserCredentials(user, pluginId) }, { headers: NO_STORE });
|
|
}
|
|
|
|
/**
|
|
* POST /api/plugin-secrets - connect an account for a plugin.
|
|
* Body: { pluginId, credentialId, label, origin, inject, username?, secret? }
|
|
*
|
|
* Sent by the host's own "Connect" dialog, where the user typed the secret
|
|
* and saw which plugin and which server it is for. Without `secret` it only
|
|
* records the approved server, for a sign-in flow that fills the secret in
|
|
* later through the proxy.
|
|
*/
|
|
export async function POST(request: NextRequest) {
|
|
const crossOrigin = rejectCrossOriginRequest(request);
|
|
if (crossOrigin) return crossOrigin;
|
|
const user = await currentUser(request);
|
|
if (user instanceof NextResponse) return user;
|
|
|
|
let body: Record<string, unknown>;
|
|
try {
|
|
body = await request.json();
|
|
} catch {
|
|
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
|
}
|
|
const { pluginId, credentialId, label, username, secret } = body;
|
|
const origin = credentialOrigin(body.origin);
|
|
const inject = checkInject(body.inject);
|
|
if (typeof pluginId !== 'string' || !PLUGIN_ID_RE.test(pluginId)) return bad('Invalid plugin id');
|
|
if (typeof credentialId !== 'string' || !CREDENTIAL_ID_RE.test(credentialId)) return bad('Invalid credential id');
|
|
if (typeof label !== 'string' || !label.trim() || label.length > 80) return bad('Invalid label');
|
|
if (!origin) return bad('The server address must start with https://');
|
|
if (!inject) return bad('Invalid inject');
|
|
if (username !== undefined && (typeof username !== 'string' || username.length > 256 || /[\r\n]/.test(username))) return bad('Invalid username');
|
|
if (secret !== undefined && (typeof secret !== 'string' || !secret || secret.length > MAX_SECRET_LENGTH || /[\r\n]/.test(secret))) return bad('Invalid secret');
|
|
|
|
try {
|
|
await putUserCredential(user, pluginId, credentialId, {
|
|
origin, inject, label: label.trim(),
|
|
...(typeof username === 'string' && username ? { username } : {}),
|
|
...(typeof secret === 'string' ? { secret } : {}),
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof VaultFullError) return NextResponse.json({ error: error.message }, { status: 409 });
|
|
throw error;
|
|
}
|
|
return NextResponse.json({ ok: true }, { headers: NO_STORE });
|
|
}
|
|
|
|
/** DELETE /api/plugin-secrets?plugin=<id>[&credential=<id>] - disconnect. */
|
|
export async function DELETE(request: NextRequest) {
|
|
const crossOrigin = rejectCrossOriginRequest(request);
|
|
if (crossOrigin) return crossOrigin;
|
|
const user = await currentUser(request);
|
|
if (user instanceof NextResponse) return user;
|
|
const pluginId = request.nextUrl.searchParams.get('plugin') ?? '';
|
|
const credentialId = request.nextUrl.searchParams.get('credential') ?? undefined;
|
|
if (!PLUGIN_ID_RE.test(pluginId)) return bad('Invalid plugin id');
|
|
if (credentialId !== undefined && !CREDENTIAL_ID_RE.test(credentialId)) return bad('Invalid credential id');
|
|
await deleteUserCredentials(user, pluginId, credentialId);
|
|
return NextResponse.json({ ok: true }, { headers: NO_STORE });
|
|
}
|
|
|
|
function bad(error: string) {
|
|
return NextResponse.json({ error }, { status: 400 });
|
|
}
|