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'; import { Public } from '../auth/public.decorator'; /** * 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 = { '.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', }; @Public('serves the sandboxed iframe document itself; the RPC inside it is authenticated') @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).path ?? (req.params as Record)[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; } // The check above is lexical, and statSync follows links, so a symlink inside // client/ still reads whatever it points at. Re-check both sides resolved: // a dev-linked plugin is a symlinked tree, so comparing against the raw root // would reject it. let realRoot: string; let realFile: string; try { realRoot = fs.realpathSync(path.resolve(clientDir)); realFile = fs.realpathSync(resolved); } catch { res.status(404).send('Not found'); return; } if (realFile !== realRoot && !realFile.startsWith(realRoot + path.sep)) { res.status(403).send('Forbidden'); return; } const ext = path.extname(resolved).toLowerCase(); res.setHeader('Content-Type', MIME[ext] ?? 'application/octet-stream'); res.setHeader('X-Content-Type-Options', 'nosniff'); // The frame document runs at an OPAQUE origin (sandbox without // allow-same-origin), so its own