diff --git a/ApiCore-Backend/.env.example b/ApiCore-Backend/.env.example index ecf3d2f..bc49470 100644 --- a/ApiCore-Backend/.env.example +++ b/ApiCore-Backend/.env.example @@ -33,6 +33,9 @@ DOCUMENTS_STORAGE_PATH="./uploads" # ihre eigenen Backup-Dateien und die Lauf-Erkennung zwischen Replicas greift # nicht. BACKUP_STORAGE_PATH="./backups" +# Directory a backup path set in the admin UI must stay under (the env +# variable above is not restricted). Default "./backups". +# BACKUP_ROOT="./backups" # Cron-Ausdruck (Standard: taeglich um 03:00 Uhr). BACKUP_CRON_SCHEDULE="0 3 * * *" # Wie viele der neuesten Backup-Dateien behalten werden, aeltere werden nach diff --git a/ApiCore-Backend/src/backup/backup-schedule-rules.spec.ts b/ApiCore-Backend/src/backup/backup-schedule-rules.spec.ts new file mode 100644 index 0000000..ce08c2b --- /dev/null +++ b/ApiCore-Backend/src/backup/backup-schedule-rules.spec.ts @@ -0,0 +1,50 @@ +import { BadRequestException } from '@nestjs/common'; +import * as path from 'node:path'; +import { + assertBackupCron, + assertGuiBackupPath, + resolveGuiBackupPath, +} from './backup-schedule-rules'; + +describe('backup-schedule-rules', () => { + const originalRoot = process.env.BACKUP_ROOT; + + beforeEach(() => { + process.env.BACKUP_ROOT = path.resolve('/data/backups'); + }); + + afterAll(() => { + process.env.BACKUP_ROOT = originalRoot; + }); + + it('keeps a GUI path under the backup root', () => { + expect(resolveGuiBackupPath('nightly')).toBe( + path.resolve('/data/backups/nightly'), + ); + expect(() => assertGuiBackupPath('nightly')).not.toThrow(); + }); + + it('refuses a GUI path that leaves the backup root', () => { + expect(() => assertGuiBackupPath('../../app/public')).toThrow( + BadRequestException, + ); + expect(() => assertGuiBackupPath('/etc')).toThrow(BadRequestException); + expect(() => assertGuiBackupPath('/data/backups-evil')).toThrow( + BadRequestException, + ); + }); + + it('accepts a daily schedule', () => { + expect(() => assertBackupCron('0 3 * * *')).not.toThrow(); + }); + + it('refuses a schedule that runs more than hourly or has seconds', () => { + expect(() => assertBackupCron('* * * * *')).toThrow(BadRequestException); + expect(() => assertBackupCron('*/10 * * * *')).toThrow(BadRequestException); + expect(() => assertBackupCron('* * * * * *')).toThrow(BadRequestException); + }); + + it('refuses an invalid expression', () => { + expect(() => assertBackupCron('every day')).toThrow(BadRequestException); + }); +}); diff --git a/ApiCore-Backend/src/backup/backup-schedule-rules.ts b/ApiCore-Backend/src/backup/backup-schedule-rules.ts new file mode 100644 index 0000000..0e02597 --- /dev/null +++ b/ApiCore-Backend/src/backup/backup-schedule-rules.ts @@ -0,0 +1,55 @@ +import { BadRequestException } from '@nestjs/common'; +import { CronTime, validateCronExpression } from 'cron'; +import * as path from 'node:path'; + +/** + * Limits on what the admin UI may configure for automatic backups. The env + * variables (set by the operator) stay unrestricted; a GUI value is data a + * logged-in user typed, so it may neither write a full dump anywhere on the + * server's file system nor run an export every second. + */ +const MIN_INTERVAL_MS = 60 * 60 * 1000; +const INTERVAL_SAMPLES = 48; + +/** Root every GUI-configured backup directory must stay under. */ +export function backupRoot(): string { + return path.resolve(process.env.BACKUP_ROOT ?? './backups'); +} + +/** The GUI path resolved under backupRoot(), or null if it would leave it. */ +export function resolveGuiBackupPath(value: string): string | null { + const root = backupRoot(); + const resolved = path.resolve(root, value); + if (resolved !== root && !resolved.startsWith(root + path.sep)) { + return null; + } + return resolved; +} + +export function assertGuiBackupPath(value: string): void { + if (resolveGuiBackupPath(value) === null) { + throw new BadRequestException( + `Backup-Pfad muss innerhalb von ${backupRoot()} liegen`, + ); + } +} + +/** A standard five-field cron expression that fires at most once an hour. */ +export function assertBackupCron(expression: string): void { + const fields = expression.trim().split(/\s+/); + if (fields.length !== 5 || !validateCronExpression(expression).valid) { + throw new BadRequestException( + 'Zeitplan muss ein Cron-Ausdruck mit fünf Feldern sein, z. B. "0 3 * * *"', + ); + } + const runs = new CronTime(expression) + .sendAt(INTERVAL_SAMPLES) + .map((run) => run.toMillis()); + for (let i = 1; i < runs.length; i++) { + if (runs[i] - runs[i - 1] < MIN_INTERVAL_MS) { + throw new BadRequestException( + 'Zeitplan darf höchstens einmal pro Stunde sichern', + ); + } + } +} diff --git a/ApiCore-Backend/src/backup/backup-scheduler.service.ts b/ApiCore-Backend/src/backup/backup-scheduler.service.ts index 49e7700..eab67ef 100644 --- a/ApiCore-Backend/src/backup/backup-scheduler.service.ts +++ b/ApiCore-Backend/src/backup/backup-scheduler.service.ts @@ -5,6 +5,10 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { BackupService } from './backup.service'; import { BackupSettingsService } from './backup-settings.service'; +import { + assertBackupCron, + resolveGuiBackupPath, +} from './backup-schedule-rules'; const SCHEDULED_JOB_NAME = 'automatic-backup'; const DEFAULT_CRON_SCHEDULE = '0 3 * * *'; @@ -68,7 +72,13 @@ export class BackupSchedulerService implements OnModuleInit { const gui = await this.backupSettingsService.get(); return { - path: hasEnvPath ? (envPath as string) : (gui.storagePath ?? null), + // A GUI path counts only inside BACKUP_ROOT (a value stored before + // that rule existed and pointing elsewhere disables the schedule). + path: hasEnvPath + ? (envPath as string) + : gui.storagePath + ? resolveGuiBackupPath(gui.storagePath) + : null, cronSchedule: hasEnvCron ? (envCron as string) : (gui.cronSchedule ?? DEFAULT_CRON_SCHEDULE), @@ -88,11 +98,22 @@ export class BackupSchedulerService implements OnModuleInit { // admin's schedule/path/retention change takes effect immediately without // a pod restart. async reload(): Promise { + const settings = await this.effectiveSettings(); + if (!settings.envOverrides.cronSchedule) { + try { + assertBackupCron(settings.cronSchedule); + } catch { + // Keep whatever is running instead of silently ending backups. + this.logger.error( + `Ungültiger Backup-Zeitplan "${settings.cronSchedule}" - bisheriger Zeitplan bleibt aktiv`, + ); + return; + } + } if (this.schedulerRegistry.doesExist('cron', SCHEDULED_JOB_NAME)) { this.schedulerRegistry.deleteCronJob(SCHEDULED_JOB_NAME); } - const settings = await this.effectiveSettings(); if (!settings.path) { this.logger.log( 'Kein Backup-Pfad konfiguriert (weder Env noch GUI) - automatische Backups deaktiviert', diff --git a/ApiCore-Backend/src/backup/backup-settings.service.ts b/ApiCore-Backend/src/backup/backup-settings.service.ts index a57a2d7..f6837ae 100644 --- a/ApiCore-Backend/src/backup/backup-settings.service.ts +++ b/ApiCore-Backend/src/backup/backup-settings.service.ts @@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { BackupSettings } from '../../generated/prisma'; import { UpdateBackupSettingsDto } from './dto/update-backup-settings.dto'; +import { assertBackupCron, assertGuiBackupPath } from './backup-schedule-rules'; const SINGLETON_ID = 'singleton'; @@ -31,6 +32,12 @@ export class BackupSettingsService { dto: UpdateBackupSettingsDto, actingUserId: string, ): Promise { + if (dto.storagePath) { + assertGuiBackupPath(dto.storagePath); + } + if (dto.cronSchedule) { + assertBackupCron(dto.cronSchedule); + } const before = await this.get(); const settings = await this.prisma.backupSettings.update({ where: { id: SINGLETON_ID }, diff --git a/ApiCore-Frontend/src/app/(app)/settings/backup/page.tsx b/ApiCore-Frontend/src/app/(app)/settings/backup/page.tsx index 3519372..63ee895 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/backup/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/backup/page.tsx @@ -225,8 +225,13 @@ function ScheduleSettingsForm({ value={storagePath} disabled={envOverrides.storagePath} onChange={(event) => setStoragePath(event.target.value)} - placeholder="/data/backups" + placeholder="nightly" /> + {!envOverrides.storagePath ? ( +

+ Unterordner im Backup-Verzeichnis des Servers (BACKUP_ROOT), z. B. „nightly“. +

+ ) : null} {envOverrides.storagePath ? (

Durch BACKUP_STORAGE_PATH vorgegeben. @@ -241,7 +246,11 @@ function ScheduleSettingsForm({ disabled={envOverrides.cronSchedule} onChange={(event) => setCronSchedule(event.target.value)} placeholder="0 3 * * *" + aria-describedby="backup-cron-hint" /> +

+ Fünf Felder, höchstens einmal pro Stunde. „0 3 * * *“ = täglich um 3 Uhr. +

{envOverrides.cronSchedule ? (

Durch BACKUP_CRON_SCHEDULE vorgegeben.