mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +00:00
feat(plugins): sandboxed page/widget frames + trekBridge (M3)
Plugins can now render UI. Page plugins appear as a nav entry and open a full-page sandboxed iframe; the frame talks to TREK only over the postMessage bridge. - Server serves plugin client assets at /plugin-frame/:id/* with a strict path guard and a locked-down, per-plugin CSP (default-src none; connect-src limited to declared outbound hosts; sandbox WITHOUT allow-same-origin -> opaque origin, so the frame can't read the session cookie or the parent DOM). Global CSP frameSrc relaxed from 'none' to 'self' for exactly these frames. - GET /api/plugins feed lists active plugins for the client. - Client: pluginStore + PluginFrame (the trekBridge host) authenticates every inbound message by SENDER WINDOW IDENTITY (event.source), not by a claimed id or origin; pushes context (theme/locale/tripId/userId), validates navigation, renders notifications as text, resizes widgets, and proxies trek:invoke to the plugin's own routes host-side (session cookie stays with the host). - Page route /plugins/:id + Navbar nav injection for page plugins. Dashboard widget slot and the trek:request core-data bridge are deferred to a later slice.
This commit is contained in:
@@ -123,7 +123,10 @@ export function applyGlobalMiddleware(
|
||||
// 'self' so same-origin file previews can embed PDFs via <object>/<embed>
|
||||
// (Firefox/Chrome enforce object-src; 'none' broke inline PDF previews there).
|
||||
objectSrc: ["'self'"],
|
||||
frameSrc: ["'none'"],
|
||||
// 'self' so the app can embed same-origin, sandboxed plugin frames
|
||||
// (/plugin-frame/*). Those frames are sandboxed WITHOUT allow-same-origin,
|
||||
// so they run at an opaque origin and get their own locked-down CSP.
|
||||
frameSrc: ["'self'"],
|
||||
frameAncestors: ["'self'"],
|
||||
// Restrict <form> submission targets (form-action has no default-src
|
||||
// fallback, so it must be set explicitly).
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Controller, Get, Param, Req, Res } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { PluginRuntimeService } from './plugin-runtime.service';
|
||||
import { pluginCodeDir } from './paths';
|
||||
|
||||
/**
|
||||
* Serves a page/widget plugin's static client from /plugin-frame/:id/* (#plugins,
|
||||
* M3). The document is embedded in a sandbox WITHOUT allow-same-origin, so it
|
||||
* runs at an OPAQUE origin: it cannot read the trek_session cookie, cannot reach
|
||||
* the parent DOM, and its only channel to TREK is the postMessage bridge.
|
||||
*
|
||||
* Each response gets a locked-down, per-plugin CSP (default-src none; own scripts
|
||||
* only; connect-src limited to declared outbound hosts) and a strict path guard
|
||||
* so a plugin can only serve files under its own client/ directory.
|
||||
*/
|
||||
const MIME: Record<string, string> = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.mjs': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.woff2': 'font/woff2',
|
||||
'.ico': 'image/x-icon',
|
||||
};
|
||||
|
||||
@Controller('plugin-frame/:pluginId')
|
||||
export class PluginFrameController {
|
||||
constructor(private readonly runtime: PluginRuntimeService) {}
|
||||
|
||||
@Get('*path')
|
||||
serve(@Param('pluginId') pluginId: string, @Req() req: Request, @Res() res: Response): void {
|
||||
if (!pluginsEnabled() || !this.runtime.isActive(pluginId)) {
|
||||
res.status(404).send('Plugin not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const rest = (req.params as Record<string, unknown>).path ?? (req.params as Record<string, unknown>)[0] ?? '';
|
||||
const rel = (Array.isArray(rest) ? rest.join('/') : String(rest)).replace(/^\/+/, '') || 'index.html';
|
||||
|
||||
const clientDir = path.join(pluginCodeDir(pluginId), 'client');
|
||||
const resolved = path.resolve(clientDir, rel);
|
||||
// Containment: never escape the plugin's own client/ dir.
|
||||
if (!resolved.startsWith(path.resolve(clientDir) + path.sep) && resolved !== path.resolve(clientDir)) {
|
||||
res.status(403).send('Forbidden');
|
||||
return;
|
||||
}
|
||||
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
|
||||
res.status(404).send('Not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = path.extname(resolved).toLowerCase();
|
||||
res.setHeader('Content-Type', MIME[ext] ?? 'application/octet-stream');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Content-Security-Policy', this.frameCsp(pluginId));
|
||||
res.sendFile(resolved);
|
||||
}
|
||||
|
||||
/** Per-plugin, locked-down CSP for the sandboxed frame document. */
|
||||
private frameCsp(pluginId: string): string {
|
||||
const outbound = this.runtime.outboundHostsOf(pluginId);
|
||||
const connect = ["'self'", ...outbound.map((h) => (h.includes('://') ? h : `https://${h}`))].join(' ');
|
||||
return [
|
||||
"default-src 'none'",
|
||||
"script-src 'self'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob:",
|
||||
"font-src 'self' data:",
|
||||
`connect-src ${connect}`,
|
||||
"frame-ancestors 'self'",
|
||||
"base-uri 'none'",
|
||||
"form-action 'self'",
|
||||
'sandbox allow-scripts allow-forms allow-popups',
|
||||
].join('; ');
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,18 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
isActive(id: string): boolean {
|
||||
return this.supervisor.isActive(id);
|
||||
}
|
||||
|
||||
/** Declared outbound hosts (from http:outbound:<host> grants) for the frame CSP. */
|
||||
outboundHostsOf(id: string): string[] {
|
||||
const row = db.prepare('SELECT granted_permissions FROM plugins WHERE id = ?').get(id) as
|
||||
| { granted_permissions: string }
|
||||
| undefined;
|
||||
if (!row) return [];
|
||||
return parseArray(row.granted_permissions)
|
||||
.filter((p) => p.startsWith('http:outbound:'))
|
||||
.map((p) => p.slice('http:outbound:'.length))
|
||||
.filter(Boolean);
|
||||
}
|
||||
routesOf(id: string): PluginRouteInfo[] {
|
||||
return this.supervisor.routesOf(id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { db } from '../../db/database';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
|
||||
/**
|
||||
* GET /api/plugins — the authenticated feed of ACTIVE plugins the client renders
|
||||
* (#plugins, M3): page plugins become nav entries, widget plugins become
|
||||
* dashboard widgets. Empty when the runtime is disabled. Distinct from the
|
||||
* admin surface (/api/admin/plugins) and the per-plugin proxy
|
||||
* (/api/plugins/:id/*) — this is the exact /api/plugins path.
|
||||
*/
|
||||
interface ActivePlugin {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
icon: string | null;
|
||||
}
|
||||
|
||||
@Controller('api/plugins')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PluginsFeedController {
|
||||
@Get()
|
||||
list(): { plugins: ActivePlugin[] } {
|
||||
if (!pluginsEnabled()) return { plugins: [] };
|
||||
const plugins = db
|
||||
.prepare("SELECT id, name, type, icon FROM plugins WHERE status = 'active' ORDER BY sort_order, name")
|
||||
.all() as ActivePlugin[];
|
||||
return { plugins };
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PluginsController } from './plugins.controller';
|
||||
import { PluginsFeedController } from './plugins-feed.controller';
|
||||
import { PluginsProxyController } from './plugins-proxy.controller';
|
||||
import { PluginFrameController } from './plugin-frame.controller';
|
||||
import { PluginsService } from './plugins.service';
|
||||
import { PluginRuntimeService } from './plugin-runtime.service';
|
||||
|
||||
/**
|
||||
* Plugin system (#plugins). M0 read side + M2 isolated runtime: the runtime
|
||||
* service owns the process supervisor and boots active plugins on startup; the
|
||||
* proxy controller forwards /api/plugins/:id/* to the isolated child.
|
||||
* Plugin system (#plugins). M0 read side + M2 isolated runtime + M3 frontend:
|
||||
* the runtime service owns the process supervisor and boots active plugins on
|
||||
* startup; the proxy forwards /api/plugins/:id/* to the child; the feed lists
|
||||
* active plugins for the client; the frame controller serves sandboxed page/
|
||||
* widget assets at /plugin-frame/:id/*.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [PluginsController, PluginsProxyController],
|
||||
controllers: [PluginsController, PluginsFeedController, PluginsProxyController, PluginFrameController],
|
||||
providers: [PluginsService, PluginRuntimeService],
|
||||
})
|
||||
export class PluginsModule {}
|
||||
|
||||
Reference in New Issue
Block a user