#232: plugin allowlist with SHA-256 hash pinning
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m21s
CI / Build container images (pull_request) Successful in 3m59s
CI / Auth e2e pack (pull_request) Successful in 8m35s
CI / Import/export fidelity gate (pull_request) Successful in 1m1s
CD / Build and push images (push) Successful in 17s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m17s
CD / Promote to Int (push) Successful in 12s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m27s
CI / Import/export fidelity gate (push) Successful in 58s
CI / Lint, typecheck, test (push) Successful in 6m30s

The install path records the SHA-256 of the delivered bundle ZIP
(plugins.bundle_hash; pre-#232 installs show it as unknown until
reinstalled). plugins.allowlist in instance_settings names permitted
ids with their pinned hashes: empty (default) = not enforced, existing
instances unchanged; non-empty = installs of unlisted or deviating
bundles are rejected (plugin_not_pinned / plugin_hash_mismatch, 403),
and an installed plugin outside the list or with a deviating hash does
not load — absent from pond mount lists, frame/assets 404. Every
rejection is audited (plugin.rejected, catalogue v1.5). A version bump
changes the hash and therefore requires an explicit re-pin — the
intended friction (ADR 0025). Admin UI shows observed vs pinned hash
per plugin with pin/re-pin/unpin. Scope stated honestly in
plugin-architecture.md: the pin answers "is this the reviewed bundle";
post-install disk tampering is platform integrity (ADR 0019), sandbox
containment stays the sandbox's job. Hardening guide row + catalog
advisory triage; residual risk R-03 resolved. e2e: empty-allowlist
compatibility, pinned load, unpinned and tampered installs rejected and
audited, pin drift blocks loading while the admin still sees the
mismatch, version bump needs re-pin. Full api suite 101 files / 561
green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
This commit is contained in:
Claude Fable 5 2026-07-31 21:26:36 +02:00
parent 404a3741c8
commit 9b7acab294
20 changed files with 511 additions and 60 deletions

View File

@ -0,0 +1,4 @@
-- #232: SHA-256 of the installed bundle ZIP, observed at install time.
-- NULL for plugins installed before this migration — the admin UI says so
-- and a reinstall records it.
ALTER TABLE "plugins" ADD COLUMN "bundle_hash" TEXT;

View File

