diff --git a/backend/__tests__/routes/databaseBackupConfigDestination.test.js b/backend/__tests__/routes/databaseBackupConfigDestination.test.js new file mode 100644 index 00000000..8adb5160 --- /dev/null +++ b/backend/__tests__/routes/databaseBackupConfigDestination.test.js @@ -0,0 +1,122 @@ +/** + * PUT /api/admin/database-backup/config must reject a + * database_backup_destination_path that resolves inside a publicly served + * directory (GHSA-jw8m-43r2-jqrm class, #1365). + * + * Before #1365, database_backup_destination_path was silently ignored by + * databaseBackupService.backup() (a destructuring bug always fell back to + * the hardcoded /backup/database), so this setting being freely writable by + * any backup.create holder — the built-in `admin` role has it without + * settings.edit or backup.restore — was harmless. Making the setting + * actually take effect reopens the exact exfiltration path GHSA-jw8m fixed + * for the per-request override, through the persisted setting instead. + */ + +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +process.env.NODE_ENV = 'test'; +process.env.TEST_DATABASE_PATH = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-dbbackup-config-')), 'db.sqlite', +); +process.env.JWT_SECRET = process.env.JWT_SECRET || 'dbbackup-config-test-secret'; +process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-dbbackup-storage-')); + +const request = require('supertest'); +const express = require('express'); +const bcrypt = require('bcrypt'); +const jwt = require('jsonwebtoken'); + +const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb'); + +describe('database backup destination-path config guard (GHSA-jw8m class, #1365)', () => { + let db; let cleanup; let app; let adminToken; + + beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + await seedMinimal(db); + + const role = await db('roles').where({ name: 'admin' }).first(); + const r = await db('admin_users').insert({ + username: 'limited-admin', + email: 'limited-admin-config@example.com', + password_hash: await bcrypt.hash('Passw0rd!', 4), + role_id: role.id, + is_active: 1, + created_at: new Date(), + updated_at: new Date(), + }).returning('id'); + const id = r[0]?.id ?? r[0]; + adminToken = jwt.sign( + { id, username: 'limited-admin', type: 'admin', role: 'admin', loginTime: Date.now() }, + process.env.JWT_SECRET, + { expiresIn: '1h', issuer: 'picpeak-auth' }, + ); + + app = express(); + app.use(express.json()); + app.use('/api/admin/database-backup', require('../../src/routes/adminDatabaseBackup')); + }, 120000); + + afterAll(async () => { if (cleanup) await cleanup(); }); + + it('rejects a destination inside the public uploads/logos mount', async () => { + const res = await request(app) + .put('/api/admin/database-backup/config') + .set('Authorization', `Bearer ${adminToken}`) + .send({ database_backup_destination_path: path.join(process.env.STORAGE_PATH, 'uploads', 'logos') }); + + expect(res.status).toBe(400); + + // The seeded default must survive untouched — the rejected value never lands. + const row = await db('app_settings').where({ setting_key: 'database_backup_destination_path' }).first(); + expect(JSON.parse(row.setting_value)).toBe('/backup/database'); + }); + + it('rejects a destination inside the public fonts mount', async () => { + const res = await request(app) + .put('/api/admin/database-backup/config') + .set('Authorization', `Bearer ${adminToken}`) + .send({ database_backup_destination_path: path.join(process.env.STORAGE_PATH, 'fonts') }); + + expect(res.status).toBe(400); + }); + + it('accepts a destination outside any public mount', async () => { + const safePath = path.join(process.env.STORAGE_PATH, 'db-backups'); + const res = await request(app) + .put('/api/admin/database-backup/config') + .set('Authorization', `Bearer ${adminToken}`) + .send({ database_backup_destination_path: safePath }); + + expect(res.status).toBe(200); + + const row = await db('app_settings').where({ setting_key: 'database_backup_destination_path' }).first(); + expect(JSON.parse(row.setting_value)).toBe(safePath); + }); + + // A retention of 0 or less pushes cleanupOldBackups' cutoff to today or + // the future, deleting every completed backup on the next scheduled run + // — a backup.create holder achieving what backup.delete gates on /cleanup. + it.each([-1, 0])('rejects database_backup_retention_days=%s', async (bad) => { + const res = await request(app) + .put('/api/admin/database-backup/config') + .set('Authorization', `Bearer ${adminToken}`) + .send({ database_backup_retention_days: bad }); + + expect(res.status).toBe(400); + }); + + it('accepts a positive database_backup_retention_days', async () => { + const res = await request(app) + .put('/api/admin/database-backup/config') + .set('Authorization', `Bearer ${adminToken}`) + .send({ database_backup_retention_days: 90 }); + + expect(res.status).toBe(200); + + const row = await db('app_settings').where({ setting_key: 'database_backup_retention_days' }).first(); + expect(JSON.parse(row.setting_value)).toBe(90); + }); +}); diff --git a/backend/src/routes/adminDatabaseBackup.js b/backend/src/routes/adminDatabaseBackup.js index b4114091..4bf7f56d 100644 --- a/backend/src/routes/adminDatabaseBackup.js +++ b/backend/src/routes/adminDatabaseBackup.js @@ -2,7 +2,7 @@ const express = require('express'); const router = express.Router(); const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); -const { databaseBackupService } = require('../services/databaseBackup'); +const { databaseBackupService, isUnderPubliclyServableRoot } = require('../services/databaseBackup'); const { db } = require('../database/db'); const logger = require('../utils/logger'); const { getPagination } = require('../utils/routeHelpers'); @@ -60,7 +60,28 @@ router.put('/config', requirePermission('backup.create'), async (req, res) => { 'database_backup_email_on_failure', 'database_backup_email_on_success' ]; - + + // A backup.create holder (the built-in `admin` role has it without + // settings.edit or backup.restore) could otherwise point backups at a + // public static mount and fetch the dump unauthenticated — see + // isUnderPubliclyServableRoot's comment (GHSA-jw8m-43r2-jqrm class). + if ( + typeof req.body.database_backup_destination_path === 'string' + && isUnderPubliclyServableRoot(req.body.database_backup_destination_path) + ) { + return res.status(400).json({ error: 'Destination path must not be inside a publicly served directory' }); + } + + // A retention of 0 or less pushes cleanupOldBackups' cutoff to today or + // the future, deleting every completed backup on the next scheduled run + // — a backup.create holder achieving what backup.delete gates on /cleanup. + if ( + req.body.database_backup_retention_days !== undefined + && (!Number.isFinite(req.body.database_backup_retention_days) || req.body.database_backup_retention_days < 1) + ) { + return res.status(400).json({ error: 'database_backup_retention_days must be a positive number' }); + } + const updates = []; for (const [key, value] of Object.entries(req.body)) { diff --git a/backend/src/services/__tests__/databaseBackup.test.js b/backend/src/services/__tests__/databaseBackup.test.js index 12e73972..2185b61e 100644 --- a/backend/src/services/__tests__/databaseBackup.test.js +++ b/backend/src/services/__tests__/databaseBackup.test.js @@ -1,4 +1,3 @@ -const { DatabaseBackupService } = require('../databaseBackup'); const { db } = require('../../database/db'); const fs = require('fs').promises; const path = require('path'); @@ -9,6 +8,10 @@ jest.mock('../../database/db'); jest.mock('../../utils/logger'); jest.mock('../emailProcessor'); jest.mock('child_process'); +jest.mock('node-cron', () => ({ schedule: jest.fn(() => ({ stop: jest.fn() })) })); + +const { DatabaseBackupService, startScheduledBackups, databaseBackupService, isUnderPubliclyServableRoot } = require('../databaseBackup'); +const cron = require('node-cron'); describe('DatabaseBackupService', () => { let service; @@ -188,6 +191,213 @@ describe('DatabaseBackupService', () => { }); }); + describe('backup() destination path resolution (#1365)', () => { + // getBackupConfig() returns database_backup_*-prefixed keys. + // Regression: backup() used to destructure the unprefixed names + // (`destinationPath`, ...) straight off that object, which never + // matched, so the configured path was silently ignored and every + // run tried to create the hardcoded /backup/database default. + it('creates the directory from database_backup_destination_path when configured', async () => { + db.mockReturnValue({ + where: jest.fn().mockReturnThis(), + select: jest.fn().mockResolvedValue([ + { setting_key: 'database_backup_destination_path', setting_value: JSON.stringify('/data/db-backups') } + ]) + }); + + const stop = new Error('stop after mkdir — nothing past it matters for this test'); + const mkdirSpy = jest.spyOn(fs, 'mkdir').mockRejectedValue(stop); + + await expect(service.backup({})).rejects.toThrow(stop.message); + + expect(mkdirSpy).toHaveBeenCalledWith('/data/db-backups', { recursive: true }); + mkdirSpy.mockRestore(); + }); + + it('falls back to /backup/database only when nothing is configured', async () => { + db.mockReturnValue({ + where: jest.fn().mockReturnThis(), + select: jest.fn().mockResolvedValue([]) + }); + + const stop = new Error('stop after mkdir'); + const mkdirSpy = jest.spyOn(fs, 'mkdir').mockRejectedValue(stop); + + await expect(service.backup({})).rejects.toThrow(stop.message); + + expect(mkdirSpy).toHaveBeenCalledWith('/backup/database', { recursive: true }); + mkdirSpy.mockRestore(); + }); + }); + + describe('isUnderPubliclyServableRoot (GHSA-jw8m class, #1365)', () => { + const originalStoragePath = process.env.STORAGE_PATH; + const storage = '/tmp/picpeak-test-storage'; + + beforeEach(() => { + process.env.STORAGE_PATH = storage; + }); + + afterAll(() => { + if (originalStoragePath === undefined) { + delete process.env.STORAGE_PATH; + } else { + process.env.STORAGE_PATH = originalStoragePath; + } + }); + + it.each([ + path.join(storage, 'uploads', 'logos'), + path.join(storage, 'uploads', 'logos', 'sub'), + path.join(storage, 'uploads', 'favicons'), + path.join(storage, 'fonts'), + path.join(storage, 'fonts', 'inter'), + // Bundled fallback fonts — nodejs-owned per the Dockerfile's + // COPY --chown, and served at the same public /fonts route. + path.resolve(__dirname, '../../../assets/fonts'), + // Case-insensitive-but-preserving filesystems (APFS, NTFS, Docker + // Desktop bind mounts of either) resolve this to the same directory + // as uploads/logos even though path.resolve() never folds case. + path.join(storage, 'UPLOADS', 'Logos') + ])('flags %s as publicly servable', (candidate) => { + expect(isUnderPubliclyServableRoot(candidate)).toBe(true); + }); + + it.each([ + path.join(storage, 'backups'), + path.join(storage, 'uploads', 'contracts', 'signed'), + path.join(storage, 'uploads', 'transfers', '123'), + '/data/db-backups' + ])('does not flag %s', (candidate) => { + expect(isUnderPubliclyServableRoot(candidate)).toBe(false); + }); + + it('backup() refuses a destination inside a publicly servable root without ever calling mkdir', async () => { + const publicPath = path.join(storage, 'uploads', 'logos'); + db.mockReturnValue({ + where: jest.fn().mockReturnThis(), + select: jest.fn().mockResolvedValue([ + { setting_key: 'database_backup_destination_path', setting_value: JSON.stringify(publicPath) } + ]) + }); + + const mkdirSpy = jest.spyOn(fs, 'mkdir'); + + await expect(service.backup({})).rejects.toThrow('publicly served directory'); + + expect(mkdirSpy).not.toHaveBeenCalled(); + mkdirSpy.mockRestore(); + }); + + it('flags FRONTEND_DIR — the all-in-one image serves its built SPA unauthenticated', () => { + const originalFrontendDir = process.env.FRONTEND_DIR; + process.env.FRONTEND_DIR = '/app/frontend/dist'; + try { + expect(isUnderPubliclyServableRoot('/app/frontend/dist')).toBe(true); + expect(isUnderPubliclyServableRoot(path.join('/app/frontend/dist', 'assets'))).toBe(true); + } finally { + if (originalFrontendDir === undefined) delete process.env.FRONTEND_DIR; + else process.env.FRONTEND_DIR = originalFrontendDir; + } + }); + + it('resolves a symlinked alias of a public root to the same real directory (all-in-one /app/storage -> /data/storage)', async () => { + const os = require('os'); + const realRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-real-')); + const linkRoot = path.join(os.tmpdir(), `picpeak-link-${process.pid}-${Date.now()}`); + await fs.mkdir(path.join(realRoot, 'uploads', 'logos'), { recursive: true }); + await fs.symlink(realRoot, linkRoot, 'dir'); + + try { + // STORAGE_PATH (what the guard's roots are built from) is the real + // path; the attacker-supplied destination goes through the symlink + // — exactly the all-in-one image's /app/storage -> /data/storage. + process.env.STORAGE_PATH = realRoot; + const aliased = path.join(linkRoot, 'uploads', 'logos'); + + expect(isUnderPubliclyServableRoot(aliased)).toBe(true); + } finally { + await fs.unlink(linkRoot); + await fs.rm(realRoot, { recursive: true, force: true }); + } + }); + }); + + describe('startScheduledBackups (#1365)', () => { + // Same key-mismatch bug as backup(): getBackupConfig() returns + // database_backup_*-prefixed keys, but this read `config.enabled` / + // `config.schedule` / `config.retentionDays` — always undefined, so + // the scheduler silently treated every install as disabled. + it('does not start the schedule while database_backup_enabled is false', async () => { + db.mockReturnValue({ + where: jest.fn().mockReturnThis(), + select: jest.fn().mockResolvedValue([ + { setting_key: 'database_backup_enabled', setting_value: 'false' } + ]) + }); + + await startScheduledBackups(); + + expect(cron.schedule).not.toHaveBeenCalled(); + }); + + it('starts the schedule with the configured cron when database_backup_enabled is true', async () => { + db.mockReturnValue({ + where: jest.fn().mockReturnThis(), + select: jest.fn().mockResolvedValue([ + { setting_key: 'database_backup_enabled', setting_value: 'true' }, + { setting_key: 'database_backup_schedule', setting_value: JSON.stringify('0 4 * * *') } + ]) + }); + + await startScheduledBackups(); + + expect(cron.schedule).toHaveBeenCalledWith('0 4 * * *', expect.any(Function)); + }); + + it('re-reads retention on every tick instead of the value captured at schedule start (#1365)', async () => { + db.mockReturnValue({ + where: jest.fn().mockReturnThis(), + select: jest.fn().mockResolvedValue([ + { setting_key: 'database_backup_enabled', setting_value: 'true' }, + { setting_key: 'database_backup_retention_days', setting_value: JSON.stringify(30) } + ]) + }); + + await startScheduledBackups(); + const tick = cron.schedule.mock.calls[0][1]; + + // A /config update between schedule-start and this tick raised + // retention to 365 — the closed-over 30 must not be what runs. + db.mockReturnValue({ + where: jest.fn().mockReturnThis(), + select: jest.fn().mockResolvedValue([ + { setting_key: 'database_backup_enabled', setting_value: 'true' }, + { setting_key: 'database_backup_retention_days', setting_value: JSON.stringify(365) } + ]) + }); + jest.spyOn(databaseBackupService, 'backup').mockResolvedValue({ success: true }); + const cleanupSpy = jest.spyOn(databaseBackupService, 'cleanupOldBackups').mockResolvedValue(undefined); + + await tick(); + + expect(cleanupSpy).toHaveBeenCalledWith(365); + + jest.restoreAllMocks(); + }); + }); + + describe('cleanupOldBackups destructive-retention guard (#1365)', () => { + it.each([-1, 0, NaN, Infinity])('refuses retentionDays=%s without touching the database', async (bad) => { + const dbSpy = jest.fn(); + db.mockImplementation(dbSpy); + + await service.cleanupOldBackups(bad); + + expect(dbSpy).not.toHaveBeenCalled(); + }); + }); + describe('cleanupOldBackups', () => { it('should delete old backup files and records', async () => { const oldBackups = [ diff --git a/backend/src/services/backupService.js b/backend/src/services/backupService.js index 1731a5ba..afb6f13a 100644 --- a/backend/src/services/backupService.js +++ b/backend/src/services/backupService.js @@ -1043,7 +1043,7 @@ function buildManifestFiles(backedUpFiles, allFiles) { async function saveManifestToLocal(manifest, manifestFileName, config) { const manifestDir = config.backup_manifest_path - || path.join(config.backup_destination_path || '/backup', 'manifests'); + || path.join(config.backup_destination_path || path.join(getStoragePath(), 'backups'), 'manifests'); await fs.mkdir(manifestDir, { recursive: true }); const manifestPath = path.join(manifestDir, manifestFileName); await backupManifest.saveManifest(manifest, manifestPath, config.backup_manifest_format || 'json'); diff --git a/backend/src/services/databaseBackup.js b/backend/src/services/databaseBackup.js index db9832fd..3b388d1e 100644 --- a/backend/src/services/databaseBackup.js +++ b/backend/src/services/databaseBackup.js @@ -4,7 +4,7 @@ const crypto = require('crypto'); const { spawnAsync, spawnToFile } = require('../utils/safeExec'); const zlib = require('zlib'); const { pipeline } = require('stream/promises'); -const { createReadStream, createWriteStream } = require('fs'); +const { createReadStream, createWriteStream, realpathSync } = require('fs'); const { db } = require('../database/db'); const knexConfig = require('../../knexfile'); const logger = require('../utils/logger'); @@ -16,6 +16,76 @@ const packageJson = require('../../package.json'); const CHUNK_SIZE = 1024 * 1024; // 1MB chunks for streaming const PROGRESS_INTERVAL = 100; // Report progress every 100 rows +function getStoragePath() { + return process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); +} + +// Public, unauthenticated static mounts (server.js) that must never become a +// backup destination — a dump landing there is downloadable by anyone who +// learns or guesses the filename, GHSA-jw8m-43r2-jqrm's exact class. Before +// #1365, `database_backup_destination_path` was silently ignored (a +// destructuring bug always fell back to the hardcoded /backup/database), so +// this setting being freely writable by any backup.create holder — the +// built-in `admin` role has it without settings.edit or backup.restore — was +// harmless. Making the setting actually take effect reopens that exact +// exfiltration path unless it's rejected here too. +function getPubliclyServableRoots() { + const storage = getStoragePath(); + return [ + path.join(storage, 'uploads', 'logos'), + path.join(storage, 'uploads', 'favicons'), + path.join(storage, 'fonts'), + // Bundled fallback fonts (server.js mounts both at /fonts, storage wins + // on overlap but express.static falls through to this one on a miss). + // COPY --chown=nodejs:nodejs in the Dockerfile makes this nodejs-owned + // and therefore writable at runtime, not just a read-only image layer. + path.resolve(__dirname, '../../assets/fonts'), + // The all-in-one image's built frontend bundle (Dockerfile.aio ships it + // nodejs-owned) — server.js serves it unauthenticated as the SPA itself. + process.env.FRONTEND_DIR || path.resolve(__dirname, '../../../frontend/dist') + ]; +} + +// Resolves symlinks in whatever prefix of candidatePath currently exists, +// then re-appends any not-yet-created remainder literally. A plain +// fs.realpathSync would throw ENOENT for the common case where the backup +// destination doesn't exist yet; a plain path.resolve() would miss the +// all-in-one image's `/app/storage -> /data/storage` symlink (Dockerfile.aio), +// which lets `/app/storage/uploads/logos` alias the real public logos +// directory under a name that never lexically matches it. +function resolveRealish(candidatePath) { + let current = path.resolve(candidatePath); + const remainder = []; + for (;;) { + try { + const real = realpathSync(current); + return remainder.length ? path.join(real, ...remainder) : real; + } catch (error) { + if (error.code !== 'ENOENT') { + return path.resolve(candidatePath); + } + const parent = path.dirname(current); + if (parent === current) { + return path.resolve(candidatePath); + } + remainder.unshift(path.basename(current)); + current = parent; + } + } +} + +function isUnderPubliclyServableRoot(candidatePath) { + // Lowercased comparison: on a case-insensitive-but-preserving filesystem + // (default macOS APFS, NTFS, and Docker Desktop's bind-mount passthrough + // of either) `STORAGE_PATH/UPLOADS/logos` and `.../uploads/logos` name the + // same directory on disk even though path.resolve() never folds case. + const resolved = resolveRealish(candidatePath).toLowerCase(); + return getPubliclyServableRoots().some((root) => { + const resolvedRoot = resolveRealish(root).toLowerCase(); + return resolved === resolvedRoot || resolved.startsWith(resolvedRoot + path.sep); + }); +} + /** * Database Backup Service * Supports both SQLite and PostgreSQL with proper escaping, @@ -296,15 +366,33 @@ class DatabaseBackupService { let backupRun = null; try { - // Get configuration + // Get configuration. getBackupConfig() returns the raw + // database_backup_*-prefixed setting keys, not the unprefixed + // names used internally below — map them explicitly rather than + // spreading `config` straight into the destructure, which silently + // matched nothing and always fell through to the hardcoded + // defaults (notably `/backup/database`, regardless of what was + // configured). const config = await this.getBackupConfig(); const { destinationPath = '/backup/database', compress = true, validateIntegrity = true, includeChecksums = true - } = { ...config, ...options }; + } = { + destinationPath: config.database_backup_destination_path, + compress: config.database_backup_compress, + validateIntegrity: config.database_backup_validate_integrity, + includeChecksums: config.database_backup_include_checksums, + ...options + }; + if (isUnderPubliclyServableRoot(destinationPath)) { + throw new Error( + `Refusing to write a database backup to a publicly served directory: ${destinationPath}` + ); + } + // Create backup directory await fs.mkdir(destinationPath, { recursive: true }); @@ -423,7 +511,7 @@ class DatabaseBackupService { logger.info(`Database backup completed: ${finalFile} (${(finalStats.size / 1024 / 1024).toFixed(2)} MB) in ${durationSeconds}s`); // Send success notification if configured - if (config.emailOnSuccess) { + if (config.database_backup_email_on_success) { await this.sendBackupNotification('success', { duration: durationSeconds, size: finalStats.size, @@ -457,7 +545,7 @@ class DatabaseBackupService { // Send failure notification const config = await this.getBackupConfig(); - if (config.emailOnFailure) { + if (config.database_backup_email_on_failure) { await this.sendBackupNotification('failure', { error: error.message }); @@ -538,10 +626,19 @@ class DatabaseBackupService { * Clean up old backups */ async cleanupOldBackups(retentionDays = 30) { + // A zero/negative/non-finite value pushes the cutoff to today or the + // future, matching (and deleting) every completed backup — including + // the one a scheduled run just created. Defense in depth: PUT /config + // already rejects such values, but this is also reachable with + // whatever database_backup_retention_days happens to be persisted. + if (!Number.isFinite(retentionDays) || retentionDays < 1) { + logger.error(`Refusing to clean up backups with invalid retentionDays: ${retentionDays}`); + return; + } try { const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - retentionDays); - + // Get old backup records const oldBackups = await db('database_backup_runs') .where('completed_at', '<', cutoffDate) @@ -686,25 +783,30 @@ async function startScheduledBackups() { try { const config = await databaseBackupService.getBackupConfig(); - - if (!config.enabled) { + + if (!config.database_backup_enabled) { logger.info('Database backup service is disabled'); return; } - + // Stop existing schedule if (backupSchedule) { backupSchedule.stop(); } - + // Default schedule: 3 AM daily (offset from file backups at 2 AM) - const schedule = config.schedule || '0 3 * * *'; - + const schedule = config.database_backup_schedule || '0 3 * * *'; + backupSchedule = cron.schedule(schedule, async () => { logger.info('Starting scheduled database backup'); try { await databaseBackupService.backup(); - await databaseBackupService.cleanupOldBackups(config.retentionDays || 30); + // Re-read retention on every tick rather than closing over the value + // from schedule start — a retention-only /config update doesn't + // restart the schedule (only enabled/schedule changes do), so the + // closed-over value would otherwise run stale until next restart. + const latestConfig = await databaseBackupService.getBackupConfig(); + await databaseBackupService.cleanupOldBackups(latestConfig.database_backup_retention_days || 30); } catch (error) { logger.error('Scheduled database backup failed:', error); } @@ -731,5 +833,6 @@ module.exports = { databaseBackupService, startScheduledBackups, stopScheduledBackups, + isUnderPubliclyServableRoot, DatabaseBackupService // Export class for testing }; \ No newline at end of file