#192: deploy-level backup target allowlist
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m52s
CI / Build container images (pull_request) Successful in 3m54s
CI / Auth e2e pack (pull_request) Successful in 8m4s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CD / Build and push images (push) Successful in 19s
CD / Deploy to Test (push) Successful in 13s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 5m0s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m41s
CI / Import/export fidelity gate (push) Successful in 56s

BACKUP_ALLOWED_TARGETS (comma-separated destination hosts) constrains
where backups may go, enforced twice: the api rejects settings writes
and connection tests towards non-allowlisted hosts with admin-visible
error codes and resolves a non-allowlisted configured target to null,
and the sidecar enforces the same policy at the point of egress for the
WebDAV upload and the rsync mirror alike (shared policy helpers in
packages/shared/src/backup-target-policy.ts).

BREAKING: the empty default disables every remote target - backups stay
local only, the VS-NfD reference configuration (ADR 0026). Existing
deployments with a remote target must list its host or uploads and
mirror stop. The admin UI distinguishes unavailable-by-policy from
unconfigured (i18n de+en) and shows the permitted hosts.

Refs #192 (ADR 0026)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
This commit is contained in:
Claude Fable 5 2026-07-30 12:17:14 +02:00
parent afef45732a
commit 394d1c811d
24 changed files with 517 additions and 15 deletions

View File

@ -155,6 +155,9 @@ describe.skipIf(!hasTestDb)('backup admin (e2e, issue #103)', () => {
secretsFile = join(mkdtempSync(join(tmpdir(), 'dorfteich-backup-secrets-')), 'secrets.env');
process.env.BACKUPS_DIR = backupsDir;
process.env.SECRETS_FILE = secretsFile;
// The in-test WebDAV server must be allowlisted (issue #192) — the
// policy paths themselves are covered by backup-allowlist*.e2e.db.test.ts.
process.env.BACKUP_ALLOWED_TARGETS = '127.0.0.1';
davUrl = await dav.start();
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});

View File

