dorfteich/apps/api/src/plugins/plugin-assets.controller.ts
Claude Fable 5 c4c84b33f9
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 5m38s
CI / Build container images (pull_request) Successful in 4m11s
CI / Auth e2e pack (pull_request) Successful in 8m55s
CI / Import/export fidelity gate (pull_request) Successful in 1m9s
CI / Import/export fidelity gate (push) Blocked by required conditions
CD / Build and push images (push) Successful in 20s
CD / Deploy to Test (push) Failing after 51s
CD / Smoke tests against Test (push) Has been skipped
CD / Promote to Int (push) Has been skipped
CI / Lint, typecheck, test (push) Successful in 5m37s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Has been cancelled
#200: hard instance-wide plugins.enabled kill switch
plugins.enabled (instance setting, default on — plugins predate the
switch; the VS-NfD reference configuration turns it off) makes every
plugin surface answer 404 via a shared guard: Site-Admin
install/list/mode, pond activation and plugin list, the sandbox frame
and asset routes. The dropzone watcher quarantines drops instead of
installing. Deliberately NOT guarded: the authenticated
fallback-metadata route — it serves no plugin code and existing
plugin_block nodes need it to render their declared fallback (an image
fallback degrades to the neutral placeholder while off, because its
bytes live on the disabled asset surface). The editor offers no plugin
blocks because the pond plugin list is one of the 404ing surfaces.
Admin settings panel gets the toggle (i18n de+en) with the documented
api-restart note (in-process settings cache).

Answers "code execution inside the zone?" with one verifiable
off-switch instead of per-plugin trust machinery (#232, ADR 0025).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-31 04:42:42 +02:00

157 lines
6.3 KiB
TypeScript

import {
Controller,
Get,
NotFoundException,
Param,
Req,
Res,
StreamableFile,
UseGuards,
} from '@nestjs/common';
import type { Request, Response } from 'express';
import type { PluginFallbackView } from '@dorfteich/shared';
import { Public } from '../auth/auth.guard';
import { AppConfig } from '../config/app-config.service';
import { AuthenticatedOnly } from '../permissions/permission.decorators';
import { InstanceSettingsService } from '../settings/instance-settings.service';
import { buildPluginAssetBase, buildPluginFrameCsp, buildPluginFrameHtml } from './plugin-frame';
import { PluginStorageService } from './plugin-storage.service';
import { PluginsEnabledGuard } from './plugins-enabled.guard';
import { PluginsService } from './plugins.service';
/** Content types for the file kinds a plugin bundle ships. Unknown extensions
* are served as opaque bytes — the sandbox iframe's CSP decides what may run. */
const CONTENT_TYPES: Record<string, string> = {
js: 'text/javascript; charset=utf-8',
mjs: 'text/javascript; charset=utf-8',
css: 'text/css; charset=utf-8',
json: 'application/json; charset=utf-8',
html: 'text/html; 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',
// Bundled-app plugins (drawio): lazy-loaded stencils/resources/icons.
xml: 'text/xml; charset=utf-8',
txt: 'text/plain; charset=utf-8',
ico: 'image/x-icon',
};
function contentTypeFor(path: string): string {
const ext = path.split('.').pop()?.toLowerCase() ?? '';
return CONTENT_TYPES[ext] ?? 'application/octet-stream';
}
/**
* Serves the unpacked assets of an installed plugin version (ADR 0008). Public
* because the sandboxed, opaque-origin iframe (#73) loads them with no session,
* and plugin bundles are client code, not user data. Paths are version-pinned
* and content-addressed by version, so responses are immutable.
*/
@Controller('plugins')
export class PluginAssetsController {
constructor(
private readonly plugins: PluginsService,
private readonly storage: PluginStorageService,
private readonly config: AppConfig,
private readonly settings: InstanceSettingsService,
) {}
/**
* The manifest fallback a `plugin_block` shows while its plugin is inactive
* (issue #76). Deliberately NOT `@Public`: it names installed plugins, which
* is instance metadata for signed-in users, not sandbox-servable content.
* 404 covers "never installed" — the client shows a neutral placeholder.
* Deliberately NOT behind the kill switch either (issue #200): it serves no
* plugin code, and with plugins disabled the existing blocks still need it
* to render their declared fallback. An image fallback degrades to the
* neutral placeholder then — its bytes live on the disabled asset surface.
*/
@Get(':id/fallback')
@AuthenticatedOnly()
async fallback(@Param('id') id: string): Promise<PluginFallbackView> {
const view = await this.plugins.fallbackFor(id);
if (!view) throw new NotFoundException();
if (view.fallback?.type === 'image' && !(await this.settings.get('plugins.enabled'))) {
return { ...view, fallback: null };
}
return view;
}
/**
* The sandbox frame document (#73). Declared before the asset wildcard so
* the static segment wins. The CSP pins every load to this plugin's asset
* path and blocks all network access; see plugin-frame.ts for the rationale.
*/
@Get(':id/:version/frame')
@Public()
@UseGuards(PluginsEnabledGuard)
async frame(
@Param('id') id: string,
@Param('version') version: string,
@Res({ passthrough: true }) response: Response,
): Promise<string> {
const plugin = await this.plugins.get(id);
if (!plugin || plugin.version !== version) throw new NotFoundException();
// Asset base is built from the configured public origin, not the request
// Host header: a proxy that rewrites Host (the vite dev proxy does) must
// not be able to produce a CSP that blocks the plugin's own bundle.
const origin = new URL(this.config.env.APP_BASE_URL).origin;
const assetBase = buildPluginAssetBase(origin, plugin.id, plugin.version);
response.set('Content-Security-Policy', buildPluginFrameCsp(assetBase));
response.set('X-Content-Type-Options', 'nosniff');
response.set('Cache-Control', 'public, max-age=31536000, immutable');
response.type('text/html; charset=utf-8');
return buildPluginFrameHtml(plugin.name);
}
@Get(':id/:version/*rest')
@Public()
@UseGuards(PluginsEnabledGuard)
async serve(
@Param('id') id: string,
@Param('version') version: string,
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
): Promise<StreamableFile> {
// Only serve assets for an installed, current version — a removed plugin or
// a stale version pointer must not leak files.
const plugin = await this.plugins.get(id);
if (!plugin || plugin.version !== version) throw new NotFoundException();
const rest = (request.params as Record<string, unknown>).rest;
const relPath = Array.isArray(rest) ? rest.join('/') : String(rest ?? '');
const full = this.storage.assetPath(id, version, relPath);
if (!full || !(await this.storage.assetExists(full))) throw new NotFoundException();
response.set('X-Content-Type-Options', 'nosniff');
response.set('Cache-Control', 'public, max-age=31536000, immutable');
// HTML assets can become documents (a plugin's own child frame, e.g. the
// bundled drawio editor) — stamp the same restrictive CSP the frame
// document carries, so no packaged page can widen the sandbox's network
// or embedding rules.
if (relPath.toLowerCase().endsWith('.html')) {
response.set(
'Content-Security-Policy',
buildPluginFrameCsp(
buildPluginAssetBase(new URL(this.config.env.APP_BASE_URL).origin, id, version),
),
);
}
// The sandbox frame has a null (opaque) origin, so it fetches its own
// bundle cross-origin; allow it. These are public, immutable client
// assets, never user data — a wildcard is safe.
response.set('Access-Control-Allow-Origin', '*');
return new StreamableFile(this.storage.createAssetReadStream(full), {
type: contentTypeFor(relPath),
});
}
}