@ -885,6 +885,8 @@ model Plugin {
mode PluginInstanceMode @default(DISABLED)
/// The full manifest as validated at install time (@dorfteich/plugin-sdk).
manifest Json
/// SHA-256 (hex) of the installed bundle ZIP (#232); null = pre-#232 install.
bundleHash String? @map("bundle_hash")
installedAt DateTime @default(now()) @map("installed_at")
updatedAt DateTime @updatedAt @map("updated_at")
/// Set when uninstalled; active queries filter `removedAt: null`.

View File

@ -36,6 +36,7 @@ export const AUDIT_EVENTS = {
'page.classification_lowered': { severity: 'warning' },
'page.classification_raised': { severity: 'notice' },
'plugin.installed': { severity: 'notice' },
'plugin.rejected': { severity: 'warning' },
'plugin.mode_set': { severity: 'notice' },
'plugin.pond_toggled': { severity: 'info' },
'plugin.uninstalled': { severity: 'notice' },

View File

@ -2,6 +2,7 @@ import {
BadRequestException,
Body,
ConflictException,
ForbiddenException,
Controller,
Delete,
Get,
@ -40,6 +41,10 @@ function toHttpException(error: PluginPackageError): HttpException {
case 'plugin_version_not_higher':
case 'plugin_not_optional':
return new ConflictException(body);
// Hash pinning (#232): the upload is well-formed, the policy says no.
case 'plugin_not_pinned':
case 'plugin_hash_mismatch':
return new ForbiddenException(body);
case 'plugin_too_large':
return new PayloadTooLargeException(body);
default:

View File

@ -96,7 +96,9 @@ export class PluginAssetsController {
@Param('version') version: string,
@Res({ passthrough: true }) response: Response,
): Promise<string> {
const plugin = await this.plugins.get(id);
// getServable enforces the hash-pinning allowlist (#232): a blocked
// plugin 404s here exactly like a missing one, and is audited.
const plugin = await this.plugins.getServable(id);
if (!plugin || plugin.version !== version) throw new NotFoundException();
// Asset base is built from the configured public origin, not the request
@ -122,8 +124,9 @@ export class PluginAssetsController {
@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);
// a stale version pointer must not leak files. getServable additionally
// enforces the hash-pinning allowlist (#232).
const plugin = await this.plugins.getServable(id);
if (!plugin || plugin.version !== version) throw new NotFoundException();
const rest = (request.params as Record<string, unknown>).rest;

View File

@ -0,0 +1,172 @@
import { createHash } from 'node:crypto';
import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { zipSync } from 'fflate';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { InstanceSettingsService } from '../settings/instance-settings.service';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
const enc = (text: string) => new TextEncoder().encode(text);
function manifest(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: 'pin-me',
name: 'Pin Me',
version: '1.0.0',
apiVersion: '1',
kind: 'code',
extensionPoints: [{ type: 'pageTool', id: 'pin', title: { de: 'Pin', en: 'Pin' } }],
permissions: [],
license: 'MIT',
...overrides,
};
}
function pluginZip(m: Record<string, unknown>, bundle = 'export default {}'): Buffer {
return Buffer.from(
zipSync({ 'manifest.json': enc(JSON.stringify(m)), 'plugin.js': enc(bundle) }),
);
}
const sha256 = (buffer: Buffer) => createHash('sha256').update(buffer).digest('hex');
/**
* Hash-pinning allowlist (issue #232, ADR 0025): with a non-empty
* `plugins.allowlist`, only pinned ids with the exact bundle hash install
* and load; tampered or unpinned bundles fail closed with a stable code,
* every rejection is audited, and a version bump requires an explicit
* re-pin. Empty allowlist = unchanged behaviour (backward compatible).
*/
describe.skipIf(!hasTestDb)('plugin hash pinning (e2e, issue #232)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let settings: InstanceSettingsService;
const suffix = uniqueSuffix();
const password = 'gepinnt ist gepinnt 1';
let adminCookie: string;
let adminId: string;
const api = () => request(app.getHttpServer());
const zipV1 = pluginZip(manifest());
const zipV1Tampered = pluginZip(manifest(), 'export default { evil: true }');
const zipV2 = pluginZip(manifest({ version: '1.1.0' }));
async function setAllowlist(entries: { id: string; sha256: string }[]): Promise<void> {
await settings.set('plugins.allowlist', entries, adminId);
}
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.pondPlugin.deleteMany({});
await prisma.plugin.deleteMany({});
await prisma.instanceSetting.deleteMany({ where: { key: 'plugins.allowlist' } });
app = await createTestApp();
settings = app.get(InstanceSettingsService);
const users = app.get(UsersService);
const adminUser = await users.createUser({
username: `pin-admin-${suffix}`,
email: `pin-admin-${suffix}@example.org`,
displayName: 'Pin Admin',
password,
locale: 'en',
});
await users.markEmailVerified(adminUser.id);
await prisma.user.update({ where: { id: adminUser.id }, data: { isSiteAdmin: true } });
adminId = adminUser.id;
adminCookie = sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: `pin-admin-${suffix}`, password })
.expect(200),
);
});
afterAll(async () => {
await prisma.instanceSetting.deleteMany({ where: { key: 'plugins.allowlist' } });
await prisma.pondPlugin.deleteMany({});
await prisma.plugin.deleteMany({});
await prisma.auditEntry.deleteMany({ where: { targetId: { in: ['pin-me', 'stranger'] } } });
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.$disconnect();
await app.close();
});
it('empty allowlist: installs record the hash, nothing is enforced', async () => {
const res = await api()
.post('/api/v1/admin/plugins')
.set('Cookie', adminCookie)
.attach('file', zipV1, 'pin-me.zip')
.expect(201);
expect(res.body.pinning).toBe('not_enforced');
expect(res.body.bundleSha256).toBe(sha256(zipV1));
await api().get(`/api/v1/plugins/pin-me/1.0.0/frame`).expect(200);
});
it('pinned hash: plugin stays loadable and reports pinned', async () => {
await setAllowlist([{ id: 'pin-me', sha256: sha256(zipV1) }]);
const list = await api().get('/api/v1/admin/plugins').set('Cookie', adminCookie).expect(200);
expect(list.body[0].pinning).toBe('pinned');
await api().get(`/api/v1/plugins/pin-me/1.0.0/frame`).expect(200);
});
it('unpinned plugin: install rejected with the stable code and audited', async () => {
const res = await api()
.post('/api/v1/admin/plugins')
.set('Cookie', adminCookie)
.attach('file', pluginZip(manifest({ id: 'stranger', name: 'Stranger' })), 'stranger.zip')
.expect(403);
expect(res.body.code).toBe('plugin_not_pinned');
const audit = await prisma.auditEntry.findFirst({
where: { action: 'plugin.rejected', targetId: 'stranger' },
});
expect(audit).not.toBeNull();
});
it('tampered bundle: same id + pin, different bytes — install rejected', async () => {
// A tampered re-delivery of the pinned version arrives as an update
// attempt; the hash gate must fire before any version comparison.
const res = await api()
.post('/api/v1/admin/plugins')
.set('Cookie', adminCookie)
.attach('file', zipV1Tampered, 'pin-me.zip')
.expect(403);
expect(res.body.code).toBe('plugin_hash_mismatch');
});
it('pin changed away from the installed hash: plugin no longer loads, audited', async () => {
await setAllowlist([{ id: 'pin-me', sha256: sha256(zipV1Tampered) }]);
const list = await api().get('/api/v1/admin/plugins').set('Cookie', adminCookie).expect(200);
// The admin still SEES the plugin, with the deviation named…
expect(list.body[0].pinning).toBe('mismatch');
// …but nothing serves it: frame 404s and the rejection is audited.
await api().get(`/api/v1/plugins/pin-me/1.0.0/frame`).expect(404);
const audit = await prisma.auditEntry.findFirst({
where: { action: 'plugin.rejected', targetId: 'pin-me' },
});
expect(audit).not.toBeNull();
});
it('version bump requires an explicit re-pin', async () => {
await setAllowlist([{ id: 'pin-me', sha256: sha256(zipV1) }]);
const rejected = await api()
.post('/api/v1/admin/plugins')
.set('Cookie', adminCookie)
.attach('file', zipV2, 'pin-me-1.1.zip')
.expect(403);
expect(rejected.body.code).toBe('plugin_hash_mismatch');
await setAllowlist([{ id: 'pin-me', sha256: sha256(zipV2) }]);
const res = await api()
.post('/api/v1/admin/plugins')
.set('Cookie', adminCookie)
.attach('file', zipV2, 'pin-me-1.1.zip')
.expect(201);
expect(res.body.pinning).toBe('pinned');
await api().get(`/api/v1/plugins/pin-me/1.1.0/frame`).expect(200);
});
});

