Pre-beta review: safer destructive commands and hardening
CI / unit (8.2) (push) Has been cancelled
CI / unit (8.3) (push) Has been cancelled
CI / unit (8.4) (push) Has been cancelled
CI / psalm (push) Has been cancelled
CI / integration (mariadb, 32) (push) Has been cancelled
CI / integration (mariadb, 33) (push) Has been cancelled
CI / integration (mariadb, 34) (push) Has been cancelled
CI / integration (mariadb, 35) (push) Has been cancelled
CI / integration (pgsql, 32) (push) Has been cancelled
CI / integration (pgsql, 33) (push) Has been cancelled
CI / integration (pgsql, 34) (push) Has been cancelled
CI / integration (pgsql, 35) (push) Has been cancelled
CI / integration (sqlite, 32) (push) Has been cancelled
CI / integration (sqlite, 33) (push) Has been cancelled
CI / integration (sqlite, 34) (push) Has been cancelled
CI / integration (sqlite, 35) (push) Has been cancelled
CI / integration-targets (push) Has been cancelled
CI / integration-user (32) (push) Has been cancelled
CI / integration-user (33) (push) Has been cancelled
CI / integration-user (34) (push) Has been cancelled
CI / integration-user (35) (push) Has been cancelled
CI / integration-full-restore (push) Has been cancelled

- backup:restore:full refuses on an installation with more than one user
  and asks before it starts (--force skips both, also to rerun after a
  failure halfway), and warns when the snapshot's database dump was not
  one consistent transaction.
- backup:user:restore --mode=replace asks before deleting an existing
  account; non-interactive use needs --force.
- The recovery kit file is created owner-only and never over an existing
  file or symlink; backup:key:import shows the deletion delay it read from
  the kit.
- Adding a location through the web API needs password confirmation, like
  removing one.
- Key derivation parameters from a kit or repository config are bounded,
  and a stalled network stream no longer spins at full CPU.
