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
197 lines
6.6 KiB
TypeScript
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);
|
|
});
|
|
}
|