#232: plugin allowlist with SHA-256 hash pinning #298
@ -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;
|
||||
@ -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`.
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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;
|
||||
|
||||
172
apps/api/src/plugins/plugin-pinning.e2e.db.test.ts
Normal file
172
apps/api/src/plugins/plugin-pinning.e2e.db.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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')}:{' '}
|
||||
|
||||
@ -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).**
|
||||
|
||||
@ -124,12 +124,13 @@ 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.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` |
|
||||
| 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` |
|
||||
|
||||
### Backup & restore (`backup.*`)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -167,12 +167,13 @@ _Meilenstein: `M31 — VS-NfD: backlog`_
|
||||
|
||||
Nur noch ein Punkt bleibt draußen:
|
||||
|
||||
- **Plugin-Allowlist mit Hash-Pinning** · 8–10 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** · 8–10 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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -26,24 +26,25 @@ Produkt-Beleg).
|
||||
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. |
|
||||
| `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. |
|
||||
| `upload.allowedExtensions` | nur das dienstlich Nötige (z. B. `pdf`) | Standardliste | jede zusätzliche Endung vergrößert die Menge nicht prüfbarer Binärformate im Bestand. Bilder sind davon unabhängig immer erlaubt (Magic-Byte-geprüft). |
|
||||
| `backup.nextcloud.enabled` | `false` | `false` | „Backup nur lokal": kein Anwendungs-Upload von Restore-Sets zu Drittdiensten. Fernspiegel regelt ausschließlich die Deploy-Allowlist (1.2). |
|
||||
| `trash.retentionDays`, `audit.retentionDays`, `conversion.payloadRetentionDays`, `mail.outboxRetentionDays` | Defaults (30/365/30/30) | ebd. | Aufbewahrung bewusst begrenzt; Verkürzung nach Betreiber-Löschkonzept zulässig (Betriebshandbuch §5). |
|
||||
| `readTrail.enabled` | `true` | `false` | **explizit setzen** — der Lesetrail (#222–#225) evidenziert Lesezugriffe auf eingestufte Seiten; Default aus, weil Lesebeobachtung mitbestimmungsrelevant ist. Einschalten NUR zusammen mit der Zweckbindung (Sicherheitsdokumentation §7); die api meldet die Schalterstellung beim Start. |
|
||||
| `readTrail.dedupWindowMinutes` | Default (5) | `5` | Dedup-Fenster des Lesetrails (#223): je (Sitzung, Seite, Kanal) ein Ereignis pro Fenster — begrenzt die Ereignisflut einer Live-Sitzung auf ~12/h. Kleiner = feineres Protokoll und mehr Zeilen; Änderung mit dem Zweckbindungs-Dokument (#225) abstimmen. |
|
||||
| `readTrail.retentionDays` | Default (365) | `365` | eigene Aufbewahrung des Lesetrails (#224), bewusst getrennt von `audit.retentionDays`; Löschläufe sind selbst auditiert (`read_trail.pruned`). Dauer mit der Zweckbindung (#225) und dem Betreiber-Löschkonzept abstimmen. |
|
||||
| `idpMapping.rules` | Gruppen→Rollen der Behörde abbilden | `[]` | deklaratives Claim-Mapping (#217): IdP-Gruppen werden bei jedem OIDC-Login auf Teich-Rollen und das Site-Admin-Flag abgeglichen — sonst pflegt die Behörde Berechtigungen doppelt und die zweite Kopie driftet. Mapping fasst nur eigene Grants an (manuell gewinnt); Details: permissions.md §IdP claim mapping. |
|
||||
| `legal.imprint`, `legal.privacyPolicy` | befüllt | leer | Betreiberpflicht; leere Seiten zeigen einen Warnbanner. |
|
||||
| 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. 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. |
|
||||
| `upload.allowedExtensions` | nur das dienstlich Nötige (z. B. `pdf`) | Standardliste | jede zusätzliche Endung vergrößert die Menge nicht prüfbarer Binärformate im Bestand. Bilder sind davon unabhängig immer erlaubt (Magic-Byte-geprüft). |
|
||||
| `backup.nextcloud.enabled` | `false` | `false` | „Backup nur lokal": kein Anwendungs-Upload von Restore-Sets zu Drittdiensten. Fernspiegel regelt ausschließlich die Deploy-Allowlist (1.2). |
|
||||
| `trash.retentionDays`, `audit.retentionDays`, `conversion.payloadRetentionDays`, `mail.outboxRetentionDays` | Defaults (30/365/30/30) | ebd. | Aufbewahrung bewusst begrenzt; Verkürzung nach Betreiber-Löschkonzept zulässig (Betriebshandbuch §5). |
|
||||
| `readTrail.enabled` | `true` | `false` | **explizit setzen** — der Lesetrail (#222–#225) evidenziert Lesezugriffe auf eingestufte Seiten; Default aus, weil Lesebeobachtung mitbestimmungsrelevant ist. Einschalten NUR zusammen mit der Zweckbindung (Sicherheitsdokumentation §7); die api meldet die Schalterstellung beim Start. |
|
||||
| `readTrail.dedupWindowMinutes` | Default (5) | `5` | Dedup-Fenster des Lesetrails (#223): je (Sitzung, Seite, Kanal) ein Ereignis pro Fenster — begrenzt die Ereignisflut einer Live-Sitzung auf ~12/h. Kleiner = feineres Protokoll und mehr Zeilen; Änderung mit dem Zweckbindungs-Dokument (#225) abstimmen. |
|
||||
| `readTrail.retentionDays` | Default (365) | `365` | eigene Aufbewahrung des Lesetrails (#224), bewusst getrennt von `audit.retentionDays`; Löschläufe sind selbst auditiert (`read_trail.pruned`). Dauer mit der Zweckbindung (#225) und dem Betreiber-Löschkonzept abstimmen. |
|
||||
| `idpMapping.rules` | Gruppen→Rollen der Behörde abbilden | `[]` | deklaratives Claim-Mapping (#217): IdP-Gruppen werden bei jedem OIDC-Login auf Teich-Rollen und das Site-Admin-Flag abgeglichen — sonst pflegt die Behörde Berechtigungen doppelt und die zweite Kopie driftet. Mapping fasst nur eigene Grants an (manuell gewinnt); Details: permissions.md §IdP claim mapping. |
|
||||
| `legal.imprint`, `legal.privacyPolicy` | befüllt | leer | Betreiberpflicht; leere Seiten zeigen einen Warnbanner. |
|
||||
|
||||
### 1.2 Deploy-Konfiguration (`.env` / Compose — nur Plattformzugriff, bewusst nicht per Admin-UI)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)."
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)."
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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];
|
||||
|
||||
|
||||
@ -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' },
|
||||
|
||||
Loading…
Reference in New Issue
Block a user