#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
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:
parent
afef45732a
commit
394d1c811d
@ -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({});
|
||||
|
||||
@ -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();
|
||||
|
||||
101
apps/api/src/admin/backup-allowlist-empty.e2e.db.test.ts
Normal file
101
apps/api/src/admin/backup-allowlist-empty.e2e.db.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
134
apps/api/src/admin/backup-allowlist.e2e.db.test.ts
Normal file
134
apps/api/src/admin/backup-allowlist.e2e.db.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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:-}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -97,7 +97,7 @@ chain`_
|
||||
separates Idle-Timeout · 1–2 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
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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",
|
||||
|
||||
41
packages/shared/src/backup-target-policy.test.ts
Normal file
41
packages/shared/src/backup-target-policy.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
47
packages/shared/src/backup-target-policy.ts
Normal file
47
packages/shared/src/backup-target-policy.ts
Normal 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);
|
||||
}
|
||||
@ -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). */
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user