@ -65,6 +65,7 @@ export class BackupAdminService {
*/
async saveSettings(input: BackupSettingsInput, actor: User): Promise<BackupSettingsView> {
if (input.nextcloud.enabled) {
this.assertTargetAllowed(input.nextcloud.baseUrl);
const test = await this.target.testConnection({
baseUrl: input.nextcloud.baseUrl,
username: input.nextcloud.username,
@ -100,9 +101,29 @@ export class BackupAdminService {
}
testConnection(input: BackupConnectionTestInput): Promise<BackupConnectionTestResult> {
// Policy first (issue #192): the "test connection" button must not be
// usable as an egress probe towards non-allowlisted hosts.
this.assertTargetAllowed(input.baseUrl);
return this.target.testConnection(input);
}
/**
* Deploy-level target policy (issue #192, ADR 0026): an empty
* `BACKUP_ALLOWED_TARGETS` disables remote targets outright; a host
* outside the list is rejected with an admin-visible error.
*/
private assertTargetAllowed(baseUrl: string): void {
if (!this.target.remoteAllowed()) {
throw new BadRequestException({ code: 'backup_remote_disabled_by_policy' });
}
if (!this.target.targetAllowed(baseUrl)) {
throw new BadRequestException({
code: 'backup_target_not_allowed',
details: { nextcloud: [`host is not in BACKUP_ALLOWED_TARGETS`] },
});
}
}
/** Both restore sources for the picker: newest first. */
async sets(): Promise<BackupSetsView> {
const local = this.localSets();

View File

@ -0,0 +1,101 @@
import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
/**
* Backup target allowlist (issue #192, ADR 0026), empty-list half: with
* `BACKUP_ALLOWED_TARGETS` unset (the default) every remote target is
* unavailable by policy the view says so, and enabling one is rejected
* before any connection attempt. Lives in its own file because the env is
* read once at app boot.
*/
describe.skipIf(!hasTestDb)('backup targets disabled by empty allowlist (e2e, issue #192)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
const password = 'backup allowlist pass 2';
let adminId: string;
let adminCookie: string;
const api = () => request(app.getHttpServer());
beforeAll(async () => {
delete process.env.BACKUP_ALLOWED_TARGETS;
prisma = createTestPrisma();
app = await createTestApp();
const users = app.get(UsersService);
const username = `bae-admin-${suffix}`;
const admin = await users.createUser({
username,
email: `${username}@example.org`,
displayName: 'Backup Admin Empty',
password,
locale: 'en',
});
adminId = admin.id;
await users.markEmailVerified(adminId);
await prisma.user.update({ where: { id: adminId }, data: { isSiteAdmin: true } });
adminCookie = sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200),
);
});
afterAll(async () => {
await prisma.auditEntry.deleteMany({ where: { actorId: adminId } });
await prisma.session.deleteMany({ where: { userId: adminId } });
await prisma.userIdentity.deleteMany({ where: { userId: adminId } });
await prisma.user.deleteMany({ where: { id: adminId } });
await prisma.$disconnect();
await app.close();
});
it('reports remote targets as unavailable by policy', async () => {
const res = await api()
.get('/api/v1/admin/system/backup/settings')
.set('Cookie', adminCookie)
.expect(200);
expect(res.body.remoteTargets).toEqual({ allowed: false, allowlist: [] });
});
it('rejects enabling any remote destination', async () => {
const res = await api()
.put('/api/v1/admin/system/backup/settings')
.set('Cookie', adminCookie)
.send({
localRetentionDays: null,
remoteRetentionDays: 30,
nextcloud: {
enabled: true,
baseUrl: 'https://cloud.example.org/dav',
username: 'backupuser',
folder: 'dorfteich-backups',
uploadSchedule: 'daily',
password: 'app-pass',
},
})
.expect(400);
expect(res.body.code).toBe('backup_remote_disabled_by_policy');
});
it('rejects the connection test outright', async () => {
const res = await api()
.post('/api/v1/admin/system/backup/nextcloud/test')
.set('Cookie', adminCookie)
.send({
baseUrl: 'https://cloud.example.org/dav',
username: 'backupuser',
folder: 'dorfteich-backups',
password: 'app-pass',
})
.expect(400);
expect(res.body.code).toBe('backup_remote_disabled_by_policy');
});
});

View File

@ -0,0 +1,134 @@
import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
// Deploy-level env — must be set BEFORE the app (AppConfig) boots.
process.env.BACKUP_ALLOWED_TARGETS = 'cloud.example.org';
/**
* Backup target allowlist (issue #192, ADR 0026), populated-list half:
* hosts outside `BACKUP_ALLOWED_TARGETS` are rejected admin-visibly, hosts
* inside pass the policy. The empty-list half lives in its own file
* (`backup-allowlist-empty.e2e.db.test.ts`) because the env is read once
* at app boot.
*/
describe.skipIf(!hasTestDb)('backup target allowlist (e2e, issue #192)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
const password = 'backup allowlist pass 1';
let adminId: string;
let adminCookie: string;
const api = () => request(app.getHttpServer());
const settingsInput = (baseUrl: string, enabled = true) => ({
localRetentionDays: null,
remoteRetentionDays: 30,
nextcloud: {
enabled,
baseUrl,
username: 'backupuser',
folder: 'dorfteich-backups',
uploadSchedule: 'daily',
password: 'app-pass',
},
});
beforeAll(async () => {
prisma = createTestPrisma();
app = await createTestApp();
const users = app.get(UsersService);
const username = `bal-admin-${suffix}`;
const admin = await users.createUser({
username,
email: `${username}@example.org`,
displayName: 'Backup Admin',
password,
locale: 'en',
});
adminId = admin.id;
await users.markEmailVerified(adminId);
await prisma.user.update({ where: { id: adminId }, data: { isSiteAdmin: true } });
adminCookie = sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password })
.expect(200),
);
});
afterAll(async () => {
await prisma.instanceSetting.deleteMany({ where: { key: { startsWith: 'backup.' } } });
await prisma.auditEntry.deleteMany({ where: { actorId: adminId } });
await prisma.session.deleteMany({ where: { userId: adminId } });
await prisma.userIdentity.deleteMany({ where: { userId: adminId } });
await prisma.user.deleteMany({ where: { id: adminId } });
await prisma.$disconnect();
await app.close();
});
it('exposes the policy in the settings view', async () => {
const res = await api()
.get('/api/v1/admin/system/backup/settings')
.set('Cookie', adminCookie)
.expect(200);
expect(res.body.remoteTargets).toEqual({
allowed: true,
allowlist: ['cloud.example.org'],
});
});
it('rejects enabling a destination outside the allowlist', async () => {
const res = await api()
.put('/api/v1/admin/system/backup/settings')
.set('Cookie', adminCookie)
.send(settingsInput('https://evil.example.net/dav'))
.expect(400);
expect(res.body.code).toBe('backup_target_not_allowed');
});
it('rejects the connection test towards a non-allowlisted host', async () => {
const res = await api()
.post('/api/v1/admin/system/backup/nextcloud/test')
.set('Cookie', adminCookie)
.send({
baseUrl: 'https://evil.example.net/dav',
username: 'backupuser',
folder: 'dorfteich-backups',
password: 'app-pass',
})
.expect(400);
expect(res.body.code).toBe('backup_target_not_allowed');
});
it('lets an allowlisted destination through the policy', async () => {
// The host passes the policy; what fails afterwards is the live
// connection test against the (unreachable) example host — proving the
// rejection above was the policy, not the connectivity.
const res = await api()
.put('/api/v1/admin/system/backup/settings')
.set('Cookie', adminCookie)
.send(settingsInput('https://cloud.example.org/dav'))
.expect(400);
expect(res.body.code).toBe('backup_connection_failed');
// Saving the same destination disabled skips the connection test and
// persists — an existing in-allowlist configuration stays untouched.
await api()
.put('/api/v1/admin/system/backup/settings')
.set('Cookie', adminCookie)
.send(settingsInput('https://cloud.example.org/dav', false))
.expect(200);
const view = await api()
.get('/api/v1/admin/system/backup/settings')
.set('Cookie', adminCookie)
.expect(200);
expect(view.body.nextcloud.baseUrl).toBe('https://cloud.example.org/dav');
});
});

