#200: hard instance-wide plugins.enabled kill switch #260

Merged
fable-5 merged 1 commits from feat/200-plugins-kill-switch into main 2026-07-31 04:59:57 +02:00
13 changed files with 238 additions and 5 deletions

View File

@ -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) {}

View File

@ -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<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;
}
@ -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,

View File

@ -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) {}

View File

@ -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);

View File

@ -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<boolean> {
if (!(await this.settings.get('plugins.enabled'))) throw new NotFoundException();
return true;
}
}

View File

@ -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);
});
});

View File

@ -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,
],

View File

@ -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.

View File

@ -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')}
</label>
<p className="api-opt-in__hint">{t('admin.feedsHint')}</p>
<label className="api-opt-in__label">
<input
type="checkbox"
checked={settings['plugins.enabled']}
onChange={(event) => void save({ 'plugins.enabled': event.target.checked })}
/>
{t('admin.pluginsLabel')}
</label>
<p className="api-opt-in__hint">{t('admin.pluginsHint')}</p>
</section>
);
}

View File

@ -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

View File

@ -110,7 +110,7 @@ chain`_
- [x] **Attachment-Integritätshashes** · +23 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** · +34 AT · #201 ⟵ neu aus Roadmap

View File

@ -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",

View File

@ -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",