Files
webmail/lib/__tests__/plugin-secret-proxy.test.ts
T
Linus Rath 022b83c005 feat: let plugins use accounts and API keys through the server without seeing them
Plugins that talk to Nextcloud, Paperless or an AI provider used to need
the password or key in their own settings, readable by the plugin and
stored in the browser. A plugin can now declare credentials in its
manifest and send requests "with credential X" through the server:

- `credentials` in the manifest: admin scope (secret and server from the
  admin config) or user scope (the user connects an account; the server
  address is fixed or chosen by the user)
- `/api/plugin-secrets`: the user's connected accounts, encrypted per
  mail account under `PLUGIN_SECRETS_DIR`; listing never returns secrets
- `/api/plugin-proxy`: adds the secret and forwards only to the
  credential's own origin, through the SSRF guard unless the admin set
  the server up or listed it in `PLUGIN_PROXY_PRIVATE_ORIGINS`; no
  redirects or WebDAV moves to other servers, streamed bodies capped at
  512 MB up and 2 GB down, 300 requests a minute per plugin, 8 at once
  per user, and one audit line per request without paths or bodies
- a capture mode that stores a secret from a sign-in response (Nextcloud
  Login Flow v2) and removes it from what the plugin gets back
2026-10-14 04:00:36 +02:00

240 lines
10 KiB
TypeScript

// @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<string, unknown>[] = [];
vi.mock('@/lib/logger', () => ({
logger: {
info: (_msg: string, extra: Record<string, unknown>) => { 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('<d:multistatus/>');
});
});
await new Promise<void>((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<Parameters<typeof proxyRequest>[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('<d:multistatus/>');
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');
});
});