#192: deploy-level backup target allowlist #247

Merged
fable-5 merged 1 commits from feat/192-backup-allowlist into main 2026-07-30 12:34:14 +02:00
24 changed files with 517 additions and 15 deletions
Showing only changes of commit 394d1c811d - Show all commits

View File

@ -155,6 +155,9 @@ describe.skipIf(!hasTestDb)('backup admin (e2e, issue #103)', () => {
secretsFile = join(mkdtempSync(join(tmpdir(), 'dorfteich-backup-secrets-')), 'secrets.env'); secretsFile = join(mkdtempSync(join(tmpdir(), 'dorfteich-backup-secrets-')), 'secrets.env');
process.env.BACKUPS_DIR = backupsDir; process.env.BACKUPS_DIR = backupsDir;
process.env.SECRETS_FILE = secretsFile; 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(); davUrl = await dav.start();
prisma = createTestPrisma(); prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({}); await prisma.rateLimit.deleteMany({});

View File

@ -65,6 +65,7 @@ export class BackupAdminService {
*/ */
async saveSettings(input: BackupSettingsInput, actor: User): Promise<BackupSettingsView> { async saveSettings(input: BackupSettingsInput, actor: User): Promise<BackupSettingsView> {
if (input.nextcloud.enabled) { if (input.nextcloud.enabled) {
this.assertTargetAllowed(input.nextcloud.baseUrl);
const test = await this.target.testConnection({ const test = await this.target.testConnection({
baseUrl: input.nextcloud.baseUrl, baseUrl: input.nextcloud.baseUrl,
username: input.nextcloud.username, username: input.nextcloud.username,
@ -100,9 +101,29 @@ export class BackupAdminService {
} }
testConnection(input: BackupConnectionTestInput): Promise<BackupConnectionTestResult> { 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); 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. */ /** Both restore sources for the picker: newest first. */
async sets(): Promise<BackupSetsView> { async sets(): Promise<BackupSetsView> {
const local = this.localSets(); 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 { 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 { webdavCheck, type WebDavTarget } from '@dorfteich/shared/webdav';
import { AppConfig } from '../config/app-config.service';
import { SecretStoreService } from '../config/secret-store.service'; import { SecretStoreService } from '../config/secret-store.service';
import { InstanceSettingsService } from '../settings/instance-settings.service'; import { InstanceSettingsService } from '../settings/instance-settings.service';
@ -19,12 +24,27 @@ export class BackupTargetService {
constructor( constructor(
private readonly settings: InstanceSettingsService, private readonly settings: InstanceSettingsService,
private readonly secretStore: SecretStoreService, 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> { async settingsView(): Promise<BackupSettingsView> {
return { return {
localRetentionDays: await this.settings.get('backup.localRetentionDays'), localRetentionDays: await this.settings.get('backup.localRetentionDays'),
remoteRetentionDays: await this.settings.get('backup.remoteRetentionDays'), remoteRetentionDays: await this.settings.get('backup.remoteRetentionDays'),
remoteTargets: { allowed: this.remoteAllowed(), allowlist: this.allowlist() },
nextcloud: { nextcloud: {
enabled: await this.settings.get('backup.nextcloud.enabled'), enabled: await this.settings.get('backup.nextcloud.enabled'),
baseUrl: await this.settings.get('backup.nextcloud.baseUrl'), baseUrl: await this.settings.get('backup.nextcloud.baseUrl'),
@ -45,6 +65,9 @@ export class BackupTargetService {
const password = this.storedPassword(); const password = this.storedPassword();
const { enabled, baseUrl, username, folder } = view.nextcloud; const { enabled, baseUrl, username, folder } = view.nextcloud;
if (!enabled || !baseUrl || !username || !password) return null; 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 }; return { baseUrl, username, password, folder };
} }

View File

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

View File

@ -27,8 +27,12 @@ function hasRsync(): boolean {
} }
describe('resolveMirrorConfig', () => { 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', () => { 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)).toBeNull();
expect( expect(
resolveMirrorConfig({ ...base, BACKUP_MIRROR_TARGET: 'u@h:/x/' } as BackupEnv), resolveMirrorConfig({ ...base, BACKUP_MIRROR_TARGET: 'u@h:/x/' } as BackupEnv),
@ -41,6 +45,21 @@ describe('resolveMirrorConfig', () => {
} as BackupEnv), } as BackupEnv),
).toEqual({ target: 'u@h:/x/', sshKeyFile: '/data/secrets/key', sshPort: 22 }); ).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', () => { describe('buildRsyncArgs', () => {

View File

@ -3,7 +3,7 @@ import { existsSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { promisify } from 'node:util'; 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 { sendMirrorFailureMail } from './mail.js';
import type { RemoteLogger } from './remote.js'; import type { RemoteLogger } from './remote.js';
@ -26,9 +26,20 @@ export interface MirrorConfig {
sshPort: number; 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 (!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 { return {
target: env.BACKUP_MIRROR_TARGET, target: env.BACKUP_MIRROR_TARGET,
sshKeyFile: env.BACKUP_MIRROR_SSH_KEY, sshKeyFile: env.BACKUP_MIRROR_SSH_KEY,

View File

@ -5,6 +5,7 @@ import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises'; import { pipeline } from 'node:stream/promises';
import { import {
isBackupTargetAllowed,
remoteBundleId, remoteBundleId,
remoteBundleName, remoteBundleName,
type BackupEnv, type BackupEnv,
@ -41,14 +42,26 @@ export interface RemoteLogger {
* The effective WebDAV target, or null when the feature is off or not fully * 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 * configured. The app password comes from the wizard-written secret store
* (never the database); base URL and username from instance settings. * (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( export function resolveRemoteTarget(
settings: BackupDbSettings, settings: BackupDbSettings,
secrets: Record<string, string>, secrets: Record<string, string>,
allowlist: string[],
log?: RemoteLogger,
): WebDavTarget | null { ): WebDavTarget | null {
const { enabled, baseUrl, username, folder } = settings.nextcloud; const { enabled, baseUrl, username, folder } = settings.nextcloud;
const password = secrets[NEXTCLOUD_PASSWORD_SECRET_KEY] ?? ''; const password = secrets[NEXTCLOUD_PASSWORD_SECRET_KEY] ?? '';
if (!enabled || !baseUrl || !username || !password) return null; 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 }; return { baseUrl, username, password, folder };
} }

View File

@ -46,8 +46,12 @@ describe('resolveRemoteTarget', () => {
{ key: 'backup.nextcloud.username', value: 'backupuser' }, { key: 'backup.nextcloud.username', value: 'backupuser' },
]); ]);
const allowlist = ['cloud.example.com'];
it('combines settings with the secret-store app password', () => { 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', baseUrl: 'https://cloud.example.com',
username: 'backupuser', username: 'backupuser',
password: 'app-pass', password: 'app-pass',
@ -56,11 +60,22 @@ describe('resolveRemoteTarget', () => {
}); });
it('is null when disabled or incompletely configured', () => { it('is null when disabled or incompletely configured', () => {
expect(resolveRemoteTarget(settings, {})).toBeNull(); expect(resolveRemoteTarget(settings, {}, allowlist)).toBeNull();
expect( expect(
resolveRemoteTarget(parseBackupSettings([]), { BACKUP_NEXTCLOUD_PASSWORD: 'app-pass' }), resolveRemoteTarget(
parseBackupSettings([]),
{ BACKUP_NEXTCLOUD_PASSWORD: 'app-pass' },
allowlist,
),
).toBeNull(); ).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', () => { describe('parseCommand', () => {

View File

@ -291,15 +291,32 @@ function BackupSettingsForm(): React.JSX.Element {
/> />
</label> </label>
<p className="system-backup__hint">{t('backup.settings.localRetentionHint')}</p> <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"> <label className="system-backup__checkbox">
<input <input
type="checkbox" type="checkbox"
checked={form.enabled} checked={form.enabled}
disabled={!view.remoteTargets.allowed}
onChange={(e) => update({ enabled: e.target.checked })} onChange={(e) => update({ enabled: e.target.checked })}
/> />
{t('backup.settings.enabled')} {t('backup.settings.enabled')}
</label> </label>
<fieldset disabled={!form.enabled} className="system-backup__nextcloud"> <fieldset
disabled={!form.enabled || !view.remoteTargets.allowed}
className="system-backup__nextcloud"
>
<label> <label>
{t('backup.settings.baseUrl')} {t('backup.settings.baseUrl')}
<input <input

View File

@ -90,6 +90,15 @@ SMTP_FROM=Dorfteich <wiki@example.com>
#BACKUP_MAIL_TO=ops@example.com #BACKUP_MAIL_TO=ops@example.com
#BACKUP_MAIL_LOCALE=en #BACKUP_MAIL_LOCALE=en
#BACKUP_INSTANCE_LABEL=dorfteich-test #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): # 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 — # 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. # 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. # (absolute 168 h, idle 72 h). Hardened deployments set them lower.
SESSION_ABSOLUTE_HOURS: ${SESSION_ABSOLUTE_HOURS:-} SESSION_ABSOLUTE_HOURS: ${SESSION_ABSOLUTE_HOURS:-}
SESSION_IDLE_HOURS: ${SESSION_IDLE_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 # SMTP relay. Empty (= unset in .env) is fine: the setup wizard writes
# the relay to the secret store on the `secrets` volume (issue #80); # the relay to the secret store on the `secrets` volume (issue #80);
# values set here in the stage .env always win over the store. # 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_TARGET: ${BACKUP_MIRROR_TARGET:-}
BACKUP_MIRROR_SSH_KEY: ${BACKUP_MIRROR_SSH_KEY:-} BACKUP_MIRROR_SSH_KEY: ${BACKUP_MIRROR_SSH_KEY:-}
BACKUP_MIRROR_SSH_PORT: ${BACKUP_MIRROR_SSH_PORT:-} 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 # Same SMTP resolution as the api: explicit env wins, the wizard-written
# secret store fills the gaps (issue #80). # secret store fills the gaps (issue #80).
SMTP_HOST: ${SMTP_HOST:-} 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 local guarantee. The api and the sidecar talk over pg NOTIFY/LISTEN
(`backup_command`); upload failures mail like run failures, staleness (`backup_command`); upload failures mail like run failures, staleness
shows up as the `backup_remote` readyz check. 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` - **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`. (exit code = outcome); list sets with `docker compose exec backup ls /backups`.
- **Restore runbook** (also the Prod-relocation procedure) — automated by - **Restore runbook** (also the Prod-relocation procedure) — automated by

View File

@ -97,7 +97,7 @@ chain`_
separates Idle-Timeout · 12 AT · #190 separates Idle-Timeout · 12 AT · #190
- [x] **Feed-Token raus aus dem Query-Parameter**, alternativ Feeds hart - [x] **Feed-Token raus aus dem Query-Parameter**, alternativ Feeds hart
abschaltbar · 2 AT · #191 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 vollständig deaktivierbar · 2 AT · #192
- [ ] **Pond-Purge implementieren** — getrashte Ponds bleiben ewig liegen · 3 AT · #193 - [ ] **Pond-Purge implementieren** — getrashte Ponds bleiben ewig liegen · 3 AT · #193
- [ ] **Orphan-File-Sweep** implementieren, `Attachment.deletedAt` nutzen - [ ] **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.", "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.", "maintenance_mode": "Die Instanz ist im Wartungsmodus, während ein Backup wiederhergestellt wird.",
"backup_connection_failed": "Der Nextcloud-Verbindungstest ist fehlgeschlagen.", "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_confirm_mismatch": "Der Bestätigungstext stimmt nicht mit der Backup-ID überein.",
"backup_restore_running": "Es läuft bereits eine Wiederherstellung.", "backup_restore_running": "Es läuft bereits eine Wiederherstellung.",
"backup_remote_not_configured": "Es ist kein Nextcloud-Backup-Ziel konfiguriert.", "backup_remote_not_configured": "Es ist kein Nextcloud-Backup-Ziel konfiguriert.",

View File

@ -91,7 +91,9 @@
"testOk": "Verbindung ok — der Ordner ist erreichbar.", "testOk": "Verbindung ok — der Ordner ist erreichbar.",
"save": "Backup-Einstellungen speichern", "save": "Backup-Einstellungen speichern",
"savePending": "Speichere…", "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": { "restore": {
"title": "Wiederherstellung", "title": "Wiederherstellung",

View File

@ -102,6 +102,8 @@
"comment_not_root": "Only top-level comments can be resolved.", "comment_not_root": "Only top-level comments can be resolved.",
"maintenance_mode": "The instance is in maintenance mode while a backup is being restored.", "maintenance_mode": "The instance is in maintenance mode while a backup is being restored.",
"backup_connection_failed": "The Nextcloud connection test failed.", "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_confirm_mismatch": "The confirmation text does not match the backup id.",
"backup_restore_running": "A restore is already running.", "backup_restore_running": "A restore is already running.",
"backup_remote_not_configured": "No Nextcloud backup target is configured.", "backup_remote_not_configured": "No Nextcloud backup target is configured.",

View File

@ -91,7 +91,9 @@
"testOk": "Connection ok — folder is reachable.", "testOk": "Connection ok — folder is reachable.",
"save": "Save backup settings", "save": "Save backup settings",
"savePending": "Saving…", "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": { "restore": {
"title": "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 { z } from 'zod';
import { parseBackupTargetAllowlist } from './backup-target-policy';
/** /**
* Environment schemas live here so api, collab, and tooling validate their * Environment schemas live here so api, collab, and tooling validate their
* configuration the same way. Every service calls `parseEnv` once at startup * 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'); 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 * SMTP delivery fields, shared by the api (transactional mail) and the
* backup sidecar (failure alert mail, issue #83). Defaults match 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). * file (readyz freshness, issue #85; admin backup card, issue #86).
*/ */
BACKUPS_DIR: z.string().min(1).default('./data/backups'), 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 * Env-backed secret store (security.md §Secrets, issue #80): a mode-600
* dotenv-style file on a persistent volume where the setup wizard writes * dotenv-style file on a persistent volume where the setup wizard writes
@ -182,6 +196,7 @@ export const backupEnvSchema = z.object({
.default('03:00'), .default('03:00'),
/** Local retention in days: 30 for Prod, 7 for Test/Int (ADR 0015). */ /** 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_RETENTION_DAYS: z.coerce.number().int().min(1).default(30),
BACKUP_ALLOWED_TARGETS: backupAllowedTargets,
/** Failure-alert recipient; unset disables the mail (logged instead). */ /** Failure-alert recipient; unset disables the mail (logged instead). */
BACKUP_MAIL_TO: z.string().optional(), BACKUP_MAIL_TO: z.string().optional(),
/** Language of the failure mail (ADR 0012 — both exist, operator picks). */ /** 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 './auth';
export * from './backup-set'; export * from './backup-set';
export * from './backup-status'; export * from './backup-status';
export * from './backup-target-policy';
export * from './collab-token'; export * from './collab-token';
export * from './comments'; export * from './comments';
export * from './editor-schema'; export * from './editor-schema';

View File

@ -46,6 +46,13 @@ export interface SystemBackupView {
export interface BackupSettingsView { export interface BackupSettingsView {
localRetentionDays: number | null; localRetentionDays: number | null;
remoteRetentionDays: number; 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: { nextcloud: {
enabled: boolean; enabled: boolean;
baseUrl: string; baseUrl: string;