// 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'; import { declaredUserCredential } from './known-plugins'; /** Largest request body the proxy forwards (one upload chunk); see proxyClientMaxBodySize in next.config.ts. */ export const MAX_PROXY_REQUEST_BYTES = 100 * 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 { const stored = await getUserCredential(user, pluginId, credentialId); if (stored) { // Only for a plugin this server knows declares it, on the server the // manifest fixes (see declaredUserCredential). const spec = await declaredUserCredential(pluginId, credentialId); if (!spec) return null; if (spec.origin && credentialOrigin(spec.origin) !== stored.origin) return null; 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 } | 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); // A credential that adds nothing (a keyless service such as a local Ollama) // needs only its server. const needsSecret = spec.inject.type !== 'none'; if (!origin || (needsSecret && (typeof secret !== 'string' || !secret))) return null; const username = spec.usernameKey ? config[spec.usernameKey] : undefined; return { origin, inject: spec.inject, secret: typeof secret === 'string' && secret ? secret : undefined, username: typeof username === 'string' ? username : undefined, trusted: true, scope: 'admin', }; } /** * Which of a plugin's admin credentials are ready to use (secret and server * set). No secret or address leaves the server; plugins learn only yes/no. */ export async function adminCredentialStatus(pluginId: string): Promise<{ credentialId: string; configured: boolean }[]> { return (await adminCredentials(pluginId)).map(({ credentialId, origin }) => ({ credentialId, configured: !!origin })); } /** * The servers of a plugin's admin credentials that are set up. Only for the * host page, which checks links a plugin shows against them: plugins * themselves still learn only yes/no (adminCredentialStatus). */ export async function adminCredentialOrigins(pluginId: string): Promise { return (await adminCredentials(pluginId)).flatMap(({ origin }) => (origin ? [origin] : [])); } async function adminCredentials(pluginId: string): Promise<{ credentialId: string; origin: string | null }[]> { const plugin = await getPlugin(pluginId); const specs = (plugin as { credentials?: Record } | null)?.credentials; if (!plugin || !plugin.enabled || !specs) return []; const adminIds = Object.entries(specs).filter(([, spec]) => spec.scope === 'admin').map(([id]) => id); if (adminIds.length === 0) return []; const user = { username: '', serverUrl: '' }; return Promise.all(adminIds.map(async (credentialId) => ({ credentialId, origin: (await resolveAdminOnly(user, pluginId, credentialId))?.origin ?? null, }))); } async function resolveAdminOnly(user: VaultUser, pluginId: string, credentialId: string): Promise { const resolved = await resolveCredential(user, pluginId, credentialId); return resolved?.scope === 'admin' ? resolved : null; } // ─── Rate limiting ──────────────────────────────────────────── const windows = new Map(); const running = new Map(); 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 | null; capture?: CaptureSpec; signal?: AbortSignal; } function forwardHeaders(input: Headers, credential: ResolvedCredential): Record { const out: Record = {}; 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 { 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; body?: Uint8Array | null; signal: AbortSignal }, trusted: boolean, ): Promise { const request = { method: init.method, headers: init.headers, signal: init.signal, ...(init.body ? { body: init.body } : {}), }; 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, capture: CaptureSpec): Record { 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 { 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); // Read the whole body first and send it with a Content-Length. Streamed // (chunked) uploads reach some servers empty: Paperless (Django) answers // "No file was submitted". Next has buffered the body for proxy.ts // already, up to the same limit (see proxyClientMaxBodySize). let body = input.body && method !== 'GET' && method !== 'HEAD' ? await readBody(input.body, MAX_PROXY_REQUEST_BYTES) : null; sent = body?.byteLength ?? 0; 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']; } 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; 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); // Where a redirect on the same server ended up (CalDAV discovery needs it). if (currentUrl !== target.toString()) outHeaders.set('x-bulwark-url', currentUrl); 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 readBody(stream: ReadableStream, limit: number): Promise { const reader = stream.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('Body too large', 413, 'too_large'); } chunks.push(value); } return new Uint8Array(Buffer.concat(chunks)); } async function readCapped(response: Response, limit: number): Promise { 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'); }