Files
webmail/lib/plugin-secrets/proxy.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

368 lines
15 KiB
TypeScript

// Server side of the plugin secret proxy. A plugin asks the host to send a
// request "with credential X"; the browser posts it to /api/plugin-proxy and
// this module adds the secret and forwards it, but only to the credential's
// own origin. Bodies stream in both directions (uploads, downloads, AI
// responses), with size caps, a per-user rate limit and an audit line per
// request that names no paths, headers or bodies.
import { getPlugin } from '@/lib/admin/plugin-registry';
import { getPluginConfig } from '@/lib/admin/plugin-config';
import { logger } from '@/lib/logger';
import { DisallowedUrlError, fetchPublicUrl } from '@/lib/security/url-guard';
import { authHeaderFor, credentialOrigin, privateProxyOrigins, type CredentialInject, type CredentialSpec } from './credentials';
import { getUserCredential, setUserCredentialSecret, userKey, type VaultUser } from './vault';
/** Largest request body the proxy forwards (one upload chunk). */
export const MAX_PROXY_REQUEST_BYTES = 512 * 1024 * 1024;
/** Largest response body it passes back (a download). */
export const MAX_PROXY_RESPONSE_BYTES = 2 * 1024 * 1024 * 1024;
/** Largest response it reads to capture a secret from. */
export const MAX_CAPTURE_BYTES = 1024 * 1024;
/** Requests per user and plugin per minute. */
export const PROXY_RATE_LIMIT = 300;
/** Requests one user may have running at once. */
export const PROXY_MAX_CONCURRENT = 8;
const PROXY_TIMEOUT_MS = 10 * 60 * 1000;
const MAX_REDIRECTS = 3;
export const PROXY_METHODS = new Set([
'GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE',
// WebDAV, for Nextcloud and other file servers.
'PROPFIND', 'PROPPATCH', 'MKCOL', 'COPY', 'MOVE', 'REPORT', 'SEARCH', 'LOCK', 'UNLOCK',
]);
/** Request headers never forwarded: the proxy owns auth, cookies and transport. */
const DROP_REQUEST_HEADERS = new Set([
'host', 'cookie', 'authorization', 'proxy-authorization', 'origin', 'referer', 'connection',
'keep-alive', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'forwarded', 'via',
'content-length', 'accept-encoding', 'expect', 'priority',
]);
const DROP_REQUEST_PREFIXES = ['x-forwarded-', 'x-real-', 'sec-', 'proxy-', 'x-bulwark-', 'cf-'];
/** Response headers passed back to the plugin. */
const PASS_RESPONSE_HEADERS = new Set([
'content-type', 'content-range', 'content-disposition', 'content-language', 'accept-ranges',
'etag', 'last-modified', 'dav', 'retry-after', 'oc-etag', 'oc-fileid', 'x-request-id',
]);
const PASS_RESPONSE_PREFIXES = ['x-ratelimit-', 'ratelimit-', 'anthropic-ratelimit-', 'x-oc-', 'oc-'];
export class ProxyError extends Error {
constructor(message: string, readonly status: number, readonly code: string) {
super(message);
}
}
export interface ResolvedCredential {
origin: string;
inject: CredentialInject;
secret?: string;
username?: string;
/** May reach private addresses: the admin set it up. */
trusted: boolean;
scope: 'admin' | 'user';
}
/**
* The credential a plugin asks for: one the user connected, else one the
* admin configured for an installed plugin that declares it.
*/
export async function resolveCredential(user: VaultUser, pluginId: string, credentialId: string): Promise<ResolvedCredential | null> {
const stored = await getUserCredential(user, pluginId, credentialId);
if (stored) {
return {
origin: stored.origin, inject: stored.inject, secret: stored.secret, username: stored.username,
trusted: privateProxyOrigins().includes(stored.origin), scope: 'user',
};
}
const plugin = await getPlugin(pluginId);
const spec = (plugin as { credentials?: Record<string, CredentialSpec> } | null)?.credentials?.[credentialId];
if (!plugin || !plugin.enabled || !spec || spec.scope !== 'admin') return null;
const config = await getPluginConfig(pluginId);
const secret = config[spec.secretKey];
const origin = spec.origin ?? credentialOrigin(spec.originKey ? config[spec.originKey] : undefined);
if (typeof secret !== 'string' || !secret || !origin) return null;
const username = spec.usernameKey ? config[spec.usernameKey] : undefined;
return {
origin, inject: spec.inject, secret,
username: typeof username === 'string' ? username : undefined,
trusted: true, scope: 'admin',
};
}
// ─── Rate limiting ────────────────────────────────────────────
const windows = new Map<string, number[]>();
const running = new Map<string, number>();
function takeSlot(user: string, pluginId: string, now = Date.now()): { ok: true } | { ok: false; retryAfter: number } {
if ((running.get(user) ?? 0) >= PROXY_MAX_CONCURRENT) return { ok: false, retryAfter: 1 };
const key = `${user}:${pluginId}`;
const recent = (windows.get(key) ?? []).filter((t) => now - t < 60_000);
if (recent.length >= PROXY_RATE_LIMIT) {
windows.set(key, recent);
return { ok: false, retryAfter: Math.max(1, Math.ceil((60_000 - (now - recent[0])) / 1000)) };
}
recent.push(now);
windows.set(key, recent);
running.set(user, (running.get(user) ?? 0) + 1);
return { ok: true };
}
function releaseSlot(user: string): void {
const n = (running.get(user) ?? 1) - 1;
if (n <= 0) running.delete(user);
else running.set(user, n);
}
/** Test helper. */
export function resetProxyLimits(): void {
windows.clear();
running.clear();
}
// ─── Forwarding ───────────────────────────────────────────────
/** Ask the proxy to store a field of a JSON response as the secret. */
export interface CaptureSpec {
/** Top-level JSON field holding the secret, e.g. Nextcloud's `appPassword`. */
secret: string;
/** Top-level JSON field holding the user name, e.g. `loginName`. */
username?: string;
}
export interface ProxyInput {
user: VaultUser;
pluginId: string;
credentialId: string;
url: string;
method: string;
headers: Headers;
body: ReadableStream<Uint8Array> | null;
capture?: CaptureSpec;
signal?: AbortSignal;
}
function forwardHeaders(input: Headers, credential: ResolvedCredential): Record<string, string> {
const out: Record<string, string> = {};
input.forEach((value, rawName) => {
const name = rawName.toLowerCase();
if (DROP_REQUEST_HEADERS.has(name)) return;
if (DROP_REQUEST_PREFIXES.some((p) => name.startsWith(p))) return;
out[name] = value;
});
// WebDAV COPY/MOVE name their target as a full URL: it must stay on the
// credential's server.
if (out.destination) {
let dest: URL;
try { dest = new URL(out.destination); } catch { throw new ProxyError('Invalid Destination header', 400, 'bad_destination'); }
if (dest.origin !== credential.origin) throw new ProxyError('Destination must be on the same server', 400, 'bad_destination');
}
const auth = authHeaderFor(credential.inject, credential.secret, credential.username);
if (auth) out[auth[0]] = auth[1];
return out;
}
function countingStream(limit: number, onDone: (bytes: number, overLimit: boolean) => void): TransformStream<Uint8Array, Uint8Array> {
let bytes = 0;
let finished = false;
const finish = (over: boolean) => { if (!finished) { finished = true; onDone(bytes, over); } };
return new TransformStream({
transform(chunk, controller) {
bytes += chunk.byteLength;
if (bytes > limit) {
finish(true);
controller.error(new ProxyError('Body too large', 413, 'too_large'));
return;
}
controller.enqueue(chunk);
},
flush() { finish(false); },
});
}
async function send(
url: string,
init: { method: string; headers: Record<string, string>; body?: ReadableStream<Uint8Array> | null; signal: AbortSignal },
trusted: boolean,
): Promise<Response> {
const request = {
method: init.method,
headers: init.headers,
signal: init.signal,
...(init.body ? { body: init.body, duplex: 'half' as const } : {}),
};
if (trusted) return fetch(url, { ...request, redirect: 'manual' } as RequestInit);
return (await fetchPublicUrl(url, request as never)) as unknown as Response;
}
function responseHeaders(upstream: Response): Headers {
const out = new Headers();
upstream.headers.forEach((value, rawName) => {
const name = rawName.toLowerCase();
if (PASS_RESPONSE_HEADERS.has(name) || PASS_RESPONSE_PREFIXES.some((p) => name.startsWith(p))) out.set(name, value);
});
// fetch has already decoded a compressed body, so its length is unknown.
if (!upstream.headers.get('content-encoding')) {
const length = upstream.headers.get('content-length');
if (length) out.set('content-length', length);
}
out.set('cache-control', 'no-store');
return out;
}
function redact(body: Record<string, unknown>, capture: CaptureSpec): Record<string, unknown> {
const out = { ...body };
delete out[capture.secret];
if (capture.username) delete out[capture.username];
return out;
}
/**
* Forward one plugin request. Returns the upstream response with a filtered
* set of headers and a size-capped body, or throws ProxyError.
*/
export async function proxyRequest(input: ProxyInput): Promise<Response> {
const method = input.method.toUpperCase();
if (!PROXY_METHODS.has(method)) throw new ProxyError(`Method ${method} is not allowed`, 405, 'bad_method');
const credential = await resolveCredential(input.user, input.pluginId, input.credentialId);
if (!credential) throw new ProxyError('No such credential', 404, 'no_credential');
let target: URL;
try { target = new URL(input.url); } catch { throw new ProxyError('Invalid URL', 400, 'bad_url'); }
if (target.origin !== credential.origin || target.username || target.password) {
throw new ProxyError('This credential is only for its own server', 403, 'wrong_origin');
}
if (input.capture && credential.scope !== 'user') {
throw new ProxyError('Only a credential the user connected can be filled in', 400, 'bad_capture');
}
const who = userKey(input.user);
const slot = takeSlot(who, input.pluginId);
if (!slot.ok) {
throw Object.assign(new ProxyError('Too many requests', 429, 'rate_limited'), { retryAfter: slot.retryAfter });
}
const started = Date.now();
let sent = 0;
let released = false;
const release = () => { if (!released) { released = true; releaseSlot(who); } };
const audit = (status: number, received: number, note?: string) => {
logger.info('Plugin proxy request', {
plugin: input.pluginId, credential: input.credentialId, user: who.slice(0, 12),
method, host: target.host, status, sentBytes: sent, receivedBytes: received,
ms: Date.now() - started, ...(note ? { note } : {}),
});
};
const timeout = AbortSignal.timeout(PROXY_TIMEOUT_MS);
const signal = input.signal ? AbortSignal.any([input.signal, timeout]) : timeout;
try {
const headers = forwardHeaders(input.headers, credential);
let body = input.body && method !== 'GET' && method !== 'HEAD'
? input.body.pipeThrough(countingStream(MAX_PROXY_REQUEST_BYTES, (n) => { sent = n; }))
: null;
let currentUrl = target.toString();
let currentMethod = method;
let upstream: Response | null = null;
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
upstream = await send(currentUrl, { method: currentMethod, headers, body, signal }, credential.trusted);
if (upstream.status < 300 || upstream.status >= 400 || upstream.status === 304) break;
const location = upstream.headers.get('location');
if (!location) break;
await upstream.body?.cancel().catch(() => {});
const next = new URL(location, currentUrl);
// A redirect off the credential's server would take the secret along.
if (next.origin !== credential.origin) {
throw new ProxyError('The server redirected to another address', 502, 'redirect_off_origin');
}
if (body && upstream.status !== 307 && upstream.status !== 308) {
currentMethod = 'GET';
body = null;
delete headers['content-type'];
} else if (body) {
// A streamed body can't be sent twice.
throw new ProxyError('The server redirected an upload', 502, 'redirect_with_body');
}
currentUrl = next.toString();
upstream = null;
}
if (!upstream) throw new ProxyError('Too many redirects', 502, 'too_many_redirects');
if (input.capture) {
const text = await readCapped(upstream, MAX_CAPTURE_BYTES);
let json: unknown;
try { json = JSON.parse(text); } catch { json = null; }
const out = new Headers({ 'content-type': 'application/json', 'cache-control': 'no-store' });
if (upstream.ok && json && typeof json === 'object' && !Array.isArray(json)) {
const record = json as Record<string, unknown>;
const secret = record[input.capture.secret];
const username = input.capture.username ? record[input.capture.username] : undefined;
if (typeof secret === 'string' && secret) {
await setUserCredentialSecret(input.user, input.pluginId, input.credentialId, secret, typeof username === 'string' ? username : undefined);
audit(upstream.status, text.length, 'captured');
return new Response(JSON.stringify({ ...redact(record, input.capture), captured: true }), { status: upstream.status, headers: out });
}
audit(upstream.status, text.length);
return new Response(JSON.stringify({ ...redact(record, input.capture), captured: false }), { status: upstream.status, headers: out });
}
audit(upstream.status, text.length);
// Never hand back a body that might hold the secret in another shape.
return new Response(JSON.stringify({ captured: false }), { status: upstream.status, headers: out });
}
const status = upstream.status;
const outHeaders = responseHeaders(upstream);
if (!upstream.body) {
audit(status, 0);
release();
return new Response(null, { status, headers: outHeaders });
}
const stream = upstream.body.pipeThrough(countingStream(MAX_PROXY_RESPONSE_BYTES, (received, over) => {
audit(status, received, over ? 'response too large' : undefined);
release();
}));
// The browser may stop reading (cancelled download); free the slot then too.
signal.addEventListener('abort', release, { once: true });
return new Response(stream, { status, headers: outHeaders });
} catch (error) {
release();
if (error instanceof ProxyError) {
audit(error.status, 0, error.code);
throw error;
}
if (error instanceof DisallowedUrlError) {
audit(403, 0, 'blocked address');
throw new ProxyError('This server address is not allowed', 403, 'blocked_address');
}
if (signal.aborted) {
audit(504, 0, 'timeout');
throw new ProxyError('The server did not answer in time', 504, 'timeout');
}
audit(502, 0, 'unreachable');
throw new ProxyError('Could not reach the server', 502, 'unreachable');
} finally {
if (input.capture) release();
}
}
async function readCapped(response: Response, limit: number): Promise<string> {
if (!response.body) return '';
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > limit) {
await reader.cancel().catch(() => {});
throw new ProxyError('Response too large', 502, 'too_large');
}
chunks.push(value);
}
return Buffer.concat(chunks).toString('utf8');
}