Files
webmail/app/api/plugin-proxy/route.ts
T
Linus Rath 022b83c005 feat: let plugins use accounts and API keys through the server without seeing them
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
2026-10-14 04:00:36 +02:00

82 lines
3.6 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 } from '@/lib/plugin-secrets/credentials';
import { ProxyError, proxyRequest, type CaptureSpec } from '@/lib/plugin-secrets/proxy';
const PLUGIN_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
const FIELD_RE = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
function parseCapture(raw: string | null): CaptureSpec | null | undefined {
if (!raw) return undefined;
try {
const v = JSON.parse(raw) as Record<string, unknown>;
if (typeof v.secret !== 'string' || !FIELD_RE.test(v.secret)) return null;
if (v.username !== undefined && (typeof v.username !== 'string' || !FIELD_RE.test(v.username))) return null;
return { secret: v.secret, ...(typeof v.username === 'string' ? { username: v.username } : {}) };
} catch {
return null;
}
}
/**
* POST /api/plugin-proxy?plugin=<id>&credential=<id>&url=<target>
*
* Forwards a plugin's request to `url` with the named credential added. The
* target method goes in `X-Bulwark-Method` (default GET); the request body
* and headers are passed through, minus cookies, auth and transport headers.
* `X-Bulwark-Capture: {"secret":"appPassword","username":"loginName"}` stores
* those JSON fields of the response as the credential instead of returning
* them (Nextcloud Login Flow v2).
*
* Called by the host page on a plugin's behalf, never by the sandboxed
* plugin itself (its iframe has an opaque origin and is refused here).
*/
export async function POST(request: NextRequest) {
const crossOrigin = rejectCrossOriginRequest(request);
if (crossOrigin) return crossOrigin;
await configManager.ensureLoaded();
if (!configManager.getPolicy().features.pluginsEnabled) {
return NextResponse.json({ error: 'Plugins are turned off', code: 'disabled' }, { status: 403 });
}
const creds = await getStalwartCredentials(request);
if (!creds) return NextResponse.json({ error: 'Not authenticated', code: 'unauthenticated' }, { status: 401 });
const params = request.nextUrl.searchParams;
const pluginId = params.get('plugin') ?? '';
const credentialId = params.get('credential') ?? '';
const url = params.get('url') ?? '';
if (!PLUGIN_ID_RE.test(pluginId) || !CREDENTIAL_ID_RE.test(credentialId) || !url) {
return NextResponse.json({ error: 'plugin, credential and url are required', code: 'bad_request' }, { status: 400 });
}
const capture = parseCapture(request.headers.get('x-bulwark-capture'));
if (capture === null) {
return NextResponse.json({ error: 'Invalid X-Bulwark-Capture header', code: 'bad_capture' }, { status: 400 });
}
try {
return await proxyRequest({
user: { username: creds.username, serverUrl: creds.serverUrl },
pluginId,
credentialId,
url,
method: request.headers.get('x-bulwark-method') ?? 'GET',
headers: request.headers,
body: request.body,
capture,
signal: request.signal,
});
} catch (error) {
if (error instanceof ProxyError) {
const headers: Record<string, string> = { 'cache-control': 'no-store' };
const retryAfter = (error as ProxyError & { retryAfter?: number }).retryAfter;
if (retryAfter) headers['retry-after'] = String(retryAfter);
return NextResponse.json({ error: error.message, code: error.code }, { status: error.status, headers });
}
return NextResponse.json({ error: 'Internal server error', code: 'internal' }, { status: 500 });
}
}