Fixes from the phase-1 code review

- one locking protocol per location: backup steps and restores hold a
  shared lock, prune and untrash an exclusive one and refuse while any
  run (backup, restore, export) exists; starting a backup checks and
  creates the run under the exclusive lock, closing the race with prune
- snapshot entries are validated when read (relative path without '.',
  '..', NUL, backslash or control characters); raw restore also checks
  that the resolved directory stays below the target; the restore API
  and service accept canonical paths only
- recovery kit confirmation requires a recent password confirmation and
  the confirmation code printed in the current kit file (new code per
  kit version), proving the kit was actually downloaded
- prune decides on the authenticated index files on the location and
  refreshes the local index cache from them
- restore job marks the run failed on any error; restore runs without
  progress for 6 hours are failed so they cannot block cleanup forever
- tests for safe paths and a malicious snapshot path
This commit is contained in:
2026-10-02 11:21:40 +02:00
parent d3aa07b106
commit ac82b38e37
14 changed files with 226 additions and 40 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
<name>NG Backup</name>
<summary>Encrypted, incremental backups of your Nextcloud with restore from the web interface</summary>
<description><![CDATA[Work in progress, not ready for use.]]></description>
<version>0.0.11</version>
<version>0.0.12</version>
<licence>agpl</licence>
<author>Dillard Blom</author>
<namespace>NgBackup</namespace>
+14
View File
@@ -47,12 +47,26 @@ class BackupJob extends TimedJob {
$run = $this->backups->step($run, min($deadline, microtime(true) + $budget));
}
}
$this->failStaleRestores();
$this->weeklyPrune();
foreach ($this->targets->list() as $t) {
$this->alerts->checkStale($t);
}
}
/** A restore run that has not moved for 6 hours is considered dead (it would block cleanup forever). */
private function failStaleRestores(): void {
foreach ($this->runs->findRunning(null, 'restore') as $run) {
if (time() - $run->getUpdatedAt() > 6 * 3600) {
$run->setStatus(\OCA\NgBackup\Db\Run::FAILED);
$run->setError('No progress for 6 hours; marked as failed');
$run->setFinishedAt(time());
$this->runs->update($run);
$this->alerts->runFailed($run, '#' . $run->getTargetId());
}
}
}
/** Once a week (after Sunday's backup), apply the retention policy to non-append-only locations. */
private function weeklyPrune(): void {
$lastWeek = $this->appConfig->getValueString(Application::APP_ID, 'last_prune_week', '');
+16 -7
View File
@@ -29,16 +29,22 @@ class RestoreJob extends QueuedJob {
}
protected function run($argument): void {
$run = $this->runs->find((int)$argument['run']);
$runId = (int)($argument['run'] ?? 0);
try {
$run = $this->runs->find($runId);
} catch (\Throwable $e) {
$this->logger->error('NG Backup restore job: run ' . $runId . ' not found', ['exception' => $e]);
return;
}
if ($run->getStatus() !== Run::RUNNING) {
return;
}
$req = json_decode($run->getState(), true, 512, JSON_THROW_ON_ERROR);
$run->setPhase('restoring');
$run->setUpdatedAt(time());
$this->runs->update($run);
@set_time_limit(0);
try {
$req = json_decode($run->getState(), true, 512, JSON_THROW_ON_ERROR);
$run->setPhase('restoring');
$run->setUpdatedAt(time());
$run = $this->runs->update($run);
@set_time_limit(0);
$result = $this->restore->restoreUserFiles($this->targets->get((string)$run->getTargetId()), $req['snapshot'], $req['path'], $req['mode']);
$run->setStatus(Run::DONE);
$run->setPhase('done');
@@ -47,7 +53,10 @@ class RestoreJob extends QueuedJob {
$this->logger->error('NG Backup restore ' . $run->getId() . ' failed: ' . $e->getMessage(), ['exception' => $e]);
$run->setStatus(Run::FAILED);
$run->setError($e->getMessage());
$this->alerts->runFailed($run, '#' . $run->getTargetId());
try {
$this->alerts->runFailed($run, '#' . $run->getTargetId());
} catch (\Throwable) {
}
}
$run->setFinishedAt(time());
$run->setUpdatedAt(time());
+5
View File
@@ -40,6 +40,11 @@ class KeyConfirm extends Command {
$output->writeln('');
$output->writeln(wordwrap(self::STATEMENT, 78));
$output->writeln('');
$code = (string)$this->getHelper('question')->ask($input, $output, new Question('Confirmation code from the recovery kit file ("confirmation_code"): '));
if (!$this->keys->checkKitCode($code)) {
$output->writeln('<error>That code does not match the current recovery kit (download it with occ backup:key:kit first).</error>');
return 1;
}
$answer = (string)$this->getHelper('question')->ask($input, $output, new Question('Type "' . self::CONFIRMATION . '" to proceed: '));
if (trim($answer) !== self::CONFIRMATION) {
$output->writeln('<error>Not confirmed.</error>');
+7 -3
View File
@@ -97,11 +97,15 @@ class AdminApiController extends Controller {
return new DataDownloadResponse($json, 'ng_backup-recovery-kit-' . $this->keys->fingerprint() . '.json', 'application/json');
}
/** The admin must tick the statement and send the exact confirmation phrase. */
public function confirmKit(bool $accepted, string $phrase): JSONResponse {
/** The admin must tick the statement, send the exact phrase and the code from the kit file. */
#[PasswordConfirmationRequired]
public function confirmKit(bool $accepted, string $phrase, string $code = ''): JSONResponse {
if (!$accepted || $phrase !== KeyConfirm::CONFIRMATION) {
return new JSONResponse(['error' => 'Not confirmed'], Http::STATUS_BAD_REQUEST);
}
if (!$this->keys->checkKitCode($code)) {
return new JSONResponse(['error' => 'The confirmation code does not match the current recovery kit'], Http::STATUS_BAD_REQUEST);
}
$this->keys->confirmRecoveryKit($this->session->getUser()?->getUID() ?? 'unknown');
return new JSONResponse(['confirmation' => $this->keys->recoveryKitConfirmation()]);
}
@@ -227,7 +231,7 @@ class AdminApiController extends Controller {
#[PasswordConfirmationRequired]
public function startRestore(int $targetId, string $snapshotId, string $path, string $mode = RestoreService::MODE_NEW_FOLDER): JSONResponse {
if (!in_array($mode, [RestoreService::MODE_NEW_FOLDER, RestoreService::MODE_MERGE, RestoreService::MODE_REPLACE], true)
|| !preg_match('#^data/[^/]+/files(/.*)?$#', $path)) {
|| !\OCA\NgBackup\Repository\Repository::isSafePath($path) || !preg_match('#^data/[^/]+/files(/.*)?$#', $path)) {
return new JSONResponse(['error' => 'Invalid path or mode'], Http::STATUS_BAD_REQUEST);
}
$run = new Run();
+17 -2
View File
@@ -45,8 +45,9 @@ final class BlobIndex {
* and only the index files that are not cached yet are downloaded. Cached packs that no
* longer exist on the backend are dropped from the cache.
*/
public function load(): void {
$cached = $this->cache?->all($this->repositoryId) ?? [];
public function load(bool $verify = false): void {
$this->blobs = [];
$cached = $verify ? [] : ($this->cache?->all($this->repositoryId) ?? []);
$present = [];
$this->downloaded = 0;
foreach ($this->backend->list('index') as $path) {
@@ -65,6 +66,20 @@ final class BlobIndex {
$this->blobs[$e['id']] = [$packId, $e['offset'], $e['length'], $e['raw'], $e['flags']];
}
}
if ($verify && $this->cache !== null) {
// Refresh the cache from what was just verified.
$this->cache->remove($this->repositoryId, array_keys($this->cache->all($this->repositoryId)));
foreach ($this->packs() as $packId => $_) {
$entries = [];
foreach ($this->blobs as $id => [$pack, $offset, $length, $raw, $flags]) {
if ($pack === $packId) {
$entries[] = ['id' => $id, 'offset' => $offset, 'length' => $length, 'raw' => $raw, 'flags' => $flags];
}
}
$this->cache->put($this->repositoryId, $packId, $entries);
}
return;
}
$stale = array_keys(array_diff_key($cached, $present));
if ($stale !== []) {
$this->cache?->remove($this->repositoryId, $stale);
+27 -1
View File
@@ -171,6 +171,12 @@ final class Repository {
if (!is_dir(dirname($dest))) {
mkdir(dirname($dest), 0700, true);
}
// Defence in depth: the resolved directory must stay below the target (no symlink escapes).
$base = realpath($target);
$dir = realpath(dirname($dest));
if ($base === false || $dir === false || ($dir !== $base && !str_starts_with($dir, $base . '/')) || is_link($dest)) {
throw new RepositoryException('Refusing to write outside the restore directory: ' . $entry['p']);
}
// Write next to the destination and rename after all blobs verified: at most one file
// is ever present twice.
$tmp = $dest . '.ngb-restore';
@@ -268,6 +274,19 @@ final class Repository {
}
}
/** Relative path of non-empty segments, without ".", "..", NUL, backslash or control characters. */
public static function isSafePath(string $path): bool {
if ($path === '' || str_starts_with($path, '/') || preg_match('/[\x00-\x1f\\\\]/', $path)) {
return false;
}
foreach (explode('/', $path) as $segment) {
if ($segment === '' || $segment === '.' || $segment === '..') {
return false;
}
}
return true;
}
/** Small encrypted objects (manifests). */
public function putObject(string $path, string $data): void {
self::putString($this->backend, $path, $this->cipher->encryptString($data, 'object:' . $path));
@@ -352,6 +371,8 @@ final class Repository {
* @return array{packs:int, deleted:int, repacked:int, freedBytes:int, keptBlobs:int, unusedBlobs:int}
*/
public function prune(bool $dryRun = false, float $repackThreshold = 0.5, array $ignoreSnapshots = []): array {
// Deleting is decided on the authenticated index files on the location, never on the local cache.
$this->index->load(true);
$used = [];
$ignore = array_flip($ignoreSnapshots); // e.g. snapshots a dry run would forget first
foreach ($this->listSnapshots() as $sid) {
@@ -546,7 +567,12 @@ final class Repository {
rewind($plain);
fgets($plain); // meta line
while (($line = fgets($plain)) !== false) {
yield json_decode($line, true, 512, JSON_THROW_ON_ERROR);
$entry = json_decode($line, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($entry) || !is_string($entry['p'] ?? null) || !self::isSafePath($entry['p'])
|| !is_int($entry['s'] ?? null) || !is_array($entry['b'] ?? null)) {
throw new RepositoryException('Invalid entry in snapshot ' . $snapshotId);
}
yield $entry;
}
fclose($plain);
}
+24 -2
View File
@@ -52,9 +52,22 @@ class BackupService {
if ($this->config->getSystemValue('objectstore', null) !== null) {
throw new \RuntimeException('Object storage as primary storage is not supported yet');
}
if ($this->locking->isLocked(PruneService::lockKey($target), ILockingProvider::LOCK_EXCLUSIVE)) {
throw new \RuntimeException('A cleanup of ' . $target->getName() . ' is running; try again later');
// Check and create the run under the location's exclusive lock, so a cleanup cannot start
// in between (it takes the same lock and refuses while a run exists).
$lock = PruneService::lockKey($target);
try {
$this->locking->acquireLock($lock, ILockingProvider::LOCK_EXCLUSIVE);
} catch (LockedException) {
throw new \RuntimeException('A cleanup or another start for ' . $target->getName() . ' is running; try again later');
}
try {
return $this->createRun($target, $label);
} finally {
$this->locking->releaseLock($lock, ILockingProvider::LOCK_EXCLUSIVE);
}
}
private function createRun(Target $target, string $label): Run {
$running = $this->runs->findRunning($target->getId(), self::KIND_FULL);
if ($running !== []) {
return $running[0];
@@ -96,6 +109,14 @@ class BackupService {
} catch (LockedException) {
return $run;
}
// Shared lock on the location while this step writes: a cleanup (exclusive) cannot run now.
$targetLock = 'ng_backup/target/' . $run->getTargetId();
try {
$this->locking->acquireLock($targetLock, ILockingProvider::LOCK_SHARED);
} catch (LockedException) {
$this->locking->releaseLock($lockKey, ILockingProvider::LOCK_EXCLUSIVE);
return $run;
}
try {
$run = $this->runs->find($run->getId()); // fresh state after acquiring the lock
if ($run->getStatus() !== Run::RUNNING || $run->getKind() !== self::KIND_FULL) {
@@ -140,6 +161,7 @@ class BackupService {
}
return $run;
} finally {
$this->locking->releaseLock($targetLock, ILockingProvider::LOCK_SHARED);
$this->locking->releaseLock($lockKey, ILockingProvider::LOCK_EXCLUSIVE);
}
}
+32
View File
@@ -29,6 +29,7 @@ class KeyService {
private const KEY_FINGERPRINT = 'master_key_fingerprint';
private const KEY_DELETE_DELAY = 'delete_delay_days';
private const KEY_KIT_VERSION = 'recovery_kit_version';
private const KEY_KIT_CODE = 'recovery_kit_code';
public const DEFAULT_DELETE_DELAY = 7;
private ?KeyRing $cached = null;
@@ -188,6 +189,7 @@ class KeyService {
'app' => 'NG Backup',
'fingerprint' => $this->fingerprint(),
'kit_version' => $this->kitVersion(),
'confirmation_code' => $this->kitCode(),
'created' => gmdate('c'),
'wrapped_key' => $this->wrappedKey(),
'slots' => $this->slots(),
@@ -197,6 +199,36 @@ class KeyService {
];
}
/**
* Short code printed in the current kit file. Typing it back when confirming proves the
* admin actually has the downloaded kit. A new code per kit version; the server only keeps
* its own copy encrypted with the instance secret.
*/
private function kitCode(): string {
$stored = json_decode($this->appConfig->getValueString(Application::APP_ID, self::KEY_KIT_CODE, '{}', true), true) ?: [];
if (($stored['version'] ?? 0) === $this->kitVersion() && isset($stored['code'])) {
return $this->crypto->decrypt($stored['code']);
}
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no 0/O, 1/I
$code = '';
for ($i = 0; $i < 8; $i++) {
$code .= $alphabet[random_int(0, strlen($alphabet) - 1)];
}
$code = substr($code, 0, 4) . '-' . substr($code, 4);
$this->appConfig->setValueString(Application::APP_ID, self::KEY_KIT_CODE,
json_encode(['version' => $this->kitVersion(), 'code' => $this->crypto->encrypt($code)], JSON_THROW_ON_ERROR), true, true);
return $code;
}
public function checkKitCode(string $code): bool {
$stored = json_decode($this->appConfig->getValueString(Application::APP_ID, self::KEY_KIT_CODE, '{}', true), true) ?: [];
if (($stored['version'] ?? 0) !== $this->kitVersion() || !isset($stored['code'])) {
return false; // no kit downloaded for this version yet
}
$normalise = static fn (string $c) => strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $c) ?? '');
return hash_equals($normalise($this->crypto->decrypt($stored['code'])), $normalise($code));
}
public function confirmRecoveryKit(string $uid): void {
$this->appConfig->setValueString(Application::APP_ID, self::KEY_CONFIRMED,
json_encode(['uid' => $uid, 'time' => time(), 'fingerprint' => $this->fingerprint(), 'kit_version' => $this->kitVersion()], JSON_THROW_ON_ERROR), true);
+26 -8
View File
@@ -33,6 +33,31 @@ class PruneService {
}
public function untrash(Target $target, string $snapshotId): void {
$this->lockExclusive($target);
try {
$this->doUntrash($target, $snapshotId);
} finally {
$this->locking->releaseLock(self::lockKey($target), ILockingProvider::LOCK_EXCLUSIVE);
}
}
/**
* Exclusive lock on the location, then make sure no run of any kind (backup, restore, export)
* is in progress: their data may not be referenced by a snapshot yet, or is being read.
*/
private function lockExclusive(Target $target): void {
try {
$this->locking->acquireLock(self::lockKey($target), ILockingProvider::LOCK_EXCLUSIVE);
} catch (LockedException) {
throw new \RuntimeException('A backup, restore or cleanup of ' . $target->getName() . ' is in progress; try again later');
}
if ($this->runs->findRunning($target->getId()) !== []) {
$this->locking->releaseLock(self::lockKey($target), ILockingProvider::LOCK_EXCLUSIVE);
throw new \RuntimeException('A backup or restore of ' . $target->getName() . ' is running; try again later');
}
}
private function doUntrash(Target $target, string $snapshotId): void {
$repo = $this->targets->repository($target);
if (!array_key_exists($snapshotId, $repo->trash())) {
throw new \InvalidArgumentException('That snapshot is not in the trash (or was already deleted)');
@@ -84,14 +109,7 @@ class PruneService {
if ($target->getAppendOnly()) {
throw new \RuntimeException('Location ' . $target->getName() . ' is append-only: NG Backup does not delete anything there');
}
if ($this->runs->findRunning($target->getId(), BackupService::KIND_FULL) !== []) {
throw new \RuntimeException('A backup to ' . $target->getName() . ' is running; try again later');
}
try {
$this->locking->acquireLock(self::lockKey($target), ILockingProvider::LOCK_EXCLUSIVE);
} catch (LockedException) {
throw new \RuntimeException('Another backup or cleanup of ' . $target->getName() . ' is running');
}
$this->lockExclusive($target);
try {
$repo = $this->targets->repository($target);
$times = [];
+22 -2
View File
@@ -36,9 +36,25 @@ class RestoreService {
public function __construct(
private TargetService $targets,
private IRootFolder $root,
private \OCP\Lock\ILockingProvider $locking,
) {
}
/** Shared lock on the location while restoring: a cleanup (exclusive) must not delete what is being read. */
private function withSharedLock(Target $target, callable $fn): mixed {
$key = PruneService::lockKey($target);
try {
$this->locking->acquireLock($key, \OCP\Lock\ILockingProvider::LOCK_SHARED);
} catch (\OCP\Lock\LockedException) {
throw new \RuntimeException('A cleanup of ' . $target->getName() . ' is running; try again later');
}
try {
return $fn();
} finally {
$this->locking->releaseLock($key, \OCP\Lock\ILockingProvider::LOCK_SHARED);
}
}
/**
* Direct children (files and folders) of a path in a snapshot.
*
@@ -72,7 +88,11 @@ class RestoreService {
* @return array{restored:int, trashed:int, target:string}
*/
public function restoreUserFiles(Target $target, string $snapshotId, string $logicalPath, string $mode = self::MODE_NEW_FOLDER, ?callable $progress = null): array {
if (!preg_match('#^data/([^/]+)/files(?:/(.*))?$#', trim($logicalPath, '/'), $m)) {
return $this->withSharedLock($target, fn () => $this->doRestoreUserFiles($target, $snapshotId, $logicalPath, $mode, $progress));
}
private function doRestoreUserFiles(Target $target, string $snapshotId, string $logicalPath, string $mode, ?callable $progress): array {
if (!Repository::isSafePath(trim($logicalPath, '/')) || !preg_match('#^data/([^/]+)/files(?:/(.*))?$#', trim($logicalPath, '/'), $m)) {
throw new \InvalidArgumentException('Expected a path like data/<user>/files/<folder or file>');
}
[$uid, $rel] = [$m[1], trim($m[2] ?? '', '/')];
@@ -127,7 +147,7 @@ class RestoreService {
if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) {
throw new \RuntimeException("Cannot create $directory");
}
return $this->targets->repository($target)->restore($snapshotId, $directory, trim($prefix, '/') === '' ? '' : trim($prefix, '/') . '/');
return $this->withSharedLock($target, fn () => $this->targets->repository($target)->restore($snapshotId, $directory, trim($prefix, '/') === '' ? '' : trim($prefix, '/') . '/'));
}
private function writeFile(Repository $repo, Folder $dest, string $relPath, array $entry): void {
+1 -1
View File
@@ -24,7 +24,7 @@ export default {
trash: (targetId) => call('get', `/targets/${targetId}/trash`),
untrash: (targetId, snapshotId) => call('post', `/targets/${targetId}/trash/${snapshotId}`),
kitUrl: () => url('/key/kit'),
confirmKit: (accepted, phrase) => call('post', '/key/confirm', { accepted, phrase }),
confirmKit: (accepted, phrase, code) => call('post', '/key/confirm', { accepted, phrase, code }),
backends: () => call('get', '/backends'),
addTarget: (target) => call('post', '/targets', target),
removeTarget: (id) => call('delete', `/targets/${id}`),
+8 -13
View File
@@ -63,8 +63,10 @@
: t('ng_backup', 'Before the first backup: download the recovery kit and store it, together with the passphrase, somewhere safe outside this server.') }}
</NcNoteCard>
<NcButton @click="downloadKit">{{ t('ng_backup', 'Download recovery kit') }}</NcButton>
<NcTextField v-model="code" :disabled="!downloaded" :label="t('ng_backup', 'Confirmation code from the kit file')"
:helper-text="t('ng_backup', 'Open the downloaded file and type the value of confirmation_code, e.g. ABCD-2345.')" />
<NcCheckboxRadioSwitch v-model="accepted" :disabled="!downloaded">{{ status.statement }}</NcCheckboxRadioSwitch>
<NcButton variant="primary" :disabled="!downloaded || !accepted || busy" @click="confirm">{{ status.confirmationPhrase }}</NcButton>
<NcButton variant="primary" :disabled="!downloaded || !accepted || code.length < 8 || busy" @click="confirm">{{ status.confirmationPhrase }}</NcButton>
</div>
<p v-else>
{{ t('ng_backup', 'Recovery kit confirmed by {user} on {date}.', { user: status.key.confirmation.uid, date: new Date(status.key.confirmation.time * 1000).toLocaleString() }) }}
@@ -99,6 +101,7 @@ const delay = ref(props.status.key.deleteDelayDays)
const slotForm = ref(null)
const downloaded = ref(false)
const accepted = ref(false)
const code = ref('')
const busy = ref(false)
const error = ref('')
const kitUrl = api.kitUrl()
@@ -148,18 +151,10 @@ const removeSlot = (slot) => guarded(async () => {
downloaded.value = accepted.value = false
})
async function confirm() {
busy.value = true
error.value = ''
try {
await api.confirmKit(accepted.value, props.status.confirmationPhrase)
emit('changed')
} catch (e) {
error.value = e.message
} finally {
busy.value = false
}
}
const confirm = () => guarded(async () => {
await api.confirmKit(accepted.value, props.status.confirmationPhrase, code.value)
code.value = ''
})
</script>
<style scoped>
+26
View File
@@ -120,4 +120,30 @@ class RepositoryTest extends TestCase {
$this->expectException(CryptoException::class);
$repo->getObject('db/2');
}
public function testSafePaths(): void {
foreach (['data/alice/files/a.txt', 'config/config.php', 'a b/c-d_e.f'] as $ok) {
$this->assertTrue(Repository::isSafePath($ok), $ok);
}
foreach (['', '/etc/passwd', '../x', 'a/../b', 'a/./b', 'a//b', 'a/', "a\0b", 'a\\b', "a\nb"] as $bad) {
$this->assertFalse(Repository::isSafePath($bad), json_encode($bad));
}
}
public function testMaliciousSnapshotPathIsRejectedOnRestore(): void {
$repo = Repository::init($this->backend, 'pw');
$stats = [];
$blob = $repo->storeData('evil', $stats);
$repo->flushPacks();
$repo->writeSnapshot('bad', ['time' => gmdate('c')], [json_encode(['p' => '../../escaped.txt', 's' => 4, 'm' => 0, 'b' => [$blob]])]);
$target = $this->tempDir() . '/inner';
mkdir($target);
try {
$repo->restore('bad', $target);
$this->fail('restore of a ../ path must fail');
} catch (RepositoryException) {
}
$this->assertFileDoesNotExist(dirname($target, 2) . '/escaped.txt');
$this->assertFileDoesNotExist(dirname($target) . '/escaped.txt');
}
}