diff --git a/src/handlers/backup.ts b/src/handlers/backup.ts index ee0d67c..09da6d0 100644 --- a/src/handlers/backup.ts +++ b/src/handlers/backup.ts @@ -19,6 +19,7 @@ import { loadBackupSettings, normalizeBackupSettingsInput, normalizeImportedBackupSettings, + redactBackupSettingsSecrets, repairBackupSettings, requireBackupDestination, saveBackupSettings, @@ -675,6 +676,7 @@ function collectExternalRemoteAttachmentBlobNames(archiveBytes: Uint8Array): str function toImportStatusCode(message: string): number { const lower = message.toLowerCase(); if (lower.includes('checksum')) return 400; + if (lower.includes('invalid remote backup path') || lower.includes('please select a backup zip file')) return 409; if (lower.includes('invalid backup') || lower.includes('invalid json')) return 400; if (lower.includes('fresh instance')) return 409; if (lower.includes('not configured') || lower.includes('kv')) return 409; @@ -858,7 +860,7 @@ export async function handleGetAdminBackupSettings(request: Request, env: Env, a const storage = new StorageService(env.DB); try { const settings = await loadBackupSettings(storage, env, 'UTC'); - return jsonResponse(settings); + return jsonResponse(redactBackupSettingsSecrets(settings)); } catch (error) { return errorResponse(error instanceof Error ? error.message : 'Backup settings could not be loaded', 409); } @@ -897,7 +899,7 @@ export async function handleUpdateAdminBackupSettings(request: Request, env: Env destinationCount: next.destinations.length, scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length, }, request); - return jsonResponse(next); + return jsonResponse(redactBackupSettingsSecrets(next)); } export async function handleGetAdminBackupSettingsRepairState(request: Request, env: Env, actorUser: User): Promise { @@ -950,7 +952,7 @@ export async function handleRepairAdminBackupSettings(request: Request, env: Env destinationCount: next.destinations.length, scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length, }, request); - return jsonResponse(next); + return jsonResponse(redactBackupSettingsSecrets(next)); } export async function handleRunAdminConfiguredBackup(request: Request, env: Env, actorUser: User): Promise { @@ -987,7 +989,7 @@ export async function handleRunAdminConfiguredBackup(request: Request, env: Env, provider: outcome.result.provider, remotePath: outcome.result.remotePath, }, - settings: outcome.settings, + settings: redactBackupSettingsSecrets(outcome.settings), }); } catch (error) { return errorResponse(error instanceof Error ? error.message : 'Backup run failed', 500); diff --git a/src/services/backup-config.ts b/src/services/backup-config.ts index 971cb73..119af68 100644 --- a/src/services/backup-config.ts +++ b/src/services/backup-config.ts @@ -28,6 +28,7 @@ import { export const BACKUP_SETTINGS_CONFIG_KEY = 'backup.settings.v1'; const BACKUP_RUNTIME_CONFIG_KEY = 'backup.runtime.v1'; export const BACKUP_SCHEDULER_WINDOW_MINUTES = 5; +export const REDACTED_BACKUP_SECRET = '********'; const MAX_BACKUP_DESTINATIONS = 24; export type { @@ -180,6 +181,32 @@ function normalizeDestination( return normalizeWebDavDestination(destination, allowIncomplete); } +function shouldPreserveBackupSecret(value: unknown): boolean { + if (value === undefined || value === null) return true; + const raw = String(value); + return raw === '' || raw === REDACTED_BACKUP_SECRET; +} + +function withPreservedDestinationSecret( + destinationType: BackupDestinationType, + inputDestination: unknown, + previous: BackupDestinationRecord | undefined +): unknown { + const source = isPlainObject(inputDestination) ? { ...inputDestination } : {}; + if (destinationType === 's3') { + const previousDestination = previous?.type === 's3' ? previous.destination as S3BackupDestination : null; + if (shouldPreserveBackupSecret(source.secretAccessKey)) { + source.secretAccessKey = previousDestination?.secretAccessKey || ''; + } + } else { + const previousDestination = previous?.type === 'webdav' ? previous.destination as WebDavBackupDestination : null; + if (shouldPreserveBackupSecret(source.password)) { + source.password = previousDestination?.password || ''; + } + } + return source; +} + function normalizeRuntime(value: unknown): BackupRuntimeState { const source = isPlainObject(value) ? value : {}; const asIso = (input: unknown): string | null => { @@ -250,7 +277,11 @@ function normalizeDestinationRecord( retentionCount: normalizeRetentionCount(retentionSource, previousSchedule.retentionCount), }; - const destination = normalizeDestination(type, input.destination, !schedule.enabled); + const destination = normalizeDestination( + type, + withPreservedDestinationSecret(type, input.destination, previous), + !schedule.enabled + ); return { id, @@ -432,6 +463,31 @@ export function serializeBackupSettings(settings: BackupSettings): string { return JSON.stringify(stripRuntimeFromSettings(settings)); } +export function redactBackupSettingsSecrets(settings: BackupSettings): BackupSettings { + return { + destinations: settings.destinations.map((destination) => { + if (destination.type === 's3') { + const config = destination.destination as S3BackupDestination; + return { + ...destination, + destination: { + ...config, + secretAccessKey: config.secretAccessKey ? REDACTED_BACKUP_SECRET : '', + }, + }; + } + const config = destination.destination as WebDavBackupDestination; + return { + ...destination, + destination: { + ...config, + password: config.password ? REDACTED_BACKUP_SECRET : '', + }, + }; + }), + }; +} + export async function loadBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise { const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY); const mergeRuntime = async (settings: BackupSettings): Promise => (