diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts index d83e156b8..61abf34f6 100644 --- a/app/api/admin/marketplace/route.ts +++ b/app/api/admin/marketplace/route.ts @@ -258,6 +258,7 @@ export async function POST(request: NextRequest) { ...(manifest.httpOrigins ? { httpOrigins: manifest.httpOrigins } : {}), ...(manifest.apiPostPaths ? { apiPostPaths: manifest.apiPostPaths } : {}), ...(manifest.locales ? { locales: manifest.locales } : {}), + ...(manifest.credentials ? { credentials: manifest.credentials } : {}), }; await savePlugin(plugin, code); diff --git a/app/api/admin/plugins/route.ts b/app/api/admin/plugins/route.ts index 2573921ab..7e0306515 100644 --- a/app/api/admin/plugins/route.ts +++ b/app/api/admin/plugins/route.ts @@ -110,6 +110,7 @@ export async function POST(request: NextRequest) { ...(manifest.frameOrigins ? { frameOrigins: manifest.frameOrigins } : {}), ...(manifest.httpOrigins ? { httpOrigins: manifest.httpOrigins } : {}), ...(manifest.apiPostPaths ? { apiPostPaths: manifest.apiPostPaths } : {}), + ...(manifest.credentials ? { credentials: manifest.credentials } : {}), installedAt: now, updatedAt: now, }; diff --git a/app/api/plugin-proxy/route.ts b/app/api/plugin-proxy/route.ts new file mode 100644 index 000000000..b8578337b --- /dev/null +++ b/app/api/plugin-proxy/route.ts @@ -0,0 +1,81 @@ +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; + 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=&credential=&url= + * + * 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 = { '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 }); + } +} diff --git a/app/api/plugin-secrets/__tests__/route.test.ts b/app/api/plugin-secrets/__tests__/route.test.ts new file mode 100644 index 000000000..1295624e7 --- /dev/null +++ b/app/api/plugin-secrets/__tests__/route.test.ts @@ -0,0 +1,117 @@ +/** + * @vitest-environment node + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +let signedIn = true; +let pluginsEnabled = true; +let sameOrigin = true; +vi.mock('@/lib/stalwart/credentials', () => ({ + getStalwartCredentials: async () => (signedIn ? { username: 'alice@example.org', serverUrl: 'https://mail.example.org', authHeader: 'x' } : null), +})); +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { ensureLoaded: async () => {}, getPolicy: () => ({ features: { pluginsEnabled } }) }, +})); +vi.mock('@/lib/security/same-origin', async () => { + const { NextResponse } = await import('next/server'); + return { rejectCrossOriginRequest: () => (sameOrigin ? null : NextResponse.json({}, { status: 403 })) }; +}); +const store = new Map>(); +vi.mock('@/lib/plugin-secrets/vault', () => ({ + VaultFullError: class extends Error {}, + putUserCredential: vi.fn(async (_u: unknown, pluginId: string, credentialId: string, input: Record) => { + store.set(`${pluginId}/${credentialId}`, input); + }), + listUserCredentials: vi.fn(async () => []), + deleteUserCredentials: vi.fn(async () => {}), +})); +const proxied: unknown[] = []; +vi.mock('@/lib/plugin-secrets/proxy', async (orig) => { + const real = await orig(); + return { ...real, proxyRequest: vi.fn(async (input: unknown) => { proxied.push(input); return new Response('ok'); }) }; +}); + +import { POST as saveCredential } from '../route'; +import { POST as proxy } from '../../plugin-proxy/route'; + +const VALID = { + pluginId: 'nextcloud', credentialId: 'cloud', label: 'Nextcloud', + origin: 'https://cloud.example.org/index.php', inject: { type: 'basic' }, username: 'alice', secret: 'app-pw', +}; + +function save(body: unknown) { + return saveCredential(new NextRequest('http://localhost/api/plugin-secrets', { method: 'POST', body: JSON.stringify(body) })); +} + +function proxyCall(query: string, headers: Record = {}) { + return proxy(new NextRequest(`http://localhost/api/plugin-proxy?${query}`, { method: 'POST', headers })); +} + +beforeEach(() => { + signedIn = true; + pluginsEnabled = true; + sameOrigin = true; + store.clear(); + proxied.length = 0; +}); + +describe('POST /api/plugin-secrets', () => { + it('stores a credential for the server origin only', async () => { + const res = await save(VALID); + + expect(res.status).toBe(200); + expect(store.get('nextcloud/cloud')).toMatchObject({ origin: 'https://cloud.example.org', secret: 'app-pw', username: 'alice' }); + }); + + it.each([ + ['a plain http server', { origin: 'http://cloud.example.org' }], + ['a header the proxy owns', { inject: { type: 'header', name: 'Cookie' } }], + ['a secret with a line break', { secret: 'a\r\nX-Evil: 1' }], + ['a bad plugin id', { pluginId: '../x' }], + ])('refuses %s', async (_name, change) => { + const res = await save({ ...VALID, ...change }); + + expect(res.status).toBe(400); + expect(store.size).toBe(0); + }); + + it('refuses without a session, with plugins off, or from another site', async () => { + signedIn = false; + expect((await save(VALID)).status).toBe(401); + signedIn = true; + pluginsEnabled = false; + expect((await save(VALID)).status).toBe(403); + pluginsEnabled = true; + sameOrigin = false; + expect((await save(VALID)).status).toBe(403); + expect(store.size).toBe(0); + }); +}); + +describe('POST /api/plugin-proxy', () => { + it('passes the request on with the method and capture header', async () => { + const res = await proxyCall('plugin=nextcloud&credential=cloud&url=https%3A%2F%2Fcloud.example.org%2Flogin%2Fv2%2Fpoll', { + 'x-bulwark-method': 'POST', 'x-bulwark-capture': '{"secret":"appPassword","username":"loginName"}', + }); + + expect(res.status).toBe(200); + expect(proxied[0]).toMatchObject({ + pluginId: 'nextcloud', credentialId: 'cloud', method: 'POST', + url: 'https://cloud.example.org/login/v2/poll', + capture: { secret: 'appPassword', username: 'loginName' }, + user: { username: 'alice@example.org' }, + }); + }); + + it('refuses without a session, from another site, or with a broken capture header', async () => { + signedIn = false; + expect((await proxyCall('plugin=nextcloud&credential=cloud&url=https%3A%2F%2Fa.example')).status).toBe(401); + signedIn = true; + sameOrigin = false; + expect((await proxyCall('plugin=nextcloud&credential=cloud&url=https%3A%2F%2Fa.example')).status).toBe(403); + sameOrigin = true; + expect((await proxyCall('plugin=nextcloud&credential=cloud&url=https%3A%2F%2Fa.example', { 'x-bulwark-capture': '{"secret":"a.b"}' })).status).toBe(400); + expect(proxied).toEqual([]); + }); +}); diff --git a/app/api/plugin-secrets/route.ts b/app/api/plugin-secrets/route.ts new file mode 100644 index 000000000..c40bdfba9 --- /dev/null +++ b/app/api/plugin-secrets/route.ts @@ -0,0 +1,100 @@ +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 { + 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=] - 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; + 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=[&credential=] - 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 }); +} diff --git a/app/api/plugins/route.ts b/app/api/plugins/route.ts index 197117906..238ecc0e7 100644 --- a/app/api/plugins/route.ts +++ b/app/api/plugins/route.ts @@ -63,6 +63,8 @@ export async function GET(request: NextRequest) { httpOrigins: p.httpOrigins, // Surface so clients can enforce api.http.post path allowlists. apiPostPaths: p.apiPostPaths, + // Which credentials the plugin may use through the proxy; no secrets. + credentials: p.credentials, // Per-user settings schema, captured from the manifest at upload/load // time so the client can render the settings UI without re-parsing. settingsSchema: p.settingsSchema, diff --git a/lib/__tests__/plugin-secret-proxy.test.ts b/lib/__tests__/plugin-secret-proxy.test.ts new file mode 100644 index 000000000..7d236d09e --- /dev/null +++ b/lib/__tests__/plugin-secret-proxy.test.ts @@ -0,0 +1,239 @@ +// @vitest-environment node +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +vi.mock('@/lib/admin/plugin-registry', () => ({ getPlugin: vi.fn(async () => null) })); +vi.mock('@/lib/admin/plugin-config', () => ({ getPluginConfig: vi.fn(async () => ({})) })); +const auditLines: Record[] = []; +vi.mock('@/lib/logger', () => ({ + logger: { + info: (_msg: string, extra: Record) => { auditLines.push(extra); }, + warn: () => {}, error: () => {}, debug: () => {}, + }, +})); + +import { getPlugin } from '@/lib/admin/plugin-registry'; +import { getPluginConfig } from '@/lib/admin/plugin-config'; +import { checkCredentials, authHeaderFor } from '../plugin-secrets/credentials'; +import { + deleteUserCredentials, getUserCredential, listUserCredentials, putUserCredential, +} from '../plugin-secrets/vault'; +import { proxyRequest, ProxyError, PROXY_RATE_LIMIT, resetProxyLimits } from '../plugin-secrets/proxy'; + +const USER = { username: 'alice@example.org', serverUrl: 'https://mail.example.org' }; +const OTHER = { username: 'bob@example.org', serverUrl: 'https://mail.example.org' }; + +interface Seen { method: string; url: string; headers: http.IncomingHttpHeaders; body: string } +let seen: Seen[] = []; +let server: http.Server; +let origin: string; +let vaultDir: string; + +beforeAll(async () => { + process.env.SESSION_SECRET = 'test-secret-for-the-plugin-vault-0123456789'; + vaultDir = mkdtempSync(path.join(tmpdir(), 'plugin-vault-')); + process.env.PLUGIN_SECRETS_DIR = vaultDir; + server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => { + seen.push({ method: req.method!, url: req.url!, headers: req.headers, body: Buffer.concat(chunks).toString() }); + if (req.url === '/redirect-away') { + res.writeHead(302, { location: 'https://elsewhere.example/steal' }); + return res.end(); + } + if (req.url === '/login/poll') { + res.writeHead(200, { 'content-type': 'application/json' }); + return res.end(JSON.stringify({ server: origin, loginName: 'alice', appPassword: 'app-pw-123' })); + } + res.writeHead(207, { 'content-type': 'application/xml', 'set-cookie': 'sid=upstream', dav: '1, 3', 'x-powered-by': 'php' }); + res.end(''); + }); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + process.env.PLUGIN_PROXY_PRIVATE_ORIGINS = origin; +}); + +afterAll(() => { + server.close(); + rmSync(vaultDir, { recursive: true, force: true }); +}); + +beforeEach(async () => { + seen = []; + auditLines.length = 0; + resetProxyLimits(); + await deleteUserCredentials(USER, 'nextcloud'); + await deleteUserCredentials(OTHER, 'nextcloud'); +}); + +async function connect(secret?: string) { + await putUserCredential(USER, 'nextcloud', 'cloud', { + origin, inject: { type: 'basic' }, label: 'Nextcloud', username: 'alice', ...(secret ? { secret } : {}), + }); +} + +function call(over: Partial[0]> = {}) { + return proxyRequest({ + user: USER, pluginId: 'nextcloud', credentialId: 'cloud', + url: `${origin}/remote.php/dav/files/alice/`, method: 'PROPFIND', + headers: new Headers({ depth: '1', cookie: 'webmail-session=secret', 'content-type': 'application/xml' }), + body: null, + ...over, + }); +} + +describe('credential specs in the manifest', () => { + it('accepts a user credential and an admin one that points at a secret field', () => { + const { credentials, errors } = checkCredentials({ + cloud: { label: 'Nextcloud', scope: 'user', inject: { type: 'basic' } }, + ai: { label: 'OpenAI', scope: 'admin', inject: { type: 'bearer' }, secretKey: 'key', origin: 'https://api.openai.com' }, + }, { key: { type: 'secret' } }); + + expect(errors).toEqual([]); + expect(credentials?.ai).toMatchObject({ scope: 'admin', secretKey: 'key', origin: 'https://api.openai.com' }); + }); + + it('refuses an admin credential whose secret is a plain field, and headers the proxy owns', () => { + expect(checkCredentials({ ai: { label: 'AI', scope: 'admin', inject: { type: 'bearer' }, secretKey: 'key', origin: 'https://a.example' } }, + { key: { type: 'string' } }).errors.join()).toMatch(/"secret" field/); + expect(checkCredentials({ x: { label: 'X', scope: 'user', inject: { type: 'header', name: 'Cookie' } } }, undefined).errors.join()) + .toMatch(/inject is invalid/); + }); + + it('builds the auth header', () => { + expect(authHeaderFor({ type: 'basic' }, 'pw', 'alice')).toEqual(['authorization', `Basic ${Buffer.from('alice:pw').toString('base64')}`]); + expect(authHeaderFor({ type: 'header', name: 'x-api-key' }, 'k', undefined)).toEqual(['x-api-key', 'k']); + expect(authHeaderFor({ type: 'bearer' }, undefined, undefined)).toBeNull(); + }); +}); + +describe('vault', () => { + it('keeps secrets encrypted and out of the list', async () => { + await connect('app-pw'); + + expect((await getUserCredential(USER, 'nextcloud', 'cloud'))?.secret).toBe('app-pw'); + const list = await listUserCredentials(USER); + expect(list).toEqual([expect.objectContaining({ pluginId: 'nextcloud', credentialId: 'cloud', origin, connected: true })]); + expect(JSON.stringify(list)).not.toContain('app-pw'); + const files = readdirSync(vaultDir).map((f) => readFileSync(path.join(vaultDir, f), 'utf8')); + expect(files.join()).not.toContain('app-pw'); + }); + + it('is per account', async () => { + await connect('app-pw'); + + expect(await getUserCredential(OTHER, 'nextcloud', 'cloud')).toBeNull(); + }); + + it('drops the secret when the server address changes', async () => { + await connect('app-pw'); + await putUserCredential(USER, 'nextcloud', 'cloud', { origin: 'https://evil.example', inject: { type: 'basic' }, label: 'Nextcloud' }); + + expect((await getUserCredential(USER, 'nextcloud', 'cloud'))?.secret).toBeUndefined(); + }); +}); + +describe('proxy', () => { + it('adds the secret, drops the browser cookie and filters what comes back', async () => { + await connect('app-pw'); + + const res = await call(); + + expect(res.status).toBe(207); + expect(await res.text()).toBe(''); + expect(seen[0].method).toBe('PROPFIND'); + expect(seen[0].headers.authorization).toBe(`Basic ${Buffer.from('alice:app-pw').toString('base64')}`); + expect(seen[0].headers.cookie).toBeUndefined(); + expect(seen[0].headers.depth).toBe('1'); + expect(res.headers.get('set-cookie')).toBeNull(); + expect(res.headers.get('x-powered-by')).toBeNull(); + expect(res.headers.get('dav')).toBe('1, 3'); + }); + + it('streams a request body through', async () => { + await connect('app-pw'); + const body = new Blob(['file contents']).stream(); + + const res = await call({ method: 'PUT', url: `${origin}/remote.php/dav/files/alice/a.txt`, body }); + await res.text(); + + expect(seen[0].body).toBe('file contents'); + }); + + it('only sends a credential to its own server', async () => { + await connect('app-pw'); + + await expect(call({ url: 'https://elsewhere.example/x' })).rejects.toMatchObject({ code: 'wrong_origin', status: 403 }); + expect(seen).toEqual([]); + }); + + it('refuses to follow a redirect to another server', async () => { + await connect('app-pw'); + + await expect(call({ method: 'GET', url: `${origin}/redirect-away` })).rejects.toMatchObject({ code: 'redirect_off_origin' }); + expect(seen).toHaveLength(1); + }); + + it('refuses a WebDAV move to another server', async () => { + await connect('app-pw'); + + await expect(call({ method: 'MOVE', headers: new Headers({ destination: 'https://elsewhere.example/x' }) })) + .rejects.toMatchObject({ code: 'bad_destination' }); + expect(seen).toEqual([]); + }); + + it('does not use a credential the user never connected', async () => { + await expect(call()).rejects.toBeInstanceOf(ProxyError); + await expect(call()).rejects.toMatchObject({ code: 'no_credential' }); + }); + + it('stores a secret from a sign-in response and hides it from the plugin', async () => { + await connect(); + + const res = await call({ method: 'POST', url: `${origin}/login/poll`, capture: { secret: 'appPassword', username: 'loginName' } }); + const body = await res.json(); + + expect(body).toEqual({ server: origin, captured: true }); + const stored = await getUserCredential(USER, 'nextcloud', 'cloud'); + expect(stored).toMatchObject({ secret: 'app-pw-123', username: 'alice' }); + }); + + it('limits requests per minute', async () => { + await connect('app-pw'); + // In batches below the concurrency cap, so only the per-minute limit applies. + for (let i = 0; i < PROXY_RATE_LIMIT; i += 6) { + const n = Math.min(6, PROXY_RATE_LIMIT - i); + await Promise.all(Array.from({ length: n }, async () => (await call()).text())); + } + + await expect(call()).rejects.toMatchObject({ status: 429 }); + }, 60_000); + + it('writes an audit line without paths, headers or bodies', async () => { + await connect('app-pw'); + await (await call()).text(); + await new Promise((r) => setTimeout(r, 10)); + + expect(auditLines).toEqual([expect.objectContaining({ plugin: 'nextcloud', credential: 'cloud', method: 'PROPFIND', status: 207 })]); + expect(JSON.stringify(auditLines)).not.toMatch(/remote\.php|app-pw|alice@/); + }); + + it('uses an admin credential for an installed plugin', async () => { + vi.mocked(getPlugin).mockResolvedValueOnce({ + id: 'ai', enabled: true, + credentials: { llm: { label: 'LLM', scope: 'admin', inject: { type: 'bearer' }, secretKey: 'key', originKey: 'url' } }, + } as never); + vi.mocked(getPluginConfig).mockResolvedValueOnce({ key: 'sk-admin', url: `${origin}/v1` }); + + const res = await call({ pluginId: 'ai', credentialId: 'llm', method: 'POST', url: `${origin}/v1/chat` }); + await res.text(); + + expect(seen[0].headers.authorization).toBe('Bearer sk-admin'); + }); +}); diff --git a/lib/admin/plugin-dev.ts b/lib/admin/plugin-dev.ts index 76f65b6c3..a92e4a93a 100644 --- a/lib/admin/plugin-dev.ts +++ b/lib/admin/plugin-dev.ts @@ -216,6 +216,7 @@ async function loadDevPlugin(pluginDir: string): Promise ...(valid.locales ? { locales: valid.locales } : {}), ...(valid.frameOrigins ? { frameOrigins: valid.frameOrigins } : {}), ...(valid.httpOrigins ? { httpOrigins: valid.httpOrigins } : {}), + ...(valid.credentials ? { credentials: valid.credentials } : {}), ...(valid.apiPostPaths ? { apiPostPaths: valid.apiPostPaths } : {}), installedAt, updatedAt: new Date().toISOString(), diff --git a/lib/admin/plugin-registry.ts b/lib/admin/plugin-registry.ts index 4f1a7a03e..b7920adc0 100644 --- a/lib/admin/plugin-registry.ts +++ b/lib/admin/plugin-registry.ts @@ -1,3 +1,4 @@ +import type { CredentialSpec } from '@/lib/plugin-secrets/credentials'; import { readFile, writeFile, mkdir, rename, unlink } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { createHash } from 'node:crypto'; @@ -89,6 +90,8 @@ export interface ServerPlugin { * `InstalledPlugin.apiPostPaths` in `lib/plugin-types.ts`. */ apiPostPaths?: string[]; + /** Credentials the plugin uses through the server proxy (no secrets here). */ + credentials?: Record; } export interface ServerTheme { diff --git a/lib/auth/crypto.ts b/lib/auth/crypto.ts index d25c16574..1dfc768ab 100644 --- a/lib/auth/crypto.ts +++ b/lib/auth/crypto.ts @@ -102,7 +102,9 @@ export type PayloadPurpose = /** WOPI access token, scoped to one file node and a six-hour expiry. */ | 'wopi-token' /** The app password an impersonation handoff minted, kept for revocation. */ - | 'impersonation-grant'; + | 'impersonation-grant' + /** A user's plugin credentials (lib/plugin-secrets/vault.ts). */ + | 'plugin-secrets'; /** * Whether a blob minted before purposes were bound may be re-admitted for @@ -122,6 +124,7 @@ const LEGACY_PAYLOAD_MARKERS: Record false, 'wopi-token': (p) => p.t === 'wopi', 'impersonation-grant': () => false, + 'plugin-secrets': () => false, }; export function encryptPayload(payload: Record, purpose: PayloadPurpose): string { diff --git a/lib/extension-package.ts b/lib/extension-package.ts index 9b7f04c1c..48221550a 100644 --- a/lib/extension-package.ts +++ b/lib/extension-package.ts @@ -6,6 +6,7 @@ // permission list, some the origin lists, and none bounded how much a ZIP may // inflate to. +import { checkCredentials } from './plugin-secrets/credentials'; import JSZip from 'jszip'; import { ALL_PERMISSIONS, @@ -453,6 +454,8 @@ export function checkPluginManifest(m: Record, opts: { appVersi const frameOrigins = originList(m, 'frameOrigins', sanitizeFrameOrigins, errors, warnings); const httpOrigins = originList(m, 'httpOrigins', sanitizeHttpOrigins, errors, warnings); const apiPostPaths = originList(m, 'apiPostPaths', sanitizeApiPostPaths, errors, warnings); + const credentialCheck = checkCredentials(m.credentials, configSchema); + errors.push(...credentialCheck.errors); if (errors.length > 0) return { manifest: null, errors, warnings }; @@ -466,6 +469,7 @@ export function checkPluginManifest(m: Record, opts: { appVersi ...(frameOrigins ? { frameOrigins } : {}), ...(httpOrigins ? { httpOrigins } : {}), ...(apiPostPaths ? { apiPostPaths } : {}), + ...(credentialCheck.credentials && Object.keys(credentialCheck.credentials).length ? { credentials: credentialCheck.credentials } : {}), }; return { manifest, errors, warnings }; } diff --git a/lib/plugin-secrets/credentials.ts b/lib/plugin-secrets/credentials.ts new file mode 100644 index 000000000..916043ba3 --- /dev/null +++ b/lib/plugin-secrets/credentials.ts @@ -0,0 +1,199 @@ +// Credentials a plugin may use through the server proxy (/api/plugin-proxy). +// The plugin names a credential by id; the server adds the secret to the +// request and forwards it only to the credential's origin. The secret never +// reaches the plugin. +// +// A plugin declares its credentials in its manifest: +// +// "credentials": { +// "nextcloud": { "label": "Nextcloud", "scope": "user", "inject": { "type": "basic" } }, +// "openai": { +// "label": "OpenAI", "scope": "admin", "inject": { "type": "bearer" }, +// "secretKey": "apiKey", "originKey": "baseUrl" +// } +// } +// +// scope "admin": the secret is a `type: "secret"` field of the plugin's admin +// config (configSchema), and the origin is fixed in the manifest (`origin`) or +// read from an admin config field (`originKey`). +// scope "user": the user connects their own account in a dialog the host +// shows; the origin is fixed in the manifest or chosen by the user there. + +export type CredentialInject = + | { type: 'none' } + | { type: 'bearer' } + | { type: 'basic' } + | { type: 'header'; name: string; prefix?: string }; + +export interface AdminCredentialSpec { + label: string; + scope: 'admin'; + inject: CredentialInject; + /** configSchema key holding the secret; it must be `type: "secret"`. */ + secretKey: string; + /** Fixed origin, or */ + origin?: string; + /** configSchema key holding the origin (a URL; only its origin is used). */ + originKey?: string; + /** configSchema key holding the user name for basic auth. */ + usernameKey?: string; +} + +export interface UserCredentialSpec { + label: string; + scope: 'user'; + inject: CredentialInject; + /** Fixed origin; when absent the user enters the server address. */ + origin?: string; +} + +export type CredentialSpec = AdminCredentialSpec | UserCredentialSpec; + +export const MAX_CREDENTIALS_PER_PLUGIN = 8; +export const CREDENTIAL_ID_RE = /^[a-z][a-z0-9-]{0,39}$/; + +/** Headers a credential may not set: the proxy or the transport owns them. */ +const RESERVED_HEADERS = new Set([ + 'host', 'cookie', 'content-length', 'content-type', 'transfer-encoding', 'connection', + 'origin', 'referer', 'forwarded', 'te', 'upgrade', 'keep-alive', 'proxy-authorization', +]); +const HEADER_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,63}$/; + +/** + * The origin (scheme + host + port) of a URL, or null. https only, except + * http for origins the admin listed in PLUGIN_PROXY_PRIVATE_ORIGINS. + */ +export function credentialOrigin(value: unknown): string | null { + if (typeof value !== 'string' || value.length === 0 || value.length > 2048) return null; + let url: URL; + try { + url = new URL(value); + } catch { + return null; + } + if (url.username || url.password) return null; + if (url.protocol === 'https:') return url.origin; + if (url.protocol === 'http:' && privateProxyOrigins().includes(url.origin)) return url.origin; + return null; +} + +/** + * Origins on the admin's own network the proxy may reach although they are + * private addresses (a Nextcloud on the LAN). Comma separated, from + * PLUGIN_PROXY_PRIVATE_ORIGINS. Everything else must resolve to a public + * address. + */ +export function privateProxyOrigins(): string[] { + const raw = (typeof process !== 'undefined' && process.env?.PLUGIN_PROXY_PRIVATE_ORIGINS) || ''; + const out: string[] = []; + for (const part of raw.split(',')) { + const trimmed = part.trim(); + if (!trimmed) continue; + try { + const url = new URL(trimmed); + if (url.protocol === 'https:' || url.protocol === 'http:') out.push(url.origin); + } catch { /* ignore */ } + } + return out; +} + +export function checkInject(value: unknown): CredentialInject | null { + if (!value || typeof value !== 'object') return null; + const v = value as Record; + switch (v.type) { + case 'none': + case 'bearer': + case 'basic': + return { type: v.type }; + case 'header': { + if (typeof v.name !== 'string' || !HEADER_NAME_RE.test(v.name)) return null; + if (RESERVED_HEADERS.has(v.name.toLowerCase())) return null; + if (v.prefix !== undefined && (typeof v.prefix !== 'string' || v.prefix.length > 32 || /[\r\n]/.test(v.prefix))) return null; + return { type: 'header', name: v.name, ...(typeof v.prefix === 'string' ? { prefix: v.prefix } : {}) }; + } + default: + return null; + } +} + +/** + * Check a manifest's `credentials` block. `configSchema` is the manifest's + * admin config schema, which admin credentials must point into. + */ +export function checkCredentials( + value: unknown, + configSchema: Record | undefined, +): { credentials?: Record; errors: string[] } { + if (value === undefined) return { errors: [] }; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return { errors: ['credentials must be an object'] }; + } + const entries = Object.entries(value as Record); + if (entries.length > MAX_CREDENTIALS_PER_PLUGIN) { + return { errors: [`At most ${MAX_CREDENTIALS_PER_PLUGIN} credentials`] }; + } + const errors: string[] = []; + const out: Record = {}; + for (const [id, raw] of entries) { + const where = `credentials.${id}`; + if (!CREDENTIAL_ID_RE.test(id)) { errors.push(`${where}: invalid id`); continue; } + if (!raw || typeof raw !== 'object') { errors.push(`${where} must be an object`); continue; } + const c = raw as Record; + if (typeof c.label !== 'string' || !c.label.trim() || c.label.length > 80) { errors.push(`${where}.label is required (max 80 characters)`); continue; } + const inject = checkInject(c.inject); + if (!inject) { errors.push(`${where}.inject is invalid`); continue; } + let fixedOrigin: string | undefined; + if (c.origin !== undefined) { + const origin = credentialOrigin(c.origin); + if (!origin) { errors.push(`${where}.origin must be an https origin`); continue; } + fixedOrigin = origin; + } + if (c.scope === 'user') { + out[id] = { label: c.label, scope: 'user', inject, ...(fixedOrigin ? { origin: fixedOrigin } : {}) }; + continue; + } + if (c.scope !== 'admin') { errors.push(`${where}.scope must be "admin" or "user"`); continue; } + const field = (key: unknown, name: string, type?: string): string | null => { + if (typeof key !== 'string' || !configSchema?.[key]) { + errors.push(`${where}.${name} must name a configSchema field`); + return null; + } + if (type && configSchema[key].type !== type) { + errors.push(`${where}.${name} must name a "${type}" field`); + return null; + } + return key; + }; + const secretKey = field(c.secretKey, 'secretKey', 'secret'); + if (!secretKey) continue; + if (!fixedOrigin && c.originKey === undefined) { errors.push(`${where} needs origin or originKey`); continue; } + const originKey = c.originKey === undefined ? undefined : field(c.originKey, 'originKey'); + if (originKey === null) continue; + const usernameKey = c.usernameKey === undefined ? undefined : field(c.usernameKey, 'usernameKey'); + if (usernameKey === null) continue; + out[id] = { + label: c.label, scope: 'admin', inject, secretKey, + ...(fixedOrigin ? { origin: fixedOrigin } : {}), + ...(originKey ? { originKey } : {}), + ...(usernameKey ? { usernameKey } : {}), + }; + } + return { credentials: errors.length ? undefined : out, errors }; +} + +/** The header to add for a secret, or null when the credential adds none. */ +export function authHeaderFor( + inject: CredentialInject, + secret: string | undefined, + username: string | undefined, +): [string, string] | null { + if (!secret || inject.type === 'none') return null; + switch (inject.type) { + case 'bearer': + return ['authorization', `Bearer ${secret}`]; + case 'basic': + return ['authorization', `Basic ${Buffer.from(`${username ?? ''}:${secret}`, 'utf8').toString('base64')}`]; + case 'header': + return [inject.name.toLowerCase(), `${inject.prefix ?? ''}${secret}`]; + } +} diff --git a/lib/plugin-secrets/proxy.ts b/lib/plugin-secrets/proxy.ts new file mode 100644 index 000000000..043e5c916 --- /dev/null +++ b/lib/plugin-secrets/proxy.ts @@ -0,0 +1,367 @@ +// 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 { + 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 } | 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(); +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?: ReadableStream | null; signal: AbortSignal }, + trusted: boolean, +): Promise { + 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, 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); + 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; + 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 { + 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'); +} diff --git a/lib/plugin-secrets/vault.ts b/lib/plugin-secrets/vault.ts new file mode 100644 index 000000000..063521cae --- /dev/null +++ b/lib/plugin-secrets/vault.ts @@ -0,0 +1,196 @@ +// Per-user store of the credentials users connected for plugins. One +// encrypted file per mail account; the secrets never leave the server again +// except as an auth header on a request the proxy forwards to the +// credential's own origin. + +import { createHash } from 'node:crypto'; +import { readFile, writeFile, mkdir, rename, unlink } from 'node:fs/promises'; +import path from 'node:path'; +import { decryptPayload, encryptPayload } from '@/lib/auth/crypto'; +import type { CredentialInject } from './credentials'; + +export interface VaultUser { + username: string; + serverUrl: string; +} + +export interface StoredCredential { + origin: string; + inject: CredentialInject; + label: string; + username?: string; + secret?: string; + createdAt: string; + updatedAt: string; +} + +/** What the browser may see about a stored credential: never the secret. */ +export interface CredentialSummary { + pluginId: string; + credentialId: string; + label: string; + origin: string; + username?: string; + connected: boolean; + updatedAt: string; +} + +type VaultData = Record>; + +/** Cap on credentials per user, across all plugins. */ +export const MAX_USER_CREDENTIALS = 64; + +function vaultDir(): string { + return process.env.PLUGIN_SECRETS_DIR + || path.join(process.env.SETTINGS_DATA_DIR || path.join(process.cwd(), 'data', 'settings'), '..', 'plugin-secrets'); +} + +export function userKey(user: VaultUser): string { + return createHash('sha256').update(`${user.username}:${user.serverUrl}`).digest('hex'); +} + +function vaultPath(user: VaultUser): string { + const dir = path.resolve(vaultDir()); + const file = path.resolve(dir, `${userKey(user)}.enc`); + if (!file.startsWith(dir + path.sep)) throw new Error('Invalid vault path'); + return file; +} + +async function load(user: VaultUser): Promise { + let raw: string; + try { + raw = await readFile(vaultPath(user), 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {}; + throw error; + } + const payload = decryptPayload(raw, 'plugin-secrets'); + // Bound to this user: a file copied over from another account's name + // decrypts, but carries the other account's key and is ignored. + if (!payload || payload.u !== userKey(user)) return {}; + const data = payload.d; + return data && typeof data === 'object' ? (data as VaultData) : {}; +} + +async function save(user: VaultUser, data: VaultData): Promise { + const file = vaultPath(user); + if (Object.keys(data).length === 0) { + try { await unlink(file); } catch { /* nothing stored */ } + return; + } + await mkdir(path.dirname(file), { recursive: true }); + const tmp = `${file}.${process.pid}.tmp`; + await writeFile(tmp, encryptPayload({ u: userKey(user), d: data }, 'plugin-secrets'), { encoding: 'utf8', mode: 0o600 }); + await rename(tmp, file); +} + +// Writes for one user run one after another, so two saves can't drop each +// other's changes. +const queues = new Map>(); +function serialize(user: VaultUser, task: () => Promise): Promise { + const key = userKey(user); + const run = (queues.get(key) ?? Promise.resolve()).then(task, task); + const tail = run.catch(() => {}); + queues.set(key, tail); + void tail.then(() => { if (queues.get(key) === tail) queues.delete(key); }); + return run; +} + +export async function getUserCredential(user: VaultUser, pluginId: string, credentialId: string): Promise { + const data = await load(user); + return data[pluginId]?.[credentialId] ?? null; +} + +export async function listUserCredentials(user: VaultUser, pluginId?: string): Promise { + const data = await load(user); + const out: CredentialSummary[] = []; + for (const [pid, creds] of Object.entries(data)) { + if (pluginId && pid !== pluginId) continue; + for (const [cid, c] of Object.entries(creds)) { + out.push({ + pluginId: pid, credentialId: cid, label: c.label, origin: c.origin, + ...(c.username ? { username: c.username } : {}), + connected: !!c.secret || c.inject.type === 'none', + updatedAt: c.updatedAt, + }); + } + } + return out; +} + +export class VaultFullError extends Error { + constructor() { + super(`At most ${MAX_USER_CREDENTIALS} plugin credentials per account`); + } +} + +/** + * Save a credential. Changing the origin of a saved one drops its secret, so + * a secret is only ever sent where the user first approved it. + */ +export function putUserCredential( + user: VaultUser, + pluginId: string, + credentialId: string, + input: { origin: string; inject: CredentialInject; label: string; username?: string; secret?: string }, +): Promise { + return serialize(user, async () => { + const data = await load(user); + const existing = data[pluginId]?.[credentialId]; + const count = Object.values(data).reduce((n, c) => n + Object.keys(c).length, 0); + if (!existing && count >= MAX_USER_CREDENTIALS) throw new VaultFullError(); + const now = new Date().toISOString(); + const keepSecret = existing && existing.origin === input.origin ? existing.secret : undefined; + data[pluginId] = { + ...(data[pluginId] ?? {}), + [credentialId]: { + origin: input.origin, + inject: input.inject, + label: input.label, + ...(input.username ? { username: input.username } : {}), + ...((input.secret ?? keepSecret) ? { secret: input.secret ?? keepSecret } : {}), + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }, + }; + await save(user, data); + }); +} + +/** Store a secret captured from a response for an existing credential. */ +export function setUserCredentialSecret( + user: VaultUser, + pluginId: string, + credentialId: string, + secret: string, + username?: string, +): Promise { + return serialize(user, async () => { + const data = await load(user); + const existing = data[pluginId]?.[credentialId]; + if (!existing) return false; + data[pluginId][credentialId] = { + ...existing, + secret, + ...(username ? { username } : {}), + updatedAt: new Date().toISOString(), + }; + await save(user, data); + return true; + }); +} + +/** Remove one credential, or all of a plugin's when credentialId is omitted. */ +export function deleteUserCredentials(user: VaultUser, pluginId: string, credentialId?: string): Promise { + return serialize(user, async () => { + const data = await load(user); + if (!data[pluginId]) return; + if (credentialId) { + delete data[pluginId][credentialId]; + if (Object.keys(data[pluginId]).length === 0) delete data[pluginId]; + } else { + delete data[pluginId]; + } + await save(user, data); + }); +} diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 2cb987675..31fa5568e 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -1,3 +1,5 @@ +import type { CredentialSpec } from './plugin-secrets/credentials'; + // Plugin & Theme system types // ─── Common ────────────────────────────────────────────────── @@ -103,6 +105,12 @@ export interface ThemeManifest { } export interface PluginManifest { + /** + * Accounts and API keys the plugin uses through the server proxy + * (`api.proxy.fetch`). The secret stays on the server; see + * lib/plugin-secrets/credentials.ts. + */ + credentials?: Record; id: string; name: string; version: string; @@ -223,6 +231,8 @@ export interface InstalledTheme { } export interface InstalledPlugin { + /** From the manifest: see PluginManifest.credentials. */ + credentials?: Record; id: string; name: string; version: string; diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index e313e1569..d07463f90 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -125,6 +125,7 @@ export const usePluginStore = create()( ...(manifest.apiPostPaths && manifest.apiPostPaths.length > 0 ? { apiPostPaths: manifest.apiPostPaths } : {}), + ...(manifest.credentials ? { credentials: manifest.credentials } : {}), }; // Save code to IndexedDB @@ -400,6 +401,7 @@ interface ServerPluginInfo { settingsSchema?: InstalledPlugin['settingsSchema']; /** Plugin-declared i18n tables (locale -> key -> string), from the manifest. */ locales?: InstalledPlugin['locales']; + credentials?: InstalledPlugin['credentials']; } /** @@ -425,6 +427,7 @@ function serverMeta(sp: ServerPluginInfo) { apiPostPaths: sp.apiPostPaths, settingsSchema: sp.settingsSchema, locales: sp.locales, + credentials: sp.credentials, }; }