feat: http:fetch permission + httpOrigins manifest field
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}));
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user