#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
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:
parent
404a3741c8
commit
9b7acab294
@ -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)
|
mode PluginInstanceMode @default(DISABLED)
|
||||||
/// The full manifest as validated at install time (@dorfteich/plugin-sdk).
|
/// The full manifest as validated at install time (@dorfteich/plugin-sdk).
|
||||||
manifest Json
|
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")
|
installedAt DateTime @default(now()) @map("installed_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
/// Set when uninstalled; active queries filter `removedAt: null`.
|
/// Set when uninstalled; active queries filter `removedAt: null`.
|
||||||
|
|||||||
@ -36,6 +36,7 @@ export const AUDIT_EVENTS = {
|
|||||||
'page.classification_lowered': { severity: 'warning' },
|
'page.classification_lowered': { severity: 'warning' },
|
||||||
'page.classification_raised': { severity: 'notice' },
|
'page.classification_raised': { severity: 'notice' },
|
||||||
'plugin.installed': { severity: 'notice' },
|
'plugin.installed': { severity: 'notice' },
|
||||||
|
'plugin.rejected': { severity: 'warning' },
|
||||||
'plugin.mode_set': { severity: 'notice' },
|
'plugin.mode_set': { severity: 'notice' },
|
||||||
'plugin.pond_toggled': { severity: 'info' },
|
'plugin.pond_toggled': { severity: 'info' },
|
||||||
'plugin.uninstalled': { severity: 'notice' },
|
'plugin.uninstalled': { severity: 'notice' },
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
@ -40,6 +41,10 @@ function toHttpException(error: PluginPackageError): HttpException {
|
|||||||
case 'plugin_version_not_higher':
|
case 'plugin_version_not_higher':
|
||||||
case 'plugin_not_optional':
|
case 'plugin_not_optional':
|
||||||
return new ConflictException(body);
|
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':
|
case 'plugin_too_large':
|
||||||
return new PayloadTooLargeException(body);
|
return new PayloadTooLargeException(body);
|
||||||
default:
|
default:
|
||||||
|
|||||||
@ -96,7 +96,9 @@ export class PluginAssetsController {
|
|||||||
@Param('version') version: string,
|
@Param('version') version: string,
|
||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response,
|
||||||
): Promise<string> {
|
): 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();
|
if (!plugin || plugin.version !== version) throw new NotFoundException();
|
||||||
|
|
||||||
// Asset base is built from the configured public origin, not the request
|
// Asset base is built from the configured public origin, not the request
|
||||||
@ -122,8 +124,9 @@ export class PluginAssetsController {
|
|||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response,
|
||||||
): Promise<StreamableFile> {
|
): Promise<StreamableFile> {
|
||||||
// Only serve assets for an installed, current version — a removed plugin or
|
// Only serve assets for an installed, current version — a removed plugin or
|
||||||
// a stale version pointer must not leak files.
|
// a stale version pointer must not leak files. getServable additionally
|
||||||
const plugin = await this.plugins.get(id);
|
// enforces the hash-pinning allowlist (#232).
|
||||||
|
const plugin = await this.plugins.getServable(id);
|
||||||
if (!plugin || plugin.version !== version) throw new NotFoundException();
|
if (!plugin || plugin.version !== version) throw new NotFoundException();
|
||||||
|
|
||||||
const rest = (request.params as Record<string, unknown>).rest;
|
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 { Injectable } from '@nestjs/common';
|
||||||
import { Plugin, PluginInstanceMode as DbPluginMode, Prisma, User } from '@prisma/client';
|
import { Plugin, PluginInstanceMode as DbPluginMode, Prisma, User } from '@prisma/client';
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
@ -12,6 +14,7 @@ import type {
|
|||||||
import { ClockService } from '../common/clock.service';
|
import { ClockService } from '../common/clock.service';
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
||||||
|
|
||||||
import { PluginPackageService } from './plugin-package.service';
|
import { PluginPackageService } from './plugin-package.service';
|
||||||
import { PluginStorageService } from './plugin-storage.service';
|
import { PluginStorageService } from './plugin-storage.service';
|
||||||
@ -44,6 +47,7 @@ export class PluginsService {
|
|||||||
private readonly storage: PluginStorageService,
|
private readonly storage: PluginStorageService,
|
||||||
private readonly clock: ClockService,
|
private readonly clock: ClockService,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
|
private readonly settings: InstanceSettingsService,
|
||||||
private readonly logger: PinoLogger,
|
private readonly logger: PinoLogger,
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(PluginsService.name);
|
this.logger.setContext(PluginsService.name);
|
||||||
@ -58,6 +62,32 @@ export class PluginsService {
|
|||||||
/** `actor` is absent for dropzone installs (watcher, no session). */
|
/** `actor` is absent for dropzone installs (watcher, no session). */
|
||||||
async install(zip: Buffer, actor?: User): Promise<PluginView> {
|
async install(zip: Buffer, actor?: User): Promise<PluginView> {
|
||||||
const { manifest, files } = this.packages.parse(zip);
|
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 existing = await this.prisma.plugin.findUnique({ where: { id: manifest.id } });
|
||||||
const isActiveUpdate = existing !== null && existing.removedAt === null;
|
const isActiveUpdate = existing !== null && existing.removedAt === null;
|
||||||
@ -81,6 +111,7 @@ export class PluginsService {
|
|||||||
apiVersion: manifest.apiVersion,
|
apiVersion: manifest.apiVersion,
|
||||||
kind: manifest.kind,
|
kind: manifest.kind,
|
||||||
manifest: manifest as unknown as Prisma.InputJsonValue,
|
manifest: manifest as unknown as Prisma.InputJsonValue,
|
||||||
|
bundleHash,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
name: manifest.name,
|
name: manifest.name,
|
||||||
@ -88,6 +119,7 @@ export class PluginsService {
|
|||||||
apiVersion: manifest.apiVersion,
|
apiVersion: manifest.apiVersion,
|
||||||
kind: manifest.kind,
|
kind: manifest.kind,
|
||||||
manifest: manifest as unknown as Prisma.InputJsonValue,
|
manifest: manifest as unknown as Prisma.InputJsonValue,
|
||||||
|
bundleHash,
|
||||||
// Reinstalling a previously removed plugin clears the tombstone.
|
// Reinstalling a previously removed plugin clears the tombstone.
|
||||||
removedAt: null,
|
removedAt: null,
|
||||||
},
|
},
|
||||||
@ -105,7 +137,7 @@ export class PluginsService {
|
|||||||
targetId: manifest.id,
|
targetId: manifest.id,
|
||||||
details: { version: manifest.version, update: isActiveUpdate },
|
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,
|
targetId: id,
|
||||||
details: { mode },
|
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 } }),
|
this.prisma.pondPlugin.findMany({ where: { pondId } }),
|
||||||
]);
|
]);
|
||||||
const enabled = new Map(activations.map((a) => [a.pluginId, a.enabled]));
|
const enabled = new Map(activations.map((a) => [a.pluginId, a.enabled]));
|
||||||
return plugins
|
const allowlist = await this.settings.get('plugins.allowlist');
|
||||||
.filter((p) => p.mode === 'REQUIRED' || (p.mode === 'OPTIONAL' && enabled.get(p.id) === true))
|
return (
|
||||||
.map((p) => this.toView(p));
|
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 } }),
|
this.prisma.pondPlugin.findMany({ where: { pondId } }),
|
||||||
]);
|
]);
|
||||||
const enabled = new Map(activations.map((a) => [a.pluginId, a.enabled]));
|
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 },
|
where: { removedAt: null },
|
||||||
orderBy: { name: 'asc' },
|
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. */
|
/** One installed plugin, or `null` if absent/removed. */
|
||||||
async get(id: string): Promise<PluginView | null> {
|
async get(id: string): Promise<PluginView | null> {
|
||||||
const plugin = await this.prisma.plugin.findUnique({ where: { id } });
|
const plugin = await this.prisma.plugin.findUnique({ where: { id } });
|
||||||
if (!plugin || plugin.removedAt !== null) return null;
|
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;
|
const manifest = plugin.manifest as unknown as PluginManifest;
|
||||||
return {
|
return {
|
||||||
|
bundleSha256: plugin.bundleHash,
|
||||||
|
pinning: this.verdict(plugin, allowlist),
|
||||||
id: plugin.id,
|
id: plugin.id,
|
||||||
name: plugin.name,
|
name: plugin.name,
|
||||||
version: plugin.version,
|
version: plugin.version,
|
||||||
|
|||||||
@ -140,6 +140,26 @@ export const INSTANCE_SETTINGS = {
|
|||||||
// (an image fallback degrades to neutral text: its bytes live on the
|
// (an image fallback degrades to neutral text: its bytes live on the
|
||||||
// disabled asset surface).
|
// disabled asset surface).
|
||||||
'plugins.enabled': z.boolean().default(true),
|
'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
|
// Atom feed master switch (issue #191). Default ON: feeds predate the
|
||||||
// switch, so existing instances and their subscribed readers keep
|
// switch, so existing instances and their subscribed readers keep
|
||||||
// working; the VS-NfD reference configuration (#227) turns it off.
|
// working; the VS-NfD reference configuration (#227) turns it off.
|
||||||
|
|||||||
@ -51,10 +51,34 @@ export function PluginManager(): React.JSX.Element {
|
|||||||
onError: (error) => setUploadError(error),
|
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 (
|
return (
|
||||||
<section className="settings-section plugin-manager">
|
<section className="settings-section plugin-manager">
|
||||||
<h2>{t('admin.title')}</h2>
|
<h2>{t('admin.title')}</h2>
|
||||||
<p className="plugin-manager__intro">{t('admin.intro')}</p>
|
<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">
|
<div className="plugin-manager__upload">
|
||||||
<label className="button">
|
<label className="button">
|
||||||
@ -97,6 +121,51 @@ export function PluginManager(): React.JSX.Element {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="plugin-manager__controls">
|
||||||
<label>
|
<label>
|
||||||
{t('admin.mode')}:{' '}
|
{t('admin.mode')}:{' '}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
# Audit event catalogue
|
# Audit event catalogue
|
||||||
|
|
||||||
**Catalogue version 1.4 (2026-07-31; 1.4 adds `auth.proxy_rejected`,
|
**Catalogue version 1.5 (2026-07-31; 1.5 adds `plugin.rejected`,
|
||||||
issue #215; 1.3 added `auth.identity_linked`, issue #214; 1.2 added
|
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_*`,
|
`read_trail.pruned`, issue #224; 1.1 added `page.classification_*`,
|
||||||
issue #205).**
|
issue #205).**
|
||||||
|
|
||||||
@ -125,8 +125,9 @@ failure), `warning` = feeds detection (suspicious or destructive),
|
|||||||
### Plugins (`plugin.*`)
|
### Plugins (`plugin.*`)
|
||||||
|
|
||||||
| Id | Trigger | Severity | Actor | Target | Fields |
|
| Id | Trigger | Severity | Actor | Target | Fields |
|
||||||
| --------------------- | -------------------------------------------------- | -------- | ------------------------------- | -------- | -------------------------- |
|
| --------------------- | ------------------------------------------------------------ | -------- | ------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `plugin.installed` | Plugin package installed or updated | notice | admin, `null` for dropzone drop | `plugin` | `version`, `update` (bool) |
|
| `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.mode_set` | Instance mode changed (disabled/optional/required) | notice | the admin | `plugin` | `mode` |
|
||||||
| `plugin.uninstalled` | Plugin removed | notice | the admin | `plugin` | — |
|
| `plugin.uninstalled` | Plugin removed | notice | the admin | `plugin` | — |
|
||||||
| `plugin.pond_toggled` | Optional plugin toggled for one pond | info | the pond admin | `pond` | `plugin`, `enabled` |
|
| `plugin.pond_toggled` | Optional plugin toggled for one pond | info | the pond admin | `pond` | `plugin`, `enabled` |
|
||||||
|
|||||||
@ -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
|
zone?" at the offer stage — cheaper than per-plugin trust machinery
|
||||||
(#232) and sufficient because it removes the surface entirely.
|
(#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)
|
## Reference plugins (shipped with the product, also serving as examples)
|
||||||
|
|
||||||
- `section-styles-basic` (`section_style`): a set of colored callout/box
|
- `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:
|
Nur noch ein Punkt bleibt draußen:
|
||||||
|
|
||||||
- **Plugin-Allowlist mit Hash-Pinning** · 8–10 AT · #232
|
- [x] **Plugin-Allowlist mit Hash-Pinning** · 8–10 AT · #232 (erledigt
|
||||||
Manifest mit SHA-256, Allowlist in `instance_settings`, Prüfung beim Laden,
|
31.07.2026)
|
||||||
Admin-UI. Bleibt zurückgestellt, weil Phase 2 mit der harten Abschaltung das
|
SHA-256-Erfassung beim Install, Allowlist in `instance_settings`
|
||||||
Risiko bereits schließt — und weil echte Code-Signierung ohne juristische
|
(`plugins.allowlist`), Prüfung bei Install und Load (fail-closed,
|
||||||
Person ohnehin nicht verfügbar ist. Hash-Pinning ist die richtige Antwort,
|
auditiert), Admin-UI mit „gesehen vs. gepinnt". Restrisiko R-03 damit
|
||||||
aber nicht die dringendste.
|
aufgelöst; Code-Signierung bleibt mangels juristischer Person
|
||||||
|
verworfen (ADR 0025).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -27,12 +27,13 @@ Nach jeder Änderung an Instanz-Settings die api neu starten — der
|
|||||||
Settings-Cache ist in-process (operations.md).
|
Settings-Cache ist in-process (operations.md).
|
||||||
|
|
||||||
| Setting | Referenzwert | Default | Warum |
|
| Setting | Referenzwert | Default | Warum |
|
||||||
| ----------------------------------------------------------------------------------------------------------- | --------------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------------------------------------------------------------------------------------------- | --------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `auth.registrationMode` | `closed` | `open` | Konten entstehen in einer VS-Umgebung nur kontrolliert; Selbstregistrierung öffnet den Nutzerkreis unkontrolliert. |
|
| `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). |
|
| `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. |
|
| `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. |
|
| `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.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. |
|
| `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.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. |
|
||||||
|
|||||||
@ -49,19 +49,25 @@ nachvollziehbar.
|
|||||||
- **Entscheidung:** Projektleitung, Issue #216, 31.07.2026 (ursprüngliche
|
- **Entscheidung:** Projektleitung, Issue #216, 31.07.2026 (ursprüngliche
|
||||||
Aufnahme: Maßnahmenplan Rev. 2, 30.07.2026).
|
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
|
- **Ursprüngliches Risiko:** Installierte Plugin-Pakete waren nicht gegen
|
||||||
festgeschriebenen Hash verankert (#232); ein manipuliertes Paket
|
einen festgeschriebenen Hash verankert; ein manipuliertes Paket
|
||||||
gleichen Namens wäre beim Neuinstallieren nicht erkennbar.
|
gleichen Namens wäre beim Neuinstallieren nicht erkennbar gewesen.
|
||||||
- **Warum akzeptiert:** Die Referenzkonfiguration betreibt Plugins
|
- **Auflösung:** Hash-Pinning ist mit #232 umgesetzt (ADR 0025):
|
||||||
**gar nicht** (`plugins.enabled=false`, #200) — der Kill-Switch deckt
|
SHA-256-Erfassung beim Install, Allowlist `plugins.allowlist`
|
||||||
den VS-NfD-Betriebsmodus vollständig; Pinning lohnt erst, wenn ein
|
(id + gepinnter Hash), Durchsetzung bei Install UND Load (fail-closed,
|
||||||
Betreiber Plugins tatsächlich freigibt.
|
Abweisungen auditiert `plugin.rejected`), Versionswechsel nur per
|
||||||
- **Kompensation:** Kill-Switch (alle Plugin-Flächen 404, Dropzone
|
explizitem Re-Pin. Die Referenzkonfiguration betreibt Plugins weiterhin
|
||||||
quarantänisiert); Sandbox mit Capability-Modell + CI-Escape-
|
gar nicht (`plugins.enabled=false`); für abweichende Betreiber ist das
|
||||||
Regressionstest (ADR 0008/0025); Install nur durch Site-Admin.
|
Pinning jetzt verfügbar statt zurückgestellt.
|
||||||
- **Entscheidung:** Projektleitung, ADR 0025 / Issue #232, 30.07.2026.
|
- **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
|
## R-04 Git-Historie: einmalige Secret-Prüfung mit begrenztem Muster
|
||||||
|
|
||||||
|
|||||||
@ -112,5 +112,7 @@
|
|||||||
"pond_not_found": "Der Teich existiert nicht.",
|
"pond_not_found": "Der Teich existiert nicht.",
|
||||||
"classification_lower_forbidden": "Zum Herabstufen der Einstufung fehlt die Berechtigung (Teich-Admin erforderlich).",
|
"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.",
|
"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",
|
"mode": "Modus",
|
||||||
"preview": "Vorschau",
|
"preview": "Vorschau",
|
||||||
"uninstall": "Deinstallieren",
|
"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": {
|
"mode": {
|
||||||
"disabled": "Deaktiviert",
|
"disabled": "Deaktiviert",
|
||||||
|
|||||||
@ -112,5 +112,7 @@
|
|||||||
"pond_not_found": "The pond does not exist.",
|
"pond_not_found": "The pond does not exist.",
|
||||||
"classification_lower_forbidden": "You lack the permission to lower the classification (Pond Admin required).",
|
"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.",
|
"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",
|
"mode": "Mode",
|
||||||
"preview": "Preview",
|
"preview": "Preview",
|
||||||
"uninstall": "Uninstall",
|
"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": {
|
"mode": {
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
|
|||||||
@ -36,6 +36,12 @@ export interface PluginView {
|
|||||||
assetBasePath: string;
|
assetBasePath: string;
|
||||||
license: string;
|
license: string;
|
||||||
homepage?: 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;
|
installedAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@ -58,6 +64,8 @@ export const PLUGIN_ERROR_CODES = [
|
|||||||
'plugin_required_cannot_uninstall',
|
'plugin_required_cannot_uninstall',
|
||||||
'plugin_not_found',
|
'plugin_not_found',
|
||||||
'plugin_not_optional',
|
'plugin_not_optional',
|
||||||
|
'plugin_not_pinned',
|
||||||
|
'plugin_hash_mismatch',
|
||||||
] as const;
|
] as const;
|
||||||
export type PluginErrorCode = (typeof PLUGIN_ERROR_CODES)[number];
|
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 }[] = [
|
export const VS_NFD_PROFILE_ADVISORY: readonly { scope: 'instance' | 'deploy'; key: string }[] = [
|
||||||
{ scope: 'instance', key: 'upload.allowedExtensions' },
|
{ scope: 'instance', key: 'upload.allowedExtensions' },
|
||||||
|
{ scope: 'instance', key: 'plugins.allowlist' },
|
||||||
{ scope: 'instance', key: 'trash.retentionDays' },
|
{ scope: 'instance', key: 'trash.retentionDays' },
|
||||||
{ scope: 'instance', key: 'audit.retentionDays' },
|
{ scope: 'instance', key: 'audit.retentionDays' },
|
||||||
{ scope: 'instance', key: 'conversion.payloadRetentionDays' },
|
{ scope: 'instance', key: 'conversion.payloadRetentionDays' },
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user