View File

@ -1,3 +1,5 @@
import { createHash } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { Plugin, PluginInstanceMode as DbPluginMode, Prisma, User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino';
@ -12,6 +14,7 @@ import type {
import { ClockService } from '../common/clock.service';
import { AuditService } from '../audit/audit.service';
import { PrismaService } from '../prisma/prisma.service';
import { InstanceSettingsService } from '../settings/instance-settings.service';
import { PluginPackageService } from './plugin-package.service';
import { PluginStorageService } from './plugin-storage.service';
@ -44,6 +47,7 @@ export class PluginsService {
private readonly storage: PluginStorageService,
private readonly clock: ClockService,
private readonly audit: AuditService,
private readonly settings: InstanceSettingsService,
private readonly logger: PinoLogger,
) {
this.logger.setContext(PluginsService.name);
@ -58,6 +62,32 @@ export class PluginsService {
/** `actor` is absent for dropzone installs (watcher, no session). */
async install(zip: Buffer, actor?: User): Promise<PluginView> {
const { manifest, files } = this.packages.parse(zip);
const bundleHash = createHash('sha256').update(zip).digest('hex');
// Hash pinning (#232, ADR 0025): while the allowlist is non-empty,
// only listed ids with the exact pinned bundle hash may install.
// Fail closed with a distinct code per cause; every rejection is
// audited so a tampering attempt leaves a trace.
const allowlist = await this.settings.get('plugins.allowlist');
if (allowlist.length > 0) {
const pin = allowlist.find((entry) => entry.id === manifest.id);
if (!pin || pin.sha256 !== bundleHash) {
const reason = pin ? 'hash_mismatch' : 'not_pinned';
await this.audit.record({
action: 'plugin.rejected',
actorId: actor?.id,
targetType: 'plugin',
targetId: manifest.id,
details: { surface: 'install', reason, version: manifest.version, bundleHash },
});
throw new PluginPackageError(
pin ? 'plugin_hash_mismatch' : 'plugin_not_pinned',
pin
? `Bundle hash ${bundleHash} does not match the pinned hash for ${manifest.id}`
: `Plugin ${manifest.id} is not on the allowlist`,
);
}
}
const existing = await this.prisma.plugin.findUnique({ where: { id: manifest.id } });
const isActiveUpdate = existing !== null && existing.removedAt === null;
@ -81,6 +111,7 @@ export class PluginsService {
apiVersion: manifest.apiVersion,
kind: manifest.kind,
manifest: manifest as unknown as Prisma.InputJsonValue,
bundleHash,
},
update: {
name: manifest.name,
@ -88,6 +119,7 @@ export class PluginsService {
apiVersion: manifest.apiVersion,
kind: manifest.kind,
manifest: manifest as unknown as Prisma.InputJsonValue,
bundleHash,
// Reinstalling a previously removed plugin clears the tombstone.
removedAt: null,
},
@ -105,7 +137,7 @@ export class PluginsService {
targetId: manifest.id,
details: { version: manifest.version, update: isActiveUpdate },
});
return this.toView(record);
return this.toView(record, await this.settings.get('plugins.allowlist'));
}
/**
@ -131,7 +163,7 @@ export class PluginsService {
targetId: id,
details: { mode },
});
return this.toView(updated);
return this.toView(updated, await this.settings.get('plugins.allowlist'));
}
/**
@ -182,9 +214,18 @@ export class PluginsService {
this.prisma.pondPlugin.findMany({ where: { pondId } }),
]);
const enabled = new Map(activations.map((a) => [a.pluginId, a.enabled]));
return plugins
.filter((p) => p.mode === 'REQUIRED' || (p.mode === 'OPTIONAL' && enabled.get(p.id) === true))
.map((p) => this.toView(p));
const allowlist = await this.settings.get('plugins.allowlist');
return (
plugins
.filter(
(p) => p.mode === 'REQUIRED' || (p.mode === 'OPTIONAL' && enabled.get(p.id) === true),
)
// Hash pinning (#232): a plugin outside the allowlist, or with a
// deviating bundle hash, does not load — it simply never appears in
// the pond's mount list. Existing blocks render their fallback.
.filter((p) => this.loadable(this.verdict(p, allowlist)))
.map((p) => this.toView(p, allowlist))
);
}
/**
@ -219,7 +260,11 @@ export class PluginsService {
this.prisma.pondPlugin.findMany({ where: { pondId } }),
]);
const enabled = new Map(activations.map((a) => [a.pluginId, a.enabled]));
return plugins.map((p) => ({ plugin: this.toView(p), enabled: enabled.get(p.id) === true }));
const allowlist = await this.settings.get('plugins.allowlist');
return plugins.map((p) => ({
plugin: this.toView(p, allowlist),
enabled: enabled.get(p.id) === true,
}));
}
/**
@ -287,19 +332,66 @@ export class PluginsService {
where: { removedAt: null },
orderBy: { name: 'asc' },
});
return plugins.map((plugin) => this.toView(plugin));
const allowlist = await this.settings.get('plugins.allowlist');
return plugins.map((plugin) => this.toView(plugin, allowlist));
}
/** One installed plugin, or `null` if absent/removed. */
async get(id: string): Promise<PluginView | null> {
const plugin = await this.prisma.plugin.findUnique({ where: { id } });
if (!plugin || plugin.removedAt !== null) return null;
return this.toView(plugin);
return this.toView(plugin, await this.settings.get('plugins.allowlist'));
}
private toView(plugin: Plugin): PluginView {
/**
* Pinning verdict against `plugins.allowlist` (#232). The observed hash
* is the one recorded at install: post-install tampering with unpacked
* files on disk is platform integrity (ADR 0019), not this check's
* scope the pin answers "is this the reviewed bundle".
*/
private verdict(
plugin: Plugin,
allowlist: { id: string; sha256: string }[],
): PluginView['pinning'] {
if (allowlist.length === 0) return 'not_enforced';
const pin = allowlist.find((entry) => entry.id === plugin.id);
if (!pin) return 'unpinned';
return plugin.bundleHash === pin.sha256 ? 'pinned' : 'mismatch';
}
private loadable(verdict: PluginView['pinning']): boolean {
return verdict === 'not_enforced' || verdict === 'pinned';
}
/**
* The load gate for code-serving surfaces (frame + assets, #232): an
* installed plugin outside the allowlist, or with a deviating bundle
* hash, does not load 404 like a missing plugin, and the rejection is
* audited (unlike the silent list filtering, an asset request proves
* something actively referenced the blocked plugin).
*/
async getServable(id: string): Promise<PluginView | null> {
const plugin = await this.prisma.plugin.findUnique({ where: { id } });
if (!plugin || plugin.removedAt !== null) return null;
const allowlist = await this.settings.get('plugins.allowlist');
const verdict = this.verdict(plugin, allowlist);
if (!this.loadable(verdict)) {
await this.audit.record({
action: 'plugin.rejected',
targetType: 'plugin',
targetId: id,
details: { surface: 'load', reason: verdict, version: plugin.version },
});
return null;
}
return this.toView(plugin, allowlist);
}
private toView(plugin: Plugin, allowlist: { id: string; sha256: string }[]): PluginView {
const manifest = plugin.manifest as unknown as PluginManifest;
return {
bundleSha256: plugin.bundleHash,
pinning: this.verdict(plugin, allowlist),
id: plugin.id,
name: plugin.name,
version: plugin.version,

View File

@ -140,6 +140,26 @@ export const INSTANCE_SETTINGS = {
// (an image fallback degrades to neutral text: its bytes live on the
// disabled asset surface).
'plugins.enabled': z.boolean().default(true),
// Plugin allowlist with SHA-256 hash pinning (issue #232, ADR 0025).
// Empty (the default) = pinning is NOT enforced — plugins load as
// before, which keeps existing instances working. Non-empty = only the
// listed plugin ids load, and only while the installed bundle's
// observed hash equals the pinned one; installs of unlisted or
// mismatching bundles are rejected, loads fail closed (assets/frame
// 404, audited `plugin.rejected`). A version bump changes the bundle
// hash, so it requires an explicit re-pin — the intended friction.
'plugins.allowlist': z
.array(
z.object({
id: z.string().min(1),
sha256: z
.string()
.trim()
.toLowerCase()
.regex(/^[a-f0-9]{64}$/),
}),
)
.default([]),
// 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

@ -51,10 +51,34 @@ export function PluginManager(): React.JSX.Element {
onError: (error) => setUploadError(error),
});
// Hash-pinning allowlist (#232, ADR 0025): stored in instance settings;
// pinning the FIRST plugin turns enforcement on for every plugin.
const settings = useQuery({
queryKey: ['admin', 'settings'],
queryFn: () => apiGet<Record<string, unknown>>('/admin/settings'),
});
const allowlist = (settings.data?.['plugins.allowlist'] ?? []) as {
id: string;
sha256: string;
}[];
const saveAllowlist = useMutation({
mutationFn: (next: { id: string; sha256: string }[]) =>
apiPatch('/admin/settings', { 'plugins.allowlist': next }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] });
void invalidate();
},
onError: (error) => setUploadError(error),
});
const pinnedHashOf = (id: string) => allowlist.find((entry) => entry.id === id)?.sha256;
return (
<section className="settings-section plugin-manager">
<h2>{t('admin.title')}</h2>
<p className="plugin-manager__intro">{t('admin.intro')}</p>
<p className="plugin-manager__intro">
{allowlist.length === 0 ? t('admin.pinning.hintOff') : t('admin.pinning.hintOn')}
</p>
<div className="plugin-manager__upload">
<label className="button">
@ -97,6 +121,51 @@ export function PluginManager(): React.JSX.Element {
)}
</div>
<div className="plugin-manager__pinning">
<span>{t('admin.pinning.state')}:</span>{' '}
<strong>{t(`admin.pinning.verdict.${plugin.pinning}`)}</strong>
<br />
<span>{t('admin.pinning.observed')}:</span>{' '}
{plugin.bundleSha256 ? (
<code className="plugin-manager__hash">{plugin.bundleSha256}</code>
) : (
<em>{t('admin.pinning.unknownHash')}</em>
)}
{pinnedHashOf(plugin.id) && pinnedHashOf(plugin.id) !== plugin.bundleSha256 && (
<>
<br />
<span>{t('admin.pinning.pinnedHash')}:</span>{' '}
<code className="plugin-manager__hash">{pinnedHashOf(plugin.id)}</code>
</>
)}
<br />
{plugin.bundleSha256 && pinnedHashOf(plugin.id) !== plugin.bundleSha256 && (
<button
type="button"
className="linklike"
onClick={() =>
saveAllowlist.mutate([
...allowlist.filter((entry) => entry.id !== plugin.id),
{ id: plugin.id, sha256: plugin.bundleSha256! },
])
}
>
{pinnedHashOf(plugin.id) ? t('admin.pinning.repin') : t('admin.pinning.pin')}
</button>
)}
{pinnedHashOf(plugin.id) && (
<button
type="button"
className="linklike"
onClick={() =>
saveAllowlist.mutate(allowlist.filter((entry) => entry.id !== plugin.id))
}
>
{t('admin.pinning.unpin')}
</button>
)}
</div>
<div className="plugin-manager__controls">
<label>
{t('admin.mode')}:{' '}

View File

@ -1,7 +1,7 @@
# Audit event catalogue
**Catalogue version 1.4 (2026-07-31; 1.4 adds `auth.proxy_rejected`,
issue #215; 1.3 added `auth.identity_linked`, issue #214; 1.2 added
**Catalogue version 1.5 (2026-07-31; 1.5 adds `plugin.rejected`,
issue #232; 1.4 added `auth.proxy_rejected`, issue #215; 1.3 added `auth.identity_linked`, issue #214; 1.2 added
`read_trail.pruned`, issue #224; 1.1 added `page.classification_*`,
issue #205).**
@ -125,8 +125,9 @@ failure), `warning` = feeds detection (suspicious or destructive),
### Plugins (`plugin.*`)
| Id | Trigger | Severity | Actor | Target | Fields |
| --------------------- | -------------------------------------------------- | -------- | ------------------------------- | -------- | -------------------------- |
| --------------------- | ------------------------------------------------------------ | -------- | ------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `plugin.installed` | Plugin package installed or updated | notice | admin, `null` for dropzone drop | `plugin` | `version`, `update` (bool) |
| `plugin.rejected` | Install or load blocked by the hash-pinning allowlist (#232) | warning | admin for installs, `null` for loads | `plugin` | `surface` (`install`/`load`), `reason` (`not_pinned`/`hash_mismatch`/`unpinned`/`mismatch`), `version`, `bundleHash` (installs) |
| `plugin.mode_set` | Instance mode changed (disabled/optional/required) | notice | the admin | `plugin` | `mode` |
| `plugin.uninstalled` | Plugin removed | notice | the admin | `plugin` | — |
| `plugin.pond_toggled` | Optional plugin toggled for one pond | info | the pond admin | `pond` | `plugin`, `enabled` |

View File

@ -132,6 +132,33 @@ 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.
## Trust: hash-pinning allowlist (issue #232, ADR 0025)
Real code signing is unavailable without a legal entity to hold a signing
identity, so bundle trust is hash pinning:
- At install the api records the SHA-256 of the delivered bundle ZIP on
the plugin row (`plugins.bundle_hash`; plugins installed before #232
show it as unknown until reinstalled).
- `plugins.allowlist` in `instance_settings` names permitted plugin ids
with their pinned hashes. **Empty (the default) = pinning is not
enforced** — plugins load as before. Non-empty = enforcement for every
plugin: installs of unlisted ids or deviating bundles are rejected
(`plugin_not_pinned` / `plugin_hash_mismatch`), and an installed plugin
outside the list or with a deviating hash does not load — it disappears
from the pond mount lists and its frame/asset routes answer 404. Every
rejection is audited (`plugin.rejected`, catalogue v1.5).
- A version bump changes the bundle, hence the hash, hence requires an
explicit re-pin in the admin UI — deliberate friction (ADR 0025).
- Scope, stated honestly: the pin answers "is this the reviewed bundle".
The observed hash is recorded at install; tampering with the unpacked
files on disk afterwards is platform integrity (ADR 0019), and what the
loaded code may do remains the sandbox's job — neither substitutes for
the other.
- The VS-NfD reference configuration keeps the allowlist empty because it
turns plugins off entirely (`plugins.enabled=false`, #200); the
allowlist is for deployments that deviate and run plugins.
## Reference plugins (shipped with the product, also serving as examples)
- `section-styles-basic` (`section_style`): a set of colored callout/box

View File

@ -167,12 +167,13 @@ _Meilenstein: `M31 — VS-NfD: backlog`_
Nur noch ein Punkt bleibt draußen:
- **Plugin-Allowlist mit Hash-Pinning** · 810 AT · #232
Manifest mit SHA-256, Allowlist in `instance_settings`, Prüfung beim Laden,
Admin-UI. Bleibt zurückgestellt, weil Phase 2 mit der harten Abschaltung das
Risiko bereits schließt — und weil echte Code-Signierung ohne juristische
Person ohnehin nicht verfügbar ist. Hash-Pinning ist die richtige Antwort,
aber nicht die dringendste.
- [x] **Plugin-Allowlist mit Hash-Pinning** · 810 AT · #232 (erledigt
31.07.2026)
SHA-256-Erfassung beim Install, Allowlist in `instance_settings`
(`plugins.allowlist`), Prüfung bei Install und Load (fail-closed,
auditiert), Admin-UI mit „gesehen vs. gepinnt". Restrisiko R-03 damit
aufgelöst; Code-Signierung bleibt mangels juristischer Person
verworfen (ADR 0025).
---

View File

@ -27,12 +27,13 @@ Nach jeder Änderung an Instanz-Settings die api neu starten — der
Settings-Cache ist in-process (operations.md).
| Setting | Referenzwert | Default | Warum |
| ----------------------------------------------------------------------------------------------------------- | --------------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ----------------------------------------------------------------------------------------------------------- | --------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `auth.registrationMode` | `closed` | `open` | Konten entstehen in einer VS-Umgebung nur kontrolliert; Selbstregistrierung öffnet den Nutzerkreis unkontrolliert. |
| `api.enabled` | `false` | `false` | Public REST API ist ein zusätzlicher Egress-Kanal; ohne dokumentierten Bedarf bleibt er zu (404 auf allen `/api/public/v1`-Routen). |
| `mcp.enabled` | `false` | `false` | gleiches Argument für den MCP-Endpoint (`/api/mcp`); unabhängiger Schalter. |
| `feeds.enabled` | `false` | `true` | **explizit setzen** — Atom-Feeds liefern Inhalte an Reader außerhalb der Kontrolle der Instanz (Feed-Token umgehen die Session); Kopien in Feed-Readern sind nicht einholbar (Kopienliste, Sicherheitsdokumentation §5). Schaltet Routen UND Feed-Token-Verwaltung auf 404. |
| `plugins.enabled` | `false` | `true` | **explizit setzen** — kein Fremdcode in der VS-Zone (#200): alle Plugin-Flächen 404, Dropzone quarantänisiert; bestehende Blöcke degradieren zu ihrem deklarierten Text-Fallback. Hash-Pinning ist verschoben (#232, Restrisikoliste) — der Kill-Switch deckt das Risiko für diesen Betriebsmodus vollständig. |
| `plugins.enabled` | `false` | `true` | **explizit setzen** — kein Fremdcode in der VS-Zone (#200): alle Plugin-Flächen 404, Dropzone quarantänisiert; bestehende Blöcke degradieren zu ihrem deklarierten Text-Fallback. Der Kill-Switch deckt das Risiko für diesen Betriebsmodus vollständig; wer abweichend Plugins betreibt, nutzt zusätzlich das Hash-Pinning (`plugins.allowlist`, #232). |
| `plugins.allowlist` | leer (Plugins sind aus) | leer | Hash-Pinning-Allowlist (#232, ADR 0025): nicht-leer erzwingt, dass nur gepinnte Plugin-Ids mit exakt übereinstimmendem Bundle-SHA-256 installieren und laden (Abweisung auditiert `plugin.rejected`; Versionswechsel = explizites Re-Pin). In der Referenzkonfiguration bleibt sie leer, weil `plugins.enabled=false` das Risiko vollständig schließt; wer abweichend Plugins betreibt, pinnt GENAU die geprüften Bundles. |
| `classification.newPageDefault` | `vs_nfd` | `unclassified` | in einer VS-NfD-Instanz beginnt nichts unmarkiert (#204); die Vererbung (#205) hält den Baum konsistent. |
| `classification.uploadPolicy` | `block` | `warn` | Anhänge können die Kennzeichnung im Inhalt nicht tragen (#212) — die Referenzkonfiguration lehnt Uploads auf eingestufte Seiten serverseitig ab (403 `classified_upload_blocked`, #213) statt nur zu warnen. |
| `upload.svgPolicy` | `reject` | `sanitize` | SVG ist aktiver Inhalt; die Sanitisierung ist gut getestet, aber Ablehnen ist die kleinere Angriffsfläche. Abweichung vertretbar, wenn SVG gebraucht wird. |

View File

@ -49,19 +49,25 @@ nachvollziehbar.
- **Entscheidung:** Projektleitung, Issue #216, 31.07.2026 (ursprüngliche
Aufnahme: Maßnahmenplan Rev. 2, 30.07.2026).
## R-03 Plugin-Hash-Pinning verschoben
## R-03 Plugin-Hash-Pinning verschoben — AUFGELÖST (31.07.2026, #232)
- **Risiko:** Installierte Plugin-Pakete sind nicht gegen einen
festgeschriebenen Hash verankert (#232); ein manipuliertes Paket
gleichen Namens wäre beim Neuinstallieren nicht erkennbar.
- **Warum akzeptiert:** Die Referenzkonfiguration betreibt Plugins
**gar nicht** (`plugins.enabled=false`, #200) — der Kill-Switch deckt
den VS-NfD-Betriebsmodus vollständig; Pinning lohnt erst, wenn ein
Betreiber Plugins tatsächlich freigibt.
- **Kompensation:** Kill-Switch (alle Plugin-Flächen 404, Dropzone
quarantänisiert); Sandbox mit Capability-Modell + CI-Escape-
Regressionstest (ADR 0008/0025); Install nur durch Site-Admin.
- **Entscheidung:** Projektleitung, ADR 0025 / Issue #232, 30.07.2026.
- **Ursprüngliches Risiko:** Installierte Plugin-Pakete waren nicht gegen
einen festgeschriebenen Hash verankert; ein manipuliertes Paket
gleichen Namens wäre beim Neuinstallieren nicht erkennbar gewesen.
- **Auflösung:** Hash-Pinning ist mit #232 umgesetzt (ADR 0025):
SHA-256-Erfassung beim Install, Allowlist `plugins.allowlist`
(id + gepinnter Hash), Durchsetzung bei Install UND Load (fail-closed,
Abweisungen auditiert `plugin.rejected`), Versionswechsel nur per
explizitem Re-Pin. Die Referenzkonfiguration betreibt Plugins weiterhin
gar nicht (`plugins.enabled=false`); für abweichende Betreiber ist das
Pinning jetzt verfügbar statt zurückgestellt.
- **Verbleibender Hinweis (dokumentiert):** Der gesehene Hash wird beim
Install erfasst; nachträgliche Manipulation entpackter Dateien auf der
Platte ist Plattform-Integrität (ADR 0019), nicht Gegenstand der
Prüfung. Vor #232 installierte Plugins tragen bis zur Neuinstallation
keinen erfassten Hash.
- **Entscheidung:** Projektleitung, ADR 0025 / Issue #232; aufgelöst
31.07.2026 (ursprüngliche Aufnahme 30.07.2026).
## R-04 Git-Historie: einmalige Secret-Prüfung mit begrenztem Muster

View File

@ -112,5 +112,7 @@
"pond_not_found": "Der Teich existiert nicht.",
"classification_lower_forbidden": "Zum Herabstufen der Einstufung fehlt die Berechtigung (Teich-Admin erforderlich).",
"classified_upload_blocked": "Uploads auf eingestufte Seiten sind auf dieser Instanz blockiert.",
"vs_nfd_profile_violation": "Diese Einstellung würde vom VS-NfD-Referenzprofil abweichen — das Deployment erzwingt das Profil (VS_NFD_MODE=enforced)."
"vs_nfd_profile_violation": "Diese Einstellung würde vom VS-NfD-Referenzprofil abweichen — das Deployment erzwingt das Profil (VS_NFD_MODE=enforced).",
"plugin_not_pinned": "Dieses Plugin steht nicht auf der Allowlist (Hash-Pinning aktiv) — erst pinnen, dann installieren.",
"plugin_hash_mismatch": "Der Bundle-Hash weicht vom gepinnten Hash ab — das Bundle ist nicht das geprüfte (oder eine neue Version braucht ein Re-Pin)."
}

View File

@ -31,7 +31,24 @@
"mode": "Modus",
"preview": "Vorschau",
"uninstall": "Deinstallieren",
"requiredLocked": "Ein erforderliches Plugin kann nicht deinstalliert werden."
"requiredLocked": "Ein erforderliches Plugin kann nicht deinstalliert werden.",
"pinning": {
"state": "Hash-Pinning",
"verdict": {
"not_enforced": "nicht erzwungen (Allowlist leer)",
"pinned": "gepinnt — Bundle entspricht dem geprüften Stand",
"unpinned": "NICHT auf der Allowlist — lädt nicht",
"mismatch": "HASH WEICHT AB — lädt nicht"
},
"observed": "Gesehener Bundle-Hash (SHA-256)",
"pinnedHash": "Gepinnter Hash",
"unknownHash": "unbekannt (vor der Hash-Erfassung installiert — neu installieren, um ihn zu erfassen)",
"pin": "Diesen Hash pinnen",
"repin": "Pin auf diesen Hash aktualisieren",
"unpin": "Pin entfernen",
"hintOff": "Hash-Pinning ist aus (Allowlist leer): Plugins laden wie bisher. Das Pinnen des ersten Plugins schaltet die Durchsetzung für ALLE Plugins ein.",
"hintOn": "Hash-Pinning aktiv: Nur gepinnte Plugins mit übereinstimmendem Bundle-Hash werden installiert und geladen; jede Abweisung wird auditiert. Ein Versions-Update braucht ein explizites Re-Pin."
}
},
"mode": {
"disabled": "Deaktiviert",

View File

@ -112,5 +112,7 @@
"pond_not_found": "The pond does not exist.",
"classification_lower_forbidden": "You lack the permission to lower the classification (Pond Admin required).",
"classified_upload_blocked": "Uploads to classified pages are blocked on this instance.",
"vs_nfd_profile_violation": "This setting would deviate from the VS-NfD reference profile — the deployment enforces the profile (VS_NFD_MODE=enforced)."
"vs_nfd_profile_violation": "This setting would deviate from the VS-NfD reference profile — the deployment enforces the profile (VS_NFD_MODE=enforced).",
"plugin_not_pinned": "This plugin is not on the allowlist (hash pinning active) — pin it first, then install.",
"plugin_hash_mismatch": "The bundle hash deviates from the pinned hash — the bundle is not the reviewed one (or a new version needs a re-pin)."
}

View File

@ -31,7 +31,24 @@
"mode": "Mode",
"preview": "Preview",
"uninstall": "Uninstall",
"requiredLocked": "A required plugin cannot be uninstalled."
"requiredLocked": "A required plugin cannot be uninstalled.",
"pinning": {
"state": "Hash pinning",
"verdict": {
"not_enforced": "not enforced (allowlist empty)",
"pinned": "pinned — bundle matches the reviewed state",
"unpinned": "NOT on the allowlist — does not load",
"mismatch": "HASH DEVIATES — does not load"
},
"observed": "Observed bundle hash (SHA-256)",
"pinnedHash": "Pinned hash",
"unknownHash": "unknown (installed before hash recording — reinstall to record it)",
"pin": "Pin this hash",
"repin": "Update pin to this hash",
"unpin": "Remove pin",
"hintOff": "Hash pinning is off (allowlist empty): plugins load as before. Pinning the first plugin turns enforcement on for ALL plugins.",
"hintOn": "Hash pinning active: only pinned plugins with a matching bundle hash install and load; every rejection is audited. A version update requires an explicit re-pin."
}
},
"mode": {
"disabled": "Disabled",

View File

@ -36,6 +36,12 @@ export interface PluginView {
assetBasePath: string;
license: string;
homepage?: string;
/** SHA-256 (hex) of the installed bundle ZIP (#232); null = installed
* before hash recording existed reinstall to record it. */
bundleSha256: string | null;
/** Verdict against `plugins.allowlist` (#232): `not_enforced` while the
* allowlist is empty; otherwise pinned / unpinned / mismatch. */
pinning: 'not_enforced' | 'pinned' | 'unpinned' | 'mismatch';
installedAt: string;
updatedAt: string;
}
@ -58,6 +64,8 @@ export const PLUGIN_ERROR_CODES = [
'plugin_required_cannot_uninstall',
'plugin_not_found',
'plugin_not_optional',
'plugin_not_pinned',
'plugin_hash_mismatch',
] as const;
export type PluginErrorCode = (typeof PLUGIN_ERROR_CODES)[number];

View File

@ -159,6 +159,7 @@ export const VS_NFD_PROFILE: readonly VsNfdProfileEntry[] = [
*/
export const VS_NFD_PROFILE_ADVISORY: readonly { scope: 'instance' | 'deploy'; key: string }[] = [
{ scope: 'instance', key: 'upload.allowedExtensions' },
{ scope: 'instance', key: 'plugins.allowlist' },
{ scope: 'instance', key: 'trash.retentionDays' },
{ scope: 'instance', key: 'audit.retentionDays' },
{ scope: 'instance', key: 'conversion.payloadRetentionDays' },