Files
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

197 lines
6.6 KiB
TypeScript

// 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);
});
}