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