feat: http:fetch permission + httpOrigins manifest field

This commit is contained in:
Linus Rath
2026-05-05 21:50:04 +02:00
parent ef8eb1d73b
commit 0885d3c13e
10 changed files with 225 additions and 9 deletions
+17 -1
View File
@@ -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 });
}
+6 -1
View File
@@ -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) {
+2
View File
@@ -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
}));
+8
View File
@@ -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
+26 -5
View File
@@ -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<DevPluginEntry | null>
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<DevPluginEntry | null>
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<DevPluginEntry | null>
? 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<DevPluginEntry | null>
...(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,
+5
View File
@@ -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 {
+130
View File
@@ -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<string, string>;
/** 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<string, string>;
/** Resolves the body as text. */
text: () => Promise<string>;
/** Resolves the body as parsed JSON, or null on parse error. */
json: () => Promise<unknown>;
/** Resolves the body as raw bytes. */
arrayBuffer: () => Promise<ArrayBuffer>;
/** Resolves the body as a Blob. */
blob: () => Promise<Blob>;
}
// --- PluginAPI interface -------------------------------------
export interface PluginAPI {
@@ -137,6 +206,16 @@ export interface PluginAPI {
};
http: {
post: (path: string, body: Record<string, unknown>) => 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<PluginFetchResponse>;
};
storage: ReturnType<typeof createPluginStorage>;
log: ReturnType<typeof createPluginLogger>;
@@ -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<string, string> = {};
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<string, string> = {};
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),
+5 -1
View File
@@ -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<T>(promise: T | Promise<T>, ms: number = DEFAULT_TIMEOUT_MS): Promise<T> {
if (!(promise instanceof Promise)) return Promise.resolve(promise);
@@ -137,7 +141,7 @@ export class HookBus<T extends (...args: any[]) => 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);
+17 -1
View File
@@ -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',
+9
View File
@@ -98,6 +98,9 @@ export const usePluginStore = create<PluginStoreState>()(
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
),