feat: http:fetch permission + httpOrigins manifest field
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
|||||||
} from '@/lib/admin/plugin-registry';
|
} from '@/lib/admin/plugin-registry';
|
||||||
import {
|
import {
|
||||||
sanitizeFrameOrigins,
|
sanitizeFrameOrigins,
|
||||||
|
sanitizeHttpOrigins,
|
||||||
invalidateFrameOriginsCache,
|
invalidateFrameOriginsCache,
|
||||||
} from '@/lib/admin/csp-frame-origins';
|
} from '@/lib/admin/csp-frame-origins';
|
||||||
import JSZip from 'jszip';
|
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 = {
|
const plugin: ServerPlugin = {
|
||||||
id: (manifest.id as string) || slug,
|
id: (manifest.id as string) || slug,
|
||||||
name: (manifest.name as string) || slug,
|
name: (manifest.name as string) || slug,
|
||||||
@@ -268,11 +281,14 @@ export async function POST(request: NextRequest) {
|
|||||||
...(declaredFrameOrigins.length > 0
|
...(declaredFrameOrigins.length > 0
|
||||||
? { frameOrigins: declaredFrameOrigins }
|
? { frameOrigins: declaredFrameOrigins }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(declaredHttpOrigins.length > 0
|
||||||
|
? { httpOrigins: declaredHttpOrigins }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
await savePlugin(plugin, code);
|
await savePlugin(plugin, code);
|
||||||
invalidateFrameOriginsCache();
|
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 });
|
return NextResponse.json({ success: true, plugin, warnings });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { listDevPlugins } from '@/lib/admin/plugin-dev';
|
import { listDevPlugins } from '@/lib/admin/plugin-dev';
|
||||||
import {
|
import {
|
||||||
sanitizeFrameOrigins,
|
sanitizeFrameOrigins,
|
||||||
|
sanitizeHttpOrigins,
|
||||||
invalidateFrameOriginsCache,
|
invalidateFrameOriginsCache,
|
||||||
} from '@/lib/admin/csp-frame-origins';
|
} from '@/lib/admin/csp-frame-origins';
|
||||||
|
|
||||||
@@ -170,6 +171,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
||||||
|
const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins);
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const plugin: ServerPlugin = {
|
const plugin: ServerPlugin = {
|
||||||
@@ -188,13 +190,16 @@ export async function POST(request: NextRequest) {
|
|||||||
...(declaredFrameOrigins.length > 0
|
...(declaredFrameOrigins.length > 0
|
||||||
? { frameOrigins: declaredFrameOrigins }
|
? { frameOrigins: declaredFrameOrigins }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(declaredHttpOrigins.length > 0
|
||||||
|
? { httpOrigins: declaredHttpOrigins }
|
||||||
|
: {}),
|
||||||
installedAt: now,
|
installedAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
await savePlugin(plugin, code);
|
await savePlugin(plugin, code);
|
||||||
invalidateFrameOriginsCache();
|
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 });
|
return NextResponse.json({ plugin });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export async function GET() {
|
|||||||
updatedAt: p.updatedAt,
|
updatedAt: p.updatedAt,
|
||||||
// Marks plugins loaded from PLUGIN_DEV_DIR. Surface in UI as a badge.
|
// Marks plugins loaded from PLUGIN_DEV_DIR. Surface in UI as a badge.
|
||||||
dev: p.dev,
|
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
|
settingsSchema: undefined, // Will be read from the bundle's manifest
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,14 @@ export function sanitizeFrameOrigins(input: unknown): string[] {
|
|||||||
return out;
|
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
|
// 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
|
// registry JSON every time is fine but cheap to skip when nothing has
|
||||||
// changed. Five seconds is short enough to make plugin install/uninstall
|
// 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 path from 'node:path';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import type { ServerPlugin } from './plugin-registry';
|
import type { ServerPlugin } from './plugin-registry';
|
||||||
|
import { sanitizeFrameOrigins, sanitizeHttpOrigins } from './csp-frame-origins';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dev-mode plugin loading.
|
* Dev-mode plugin loading.
|
||||||
@@ -118,16 +119,28 @@ async function loadDevPlugin(pluginDir: string): Promise<DevPluginEntry | null>
|
|||||||
if (!existsSync(manifestPath)) {
|
if (!existsSync(manifestPath)) {
|
||||||
manifestPath = path.join(pluginDir, 'dist', 'manifest.json');
|
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);
|
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);
|
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 entrypoint = asString(manifest.entrypoint, 'index.js');
|
||||||
const resolved = resolveBundlePath(pluginDir, entrypoint);
|
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
|
// 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
|
// 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 {
|
try {
|
||||||
const code = await readFile(resolved.bundlePath);
|
const code = await readFile(resolved.bundlePath);
|
||||||
bundleHash = createHash('sha256').update(code).digest('hex').slice(0, 16);
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +168,9 @@ async function loadDevPlugin(pluginDir: string): Promise<DevPluginEntry | null>
|
|||||||
? manifest.permissions.filter((p): p is string => typeof p === 'string')
|
? manifest.permissions.filter((p): p is string => typeof p === 'string')
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
const frameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
|
||||||
|
const httpOrigins = sanitizeHttpOrigins(manifest.httpOrigins);
|
||||||
|
|
||||||
const plugin: ServerPlugin = {
|
const plugin: ServerPlugin = {
|
||||||
id,
|
id,
|
||||||
name: asString(manifest.name, id),
|
name: asString(manifest.name, id),
|
||||||
@@ -166,6 +185,8 @@ async function loadDevPlugin(pluginDir: string): Promise<DevPluginEntry | null>
|
|||||||
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
||||||
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(frameOrigins.length > 0 ? { frameOrigins } : {}),
|
||||||
|
...(httpOrigins.length > 0 ? { httpOrigins } : {}),
|
||||||
installedAt,
|
installedAt,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
bundleHash,
|
bundleHash,
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ export interface ServerPlugin {
|
|||||||
* embed. Merged into the host frame-src by the proxy.
|
* embed. Merged into the host frame-src by the proxy.
|
||||||
*/
|
*/
|
||||||
frameOrigins?: string[];
|
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 {
|
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 -------------------------------------
|
// --- PluginAPI interface -------------------------------------
|
||||||
|
|
||||||
export interface PluginAPI {
|
export interface PluginAPI {
|
||||||
@@ -137,6 +206,16 @@ export interface PluginAPI {
|
|||||||
};
|
};
|
||||||
http: {
|
http: {
|
||||||
post: (path: string, body: Record<string, unknown>) => Promise<{ ok: boolean; status: number; data: unknown }>;
|
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>;
|
storage: ReturnType<typeof createPluginStorage>;
|
||||||
log: ReturnType<typeof createPluginLogger>;
|
log: ReturnType<typeof createPluginLogger>;
|
||||||
@@ -759,6 +838,57 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
|
|||||||
const data = await res.json().catch(() => null);
|
const data = await res.json().catch(() => null);
|
||||||
return { ok: res.ok, status: res.status, data };
|
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),
|
storage: createPluginStorage(plugin.id),
|
||||||
|
|||||||
+5
-1
@@ -59,6 +59,10 @@ export const pluginErrorTracker = new PluginErrorTracker();
|
|||||||
// ─── Timeout Helper ──────────────────────────────────────────
|
// ─── Timeout Helper ──────────────────────────────────────────
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 5000;
|
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> {
|
function withTimeout<T>(promise: T | Promise<T>, ms: number = DEFAULT_TIMEOUT_MS): Promise<T> {
|
||||||
if (!(promise instanceof Promise)) return Promise.resolve(promise);
|
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) {
|
for (const { pluginId, handler } of this.handlers) {
|
||||||
if (pluginErrorTracker.isDisabled(pluginId)) continue;
|
if (pluginErrorTracker.isDisabled(pluginId)) continue;
|
||||||
try {
|
try {
|
||||||
const result = await withTimeout(handler(...args));
|
const result = await withTimeout(handler(...args), INTERCEPT_TIMEOUT_MS);
|
||||||
if (result === false) return false;
|
if (result === false) return false;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
pluginErrorTracker.record(pluginId, 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`.
|
* Validated at install time and merged into the host CSP `frame-src`.
|
||||||
*/
|
*/
|
||||||
frameOrigins?: string[];
|
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) ──────
|
// ─── 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.
|
* detect re-uploads of the same version so clients re-download the JS.
|
||||||
*/
|
*/
|
||||||
bundleHash?: string;
|
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 ────────────────────────────────────────────────
|
// ─── UI Slots ────────────────────────────────────────────────
|
||||||
@@ -761,7 +777,7 @@ export const ALL_PERMISSIONS = [
|
|||||||
'settings:read', 'settings:write',
|
'settings:read', 'settings:write',
|
||||||
'security:read',
|
'security:read',
|
||||||
'auth:observe',
|
'auth:observe',
|
||||||
'http:post',
|
'http:post', 'http:fetch',
|
||||||
'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer',
|
'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer',
|
||||||
'ui:composer-toolbar', 'ui:composer-sidebar',
|
'ui:composer-toolbar', 'ui:composer-sidebar',
|
||||||
'ui:sidebar-widget', 'ui:settings-section',
|
'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
|
adminApproved: false, // Requires admin approval before it can be enabled
|
||||||
settings: existing?.settings ?? {},
|
settings: existing?.settings ?? {},
|
||||||
settingsSchema: manifest.settingsSchema,
|
settingsSchema: manifest.settingsSchema,
|
||||||
|
...(manifest.httpOrigins && manifest.httpOrigins.length > 0
|
||||||
|
? { httpOrigins: manifest.httpOrigins }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save code to IndexedDB
|
// Save code to IndexedDB
|
||||||
@@ -308,6 +311,8 @@ interface ServerPluginInfo {
|
|||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
/** True when the plugin was loaded from the server's PLUGIN_DEV_DIR */
|
/** True when the plugin was loaded from the server's PLUGIN_DEV_DIR */
|
||||||
dev?: boolean;
|
dev?: boolean;
|
||||||
|
/** Allowlist of origins this plugin may target via api.http.fetch(). */
|
||||||
|
httpOrigins?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const SERVER_MANAGED_KEY = 'server-managed-plugin-ids';
|
const SERVER_MANAGED_KEY = 'server-managed-plugin-ids';
|
||||||
@@ -408,6 +413,9 @@ async function syncServerPlugins(
|
|||||||
adminApproved: true, // Server-managed plugins are always approved
|
adminApproved: true, // Server-managed plugins are always approved
|
||||||
settings: {},
|
settings: {},
|
||||||
bundleHash: sp.bundleHash,
|
bundleHash: sp.bundleHash,
|
||||||
|
...(sp.httpOrigins && sp.httpOrigins.length > 0
|
||||||
|
? { httpOrigins: sp.httpOrigins }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
set(state => {
|
set(state => {
|
||||||
@@ -443,6 +451,7 @@ async function syncServerPlugins(
|
|||||||
managed: true,
|
managed: true,
|
||||||
forceEnabled: sp.forceEnabled,
|
forceEnabled: sp.forceEnabled,
|
||||||
bundleHash: sp.bundleHash,
|
bundleHash: sp.bundleHash,
|
||||||
|
httpOrigins: sp.httpOrigins,
|
||||||
}
|
}
|
||||||
: p
|
: p
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user