- Floating-point columns are dumped exactly instead of with 14 digits.
This commit is contained in:
2026-10-06 23:48:41 +02:00
parent 0f4a238414
commit 45069994e9
11 changed files with 92 additions and 9 deletions
+6 -1
View File
@@ -102,7 +102,9 @@ occ backup:user:restore offsite users/<uid>/<exportId> --mode=as-backup
use, add `--install-user-migration` to approve that explicitly. The command stops after the use, add `--install-user-migration` to approve that explicitly. The command stops after the
installation; run it once more. installation; run it once more.
- `replace` first makes a safety export of the current account, then recreates the user under the - `replace` first makes a safety export of the current account, then recreates the user under the
same id. `as-backup` restores into `<uid>-bak` and leaves the original untouched. same id. When the account exists, the command asks first; in scripts, add `--force`. Shares to
others and data of apps without a user_migration migrator are not restored, so prefer
`as-backup` when in doubt: it restores into `<uid>-bak` and leaves the original untouched.
- A restore onto a server where the user does not exist works. Calendars and address books get an - A restore onto a server where the user does not exist works. Calendars and address books get an
internal name with the prefix `migrated-` (for example `migrated-personal`). That prefix comes from internal name with the prefix `migrated-` (for example `migrated-personal`). That prefix comes from
user_migration and cannot be turned off. The user can change the display name of these calendars and user_migration and cannot be turned off. The user can change the display name of these calendars and
@@ -124,6 +126,9 @@ occ backup:list offsite # the snapshots must be lis
occ backup:restore:full offsite <snapshot> occ backup:restore:full offsite <snapshot>
``` ```
The command refuses on an installation that already has more than one user, and asks before it
starts; `--force` skips both (also needed to rerun it after it stopped halfway).
Afterwards, check that users, files and the database are complete, and run a backup of the restored Afterwards, check that users, files and the database are complete, and run a backup of the restored
server. The restored installation keeps its own app configuration and secret. server. The restored installation keeps its own app configuration and secret.
+3
View File
@@ -76,6 +76,9 @@ HELP);
return 1; return 1;
} }
$output->writeln('Backup key imported, fingerprint <info>' . $this->keys->fingerprint() . '</info>.'); $output->writeln('Backup key imported, fingerprint <info>' . $this->keys->fingerprint() . '</info>.');
// Read from the kit file, which is not authenticated beyond the wrapped key: show it so a
// wrong or altered value stands out.
$output->writeln('Deletion delay for forgotten snapshots: <info>' . $this->keys->deleteDelayDays() . '</info> days (from the kit).');
$output->writeln('Next: connect the backup location with the same details as before (<info>occ backup:target:add</info>), then <info>occ backup:list</info> and <info>occ backup:restore:full</info>.'); $output->writeln('Next: connect the backup location with the same details as before (<info>occ backup:target:add</info>), then <info>occ backup:list</info> and <info>occ backup:restore:full</info>.');
return 0; return 0;
} }
+10 -3
View File
@@ -39,11 +39,18 @@ final class KeyKit extends Command {
$output->write($json, false, OutputInterface::OUTPUT_RAW); $output->write($json, false, OutputInterface::OUTPUT_RAW);
return 0; return 0;
} }
if (file_put_contents($file, $json) === false) { // Created owner-only from the start (no window where it is world-readable), never over an
$output->writeln("<error>Cannot write $file</error>"); // existing file or symlink someone else may have placed at a predictable path.
$old = umask(0077);
try {
$fh = @fopen($file, 'xb');
} finally {
umask($old);
}
if ($fh === false || fwrite($fh, $json) !== strlen($json) || !fclose($fh)) {
$output->writeln("<error>Cannot write $file (it must not exist yet)</error>");
return 1; return 1;
} }
@chmod($file, 0600);
$output->writeln("Recovery kit written to <info>$file</info>. Copy it off this server, then run <info>occ backup:key:confirm</info>."); $output->writeln("Recovery kit written to <info>$file</info>. Copy it off this server, then run <info>occ backup:key:confirm</info>.");
return 0; return 0;
} }
+26
View File
@@ -9,7 +9,11 @@ namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\RestoreFullService; use OCA\NgBackup\Service\RestoreFullService;
use OCA\NgBackup\Service\TargetService; use OCA\NgBackup\Service\TargetService;
use OCP\IUserManager;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
@@ -18,6 +22,7 @@ final class RestoreFull extends Command {
public function __construct( public function __construct(
private TargetService $targets, private TargetService $targets,
private RestoreFullService $restoreFull, private RestoreFullService $restoreFull,
private IUserManager $userManager,
) { ) {
parent::__construct(); parent::__construct();
} }
@@ -27,6 +32,7 @@ final class RestoreFull extends Command {
->setDescription('Disaster recovery: restore the database, data directory and config on a fresh installation') ->setDescription('Disaster recovery: restore the database, data directory and config on a fresh installation')
->addArgument('target', InputArgument::REQUIRED, 'Location name or id') ->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
->addArgument('snapshot', InputArgument::REQUIRED, 'Snapshot id (see backup:list)') ->addArgument('snapshot', InputArgument::REQUIRED, 'Snapshot id (see backup:list)')
->addOption('force', null, InputOption::VALUE_NONE, 'Also on an installation that already has more than one user, and without asking')
->setHelp(<<<'HELP' ->setHelp(<<<'HELP'
For a FRESH Nextcloud installation only, same major version as the backup, right after For a FRESH Nextcloud installation only, same major version as the backup, right after
occ maintenance:install, occ backup:key:import and occ backup:target:add -- before anyone else occ maintenance:install, occ backup:key:import and occ backup:target:add -- before anyone else
@@ -50,12 +56,29 @@ HELP);
} }
protected function execute(InputInterface $input, OutputInterface $output): int { protected function execute(InputInterface $input, OutputInterface $output): int {
$force = (bool)$input->getOption('force');
// A whole-server restore replaces the database and data directory of THIS instance. On
// anything but a fresh install (only the admin from occ maintenance:install) that is
// almost certainly a mistake, so it needs --force.
$users = $this->userManager->countUsersTotal(2);
if (!$force && ($users === false || $users > 1)) {
$output->writeln('<error>This installation already has more than one user: backup:restore:full would replace its whole database and data directory. It is meant for a fresh installation. Use --force if this really is what you want.</error>');
return 1;
}
if (!$force && $input->isInteractive()) {
$q = new ConfirmationQuestion('This replaces the database, the data directory and config.php of this installation. Continue? [y/N] ', false);
if (!(new QuestionHelper())->ask($input, $output, $q)) {
$output->writeln('Cancelled.');
return 1;
}
}
try { try {
$target = $this->targets->get($input->getArgument('target')); $target = $this->targets->get($input->getArgument('target'));
$snapshot = (string)$input->getArgument('snapshot'); $snapshot = (string)$input->getArgument('snapshot');
$result = $this->restoreFull->restoreFull($target, $snapshot); $result = $this->restoreFull->restoreFull($target, $snapshot);
} catch (\Throwable $e) { } catch (\Throwable $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>'); $output->writeln('<error>' . $e->getMessage() . '</error>');
$output->writeln('If this happened after the restore had started, the instance may be partly restored: fix the cause and run the same command again with --force.');
return 1; return 1;
} }
$output->writeln(sprintf( $output->writeln(sprintf(
@@ -65,6 +88,9 @@ HELP);
$result['configKeys'], $result['configKeys'],
$result['dataFiles'], $result['dataFiles'],
)); ));
if (!$result['dbConsistent']) {
$output->writeln('<comment>Warning: this snapshot\'s database dump was made over several steps (web cron), so it is not one consistent point in time. Check the instance carefully; prefer a snapshot made with system cron.</comment>');
}
$output->writeln('Next: check trusted_domains and overwritehost/overwriteprotocol match this server, then occ maintenance:repair.'); $output->writeln('Next: check trusted_domains and overwritehost/overwriteprotocol match this server, then occ maintenance:repair.');
return 0; return 0;
} }
+21 -1
View File
@@ -10,7 +10,10 @@ namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\TargetService; use OCA\NgBackup\Service\TargetService;
use OCA\NgBackup\Service\UserRestoreService; use OCA\NgBackup\Service\UserRestoreService;
use OCA\NgBackup\UserMigration\UserMigrationInstaller; use OCA\NgBackup\UserMigration\UserMigrationInstaller;
use OCP\IUserManager;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
@@ -21,6 +24,7 @@ final class RestoreUser extends Command {
private TargetService $targets, private TargetService $targets,
private UserRestoreService $userRestore, private UserRestoreService $userRestore,
private UserMigrationInstaller $installer, private UserMigrationInstaller $installer,
private IUserManager $userManager,
) { ) {
parent::__construct(); parent::__construct();
} }
@@ -31,6 +35,7 @@ final class RestoreUser extends Command {
->addArgument('target', InputArgument::REQUIRED, 'Location name or id') ->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
->addArgument('manifest', InputArgument::REQUIRED, 'Manifest path printed by backup:user:backup (users/<uid>/<exportId>)') ->addArgument('manifest', InputArgument::REQUIRED, 'Manifest path printed by backup:user:backup (users/<uid>/<exportId>)')
->addOption('mode', 'm', InputOption::VALUE_REQUIRED, 'replace or as-backup', UserRestoreService::MODE_REPLACE) ->addOption('mode', 'm', InputOption::VALUE_REQUIRED, 'replace or as-backup', UserRestoreService::MODE_REPLACE)
->addOption('force', null, InputOption::VALUE_NONE, 'Replace an existing account without asking (required when not interactive)')
->addOption('install-user-migration', null, InputOption::VALUE_NONE, 'Install and enable user_migration if it is missing (non-interactive consent)') ->addOption('install-user-migration', null, InputOption::VALUE_NONE, 'Install and enable user_migration if it is missing (non-interactive consent)')
->setHelp(<<<'HELP' ->setHelp(<<<'HELP'
Never imports over a live account. Modes: Never imports over a live account. Modes:
@@ -47,11 +52,26 @@ HELP);
} }
protected function execute(InputInterface $input, OutputInterface $output): int { protected function execute(InputInterface $input, OutputInterface $output): int {
$mode = (string)$input->getOption('mode');
$uid = explode('/', (string)$input->getArgument('manifest'))[1] ?? '';
if ($mode === UserRestoreService::MODE_REPLACE && $uid !== '' && $this->userManager->userExists($uid) && !$input->getOption('force')) {
// Replace deletes the live account (after a safety export). Shares to others and data of
// apps without a user_migration migrator do not come back, so never do it unasked.
if (!$input->isInteractive()) {
$output->writeln("<error>$uid exists: --mode=replace deletes and recreates it. Add --force, or use --mode=as-backup.</error>");
return 1;
}
$q = new ConfirmationQuestion("$uid exists and will be deleted and recreated from the backup (a safety export is made first; its shares to others and data of apps without a migrator are not restored). Continue? [y/N] ", false);
if (!(new QuestionHelper())->ask($input, $output, $q)) {
$output->writeln('Cancelled.');
return 1;
}
}
try { try {
$output->writeln('<comment>Note: on restore, user_migration only imports the app settings on its own allowlist (e.g. calendar view and reminder settings); other app settings of this user are skipped. Reason: NG Backup uses user_migration\'s importer; widening the list would mean building a settings importer of our own.</comment>'); $output->writeln('<comment>Note: on restore, user_migration only imports the app settings on its own allowlist (e.g. calendar view and reminder settings); other app settings of this user are skipped. Reason: NG Backup uses user_migration\'s importer; widening the list would mean building a settings importer of our own.</comment>');
$target = $this->targets->get($input->getArgument('target')); $target = $this->targets->get($input->getArgument('target'));
$this->installer->ensureEnabled($input, $output); $this->installer->ensureEnabled($input, $output);
$user = $this->userRestore->restoreUser($target, (string)$input->getArgument('manifest'), (string)$input->getOption('mode')); $user = $this->userRestore->restoreUser($target, (string)$input->getArgument('manifest'), $mode);
} catch (\Throwable $e) { } catch (\Throwable $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>'); $output->writeln('<error>' . $e->getMessage() . '</error>');
return 1; return 1;
+1
View File
@@ -168,6 +168,7 @@ final class AdminApiController extends Controller {
return new JSONResponse($this->targets->availableBackends()); return new JSONResponse($this->targets->availableBackends());
} }
#[PasswordConfirmationRequired]
public function addTarget(string $name, string $backend, string $auth, array $options = [], string $path = 'ng_backup', ?float $maxGb = null): JSONResponse { public function addTarget(string $name, string $backend, string $auth, array $options = [], string $path = 'ng_backup', ?float $maxGb = null): JSONResponse {
try { try {
$r = $this->targets->add($name, $backend, $auth, $options, $path); $r = $this->targets->add($name, $backend, $auth, $options, $path);
+6
View File
@@ -96,6 +96,12 @@ final class KeyRing {
/** @param array{v:int, salt:string, ops:int, mem:int, nonce:string, key:string} $wrapped */ /** @param array{v:int, salt:string, ops:int, mem:int, nonce:string, key:string} $wrapped */
public static function unwrap(array $wrapped, #[\SensitiveParameter] string $passphrase): self { public static function unwrap(array $wrapped, #[\SensitiveParameter] string $passphrase): self {
$b64 = static fn (string $s): string => sodium_base642bin($s, SODIUM_BASE64_VARIANT_ORIGINAL); $b64 = static fn (string $s): string => sodium_base642bin($s, SODIUM_BASE64_VARIANT_ORIGINAL);
// ops/mem come from unauthenticated JSON (kit file, repository config): bound them, so a
// damaged or crafted file cannot make the KDF use unbounded memory or time.
if ($wrapped['ops'] < SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE || $wrapped['ops'] > SODIUM_CRYPTO_PWHASH_OPSLIMIT_SENSITIVE
|| $wrapped['mem'] < SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE || $wrapped['mem'] > SODIUM_CRYPTO_PWHASH_MEMLIMIT_SENSITIVE) {
throw new CryptoException('Unsupported key derivation parameters (damaged or foreign key file)');
}
$kek = sodium_crypto_pwhash(SODIUM_CRYPTO_SECRETBOX_KEYBYTES, $passphrase, $b64($wrapped['salt']), $kek = sodium_crypto_pwhash(SODIUM_CRYPTO_SECRETBOX_KEYBYTES, $passphrase, $b64($wrapped['salt']),
$wrapped['ops'], $wrapped['mem'], SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13); $wrapped['ops'], $wrapped['mem'], SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13);
$master = sodium_crypto_secretbox_open($b64($wrapped['key']), $b64($wrapped['nonce']), $kek); $master = sodium_crypto_secretbox_open($b64($wrapped['key']), $b64($wrapped['nonce']), $kek);
+8
View File
@@ -122,6 +122,7 @@ final class StreamCipher {
/** fread() may return short reads on network streams; keep reading until $length or EOF. */ /** fread() may return short reads on network streams; keep reading until $length or EOF. */
private static function readFull($in, int $length): string { private static function readFull($in, int $length): string {
$buffer = ''; $buffer = '';
$empty = 0;
while (strlen($buffer) < $length && !feof($in)) { while (strlen($buffer) < $length && !feof($in)) {
$chunk = fread($in, $length - strlen($buffer)); $chunk = fread($in, $length - strlen($buffer));
if ($chunk === false) { if ($chunk === false) {
@@ -132,8 +133,15 @@ final class StreamCipher {
if (feof($in)) { if (feof($in)) {
break; break;
} }
// A stalled network stream returns '' without EOF forever: back off, then give up
// (about a minute) instead of spinning at full CPU until the lease expires.
if (++$empty > 600) {
throw new CryptoException('Read stalled');
}
usleep(100000);
continue; continue;
} }
$empty = 0;
$buffer .= $chunk; $buffer .= $chunk;
} }
return $buffer; return $buffer;
+5 -1
View File
@@ -256,7 +256,11 @@ final class DbDumper {
} elseif (is_string($row[$i]) && !mb_check_encoding($row[$i], 'UTF-8')) { } elseif (is_string($row[$i]) && !mb_check_encoding($row[$i], 'UTF-8')) {
// Keep invalid UTF-8 byte-exact instead of substituting characters. // Keep invalid UTF-8 byte-exact instead of substituting characters.
$row[$i] = ['$b' => base64_encode($row[$i])]; $row[$i] = ['$b' => base64_encode($row[$i])];
} elseif (is_int($row[$i]) || is_float($row[$i]) || is_bool($row[$i])) { } elseif (is_float($row[$i])) {
// var_export uses serialize_precision (shortest exact form), not the lossy
// 14-digit 'precision' of a (string) cast.
$row[$i] = var_export($row[$i], true);
} elseif (is_int($row[$i]) || is_bool($row[$i])) {
// Normalise: drivers differ in returning numbers as int or string. // Normalise: drivers differ in returning numbers as int or string.
$row[$i] = (string)(is_bool($row[$i]) ? (int)$row[$i] : $row[$i]); $row[$i] = (string)(is_bool($row[$i]) ? (int)$row[$i] : $row[$i]);
} }
+4 -2
View File
@@ -64,7 +64,7 @@ final class RestoreFullService {
]; ];
} }
/** @return array{dbReport:array, dataFiles:int, configKeys:int} */ /** @return array{dbReport:array, dataFiles:int, configKeys:int, dbConsistent:bool} */
public function restoreFull(Target $target, string $snapshotId): array { public function restoreFull(Target $target, string $snapshotId): array {
$check = $this->checkCompatibility($target, $snapshotId); $check = $this->checkCompatibility($target, $snapshotId);
if (!$check['ok']) { if (!$check['ok']) {
@@ -76,6 +76,7 @@ final class RestoreFullService {
$meta = $repo->snapshotMeta($snapshotId); $meta = $repo->snapshotMeta($snapshotId);
$dbReport = null; $dbReport = null;
$manifest = [];
if (isset($meta['db'])) { if (isset($meta['db'])) {
$manifest = json_decode($repo->getObject($meta['db']), true, 512, JSON_THROW_ON_ERROR); $manifest = json_decode($repo->getObject($meta['db']), true, 512, JSON_THROW_ON_ERROR);
$prefix = $this->config->getSystemValueString('dbtableprefix', 'oc_'); $prefix = $this->config->getSystemValueString('dbtableprefix', 'oc_');
@@ -124,7 +125,8 @@ final class RestoreFullService {
); );
} }
return ['dbReport' => $dbReport ?? ['skipped' => [], 'restored' => []], 'dataFiles' => $dataFiles, 'configKeys' => count($backupConfig)]; return ['dbReport' => $dbReport ?? ['skipped' => [], 'restored' => []], 'dataFiles' => $dataFiles, 'configKeys' => count($backupConfig),
'dbConsistent' => (bool)($manifest['consistent'] ?? true)];
}); });
} }
+2 -1
View File
@@ -48,7 +48,8 @@ ok "as-backup restores into <uid>-bak, byte-identical, original account untouche
# replace: damage the account, then restore it in place under the same uid. # replace: damage the account, then restore it in place under the same uid.
$X sh -c "rm '$DATA/$U/files/Docs/f1.bin' && echo damaged > '$DATA/$U/files/Docs/f1.bin'" $X sh -c "rm '$DATA/$U/files/Docs/f1.bin' && echo damaged > '$DATA/$U/files/Docs/f1.bin'"
occ files:scan "$U" >/dev/null occ files:scan "$U" >/dev/null
occ backup:user:restore ci "$MANIFEST" --mode=replace >/dev/null || fail "restore replace" if occ backup:user:restore ci "$MANIFEST" --mode=replace --no-interaction >/dev/null 2>&1; then fail "replace of an existing account ran without --force"; fi
occ backup:user:restore ci "$MANIFEST" --mode=replace --force >/dev/null || fail "restore replace"
occ files:scan "$U" >/dev/null occ files:scan "$U" >/dev/null
SUM_AFTER=$($X sh -c "cd $DATA/$U/files && find . -type f -print0 | sort -z | xargs -0 md5sum | md5sum") SUM_AFTER=$($X sh -c "cd $DATA/$U/files && find . -type f -print0 | sort -z | xargs -0 md5sum | md5sum")
[ "$SUM_BEFORE" = "$SUM_AFTER" ] || fail "files differ after a replace restore" [ "$SUM_BEFORE" = "$SUM_AFTER" ] || fail "files differ after a replace restore"