mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
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
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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=<id>&credential=<id>&url=<target>
|
||||
*
|
||||
* 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<string, string> = { '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 });
|
||||
}
|
||||
}
|
||||
@@ -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<string, Record<string, unknown>>();
|
||||
vi.mock('@/lib/plugin-secrets/vault', () => ({
|
||||
VaultFullError: class extends Error {},
|
||||
putUserCredential: vi.fn(async (_u: unknown, pluginId: string, credentialId: string, input: Record<string, unknown>) => {
|
||||
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<typeof import('@/lib/plugin-secrets/proxy')>();
|
||||
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<string, string> = {}) {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<VaultUser | NextResponse> {
|
||||
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=<id>] - 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<string, unknown>;
|
||||
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=<id>[&credential=<id>] - 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 });
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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');
|
||||
});
|
||||
});
|
||||
@@ -216,6 +216,7 @@ async function loadDevPlugin(pluginDir: string): Promise<DevPluginEntry | null>
|
||||
...(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(),
|
||||
|
||||
@@ -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<string, CredentialSpec>;
|
||||
}
|
||||
|
||||
export interface ServerTheme {
|
||||
|
||||
+4
-1
@@ -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<PayloadPurpose, (payload: Record<string, un
|
||||
'pair-refresh': () => false,
|
||||
'wopi-token': (p) => p.t === 'wopi',
|
||||
'impersonation-grant': () => false,
|
||||
'plugin-secrets': () => false,
|
||||
};
|
||||
|
||||
export function encryptPayload(payload: Record<string, unknown>, purpose: PayloadPurpose): string {
|
||||
|
||||
@@ -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<string, unknown>, 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<string, unknown>, opts: { appVersi
|
||||
...(frameOrigins ? { frameOrigins } : {}),
|
||||
...(httpOrigins ? { httpOrigins } : {}),
|
||||
...(apiPostPaths ? { apiPostPaths } : {}),
|
||||
...(credentialCheck.credentials && Object.keys(credentialCheck.credentials).length ? { credentials: credentialCheck.credentials } : {}),
|
||||
};
|
||||
return { manifest, errors, warnings };
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<string, { type?: string }> | undefined,
|
||||
): { credentials?: Record<string, CredentialSpec>; 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<string, unknown>);
|
||||
if (entries.length > MAX_CREDENTIALS_PER_PLUGIN) {
|
||||
return { errors: [`At most ${MAX_CREDENTIALS_PER_PLUGIN} credentials`] };
|
||||
}
|
||||
const errors: string[] = [];
|
||||
const out: Record<string, CredentialSpec> = {};
|
||||
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<string, unknown>;
|
||||
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}`];
|
||||
}
|
||||
}
|
||||
@@ -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<ResolvedCredential | null> {
|
||||
const stored = await getUserCredential(user, pluginId, credentialId);
|
||||
if (stored) {
|
||||
return {
|
||||
origin: stored.origin, inject: stored.inject, secret: stored.secret, username: stored.username,
|
||||
trusted: privateProxyOrigins().includes(stored.origin), scope: 'user',
|
||||
};
|
||||
}
|
||||
const plugin = await getPlugin(pluginId);
|
||||
const spec = (plugin as { credentials?: Record<string, CredentialSpec> } | null)?.credentials?.[credentialId];
|
||||
if (!plugin || !plugin.enabled || !spec || spec.scope !== 'admin') return null;
|
||||
const config = await getPluginConfig(pluginId);
|
||||
const secret = config[spec.secretKey];
|
||||
const origin = spec.origin ?? credentialOrigin(spec.originKey ? config[spec.originKey] : undefined);
|
||||
if (typeof secret !== 'string' || !secret || !origin) return null;
|
||||
const username = spec.usernameKey ? config[spec.usernameKey] : undefined;
|
||||
return {
|
||||
origin, inject: spec.inject, secret,
|
||||
username: typeof username === 'string' ? username : undefined,
|
||||
trusted: true, scope: 'admin',
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Rate limiting ────────────────────────────────────────────
|
||||
|
||||
const windows = new Map<string, number[]>();
|
||||
const running = new Map<string, number>();
|
||||
|
||||
function takeSlot(user: string, pluginId: string, now = Date.now()): { ok: true } | { ok: false; retryAfter: number } {
|
||||
if ((running.get(user) ?? 0) >= PROXY_MAX_CONCURRENT) return { ok: false, retryAfter: 1 };
|
||||
const key = `${user}:${pluginId}`;
|
||||
const recent = (windows.get(key) ?? []).filter((t) => now - t < 60_000);
|
||||
if (recent.length >= PROXY_RATE_LIMIT) {
|
||||
windows.set(key, recent);
|
||||
return { ok: false, retryAfter: Math.max(1, Math.ceil((60_000 - (now - recent[0])) / 1000)) };
|
||||
}
|
||||
recent.push(now);
|
||||
windows.set(key, recent);
|
||||
running.set(user, (running.get(user) ?? 0) + 1);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function releaseSlot(user: string): void {
|
||||
const n = (running.get(user) ?? 1) - 1;
|
||||
if (n <= 0) running.delete(user);
|
||||
else running.set(user, n);
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
export function resetProxyLimits(): void {
|
||||
windows.clear();
|
||||
running.clear();
|
||||
}
|
||||
|
||||
// ─── Forwarding ───────────────────────────────────────────────
|
||||
|
||||
/** Ask the proxy to store a field of a JSON response as the secret. */
|
||||
export interface CaptureSpec {
|
||||
/** Top-level JSON field holding the secret, e.g. Nextcloud's `appPassword`. */
|
||||
secret: string;
|
||||
/** Top-level JSON field holding the user name, e.g. `loginName`. */
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface ProxyInput {
|
||||
user: VaultUser;
|
||||
pluginId: string;
|
||||
credentialId: string;
|
||||
url: string;
|
||||
method: string;
|
||||
headers: Headers;
|
||||
body: ReadableStream<Uint8Array> | null;
|
||||
capture?: CaptureSpec;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
function forwardHeaders(input: Headers, credential: ResolvedCredential): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
input.forEach((value, rawName) => {
|
||||
const name = rawName.toLowerCase();
|
||||
if (DROP_REQUEST_HEADERS.has(name)) return;
|
||||
if (DROP_REQUEST_PREFIXES.some((p) => name.startsWith(p))) return;
|
||||
out[name] = value;
|
||||
});
|
||||
// WebDAV COPY/MOVE name their target as a full URL: it must stay on the
|
||||
// credential's server.
|
||||
if (out.destination) {
|
||||
let dest: URL;
|
||||
try { dest = new URL(out.destination); } catch { throw new ProxyError('Invalid Destination header', 400, 'bad_destination'); }
|
||||
if (dest.origin !== credential.origin) throw new ProxyError('Destination must be on the same server', 400, 'bad_destination');
|
||||
}
|
||||
const auth = authHeaderFor(credential.inject, credential.secret, credential.username);
|
||||
if (auth) out[auth[0]] = auth[1];
|
||||
return out;
|
||||
}
|
||||
|
||||
function countingStream(limit: number, onDone: (bytes: number, overLimit: boolean) => void): TransformStream<Uint8Array, Uint8Array> {
|
||||
let bytes = 0;
|
||||
let finished = false;
|
||||
const finish = (over: boolean) => { if (!finished) { finished = true; onDone(bytes, over); } };
|
||||
return new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
bytes += chunk.byteLength;
|
||||
if (bytes > limit) {
|
||||
finish(true);
|
||||
controller.error(new ProxyError('Body too large', 413, 'too_large'));
|
||||
return;
|
||||
}
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
flush() { finish(false); },
|
||||
});
|
||||
}
|
||||
|
||||
async function send(
|
||||
url: string,
|
||||
init: { method: string; headers: Record<string, string>; body?: ReadableStream<Uint8Array> | null; signal: AbortSignal },
|
||||
trusted: boolean,
|
||||
): Promise<Response> {
|
||||
const request = {
|
||||
method: init.method,
|
||||
headers: init.headers,
|
||||
signal: init.signal,
|
||||
...(init.body ? { body: init.body, duplex: 'half' as const } : {}),
|
||||
};
|
||||
if (trusted) return fetch(url, { ...request, redirect: 'manual' } as RequestInit);
|
||||
return (await fetchPublicUrl(url, request as never)) as unknown as Response;
|
||||
}
|
||||
|
||||
function responseHeaders(upstream: Response): Headers {
|
||||
const out = new Headers();
|
||||
upstream.headers.forEach((value, rawName) => {
|
||||
const name = rawName.toLowerCase();
|
||||
if (PASS_RESPONSE_HEADERS.has(name) || PASS_RESPONSE_PREFIXES.some((p) => name.startsWith(p))) out.set(name, value);
|
||||
});
|
||||
// fetch has already decoded a compressed body, so its length is unknown.
|
||||
if (!upstream.headers.get('content-encoding')) {
|
||||
const length = upstream.headers.get('content-length');
|
||||
if (length) out.set('content-length', length);
|
||||
}
|
||||
out.set('cache-control', 'no-store');
|
||||
return out;
|
||||
}
|
||||
|
||||
function redact(body: Record<string, unknown>, capture: CaptureSpec): Record<string, unknown> {
|
||||
const out = { ...body };
|
||||
delete out[capture.secret];
|
||||
if (capture.username) delete out[capture.username];
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward one plugin request. Returns the upstream response with a filtered
|
||||
* set of headers and a size-capped body, or throws ProxyError.
|
||||
*/
|
||||
export async function proxyRequest(input: ProxyInput): Promise<Response> {
|
||||
const method = input.method.toUpperCase();
|
||||
if (!PROXY_METHODS.has(method)) throw new ProxyError(`Method ${method} is not allowed`, 405, 'bad_method');
|
||||
|
||||
const credential = await resolveCredential(input.user, input.pluginId, input.credentialId);
|
||||
if (!credential) throw new ProxyError('No such credential', 404, 'no_credential');
|
||||
|
||||
let target: URL;
|
||||
try { target = new URL(input.url); } catch { throw new ProxyError('Invalid URL', 400, 'bad_url'); }
|
||||
if (target.origin !== credential.origin || target.username || target.password) {
|
||||
throw new ProxyError('This credential is only for its own server', 403, 'wrong_origin');
|
||||
}
|
||||
if (input.capture && credential.scope !== 'user') {
|
||||
throw new ProxyError('Only a credential the user connected can be filled in', 400, 'bad_capture');
|
||||
}
|
||||
|
||||
const who = userKey(input.user);
|
||||
const slot = takeSlot(who, input.pluginId);
|
||||
if (!slot.ok) {
|
||||
throw Object.assign(new ProxyError('Too many requests', 429, 'rate_limited'), { retryAfter: slot.retryAfter });
|
||||
}
|
||||
|
||||
const started = Date.now();
|
||||
let sent = 0;
|
||||
let released = false;
|
||||
const release = () => { if (!released) { released = true; releaseSlot(who); } };
|
||||
const audit = (status: number, received: number, note?: string) => {
|
||||
logger.info('Plugin proxy request', {
|
||||
plugin: input.pluginId, credential: input.credentialId, user: who.slice(0, 12),
|
||||
method, host: target.host, status, sentBytes: sent, receivedBytes: received,
|
||||
ms: Date.now() - started, ...(note ? { note } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
const timeout = AbortSignal.timeout(PROXY_TIMEOUT_MS);
|
||||
const signal = input.signal ? AbortSignal.any([input.signal, timeout]) : timeout;
|
||||
|
||||
try {
|
||||
const headers = forwardHeaders(input.headers, credential);
|
||||
let body = input.body && method !== 'GET' && method !== 'HEAD'
|
||||
? input.body.pipeThrough(countingStream(MAX_PROXY_REQUEST_BYTES, (n) => { sent = n; }))
|
||||
: null;
|
||||
let currentUrl = target.toString();
|
||||
let currentMethod = method;
|
||||
let upstream: Response | null = null;
|
||||
|
||||
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
||||
upstream = await send(currentUrl, { method: currentMethod, headers, body, signal }, credential.trusted);
|
||||
if (upstream.status < 300 || upstream.status >= 400 || upstream.status === 304) break;
|
||||
const location = upstream.headers.get('location');
|
||||
if (!location) break;
|
||||
await upstream.body?.cancel().catch(() => {});
|
||||
const next = new URL(location, currentUrl);
|
||||
// A redirect off the credential's server would take the secret along.
|
||||
if (next.origin !== credential.origin) {
|
||||
throw new ProxyError('The server redirected to another address', 502, 'redirect_off_origin');
|
||||
}
|
||||
if (body && upstream.status !== 307 && upstream.status !== 308) {
|
||||
currentMethod = 'GET';
|
||||
body = null;
|
||||
delete headers['content-type'];
|
||||
} else if (body) {
|
||||
// A streamed body can't be sent twice.
|
||||
throw new ProxyError('The server redirected an upload', 502, 'redirect_with_body');
|
||||
}
|
||||
currentUrl = next.toString();
|
||||
upstream = null;
|
||||
}
|
||||
if (!upstream) throw new ProxyError('Too many redirects', 502, 'too_many_redirects');
|
||||
|
||||
if (input.capture) {
|
||||
const text = await readCapped(upstream, MAX_CAPTURE_BYTES);
|
||||
let json: unknown;
|
||||
try { json = JSON.parse(text); } catch { json = null; }
|
||||
const out = new Headers({ 'content-type': 'application/json', 'cache-control': 'no-store' });
|
||||
if (upstream.ok && json && typeof json === 'object' && !Array.isArray(json)) {
|
||||
const record = json as Record<string, unknown>;
|
||||
const secret = record[input.capture.secret];
|
||||
const username = input.capture.username ? record[input.capture.username] : undefined;
|
||||
if (typeof secret === 'string' && secret) {
|
||||
await setUserCredentialSecret(input.user, input.pluginId, input.credentialId, secret, typeof username === 'string' ? username : undefined);
|
||||
audit(upstream.status, text.length, 'captured');
|
||||
return new Response(JSON.stringify({ ...redact(record, input.capture), captured: true }), { status: upstream.status, headers: out });
|
||||
}
|
||||
audit(upstream.status, text.length);
|
||||
return new Response(JSON.stringify({ ...redact(record, input.capture), captured: false }), { status: upstream.status, headers: out });
|
||||
}
|
||||
audit(upstream.status, text.length);
|
||||
// Never hand back a body that might hold the secret in another shape.
|
||||
return new Response(JSON.stringify({ captured: false }), { status: upstream.status, headers: out });
|
||||
}
|
||||
|
||||
const status = upstream.status;
|
||||
const outHeaders = responseHeaders(upstream);
|
||||
if (!upstream.body) {
|
||||
audit(status, 0);
|
||||
release();
|
||||
return new Response(null, { status, headers: outHeaders });
|
||||
}
|
||||
const stream = upstream.body.pipeThrough(countingStream(MAX_PROXY_RESPONSE_BYTES, (received, over) => {
|
||||
audit(status, received, over ? 'response too large' : undefined);
|
||||
release();
|
||||
}));
|
||||
// The browser may stop reading (cancelled download); free the slot then too.
|
||||
signal.addEventListener('abort', release, { once: true });
|
||||
return new Response(stream, { status, headers: outHeaders });
|
||||
} catch (error) {
|
||||
release();
|
||||
if (error instanceof ProxyError) {
|
||||
audit(error.status, 0, error.code);
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof DisallowedUrlError) {
|
||||
audit(403, 0, 'blocked address');
|
||||
throw new ProxyError('This server address is not allowed', 403, 'blocked_address');
|
||||
}
|
||||
if (signal.aborted) {
|
||||
audit(504, 0, 'timeout');
|
||||
throw new ProxyError('The server did not answer in time', 504, 'timeout');
|
||||
}
|
||||
audit(502, 0, 'unreachable');
|
||||
throw new ProxyError('Could not reach the server', 502, 'unreachable');
|
||||
} finally {
|
||||
if (input.capture) release();
|
||||
}
|
||||
}
|
||||
|
||||
async function readCapped(response: Response, limit: number): Promise<string> {
|
||||
if (!response.body) return '';
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > limit) {
|
||||
await reader.cancel().catch(() => {});
|
||||
throw new ProxyError('Response too large', 502, 'too_large');
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
@@ -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<string, Record<string, StoredCredential>>;
|
||||
|
||||
/** 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<VaultData> {
|
||||
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<void> {
|
||||
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<string, Promise<unknown>>();
|
||||
function serialize<T>(user: VaultUser, task: () => Promise<T>): Promise<T> {
|
||||
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<StoredCredential | null> {
|
||||
const data = await load(user);
|
||||
return data[pluginId]?.[credentialId] ?? null;
|
||||
}
|
||||
|
||||
export async function listUserCredentials(user: VaultUser, pluginId?: string): Promise<CredentialSummary[]> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -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<string, CredentialSpec>;
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
@@ -223,6 +231,8 @@ export interface InstalledTheme {
|
||||
}
|
||||
|
||||
export interface InstalledPlugin {
|
||||
/** From the manifest: see PluginManifest.credentials. */
|
||||
credentials?: Record<string, CredentialSpec>;
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
|
||||
@@ -125,6 +125,7 @@ export const usePluginStore = create<PluginStoreState>()(
|
||||
...(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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user