diff --git a/apps/api/src/plugins/plugin-admin.controller.ts b/apps/api/src/plugins/plugin-admin.controller.ts index 671b684..7d50236 100644 --- a/apps/api/src/plugins/plugin-admin.controller.ts +++ b/apps/api/src/plugins/plugin-admin.controller.ts @@ -24,6 +24,7 @@ import { SiteAdminGuard } from '../admin/site-admin.guard'; import { AuthedRequest } from '../auth/auth.guard'; import { ZodValidationPipe } from '../common/zod-validation.pipe'; +import { PluginsEnabledGuard } from './plugins-enabled.guard'; import { PluginsService } from './plugins.service'; import { MAX_PLUGIN_ZIP_BYTES, PluginPackageError } from './plugin.constants'; @@ -52,7 +53,10 @@ function toHttpException(error: PluginPackageError): HttpException { * and per-pond-activation writes arrive with the admin UI (#72). */ @Controller('admin/plugins') -@UseGuards(SiteAdminGuard) +// Kill switch first (issue #200): while plugins are disabled instance-wide, +// even a Site Admin sees 404 here — the switch is flipped in the settings +// panel, not by probing dead routes. +@UseGuards(PluginsEnabledGuard, SiteAdminGuard) export class PluginAdminController { constructor(private readonly plugins: PluginsService) {} diff --git a/apps/api/src/plugins/plugin-assets.controller.ts b/apps/api/src/plugins/plugin-assets.controller.ts index 698c5c8..a20a20b 100644 --- a/apps/api/src/plugins/plugin-assets.controller.ts +++ b/apps/api/src/plugins/plugin-assets.controller.ts @@ -6,6 +6,7 @@ import { Req, Res, StreamableFile, + UseGuards, } from '@nestjs/common'; import type { Request, Response } from 'express'; import type { PluginFallbackView } from '@dorfteich/shared'; @@ -13,9 +14,11 @@ 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 @@ -56,6 +59,7 @@ export class PluginAssetsController { private readonly plugins: PluginsService, private readonly storage: PluginStorageService, private readonly config: AppConfig, + private readonly settings: InstanceSettingsService, ) {} /** @@ -63,12 +67,19 @@ export class PluginAssetsController { * (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 { 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; } @@ -79,6 +90,7 @@ export class PluginAssetsController { */ @Get(':id/:version/frame') @Public() + @UseGuards(PluginsEnabledGuard) async frame( @Param('id') id: string, @Param('version') version: string, @@ -102,6 +114,7 @@ export class PluginAssetsController { @Get(':id/:version/*rest') @Public() + @UseGuards(PluginsEnabledGuard) async serve( @Param('id') id: string, @Param('version') version: string, diff --git a/apps/api/src/plugins/plugin-pond.controller.ts b/apps/api/src/plugins/plugin-pond.controller.ts index d2de6b1..24eb9c6 100644 --- a/apps/api/src/plugins/plugin-pond.controller.ts +++ b/apps/api/src/plugins/plugin-pond.controller.ts @@ -9,6 +9,7 @@ import { Put, Body, Req, + UseGuards, } from '@nestjs/common'; import { pondPluginToggleInputSchema, @@ -21,6 +22,7 @@ import { AuthedRequest } from '../auth/auth.guard'; import { ZodValidationPipe } from '../common/zod-validation.pipe'; import { RequiresPondRole } from '../permissions/permission.decorators'; +import { PluginsEnabledGuard } from './plugins-enabled.guard'; import { PluginsService } from './plugins.service'; import { PluginPackageError } from './plugin.constants'; @@ -31,6 +33,10 @@ import { PluginPackageError } from './plugin.constants'; * plugins is a Pond Admin action. */ @Controller('ponds/:pondId/plugins') +// Kill switch (issue #200): with plugins disabled instance-wide the SPA's +// plugin-list query 404s, which its consumers treat as "no plugins" — the +// editor then offers no plugin blocks. +@UseGuards(PluginsEnabledGuard) export class PluginPondController { constructor(private readonly plugins: PluginsService) {} diff --git a/apps/api/src/plugins/plugin-watcher.service.ts b/apps/api/src/plugins/plugin-watcher.service.ts index 56bdae3..219a71e 100644 --- a/apps/api/src/plugins/plugin-watcher.service.ts +++ b/apps/api/src/plugins/plugin-watcher.service.ts @@ -7,6 +7,7 @@ import { PinoLogger } from 'nestjs-pino'; import { ClockService } from '../common/clock.service'; import { AppConfig } from '../config/app-config.service'; +import { InstanceSettingsService } from '../settings/instance-settings.service'; import { PluginStorageService } from './plugin-storage.service'; import { PluginPackageError } from './plugin.constants'; @@ -31,6 +32,7 @@ export class PluginWatcherService implements OnModuleInit, OnModuleDestroy { private readonly plugins: PluginsService, private readonly storage: PluginStorageService, private readonly config: AppConfig, + private readonly settings: InstanceSettingsService, private readonly clock: ClockService, private readonly logger: PinoLogger, ) { @@ -83,6 +85,13 @@ export class PluginWatcherService implements OnModuleInit, OnModuleDestroy { async processDropped( filePath: string, ): Promise<{ installed: true; id: string } | { installed: false; code: string }> { + // The kill switch (issue #200) covers this install surface like the GUI + // routes: a drop is quarantined, not installed, while plugins are off. + if (!(await this.settings.get('plugins.enabled'))) { + await this.quarantine(filePath); + this.logger.warn({ file: filePath }, 'plugins disabled; quarantined dropped plugin ZIP'); + return { installed: false, code: 'plugins_disabled' }; + } let zip: Buffer; try { zip = await readFile(filePath); diff --git a/apps/api/src/plugins/plugins-enabled.guard.ts b/apps/api/src/plugins/plugins-enabled.guard.ts new file mode 100644 index 0000000..85db8be --- /dev/null +++ b/apps/api/src/plugins/plugins-enabled.guard.ts @@ -0,0 +1,23 @@ +import { CanActivate, Injectable, NotFoundException } from '@nestjs/common'; + +import { InstanceSettingsService } from '../settings/instance-settings.service'; + +/** + * Instance-wide plugin kill switch (issue #200, ADR 0025): while + * `plugins.enabled` is false, every guarded plugin surface answers 404 — + * existence stays hidden, the same semantics as `api.enabled` and + * `mcp.enabled`. The fallback-metadata route is deliberately NOT guarded + * (it serves no plugin code and existing blocks need it for their declared + * fallback). The settings cache is in-process, so flipping the switch is + * followed by an api restart like every other instance setting + * (operations.md); the admin UI toggle documents that. + */ +@Injectable() +export class PluginsEnabledGuard implements CanActivate { + constructor(private readonly settings: InstanceSettingsService) {} + + async canActivate(): Promise { + if (!(await this.settings.get('plugins.enabled'))) throw new NotFoundException(); + return true; + } +} diff --git a/apps/api/src/plugins/plugins.e2e.db.test.ts b/apps/api/src/plugins/plugins.e2e.db.test.ts index 316ad44..fc013fd 100644 --- a/apps/api/src/plugins/plugins.e2e.db.test.ts +++ b/apps/api/src/plugins/plugins.e2e.db.test.ts @@ -379,3 +379,138 @@ describe.skipIf(!hasTestDb)('plugins install (e2e, issue #71)', () => { expect(list.body.map((p: { id: string }) => p.id)).not.toContain('removable'); }); }); + +/** + * Instance-wide plugin kill switch (issue #200, ADR 0025): with + * `plugins.enabled = false` every plugin surface answers 404 — even for a + * Site Admin — while existing blocks keep their declared fallback readable; + * the dropzone quarantines instead of installing; and the switch itself is + * flipped through the admin settings surface. Lives in this file because the + * install suite above wipes the plugin registry in its setup — a separate + * parallel file would race that wipe. + */ +describe.skipIf(!hasTestDb)('plugins kill switch (e2e, issue #200)', () => { + let app: INestApplication; + let prisma: PrismaClient; + let watcher: PluginWatcherService; + let storage: PluginStorageService; + const suffix = uniqueSuffix(); + const password = 'schalter aus heisst wirklich aus 1'; + const admin = `kira-killswitch-${suffix}`; + const pluginId = `switched-${suffix}`; + let adminCookie: string; + let pondId: string; + + const api = () => request(app.getHttpServer()); + + beforeAll(async () => { + prisma = createTestPrisma(); + // Written BEFORE the app boots — the settings cache is in-process and + // fills on first read. Cleared afterAll. + await prisma.instanceSetting.upsert({ + where: { key: 'plugins.enabled' }, + create: { key: 'plugins.enabled', value: false }, + update: { value: false }, + }); + // Registry row for the fallback assertion, created directly — the + // install route is exactly what the switch turns off. + await prisma.plugin.create({ + data: { + id: pluginId, + name: 'Switched Off', + version: '1.0.0', + apiVersion: '1', + kind: 'code', + mode: 'OPTIONAL', + manifest: { + ...codeManifest({ id: pluginId, name: 'Switched Off' }), + fallback: { type: 'text', value: 'Der Block schlummert.' }, + }, + }, + }); + app = await createTestApp(); + watcher = app.get(PluginWatcherService); + storage = app.get(PluginStorageService); + const users = app.get(UsersService); + const tokens = app.get(AuthTokensService); + const adminUser = await users.createUser({ + username: admin, + email: `${admin}@example.org`, + displayName: `Kira Killswitch ${suffix}`, + password, + locale: 'en', + }); + const verify = await tokens.issue(adminUser.id, 'EMAIL_VERIFICATION', 600); + await api().post('/api/v1/auth/verify-email').send({ token: verify }).expect(204); + await prisma.user.update({ where: { id: adminUser.id }, data: { isSiteAdmin: true } }); + const login = await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: admin, password }) + .expect(200); + adminCookie = sessionCookieOf(login); + pondId = (await prisma.pond.findFirstOrThrow({ where: { ownerId: adminUser.id } })).id; + }); + + afterAll(async () => { + await prisma.instanceSetting.deleteMany({ where: { key: 'plugins.enabled' } }); + await prisma.plugin.deleteMany({ where: { id: pluginId } }); + const where = { pond: { owner: { username: { contains: suffix } } } }; + await prisma.roleGrant.deleteMany({ where }); + await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } }); + await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); + await app.close(); + }); + + it('404s every plugin surface, even for a Site Admin', async () => { + await api().get('/api/v1/admin/plugins').set('Cookie', adminCookie).expect(404); + await api() + .post('/api/v1/admin/plugins') + .set('Cookie', adminCookie) + .attach('file', pluginZip(codeManifest({ id: 'nope', name: 'Nope' })), 'nope.zip') + .expect(404); + await api().get(`/api/v1/ponds/${pondId}/plugins`).set('Cookie', adminCookie).expect(404); + await api() + .put(`/api/v1/ponds/${pondId}/plugins/${pluginId}`) + .set('Cookie', adminCookie) + .send({ enabled: true }) + .expect(404); + await api().get(`/api/v1/plugins/${pluginId}/1.0.0/frame`).expect(404); + await api().get(`/api/v1/plugins/${pluginId}/1.0.0/plugin.js`).expect(404); + }); + + it('keeps the declared fallback readable so existing blocks render it', async () => { + const res = await api() + .get(`/api/v1/plugins/${pluginId}/fallback`) + .set('Cookie', adminCookie) + .expect(200); + expect(res.body).toMatchObject({ + name: 'Switched Off', + fallback: { type: 'text', value: 'Der Block schlummert.' }, + }); + }); + + it('quarantines a dropzone drop instead of installing it', async () => { + await storage.ensureServiceDirs(); + const dropPath = join(storage.dropzoneDir, `switched-drop-${suffix}.zip`); + await writeFile(dropPath, pluginZip(codeManifest({ id: 'dropped', name: 'Dropped' }))); + const outcome = await watcher.processDropped(dropPath); + expect(outcome).toEqual({ installed: false, code: 'plugins_disabled' }); + const quarantined = await readdir(storage.quarantineDir); + expect(quarantined.some((name) => name.endsWith(`switched-drop-${suffix}.zip`))).toBe(true); + }); + + it('is flipped through the admin settings surface', async () => { + await api() + .patch('/api/v1/admin/settings') + .set('Cookie', adminCookie) + .send({ 'plugins.enabled': true }) + .expect(200); + await api().get('/api/v1/admin/plugins').set('Cookie', adminCookie).expect(200); + await api() + .patch('/api/v1/admin/settings') + .set('Cookie', adminCookie) + .send({ 'plugins.enabled': false }) + .expect(200); + await api().get('/api/v1/admin/plugins').set('Cookie', adminCookie).expect(404); + }); +}); diff --git a/apps/api/src/plugins/plugins.module.ts b/apps/api/src/plugins/plugins.module.ts index 150fdea..5739e76 100644 --- a/apps/api/src/plugins/plugins.module.ts +++ b/apps/api/src/plugins/plugins.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { CommonModule } from '../common/common.module'; +import { SettingsModule } from '../settings/settings.module'; import { PluginAdminController } from './plugin-admin.controller'; import { PluginAssetsController } from './plugin-assets.controller'; @@ -9,6 +10,7 @@ import { PluginPondController } from './plugin-pond.controller'; import { PluginPackageService } from './plugin-package.service'; import { PluginStorageService } from './plugin-storage.service'; import { PluginWatcherService } from './plugin-watcher.service'; +import { PluginsEnabledGuard } from './plugins-enabled.guard'; import { PluginsService } from './plugins.service'; /** @@ -18,12 +20,13 @@ import { PluginsService } from './plugins.service'; * dropzone directory watcher. */ @Module({ - imports: [CommonModule], + imports: [CommonModule, SettingsModule], controllers: [PluginAdminController, PluginAssetsController, PluginPondController], providers: [ PluginFallbackRenderer, PluginPackageService, PluginStorageService, + PluginsEnabledGuard, PluginsService, PluginWatcherService, ], diff --git a/apps/api/src/settings/instance-settings.service.ts b/apps/api/src/settings/instance-settings.service.ts index f86fa69..4b42156 100644 --- a/apps/api/src/settings/instance-settings.service.ts +++ b/apps/api/src/settings/instance-settings.service.ts @@ -72,6 +72,15 @@ export const INSTANCE_SETTINGS = { // Built-in MCP endpoint master switch (issue #105, default off) — // independent of the REST switch; ponds opt in via `mcpEnabled`. 'mcp.enabled': z.boolean().default(false), + // Plugin-architecture master switch (issue #200, ADR 0025). Default ON: + // plugins predate the switch, so existing instances keep working; the + // VS-NfD reference configuration (#227) turns it off. While off, every + // plugin surface answers 404 (admin install/list, pond toggles, frame + // and asset routes) — only the authenticated fallback-metadata route + // stays, so existing blocks still render their declared text fallback + // (an image fallback degrades to neutral text: its bytes live on the + // disabled asset surface). + 'plugins.enabled': z.boolean().default(true), // Atom feed master switch (issue #191). Default ON: feeds predate the // switch, so existing instances and their subscribed readers keep // working; the VS-NfD reference configuration (#227) turns it off. diff --git a/apps/web/src/pages/AdminSettingsPage.tsx b/apps/web/src/pages/AdminSettingsPage.tsx index 13ff59d..c0e26c6 100644 --- a/apps/web/src/pages/AdminSettingsPage.tsx +++ b/apps/web/src/pages/AdminSettingsPage.tsx @@ -25,6 +25,7 @@ interface InstanceSettings { 'api.enabled': boolean; 'mcp.enabled': boolean; 'feeds.enabled': boolean; + 'plugins.enabled': boolean; 'upload.allowedExtensions': string[]; 'upload.svgPolicy': 'reject' | 'sanitize'; 'legal.imprint': string; @@ -249,6 +250,15 @@ function PublicApiSettingsForm({ settings }: { settings: InstanceSettings }): Re {t('admin.feedsLabel')}

{t('admin.feedsHint')}

+ +

{t('admin.pluginsHint')}

); } diff --git a/docs/architecture/plugin-architecture.md b/docs/architecture/plugin-architecture.md index d502886..8f95f31 100644 --- a/docs/architecture/plugin-architecture.md +++ b/docs/architecture/plugin-architecture.md @@ -115,6 +115,23 @@ person looking at it could. removed, existing `plugin_block` nodes render the manifest `fallback` (documents are never mutated by plugin removal). +**Instance kill switch (issue #200, ADR 0025)**: `plugins.enabled` +(instance setting, default on; the VS-NfD reference configuration turns +it off) sits above the whole lifecycle. While off, every plugin surface +answers 404 — admin install/list/mode, pond activation, the sandbox frame +and asset routes — and the dropzone watcher quarantines instead of +installing. Only the authenticated fallback-metadata route stays alive: +it serves no plugin code, and existing `plugin_block` nodes use it to +render their declared fallback (an image fallback degrades to the neutral +placeholder, because its bytes live on the disabled asset surface — in +the reference configuration no plugin is installed, so nothing degrades). +The editor offers no plugin blocks because the pond plugin list is one of +the 404ing surfaces. Like every instance setting it is cached in-process: +flipping it is followed by an api restart to take full effect. This +single, verifiable off-switch is what answers "code execution inside the +zone?" at the offer stage — cheaper than per-plugin trust machinery +(#232) and sufficient because it removes the surface entirely. + ## Reference plugins (shipped with the product, also serving as examples) - `section-styles-basic` (`section_style`): a set of colored callout/box diff --git a/docs/vs-nfd/20-massnahmenplan.md b/docs/vs-nfd/20-massnahmenplan.md index 61047d5..94f55fb 100644 --- a/docs/vs-nfd/20-massnahmenplan.md +++ b/docs/vs-nfd/20-massnahmenplan.md @@ -110,7 +110,7 @@ chain`_ - [x] **Attachment-Integritätshashes** · +2–3 AT · #199 ⟵ neu aus Roadmap SHA-256-Spalte, Berechnung beim Upload, Prüfung beim Download, Backfill-Migration. Nebennutzen: Orphan-Sweep, Dedup, Backup-Verifikation. -- [ ] **Plugins hart abschaltbar** (`plugins.enabled = false`) · +2 AT · #200 ⟵ neu +- [x] **Plugins hart abschaltbar** (`plugins.enabled = false`) · +2 AT · #200 ⟵ neu Deckt das Risiko „Codeausführung in der VS-Zone" für den Angebotsstand vollständig ab. Hash-Pinning siehe Phase 4. - [ ] **Syslog/SIEM: Ereigniskatalog** · +3–4 AT · #201 ⟵ neu aus Roadmap diff --git a/packages/shared/i18n/de/apiTokens.json b/packages/shared/i18n/de/apiTokens.json index fd58e4f..ddd86f3 100644 --- a/packages/shared/i18n/de/apiTokens.json +++ b/packages/shared/i18n/de/apiTokens.json @@ -52,7 +52,9 @@ "mcpLabel": "Eingebauten MCP-Endpoint aktivieren", "mcpHint": "Hauptschalter (standardmäßig aus), unabhängig von der REST-API. MCP-Clients verbinden sich mit einem API-Token auf /api/mcp; jeder Teich gibt sich zusätzlich über seine Teich-Einstellungen frei. Siehe docs/self-hosting/public-api.md.", "feedsLabel": "Atom-Feeds aktivieren", - "feedsHint": "Hauptschalter (standardmäßig an). Ausgeschaltet antworten alle Feed-Adressen und die Feed-Token-Verwaltung mit 404 — für gehärtete Umgebungen, in denen Feed-Tokens als Lese-Zugangsdaten nicht in URLs auftauchen dürfen." + "feedsHint": "Hauptschalter (standardmäßig an). Ausgeschaltet antworten alle Feed-Adressen und die Feed-Token-Verwaltung mit 404 — für gehärtete Umgebungen, in denen Feed-Tokens als Lese-Zugangsdaten nicht in URLs auftauchen dürfen.", + "pluginsLabel": "Plugin-Architektur aktivieren", + "pluginsHint": "Hauptschalter (standardmäßig an). Ausgeschaltet antworten alle Plugin-Oberflächen mit 404 — Installation, Teich-Freigaben, Sandbox-Frames und -Assets — und bestehende Plugin-Blöcke zeigen ihren hinterlegten Fallback. Für gehärtete Umgebungen, die „keine Fremdcode-Ausführung“ nachweisbar beantworten müssen. Greift vollständig nach einem api-Neustart (Einstellungen sind im Prozess gecacht)." }, "feed": { "title": "Feed-Tokens", diff --git a/packages/shared/i18n/en/apiTokens.json b/packages/shared/i18n/en/apiTokens.json index 7af1b71..07ce95c 100644 --- a/packages/shared/i18n/en/apiTokens.json +++ b/packages/shared/i18n/en/apiTokens.json @@ -52,7 +52,9 @@ "mcpLabel": "Enable the built-in MCP endpoint", "mcpHint": "Master switch (default off), independent of the REST API. MCP clients connect to /api/mcp with an API token; each pond additionally opts in via its pond settings. See docs/self-hosting/public-api.md.", "feedsLabel": "Enable Atom feeds", - "feedsHint": "Master switch (default on). While off, every feed URL and the feed-token management answer 404 — for hardened environments where feed tokens must not appear in URLs as read credentials." + "feedsHint": "Master switch (default on). While off, every feed URL and the feed-token management answer 404 — for hardened environments where feed tokens must not appear in URLs as read credentials.", + "pluginsLabel": "Enable the plugin architecture", + "pluginsHint": "Master switch (default on). While off, every plugin surface answers 404 — install, pond activation, sandbox frames and assets — and existing plugin blocks show their declared fallback. For hardened environments that must answer \"no third-party code execution\" verifiably. Takes full effect after an api restart (settings are cached in-process)." }, "feed": { "title": "Feed tokens",