View File

@ -1,7 +1,12 @@
import { Injectable } from '@nestjs/common';
import type { BackupConnectionTestResult, BackupSettingsView } from '@dorfteich/shared';
import {
isBackupTargetAllowed,
type BackupConnectionTestResult,
type BackupSettingsView,
} from '@dorfteich/shared';
import { webdavCheck, type WebDavTarget } from '@dorfteich/shared/webdav';
import { AppConfig } from '../config/app-config.service';
import { SecretStoreService } from '../config/secret-store.service';
import { InstanceSettingsService } from '../settings/instance-settings.service';
@ -19,12 +24,27 @@ export class BackupTargetService {
constructor(
private readonly settings: InstanceSettingsService,
private readonly secretStore: SecretStoreService,
private readonly config: AppConfig,
) {}
/** Deploy-level allowlist (issue #192): empty = remote targets disabled. */
allowlist(): string[] {
return this.config.env.BACKUP_ALLOWED_TARGETS;
}
remoteAllowed(): boolean {
return this.allowlist().length > 0;
}
targetAllowed(target: string): boolean {
return isBackupTargetAllowed(this.allowlist(), target);
}
async settingsView(): Promise<BackupSettingsView> {
return {
localRetentionDays: await this.settings.get('backup.localRetentionDays'),
remoteRetentionDays: await this.settings.get('backup.remoteRetentionDays'),
remoteTargets: { allowed: this.remoteAllowed(), allowlist: this.allowlist() },
nextcloud: {
enabled: await this.settings.get('backup.nextcloud.enabled'),
baseUrl: await this.settings.get('backup.nextcloud.baseUrl'),
@ -45,6 +65,9 @@ export class BackupTargetService {
const password = this.storedPassword();
const { enabled, baseUrl, username, folder } = view.nextcloud;
if (!enabled || !baseUrl || !username || !password) return null;
// Policy backstop (issue #192): a configured target outside the deploy
// allowlist behaves like no target at all.
if (!this.targetAllowed(baseUrl)) return null;
return { baseUrl, username, password, folder };
}

View File

@ -43,8 +43,8 @@ function readSecrets(): Record<string, string> {
*/
async function buildRunnerDeps(trigger: 'scheduled' | 'manual'): Promise<RunnerDeps> {
const settings = await readBackupDbSettings(env.DATABASE_URL);
const target = resolveRemoteTarget(settings, readSecrets());
const mirrorConfig = resolveMirrorConfig(env);
const target = resolveRemoteTarget(settings, readSecrets(), env.BACKUP_ALLOWED_TARGETS, log);
const mirrorConfig = resolveMirrorConfig(env, log);
return {
backupsDir: env.BACKUPS_DIR,
retentionDays: settings.localRetentionDays ?? env.BACKUP_RETENTION_DAYS,
@ -162,7 +162,7 @@ async function terminateOtherConnections(): Promise<void> {
async function handleRestore(command: Extract<BackupCommand, { kind: 'restore' }>): Promise<void> {
const settings = await readBackupDbSettings(env.DATABASE_URL);
const target = resolveRemoteTarget(settings, readSecrets());
const target = resolveRemoteTarget(settings, readSecrets(), env.BACKUP_ALLOWED_TARGETS, log);
await orchestrateRestore(
{
backupsDir: env.BACKUPS_DIR,

View File

@ -27,8 +27,12 @@ function hasRsync(): boolean {
}
describe('resolveMirrorConfig', () => {
const base = {
BACKUP_MIRROR_SSH_PORT: 22,
BACKUP_ALLOWED_TARGETS: ['h'],
} as unknown as BackupEnv;
it('requires both target and key; port defaults to 22', () => {
const base = { BACKUP_MIRROR_SSH_PORT: 22 } as unknown as BackupEnv;
expect(resolveMirrorConfig(base)).toBeNull();
expect(
resolveMirrorConfig({ ...base, BACKUP_MIRROR_TARGET: 'u@h:/x/' } as BackupEnv),
@ -41,6 +45,21 @@ describe('resolveMirrorConfig', () => {
} as BackupEnv),
).toEqual({ target: 'u@h:/x/', sshKeyFile: '/data/secrets/key', sshPort: 22 });
});
it('is null when the target host is outside the deploy allowlist (issue #192)', () => {
const configured = {
...base,
BACKUP_MIRROR_TARGET: 'u@h:/x/',
BACKUP_MIRROR_SSH_KEY: '/data/secrets/key',
} as BackupEnv;
expect(
resolveMirrorConfig({ ...configured, BACKUP_ALLOWED_TARGETS: ['other.host'] } as BackupEnv),
).toBeNull();
// The empty allowlist disables the mirror outright.
expect(
resolveMirrorConfig({ ...configured, BACKUP_ALLOWED_TARGETS: [] } as BackupEnv),
).toBeNull();
});
});
describe('buildRsyncArgs', () => {

View File

@ -3,7 +3,7 @@ import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { promisify } from 'node:util';
import type { BackupEnv, BackupMirrorStatus } from '@dorfteich/shared';
import { isBackupTargetAllowed, type BackupEnv, type BackupMirrorStatus } from '@dorfteich/shared';
import { sendMirrorFailureMail } from './mail.js';
import type { RemoteLogger } from './remote.js';
@ -26,9 +26,20 @@ export interface MirrorConfig {
sshPort: number;
}
/** The mirror configuration, or null when the env does not enable it. */
export function resolveMirrorConfig(env: BackupEnv): MirrorConfig | null {
/**
* The mirror configuration, or null when the env does not enable it or
* when the target host is outside the deploy-level allowlist (issue #192,
* ADR 0026): an empty `BACKUP_ALLOWED_TARGETS` disables the mirror too.
*/
export function resolveMirrorConfig(env: BackupEnv, log?: RemoteLogger): MirrorConfig | null {
if (!env.BACKUP_MIRROR_TARGET || !env.BACKUP_MIRROR_SSH_KEY) return null;
if (!isBackupTargetAllowed(env.BACKUP_ALLOWED_TARGETS, env.BACKUP_MIRROR_TARGET)) {
log?.warn(
{ target: env.BACKUP_MIRROR_TARGET, allowlist: env.BACKUP_ALLOWED_TARGETS },
'rsync mirror blocked: host not in BACKUP_ALLOWED_TARGETS (issue #192)',
);
return null;
}
return {
target: env.BACKUP_MIRROR_TARGET,
sshKeyFile: env.BACKUP_MIRROR_SSH_KEY,

View File

@ -5,6 +5,7 @@ import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import {
isBackupTargetAllowed,
remoteBundleId,
remoteBundleName,
type BackupEnv,
@ -41,14 +42,26 @@ export interface RemoteLogger {
* The effective WebDAV target, or null when the feature is off or not fully
* configured. The app password comes from the wizard-written secret store
* (never the database); base URL and username from instance settings.
* Enforces the deploy-level target policy (issue #192, ADR 0026) at the
* point of egress: a configured host outside `BACKUP_ALLOWED_TARGETS`
* behaves like no target `log` (when given) says why.
*/
export function resolveRemoteTarget(
settings: BackupDbSettings,
secrets: Record<string, string>,
allowlist: string[],
log?: RemoteLogger,
): WebDavTarget | null {
const { enabled, baseUrl, username, folder } = settings.nextcloud;
const password = secrets[NEXTCLOUD_PASSWORD_SECRET_KEY] ?? '';
if (!enabled || !baseUrl || !username || !password) return null;
if (!isBackupTargetAllowed(allowlist, baseUrl)) {
log?.warn(
{ baseUrl, allowlist },
'remote backup target blocked: host not in BACKUP_ALLOWED_TARGETS (issue #192)',
);
return null;
}
return { baseUrl, username, password, folder };
}

View File

@ -46,8 +46,12 @@ describe('resolveRemoteTarget', () => {
{ key: 'backup.nextcloud.username', value: 'backupuser' },
]);
const allowlist = ['cloud.example.com'];
it('combines settings with the secret-store app password', () => {
expect(resolveRemoteTarget(settings, { BACKUP_NEXTCLOUD_PASSWORD: 'app-pass' })).toEqual({
expect(
resolveRemoteTarget(settings, { BACKUP_NEXTCLOUD_PASSWORD: 'app-pass' }, allowlist),
).toEqual({
baseUrl: 'https://cloud.example.com',
username: 'backupuser',
password: 'app-pass',
@ -56,11 +60,22 @@ describe('resolveRemoteTarget', () => {
});
it('is null when disabled or incompletely configured', () => {
expect(resolveRemoteTarget(settings, {})).toBeNull();
expect(resolveRemoteTarget(settings, {}, allowlist)).toBeNull();
expect(
resolveRemoteTarget(parseBackupSettings([]), { BACKUP_NEXTCLOUD_PASSWORD: 'app-pass' }),
resolveRemoteTarget(
parseBackupSettings([]),
{ BACKUP_NEXTCLOUD_PASSWORD: 'app-pass' },
allowlist,
),
).toBeNull();
});
it('is null when the configured host is outside the deploy allowlist (issue #192)', () => {
const secrets = { BACKUP_NEXTCLOUD_PASSWORD: 'app-pass' };
expect(resolveRemoteTarget(settings, secrets, ['other.host'])).toBeNull();
// The empty allowlist disables the WebDAV target outright.
expect(resolveRemoteTarget(settings, secrets, [])).toBeNull();
});
});
describe('parseCommand', () => {

View File

@ -291,15 +291,32 @@ function BackupSettingsForm(): React.JSX.Element {
/>
</label>
<p className="system-backup__hint">{t('backup.settings.localRetentionHint')}</p>
{!view.remoteTargets.allowed && (
// Unavailable by deploy policy (#192) — not merely unconfigured.
<p role="note" className="system-backup__hint system-backup__hint--policy">
{t('backup.settings.remoteDisabledByPolicy')}
</p>
)}
{view.remoteTargets.allowed && (
<p className="system-backup__hint">
{t('backup.settings.allowedTargets', {
hosts: view.remoteTargets.allowlist.join(', '),
})}
</p>
)}
<label className="system-backup__checkbox">
<input
type="checkbox"
checked={form.enabled}
disabled={!view.remoteTargets.allowed}
onChange={(e) => update({ enabled: e.target.checked })}
/>
{t('backup.settings.enabled')}
</label>
<fieldset disabled={!form.enabled} className="system-backup__nextcloud">
<fieldset
disabled={!form.enabled || !view.remoteTargets.allowed}
className="system-backup__nextcloud"
>
<label>
{t('backup.settings.baseUrl')}
<input

View File

@ -90,6 +90,15 @@ SMTP_FROM=Dorfteich <wiki@example.com>
#BACKUP_MAIL_TO=ops@example.com
#BACKUP_MAIL_LOCALE=en
#BACKUP_INSTANCE_LABEL=dorfteich-test
# Deploy-level allowlist of permissible backup destination HOSTS (issue
# #192, ADR 0026), comma-separated — e.g. "cloud.example.org,172.30.1.10".
# EMPTY (the default) DISABLES every remote target, the admin-configured
# WebDAV/Nextcloud upload and the rsync mirror alike; backups then stay
# local only (the VS-NfD reference configuration). BREAKING: existing
# deployments with a remote target must list its host here, or uploads and
# mirror stop. Deploy-level on purpose: Site-Admins cannot widen it.
#BACKUP_ALLOWED_TARGETS=cloud.example.org,172.30.1.10
# Optional rsync mirror of the backup sets to a private host (issue #84):
# rsync-over-ssh target plus the private key file INSIDE the container —
# put the key on the secrets volume (docker compose cp), never in the repo.

View File

@ -55,6 +55,9 @@ services:
# (absolute 168 h, idle 72 h). Hardened deployments set them lower.
SESSION_ABSOLUTE_HOURS: ${SESSION_ABSOLUTE_HOURS:-}
SESSION_IDLE_HOURS: ${SESSION_IDLE_HOURS:-}
# Must match the backup service's value — the api validates admin
# backup settings against the same allowlist (issue #192).
BACKUP_ALLOWED_TARGETS: ${BACKUP_ALLOWED_TARGETS:-}
# SMTP relay. Empty (= unset in .env) is fine: the setup wizard writes
# the relay to the secret store on the `secrets` volume (issue #80);
# values set here in the stage .env always win over the store.
@ -165,6 +168,9 @@ services:
BACKUP_MIRROR_TARGET: ${BACKUP_MIRROR_TARGET:-}
BACKUP_MIRROR_SSH_KEY: ${BACKUP_MIRROR_SSH_KEY:-}
BACKUP_MIRROR_SSH_PORT: ${BACKUP_MIRROR_SSH_PORT:-}
# Deploy-level allowlist of backup destination hosts (issue #192,
# ADR 0026). Empty disables ALL remote targets (WebDAV + mirror).
BACKUP_ALLOWED_TARGETS: ${BACKUP_ALLOWED_TARGETS:-}
# Same SMTP resolution as the api: explicit env wins, the wizard-written
# secret store fills the gaps (issue #80).
SMTP_HOST: ${SMTP_HOST:-}

View File

@ -63,6 +63,17 @@ monitoring, structured logs, backup alerting — no dedicated metrics stack.
local guarantee. The api and the sidecar talk over pg NOTIFY/LISTEN
(`backup_command`); upload failures mail like run failures, staleness
shows up as the `backup_remote` readyz check.
- **Target policy** (issue #192, ADR 0026): `BACKUP_ALLOWED_TARGETS` is a
deploy-level, comma-separated allowlist of permissible destination
hosts, enforced by the api (settings writes, connection test, admin
view) and by the sidecar at the point of egress (WebDAV upload and
rsync mirror alike). **Empty — the default — disables every remote
target: backups stay local only, which is the VS-NfD reference
configuration.** Existing deployments with a remote target must list
its host, or uploads and mirror stop (called out in the release notes).
The admin UI shows "unavailable by policy" as distinct from
"not configured". Deliberately not a runtime setting: a compromised
Site-Admin account cannot widen it.
- **On-demand run**: `docker compose run --rm -e BACKUP_RUN_ONCE=1 backup`
(exit code = outcome); list sets with `docker compose exec backup ls /backups`.
- **Restore runbook** (also the Prod-relocation procedure) — automated by

View File

@ -97,7 +97,7 @@ chain`_
separates Idle-Timeout · 12 AT · #190
- [x] **Feed-Token raus aus dem Query-Parameter**, alternativ Feeds hart
abschaltbar · 2 AT · #191
- [ ] **Backup-Ziele einschränkbar** — Allowlist, WebDAV/rsync per Deploy
- [x] **Backup-Ziele einschränkbar** — Allowlist, WebDAV/rsync per Deploy
vollständig deaktivierbar · 2 AT · #192
- [ ] **Pond-Purge implementieren** — getrashte Ponds bleiben ewig liegen · 3 AT · #193
- [ ] **Orphan-File-Sweep** implementieren, `Attachment.deletedAt` nutzen

View File

@ -102,6 +102,8 @@
"comment_not_root": "Nur Kommentare der obersten Ebene können als erledigt markiert werden.",
"maintenance_mode": "Die Instanz ist im Wartungsmodus, während ein Backup wiederhergestellt wird.",
"backup_connection_failed": "Der Nextcloud-Verbindungstest ist fehlgeschlagen.",
"backup_remote_disabled_by_policy": "Entfernte Backup-Ziele sind durch die Deployment-Richtlinie deaktiviert (BACKUP_ALLOWED_TARGETS ist leer).",
"backup_target_not_allowed": "Dieser Ziel-Host steht nicht in der Deployment-Richtlinie (BACKUP_ALLOWED_TARGETS).",
"backup_restore_confirm_mismatch": "Der Bestätigungstext stimmt nicht mit der Backup-ID überein.",
"backup_restore_running": "Es läuft bereits eine Wiederherstellung.",
"backup_remote_not_configured": "Es ist kein Nextcloud-Backup-Ziel konfiguriert.",

View File

@ -91,7 +91,9 @@
"testOk": "Verbindung ok — der Ordner ist erreichbar.",
"save": "Backup-Einstellungen speichern",
"savePending": "Speichere…",
"saved": "Backup-Einstellungen gespeichert."
"saved": "Backup-Einstellungen gespeichert.",
"remoteDisabledByPolicy": "Entfernte Backup-Ziele sind durch die Deployment-Richtlinie deaktiviert (BACKUP_ALLOWED_TARGETS ist leer) — Backups bleiben lokal. Das ist eine bewusste Einstellung des Betreibers, keine fehlende Konfiguration.",
"allowedTargets": "Zulässige Ziel-Hosts laut Deployment-Richtlinie: {{hosts}}"
},
"restore": {
"title": "Wiederherstellung",

View File

@ -102,6 +102,8 @@
"comment_not_root": "Only top-level comments can be resolved.",
"maintenance_mode": "The instance is in maintenance mode while a backup is being restored.",
"backup_connection_failed": "The Nextcloud connection test failed.",
"backup_remote_disabled_by_policy": "Remote backup targets are disabled by deployment policy (BACKUP_ALLOWED_TARGETS is empty).",
"backup_target_not_allowed": "This destination host is not in the deployment policy (BACKUP_ALLOWED_TARGETS).",
"backup_restore_confirm_mismatch": "The confirmation text does not match the backup id.",
"backup_restore_running": "A restore is already running.",
"backup_remote_not_configured": "No Nextcloud backup target is configured.",

View File

@ -91,7 +91,9 @@
"testOk": "Connection ok — folder is reachable.",
"save": "Save backup settings",
"savePending": "Saving…",
"saved": "Backup settings saved."
"saved": "Backup settings saved.",
"remoteDisabledByPolicy": "Remote backup targets are disabled by deployment policy (BACKUP_ALLOWED_TARGETS is empty) — backups stay local. This is a deliberate operator setting, not a missing configuration.",
"allowedTargets": "Destination hosts permitted by deployment policy: {{hosts}}"
},
"restore": {
"title": "Restore",

View File

@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import {
backupTargetHost,
isBackupTargetAllowed,
parseBackupTargetAllowlist,
} from './backup-target-policy';
describe('backup target policy (issue #192)', () => {
it('parses, trims, lowercases and deduplicates the allowlist', () => {
expect(parseBackupTargetAllowlist('')).toEqual([]);
expect(
parseBackupTargetAllowlist(' Cloud.Example.org, 172.30.1.10 ,cloud.example.org,'),
).toEqual(['cloud.example.org', '172.30.1.10']);
});
it('extracts the host from WebDAV URLs and rsync targets', () => {
expect(backupTargetHost('https://cloud.example.org/remote.php/dav')).toBe('cloud.example.org');
expect(backupTargetHost('https://Cloud.Example.org:8443/dav')).toBe('cloud.example.org');
expect(backupTargetHost('dorfteich-backup@172.30.1.10:/home/RAID/BACKUPS/prod/')).toBe(
'172.30.1.10',
);
expect(backupTargetHost('172.30.1.10:/srv/backups/')).toBe('172.30.1.10');
expect(backupTargetHost('not a target')).toBeNull();
expect(backupTargetHost('')).toBeNull();
});
it('never allows anything on an empty allowlist', () => {
expect(isBackupTargetAllowed([], 'https://cloud.example.org/dav')).toBe(false);
expect(isBackupTargetAllowed([], 'backup@172.30.1.10:/srv/')).toBe(false);
});
it('matches hosts case-insensitively and rejects outsiders and garbage', () => {
const allowlist = parseBackupTargetAllowlist('cloud.example.org,172.30.1.10');
expect(isBackupTargetAllowed(allowlist, 'https://Cloud.Example.ORG/dav')).toBe(true);
expect(isBackupTargetAllowed(allowlist, 'backup@172.30.1.10:/srv/')).toBe(true);
expect(isBackupTargetAllowed(allowlist, 'https://evil.example.net/dav')).toBe(false);
expect(isBackupTargetAllowed(allowlist, 'backup@evil.example.net:/srv/')).toBe(false);
expect(isBackupTargetAllowed(allowlist, 'garbage')).toBe(false);
});
});

View File

@ -0,0 +1,47 @@
/**
* Deploy-level backup target policy (issue #192, ADR 0026). A backup
* destination is an egress path for the full content of the instance, so
* the set of permissible destination HOSTS is fixed at deploy time via
* `BACKUP_ALLOWED_TARGETS` a runtime setting could be widened by a
* compromised Site-Admin account. An empty allowlist disables every
* remote target (WebDAV and rsync mirror); "local only" is the VS-NfD
* reference configuration. Both the api (settings writes, admin view) and
* the backup sidecar (the actual egress) enforce the same policy through
* these helpers.
*/
/** Comma-separated env value → normalized, deduplicated host list. */
export function parseBackupTargetAllowlist(raw: string): string[] {
return [
...new Set(
raw
.split(',')
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean),
),
];
}
/**
* The destination host of a backup target: the hostname of a WebDAV URL,
* or the host part of an rsync-over-ssh target (`user@host:/path`).
* `null` for anything unparsable which is never allowed.
*/
export function backupTargetHost(target: string): string | null {
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(target)) {
try {
const hostname = new URL(target).hostname;
return hostname ? hostname.toLowerCase() : null;
} catch {
return null;
}
}
const rsync = /^(?:[^@\s]+@)?([^/:@\s]+):/.exec(target);
return rsync ? rsync[1]!.toLowerCase() : null;
}
/** Whether `target` points at an allowlisted host. Empty list ⇒ never. */
export function isBackupTargetAllowed(allowlist: string[], target: string): boolean {
const host = backupTargetHost(target);
return host !== null && allowlist.includes(host);
}

View File

@ -1,5 +1,7 @@
import { z } from 'zod';
import { parseBackupTargetAllowlist } from './backup-target-policy';
/**
* Environment schemas live here so api, collab, and tooling validate their
* configuration the same way. Every service calls `parseEnv` once at startup
@ -27,6 +29,17 @@ const databaseUrl = z
*/
const collabTokenSecret = z.string().min(16).default('dev-insecure-collab-token-secret-change-me');
/**
* Deploy-level allowlist of permissible backup destination HOSTS (issue
* #192, ADR 0026), comma-separated e.g. "cloud.example.org,172.30.1.10".
* Empty (the default) disables EVERY remote target, WebDAV and rsync
* mirror alike; "local only" is the VS-NfD reference configuration.
* Deliberately env-only: a compromised Site-Admin account cannot widen it.
* Shared by the api (settings writes, admin view) and the backup sidecar
* (the actual egress).
*/
const backupAllowedTargets = z.string().default('').transform(parseBackupTargetAllowlist);
/**
* SMTP delivery fields, shared by the api (transactional mail) and the
* backup sidecar (failure alert mail, issue #83). Defaults match the
@ -118,6 +131,7 @@ export const apiEnvSchema = z.object({
* file (readyz freshness, issue #85; admin backup card, issue #86).
*/
BACKUPS_DIR: z.string().min(1).default('./data/backups'),
BACKUP_ALLOWED_TARGETS: backupAllowedTargets,
/**
* Env-backed secret store (security.md §Secrets, issue #80): a mode-600
* dotenv-style file on a persistent volume where the setup wizard writes
@ -182,6 +196,7 @@ export const backupEnvSchema = z.object({
.default('03:00'),
/** Local retention in days: 30 for Prod, 7 for Test/Int (ADR 0015). */
BACKUP_RETENTION_DAYS: z.coerce.number().int().min(1).default(30),
BACKUP_ALLOWED_TARGETS: backupAllowedTargets,
/** Failure-alert recipient; unset disables the mail (logged instead). */
BACKUP_MAIL_TO: z.string().optional(),
/** Language of the failure mail (ADR 0012 — both exist, operator picks). */

View File

@ -5,6 +5,7 @@ export * from './api-error';
export * from './auth';
export * from './backup-set';
export * from './backup-status';
export * from './backup-target-policy';
export * from './collab-token';
export * from './comments';
export * from './editor-schema';

View File

@ -46,6 +46,13 @@ export interface SystemBackupView {
export interface BackupSettingsView {
localRetentionDays: number | null;
remoteRetentionDays: number;
/**
* Deploy-level target policy (issue #192, ADR 0026): `allowed` is false
* when `BACKUP_ALLOWED_TARGETS` is empty remote targets are then
* UNAVAILABLE by policy, which the UI must distinguish from merely
* unconfigured. `allowlist` lets the admin see which hosts qualify.
*/
remoteTargets: { allowed: boolean; allowlist: string[] };
nextcloud: {
enabled: boolean;
baseUrl: string;