mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +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
368 lines
15 KiB
TypeScript
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');
|
|
}
|