#192: deploy-level backup target allowlist #247
@ -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({});
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
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 { 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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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', () => {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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', () => {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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:-}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -97,7 +97,7 @@ chain`_
|
|||||||
separates Idle-Timeout · 1–2 AT · #190
|
separates Idle-Timeout · 1–2 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
|
||||||
|
|||||||
@ -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.",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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.",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
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 { 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). */
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user