fix(backup): keep GUI backup paths under BACKUP_ROOT and limit the schedule

backup:configure could point automatic backups at any writable directory
(e.g. a statically served one) and set a cron of every second; an invalid
expression deleted the running job before failing, silently ending
backups.

- A path entered in the UI is resolved under BACKUP_ROOT (default
  ./backups) and refused if it leaves it; env values stay unrestricted.
- The schedule must be a five-field cron that runs at most hourly.
- reload() validates before replacing the running job.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 02:14:18 +02:00
1 parent bd592adf66
commit ebeb499a18
6 files changed
+148 -3

No files matched your search

@@ -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);
});
});