mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
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:
@@ -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('invoicefdp.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
@@ -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': {
|
||||
|
||||
Reference in New Issue
Block a user