diff --git a/lib/__tests__/plugin-host-api-limits.test.ts b/lib/__tests__/plugin-host-api-limits.test.ts new file mode 100644 index 000000000..5952fbb49 --- /dev/null +++ b/lib/__tests__/plugin-host-api-limits.test.ts @@ -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()), + apiFetch: (...args: unknown[]) => fetchMock(...args), +})); + +import { dispatchApiCall, PLUGIN_STORAGE_QUOTA_BYTES, safeDownloadName, doGetPublicKeyFromWKD } from '../plugin-sandbox/host-api'; + +function plugin(overrides: Partial = {}): 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(); + }); +}); diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 26a0ab710..d437bb2c7 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -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 { 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 { } // ─── 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 { - 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 { + 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 { return all[key] ?? null; } async function adminSet(pluginId: string, key: string, value: unknown): Promise { - 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): Promise { + 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 { - 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(); + +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': {