fix: limit what a plugin can store, download, toast and send to a WKD lookup, and report refused admin config writes

This commit is contained in:
Linus Rath
2026-10-14 02:30:54 +02:00
parent 22846b4a9b
commit 23c4c654d7
2 changed files with 207 additions and 25 deletions
@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
import type { InstalledPlugin } from '../plugin-types';
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }));
vi.mock('@/stores/toast-store', () => ({ toast }));
const fetchMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/browser-navigation', async (orig) => ({
...(await orig<typeof import('@/lib/browser-navigation')>()),
apiFetch: (...args: unknown[]) => fetchMock(...args),
}));
import { dispatchApiCall, PLUGIN_STORAGE_QUOTA_BYTES, safeDownloadName, doGetPublicKeyFromWKD } from '../plugin-sandbox/host-api';
function plugin(overrides: Partial<InstalledPlugin> = {}): InstalledPlugin {
return {
id: 'limits', name: 'Limits', version: '1.0.0', author: '', description: '', type: 'hook',
permissions: [], entrypoint: 'index.js', enabled: true, status: 'running', settings: {},
...overrides,
};
}
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
describe('plugin storage quota', () => {
it('refuses a write that would take the plugin past its share', async () => {
// Without a cap one plugin could fill localStorage and break sign-in.
const half = 'x'.repeat(PLUGIN_STORAGE_QUOTA_BYTES / 2);
await dispatchApiCall(plugin(), 'storage.set', ['a', half]);
await expect(dispatchApiCall(plugin(), 'storage.set', ['b', half])).rejects.toThrow(/storage is full/);
});
it('lets a plugin overwrite a key within its share', async () => {
const big = 'x'.repeat(PLUGIN_STORAGE_QUOTA_BYTES * 0.8);
await dispatchApiCall(plugin(), 'storage.set', ['a', big]);
await expect(dispatchApiCall(plugin(), 'storage.set', ['a', big])).resolves.toBeUndefined();
});
});
describe('ui.downloadFile', () => {
const p = plugin({ permissions: ['ui:download-file'], grantedPermissions: ['ui:download-file'] });
it.each([
['invoice.pdf.exe', 'application/pdf'],
['page.html', 'text/plain'],
['report.txt', 'text/html'],
['logo.svg', 'image/svg+xml'],
])('refuses %s (%s)', async (filename, contentType) => {
await expect(dispatchApiCall(p, 'ui.downloadFile', [{ content: 'x', filename, contentType }]))
.rejects.toThrow(/can't be downloaded/);
});
it('strips paths and direction tricks from the name', () => {
expect(safeDownloadName('../../etc/passwd')).toBe('_.._etc_passwd');
expect(safeDownloadName('invoice‮fdp.exe')).toBe('invoicefdp.exe');
expect(safeDownloadName('')).toBe('download');
});
});
describe('toasts', () => {
it('caps how many toasts a plugin shows in a burst', async () => {
for (let i = 0; i < 20; i++) await dispatchApiCall(plugin(), 'toast.info', [`n${i}`]);
expect(toast.info).toHaveBeenCalledTimes(5);
});
it('shortens very long messages', async () => {
await dispatchApiCall(plugin({ id: 'long' }), 'toast.error', ['x'.repeat(5000)]);
expect((toast.error.mock.calls[0][0] as string).length).toBeLessThanOrEqual(300);
});
});
describe('admin config writes', () => {
it('tells the plugin when the server refused the write', async () => {
// A 403 used to look like success.
fetchMock.mockResolvedValue(new Response('{}', { status: 403 }));
const p = plugin({ permissions: ['admin:config'], grantedPermissions: ['admin:config'] });
await expect(dispatchApiCall(p, 'admin.setConfig', ['k', 'v'])).rejects.toThrow(/only an administrator/);
});
});
describe('WKD lookup', () => {
it('refuses an address whose domain is not a host name', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
expect(await doGetPublicKeyFromWKD('a@evil.example/x?')).toMatchObject({ status: 'ERROR' });
expect(fetchSpy).not.toHaveBeenCalled();
fetchSpy.mockRestore();
});
});
+113 -25
View File
@@ -213,9 +213,32 @@ function storageGet(pluginId: string, key: string): unknown {
if (raw === null) return null;
try { return JSON.parse(raw); } catch { return null; }
}
/**
* What one plugin may keep per account in localStorage. The host's own
* stores (sign-in, settings) live in the same 5-10 MB browser quota, so a
* plugin that filled it would break them.
*/
export const PLUGIN_STORAGE_QUOTA_BYTES = 1024 * 1024;
function storageSet(pluginId: string, key: string, value: unknown): void {
if (typeof window === 'undefined') return;
window.localStorage.setItem(storagePrefix(pluginId) + key, JSON.stringify(value));
if (typeof key !== 'string' || key.length === 0 || key.length > 256) {
throw new Error('storage.set: key must be a string of 1-256 characters');
}
const prefix = storagePrefix(pluginId);
const serialized = JSON.stringify(value);
if (serialized === undefined) throw new Error('storage.set: value is not JSON-serialisable');
let used = 0;
for (let i = 0; i < window.localStorage.length; i++) {
const k = window.localStorage.key(i);
if (!k?.startsWith(prefix) || k === prefix + key) continue;
used += k.length + (window.localStorage.getItem(k)?.length ?? 0);
}
// UTF-16 code units, which is what browsers count against the quota.
if (used + prefix.length + key.length + serialized.length > PLUGIN_STORAGE_QUOTA_BYTES) {
throw new Error(`Plugin storage is full (${Math.round(PLUGIN_STORAGE_QUOTA_BYTES / 1024)} KB per account)`);
}
window.localStorage.setItem(prefix + key, serialized);
}
function storageRemove(pluginId: string, key: string): void {
if (typeof window === 'undefined') return;
@@ -1003,6 +1026,11 @@ export async function doGetPublicKeyFromWKD(email: string): Promise<WkdResult> {
const localPart = cleanEmail.slice(0, atIndex);
const domain = cleanEmail.slice(atIndex + 1).toLowerCase();
// The domain goes into a URL; anything but a host name would change where
// the request goes (a@evil.example/x?).
if (!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(domain)) {
return { status: 'ERROR', error: 'Invalid email address' };
}
const hash = getWkdHash(localPart);
const urls = [
@@ -1063,21 +1091,47 @@ async function saveFile(formerFileID:string, file: File): Promise<string> {
}
// ─── Download files generated by the plugin. This is not user's files or attachments ──────────────────────────
async function downloadFile(args: { content: string; filename: string; contentType?: string }): Promise<void> {
const { content, filename, contentType = 'application/json' } = args;
// File types a plugin may not hand the user: programs, scripts, and
// documents a browser opens as an active page.
const BLOCKED_DOWNLOAD_EXTENSIONS = new Set([
'exe', 'msi', 'bat', 'cmd', 'com', 'scr', 'ps1', 'vbs', 'vbe', 'js', 'jse', 'wsf', 'hta', 'jar',
'lnk', 'app', 'dmg', 'pkg', 'sh', 'command', 'html', 'htm', 'xhtml', 'shtml', 'svg', 'mhtml', 'xml',
]);
const BLOCKED_DOWNLOAD_TYPES = /^(text\/html|application\/xhtml|image\/svg|application\/(x-)?javascript|text\/javascript|application\/x-msdownload|application\/xml|text\/xml)/i;
try {
const url = URL.createObjectURL(new Blob([content], { type: contentType }));
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
throw new Error(`Failed to download file: ${error}`);
}
/** A file name that can't carry a path, control characters or a hidden extension. */
export function safeDownloadName(name: unknown): string {
const raw = typeof name === 'string' ? name : '';
const cleaned = raw
// eslint-disable-next-line no-control-regex -- removing control characters is the point
.replace(/[\u0000-\u001f\u007f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, '')
.replace(/[\\/:*?"<>|]/g, '_')
.replace(/^[.\s]+|[.\s]+$/g, '')
.slice(0, 120);
return cleaned || 'download';
}
async function downloadFile(args: { content: string | ArrayBuffer | Uint8Array; filename: string; contentType?: string }): Promise<void> {
const { content } = args;
const filename = safeDownloadName(args.filename);
const contentType = typeof args.contentType === 'string' && args.contentType ? args.contentType : 'application/octet-stream';
const ext = filename.includes('.') ? filename.slice(filename.lastIndexOf('.') + 1).toLowerCase() : '';
if (BLOCKED_DOWNLOAD_EXTENSIONS.has(ext) || BLOCKED_DOWNLOAD_TYPES.test(contentType)) {
throw new Error(`ui.downloadFile: ${ext ? `.${ext} files` : contentType} can't be downloaded from a plugin`);
}
if (typeof content !== 'string' && !(content instanceof ArrayBuffer) && !(content instanceof Uint8Array)) {
throw new Error('ui.downloadFile: content must be a string or bytes');
}
const url = URL.createObjectURL(new Blob([content as BlobPart], { type: contentType }));
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Revoking at once can cancel the download in some browsers.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
// ─── Email keyword mutations ──────────────────────────────────
@@ -1462,18 +1516,52 @@ async function adminGet(pluginId: string, key: string): Promise<unknown> {
return all[key] ?? null;
}
async function adminSet(pluginId: string, key: string, value: unknown): Promise<void> {
await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
await expectOk('admin.setConfig', apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value }),
});
}));
}
/** A failed admin config write used to look like success to the plugin. */
async function expectOk(method: string, request: Promise<Response>): Promise<void> {
const res = await request;
if (!res.ok) {
throw new Error(res.status === 401 || res.status === 403
? `${method}: only an administrator can change this plugin's admin settings`
: `${method} failed (HTTP ${res.status})`);
}
}
async function adminDelete(pluginId: string, key: string): Promise<void> {
await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
await expectOk('admin.deleteConfig', apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }),
});
}));
}
// ─── Toasts ──────────────────────────────────────────────────
const MAX_TOAST_LENGTH = 300;
const TOASTS_PER_TEN_SECONDS = 5;
const recentToasts = new Map<string, number[]>();
function toastText(value: unknown): string {
const text = String(value ?? '');
return text.length > MAX_TOAST_LENGTH ? text.slice(0, MAX_TOAST_LENGTH - 1) + '…' : text;
}
/** A plugin may show a few toasts; past that they are dropped, not queued. */
function mayToast(pluginId: string): boolean {
const now = Date.now();
const recent = (recentToasts.get(pluginId) ?? []).filter((t) => now - t < 10_000);
if (recent.length >= TOASTS_PER_TEN_SECONDS) {
recentToasts.set(pluginId, recent);
return false;
}
recent.push(now);
recentToasts.set(pluginId, recent);
return true;
}
// ─── Dispatcher ──────────────────────────────────────────────
@@ -1508,10 +1596,10 @@ export async function dispatchApiCall(
case 'storage.remove': storageRemove(plugin.id, args[0] as string); return undefined;
case 'storage.keys': return storageKeys(plugin.id);
case 'toast.success': appToast.success(String(args[0] ?? '')); return undefined;
case 'toast.error': appToast.error(String(args[0] ?? '')); return undefined;
case 'toast.info': appToast.info(String(args[0] ?? '')); return undefined;
case 'toast.warning': appToast.warning(String(args[0] ?? '')); return undefined;
case 'toast.success': if (mayToast(plugin.id)) appToast.success(toastText(args[0])); return undefined;
case 'toast.error': if (mayToast(plugin.id)) appToast.error(toastText(args[0])); return undefined;
case 'toast.info': if (mayToast(plugin.id)) appToast.info(toastText(args[0])); return undefined;
case 'toast.warning': if (mayToast(plugin.id)) appToast.warning(toastText(args[0])); return undefined;
case 'http.post': return doHttpPost(plugin, args[0] as string, args[1], args[2] as PluginHttpPostOptions | undefined);
case 'http.fetch': return doHttpFetch(plugin, args[0] as string, args[1] as PluginFetchInit | undefined);
@@ -1681,8 +1769,8 @@ export async function dispatchApiCall(
return undefined;
}
case 'ui.downloadFile': {
const opts = args[0] as { content: string; filename: string; contentType?: string };
return downloadFile(opts);
const opts = args[0] as { content: string | ArrayBuffer | Uint8Array; filename: string; contentType?: string };
return downloadFile(opts ?? { content: '', filename: '' });
}
case 'email.setKeyword': {