diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts index 5f0254f1..60ebef10 100644 --- a/app/api/admin/marketplace/route.ts +++ b/app/api/admin/marketplace/route.ts @@ -12,6 +12,7 @@ import { } from '@/lib/admin/plugin-registry'; import { sanitizeFrameOrigins, + sanitizeHttpOrigins, invalidateFrameOriginsCache, } from '@/lib/admin/csp-frame-origins'; import JSZip from 'jszip'; @@ -253,6 +254,18 @@ export async function POST(request: NextRequest) { ); } + const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins); + const droppedHttpOrigins = Array.isArray(manifest.httpOrigins) + ? (manifest.httpOrigins as unknown[]).filter( + (v) => typeof v !== 'string' || !declaredHttpOrigins.includes(v), + ) + : []; + if (droppedHttpOrigins.length > 0) { + warnings.push( + `Ignored invalid httpOrigins: ${droppedHttpOrigins.join(', ')}`, + ); + } + const plugin: ServerPlugin = { id: (manifest.id as string) || slug, name: (manifest.name as string) || slug, @@ -268,11 +281,14 @@ export async function POST(request: NextRequest) { ...(declaredFrameOrigins.length > 0 ? { frameOrigins: declaredFrameOrigins } : {}), + ...(declaredHttpOrigins.length > 0 + ? { httpOrigins: declaredHttpOrigins } + : {}), }; await savePlugin(plugin, code); invalidateFrameOriginsCache(); - await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug, frameOrigins: declaredFrameOrigins }, ip); + await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins }, ip); return NextResponse.json({ success: true, plugin, warnings }); } diff --git a/app/api/admin/plugins/route.ts b/app/api/admin/plugins/route.ts index 3f1f4e12..dd81a9d0 100644 --- a/app/api/admin/plugins/route.ts +++ b/app/api/admin/plugins/route.ts @@ -11,6 +11,7 @@ import { import { listDevPlugins } from '@/lib/admin/plugin-dev'; import { sanitizeFrameOrigins, + sanitizeHttpOrigins, invalidateFrameOriginsCache, } from '@/lib/admin/csp-frame-origins'; @@ -170,6 +171,7 @@ export async function POST(request: NextRequest) { } const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins); + const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins); const now = new Date().toISOString(); const plugin: ServerPlugin = { @@ -188,13 +190,16 @@ export async function POST(request: NextRequest) { ...(declaredFrameOrigins.length > 0 ? { frameOrigins: declaredFrameOrigins } : {}), + ...(declaredHttpOrigins.length > 0 + ? { httpOrigins: declaredHttpOrigins } + : {}), installedAt: now, updatedAt: now, }; await savePlugin(plugin, code); invalidateFrameOriginsCache(); - await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins }, ip); + await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins }, ip); return NextResponse.json({ plugin }); } catch (error) { diff --git a/app/api/plugins/route.ts b/app/api/plugins/route.ts index 80498639..d3674534 100644 --- a/app/api/plugins/route.ts +++ b/app/api/plugins/route.ts @@ -41,6 +41,8 @@ export async function GET() { updatedAt: p.updatedAt, // Marks plugins loaded from PLUGIN_DEV_DIR. Surface in UI as a badge. dev: p.dev, + // Surface so clients can enforce api.http.fetch origin allowlists. + httpOrigins: p.httpOrigins, settingsSchema: undefined, // Will be read from the bundle's manifest })); diff --git a/lib/admin/csp-frame-origins.ts b/lib/admin/csp-frame-origins.ts index 910aca17..4b6f69af 100644 --- a/lib/admin/csp-frame-origins.ts +++ b/lib/admin/csp-frame-origins.ts @@ -52,6 +52,14 @@ export function sanitizeFrameOrigins(input: unknown): string[] { return out; } +/** + * Same syntax + validation as `sanitizeFrameOrigins`, but for the + * `httpOrigins` manifest field. Kept as a separate exported function so the + * intent is explicit at every call site (frame embedding vs. HTTP fetch). + */ +export const sanitizeHttpOrigins = sanitizeFrameOrigins; +export const isValidHttpOrigin = isValidFrameOrigin; + // In-memory cache. The proxy fires on every page navigation; reading the // registry JSON every time is fine but cheap to skip when nothing has // changed. Five seconds is short enough to make plugin install/uninstall diff --git a/lib/admin/plugin-dev.ts b/lib/admin/plugin-dev.ts index 6f3b1bf8..0632d8e6 100644 --- a/lib/admin/plugin-dev.ts +++ b/lib/admin/plugin-dev.ts @@ -4,6 +4,7 @@ import { createHash } from 'node:crypto'; import path from 'node:path'; import { logger } from '@/lib/logger'; import type { ServerPlugin } from './plugin-registry'; +import { sanitizeFrameOrigins, sanitizeHttpOrigins } from './csp-frame-origins'; /** * Dev-mode plugin loading. @@ -118,16 +119,28 @@ async function loadDevPlugin(pluginDir: string): Promise if (!existsSync(manifestPath)) { manifestPath = path.join(pluginDir, 'dist', 'manifest.json'); } - if (!existsSync(manifestPath)) return null; + if (!existsSync(manifestPath)) { + logger.warn(`[plugin-dev] no manifest.json at root or dist/ in ${pluginDir}`); + return null; + } const manifest = await readManifest(manifestPath); - if (!manifest) return null; + if (!manifest) { + logger.warn(`[plugin-dev] manifest unreadable or not a JSON object: ${manifestPath}`); + return null; + } const id = asString(manifest.id); - if (!PLUGIN_ID_RE.test(id)) return null; + if (!PLUGIN_ID_RE.test(id)) { + logger.warn(`[plugin-dev] manifest id "${id}" rejected by id regex (${manifestPath})`); + return null; + } const entrypoint = asString(manifest.entrypoint, 'index.js'); const resolved = resolveBundlePath(pluginDir, entrypoint); - if (!resolved) return null; + if (!resolved) { + logger.warn(`[plugin-dev] entrypoint "${entrypoint}" not found at src/, root, or dist/ for ${id}`); + return null; + } // Hash from the on-disk source so any edit propagates. For src/ sources // we hash the source — close enough for dev-time change detection (we @@ -136,7 +149,10 @@ async function loadDevPlugin(pluginDir: string): Promise try { const code = await readFile(resolved.bundlePath); bundleHash = createHash('sha256').update(code).digest('hex').slice(0, 16); - } catch { + } catch (err) { + logger.warn(`[plugin-dev] failed to read ${resolved.bundlePath} for ${id}`, { + error: err instanceof Error ? err.message : String(err), + }); return null; } @@ -152,6 +168,9 @@ async function loadDevPlugin(pluginDir: string): Promise ? manifest.permissions.filter((p): p is string => typeof p === 'string') : []; + const frameOrigins = sanitizeFrameOrigins(manifest.frameOrigins); + const httpOrigins = sanitizeHttpOrigins(manifest.httpOrigins); + const plugin: ServerPlugin = { id, name: asString(manifest.name, id), @@ -166,6 +185,8 @@ async function loadDevPlugin(pluginDir: string): Promise ...(manifest.configSchema && typeof manifest.configSchema === 'object' ? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] } : {}), + ...(frameOrigins.length > 0 ? { frameOrigins } : {}), + ...(httpOrigins.length > 0 ? { httpOrigins } : {}), installedAt, updatedAt: new Date().toISOString(), bundleHash, diff --git a/lib/admin/plugin-registry.ts b/lib/admin/plugin-registry.ts index 13fab06c..f3bc582e 100644 --- a/lib/admin/plugin-registry.ts +++ b/lib/admin/plugin-registry.ts @@ -53,6 +53,11 @@ export interface ServerPlugin { * embed. Merged into the host frame-src by the proxy. */ frameOrigins?: string[]; + /** + * Validated HTTPS origins the plugin may target via `api.http.fetch()`. + * Same syntax as `frameOrigins`. Surfaced to clients via /api/plugins. + */ + httpOrigins?: string[]; } export interface ServerTheme { diff --git a/lib/plugin-api.ts b/lib/plugin-api.ts index ee717b10..e8547650 100644 --- a/lib/plugin-api.ts +++ b/lib/plugin-api.ts @@ -108,6 +108,75 @@ function createPluginLogger(pluginId: string) { }; } +// --- Cross-origin fetch helpers ------------------------------ + +/** + * Returns true when `url`'s origin is allowed by one of the plugin's + * declared `httpOrigins` patterns. Patterns are either a literal origin + * (`https://host[:port]`) or a wildcard subdomain form (`https://*.host`). + * + * Wildcards match exactly one subdomain layer above `host` — e.g. + * `https://*.example.com` matches `https://a.example.com` but NOT + * `https://example.com` and NOT `https://a.b.example.com`. This mirrors how + * the CSP frame-src handles wildcards and avoids accidentally widening + * access when the manifest only intended a single tier. + */ +function originMatchesAllowlist(url: URL, allowlist: string[]): boolean { + if (url.protocol !== 'https:') return false; + for (const entry of allowlist) { + let parsed: URL; + try { + parsed = new URL(entry.replace('*.', '')); + } catch { + continue; + } + if (parsed.protocol !== 'https:') continue; + const port = url.port || ''; + const expectedPort = parsed.port || ''; + if (port !== expectedPort) continue; + if (entry.includes('*.')) { + const suffix = '.' + parsed.hostname.toLowerCase(); + if (url.hostname.toLowerCase().endsWith(suffix)) { + const prefix = url.hostname.slice(0, url.hostname.length - suffix.length); + // Require exactly one non-empty subdomain label. + if (prefix.length > 0 && !prefix.includes('.')) return true; + } + } else { + if (url.hostname.toLowerCase() === parsed.hostname.toLowerCase()) return true; + } + } + return false; +} + +// --- Cross-origin fetch types -------------------------------- + +export interface PluginFetchInit { + /** HTTP method. Defaults to GET. */ + method?: string; + /** Request headers. Plain object only — no Headers / cookies forwarded. */ + headers?: Record; + /** Body. Plain string, ArrayBuffer, Uint8Array, Blob, or FormData. */ + body?: string | ArrayBuffer | ArrayBufferView | Blob | FormData | null; + /** Optional AbortSignal for cancellation. */ + signal?: AbortSignal; +} + +export interface PluginFetchResponse { + ok: boolean; + status: number; + statusText: string; + /** Response headers, lower-cased keys. */ + headers: Record; + /** Resolves the body as text. */ + text: () => Promise; + /** Resolves the body as parsed JSON, or null on parse error. */ + json: () => Promise; + /** Resolves the body as raw bytes. */ + arrayBuffer: () => Promise; + /** Resolves the body as a Blob. */ + blob: () => Promise; +} + // --- PluginAPI interface ------------------------------------- export interface PluginAPI { @@ -137,6 +206,16 @@ export interface PluginAPI { }; http: { post: (path: string, body: Record) => Promise<{ ok: boolean; status: number; data: unknown }>; + /** + * Cross-origin fetch against an origin declared in the manifest's + * `httpOrigins` allowlist. Requires `http:fetch` permission. + * + * No webmail credentials are forwarded — the plugin must supply its own + * `Authorization` (or other auth) header. Each call is gated on origin + * even when the URL came from plugin settings, so a user-pasted URL + * outside the allowlist is rejected at the boundary. + */ + fetch: (url: string, init?: PluginFetchInit) => Promise; }; storage: ReturnType; log: ReturnType; @@ -759,6 +838,57 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI { const data = await res.json().catch(() => null); return { ok: res.ok, status: res.status, data }; }, + + fetch: async (rawUrl: string, init?: PluginFetchInit) => { + requirePermission(plugin, 'http:fetch'); + if (typeof rawUrl !== 'string') { + throw new Error('url must be a string'); + } + let url: URL; + try { + url = new URL(rawUrl); + } catch { + throw new Error('url must be an absolute https:// URL'); + } + const allowlist = plugin.httpOrigins ?? []; + if (allowlist.length === 0) { + throw new Error(`Plugin "${plugin.id}" has no httpOrigins declared`); + } + if (!originMatchesAllowlist(url, allowlist)) { + throw new Error(`Origin ${url.origin} not in plugin httpOrigins allowlist`); + } + // Defence-in-depth: don't let the plugin smuggle a header that the + // host's same-origin /api flow uses to authenticate the user. + const safeHeaders: Record = {}; + if (init?.headers) { + for (const [k, v] of Object.entries(init.headers)) { + const lower = k.toLowerCase(); + if (lower === 'cookie' || lower === 'x-jmap-username') continue; + safeHeaders[k] = v; + } + } + const res = await fetch(url.toString(), { + method: init?.method ?? 'GET', + headers: safeHeaders, + body: (init?.body ?? undefined) as BodyInit | undefined, + signal: init?.signal, + credentials: 'omit', + mode: 'cors', + redirect: 'follow', + }); + const headersOut: Record = {}; + res.headers.forEach((value, key) => { headersOut[key.toLowerCase()] = value; }); + return { + ok: res.ok, + status: res.status, + statusText: res.statusText, + headers: headersOut, + text: () => res.text(), + json: () => res.json().catch(() => null), + arrayBuffer: () => res.arrayBuffer(), + blob: () => res.blob(), + }; + }, }, storage: createPluginStorage(plugin.id), diff --git a/lib/plugin-hooks.ts b/lib/plugin-hooks.ts index 61f3a6f7..4dc57061 100644 --- a/lib/plugin-hooks.ts +++ b/lib/plugin-hooks.ts @@ -59,6 +59,10 @@ export const pluginErrorTracker = new PluginErrorTracker(); // ─── Timeout Helper ────────────────────────────────────────── const DEFAULT_TIMEOUT_MS = 5000; +// Intercept hooks frequently block on user confirmation modals (send, +// reply-all, mailto, attachment upload), so they need a much longer budget +// than observer / transform hooks. +const INTERCEPT_TIMEOUT_MS = 60_000; function withTimeout(promise: T | Promise, ms: number = DEFAULT_TIMEOUT_MS): Promise { if (!(promise instanceof Promise)) return Promise.resolve(promise); @@ -137,7 +141,7 @@ export class HookBus any> { for (const { pluginId, handler } of this.handlers) { if (pluginErrorTracker.isDisabled(pluginId)) continue; try { - const result = await withTimeout(handler(...args)); + const result = await withTimeout(handler(...args), INTERCEPT_TIMEOUT_MS); if (result === false) return false; } catch (err) { pluginErrorTracker.record(pluginId, err); diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index a1a58de3..4aaae59e 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -116,6 +116,17 @@ export interface PluginManifest { * Validated at install time and merged into the host CSP `frame-src`. */ frameOrigins?: string[]; + /** + * External HTTPS origins this plugin may make `api.http.fetch()` requests + * to. Same syntax as `frameOrigins`. Validated at install time. Each + * `api.http.fetch` call's URL must resolve to one of these origins (exact + * host or a `*.host` wildcard match). + * + * Use for plugins that talk directly to a third-party service (e.g. + * Nextcloud, Slack) instead of going through a same-origin /api/* route. + * The remote host must serve CORS headers permitting the webmail origin. + */ + httpOrigins?: string[]; // ─── Marketplace media (NOT shipped in the runtime zip) ────── /** @@ -208,6 +219,11 @@ export interface InstalledPlugin { * detect re-uploads of the same version so clients re-download the JS. */ bundleHash?: string; + /** + * Validated allowlist of external HTTPS origins this plugin may target via + * `api.http.fetch()`. Carried over from the manifest at install time. + */ + httpOrigins?: string[]; } // ─── UI Slots ──────────────────────────────────────────────── @@ -761,7 +777,7 @@ export const ALL_PERMISSIONS = [ 'settings:read', 'settings:write', 'security:read', 'auth:observe', - 'http:post', + 'http:post', 'http:fetch', 'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer', 'ui:composer-toolbar', 'ui:composer-sidebar', 'ui:sidebar-widget', 'ui:settings-section', diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index 6b455826..4ad281cf 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -98,6 +98,9 @@ export const usePluginStore = create()( adminApproved: false, // Requires admin approval before it can be enabled settings: existing?.settings ?? {}, settingsSchema: manifest.settingsSchema, + ...(manifest.httpOrigins && manifest.httpOrigins.length > 0 + ? { httpOrigins: manifest.httpOrigins } + : {}), }; // Save code to IndexedDB @@ -308,6 +311,8 @@ interface ServerPluginInfo { updatedAt?: string; /** True when the plugin was loaded from the server's PLUGIN_DEV_DIR */ dev?: boolean; + /** Allowlist of origins this plugin may target via api.http.fetch(). */ + httpOrigins?: string[]; } const SERVER_MANAGED_KEY = 'server-managed-plugin-ids'; @@ -408,6 +413,9 @@ async function syncServerPlugins( adminApproved: true, // Server-managed plugins are always approved settings: {}, bundleHash: sp.bundleHash, + ...(sp.httpOrigins && sp.httpOrigins.length > 0 + ? { httpOrigins: sp.httpOrigins } + : {}), }; set(state => { @@ -443,6 +451,7 @@ async function syncServerPlugins( managed: true, forceEnabled: sp.forceEnabled, bundleHash: sp.bundleHash, + httpOrigins: sp.httpOrigins, } : p ),