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:
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
<name>NG Backup</name>
|
<name>NG Backup</name>
|
||||||
<summary>Encrypted, incremental backups of your Nextcloud with restore from the web interface</summary>
|
<summary>Encrypted, incremental backups of your Nextcloud with restore from the web interface</summary>
|
||||||
<description><![CDATA[Work in progress, not ready for use.]]></description>
|
<description><![CDATA[Work in progress, not ready for use.]]></description>
|
||||||
<version>0.0.11</version>
|
<version>0.0.12</version>
|
||||||
<licence>agpl</licence>
|
<licence>agpl</licence>
|
||||||
<author>Dillard Blom</author>
|
<author>Dillard Blom</author>
|
||||||
<namespace>NgBackup</namespace>
|
<namespace>NgBackup</namespace>
|
||||||
|
|||||||
@@ -47,12 +47,26 @@ class BackupJob extends TimedJob {
|
|||||||
$run = $this->backups->step($run, min($deadline, microtime(true) + $budget));
|
$run = $this->backups->step($run, min($deadline, microtime(true) + $budget));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$this->failStaleRestores();
|
||||||
$this->weeklyPrune();
|
$this->weeklyPrune();
|
||||||
foreach ($this->targets->list() as $t) {
|
foreach ($this->targets->list() as $t) {
|
||||||
$this->alerts->checkStale($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. */
|
/** Once a week (after Sunday's backup), apply the retention policy to non-append-only locations. */
|
||||||
private function weeklyPrune(): void {
|
private function weeklyPrune(): void {
|
||||||
$lastWeek = $this->appConfig->getValueString(Application::APP_ID, 'last_prune_week', '');
|
$lastWeek = $this->appConfig->getValueString(Application::APP_ID, 'last_prune_week', '');
|
||||||
|
|||||||
@@ -29,16 +29,22 @@ class RestoreJob extends QueuedJob {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected function run($argument): void {
|
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) {
|
if ($run->getStatus() !== Run::RUNNING) {
|
||||||
return;
|
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 {
|
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']);
|
$result = $this->restore->restoreUserFiles($this->targets->get((string)$run->getTargetId()), $req['snapshot'], $req['path'], $req['mode']);
|
||||||
$run->setStatus(Run::DONE);
|
$run->setStatus(Run::DONE);
|
||||||
$run->setPhase('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]);
|
$this->logger->error('NG Backup restore ' . $run->getId() . ' failed: ' . $e->getMessage(), ['exception' => $e]);
|
||||||
$run->setStatus(Run::FAILED);
|
$run->setStatus(Run::FAILED);
|
||||||
$run->setError($e->getMessage());
|
$run->setError($e->getMessage());
|
||||||
$this->alerts->runFailed($run, '#' . $run->getTargetId());
|
try {
|
||||||
|
$this->alerts->runFailed($run, '#' . $run->getTargetId());
|
||||||
|
} catch (\Throwable) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
$run->setFinishedAt(time());
|
$run->setFinishedAt(time());
|
||||||
$run->setUpdatedAt(time());
|
$run->setUpdatedAt(time());
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ class KeyConfirm extends Command {
|
|||||||
$output->writeln('');
|
$output->writeln('');
|
||||||
$output->writeln(wordwrap(self::STATEMENT, 78));
|
$output->writeln(wordwrap(self::STATEMENT, 78));
|
||||||
$output->writeln('');
|
$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: '));
|
$answer = (string)$this->getHelper('question')->ask($input, $output, new Question('Type "' . self::CONFIRMATION . '" to proceed: '));
|
||||||
if (trim($answer) !== self::CONFIRMATION) {
|
if (trim($answer) !== self::CONFIRMATION) {
|
||||||
$output->writeln('<error>Not confirmed.</error>');
|
$output->writeln('<error>Not confirmed.</error>');
|
||||||
|
|||||||
@@ -97,11 +97,15 @@ class AdminApiController extends Controller {
|
|||||||
return new DataDownloadResponse($json, 'ng_backup-recovery-kit-' . $this->keys->fingerprint() . '.json', 'application/json');
|
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. */
|
/** The admin must tick the statement, send the exact phrase and the code from the kit file. */
|
||||||
public function confirmKit(bool $accepted, string $phrase): JSONResponse {
|
#[PasswordConfirmationRequired]
|
||||||
|
public function confirmKit(bool $accepted, string $phrase, string $code = ''): JSONResponse {
|
||||||
if (!$accepted || $phrase !== KeyConfirm::CONFIRMATION) {
|
if (!$accepted || $phrase !== KeyConfirm::CONFIRMATION) {
|
||||||
return new JSONResponse(['error' => 'Not confirmed'], Http::STATUS_BAD_REQUEST);
|
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');
|
$this->keys->confirmRecoveryKit($this->session->getUser()?->getUID() ?? 'unknown');
|
||||||
return new JSONResponse(['confirmation' => $this->keys->recoveryKitConfirmation()]);
|
return new JSONResponse(['confirmation' => $this->keys->recoveryKitConfirmation()]);
|
||||||
}
|
}
|
||||||
@@ -227,7 +231,7 @@ class AdminApiController extends Controller {
|
|||||||
#[PasswordConfirmationRequired]
|
#[PasswordConfirmationRequired]
|
||||||
public function startRestore(int $targetId, string $snapshotId, string $path, string $mode = RestoreService::MODE_NEW_FOLDER): JSONResponse {
|
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)
|
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);
|
return new JSONResponse(['error' => 'Invalid path or mode'], Http::STATUS_BAD_REQUEST);
|
||||||
}
|
}
|
||||||
$run = new Run();
|
$run = new Run();
|
||||||
|
|||||||
@@ -45,8 +45,9 @@ final class BlobIndex {
|
|||||||
* and only the index files that are not cached yet are downloaded. Cached packs that no
|
* 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.
|
* longer exist on the backend are dropped from the cache.
|
||||||
*/
|
*/
|
||||||
public function load(): void {
|
public function load(bool $verify = false): void {
|
||||||
$cached = $this->cache?->all($this->repositoryId) ?? [];
|
$this->blobs = [];
|
||||||
|
$cached = $verify ? [] : ($this->cache?->all($this->repositoryId) ?? []);
|
||||||
$present = [];
|
$present = [];
|
||||||
$this->downloaded = 0;
|
$this->downloaded = 0;
|
||||||
foreach ($this->backend->list('index') as $path) {
|
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']];
|
$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));
|
$stale = array_keys(array_diff_key($cached, $present));
|
||||||
if ($stale !== []) {
|
if ($stale !== []) {
|
||||||
$this->cache?->remove($this->repositoryId, $stale);
|
$this->cache?->remove($this->repositoryId, $stale);
|
||||||
|
|||||||
@@ -171,6 +171,12 @@ final class Repository {
|
|||||||
if (!is_dir(dirname($dest))) {
|
if (!is_dir(dirname($dest))) {
|
||||||
mkdir(dirname($dest), 0700, true);
|
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
|
// Write next to the destination and rename after all blobs verified: at most one file
|
||||||
// is ever present twice.
|
// is ever present twice.
|
||||||
$tmp = $dest . '.ngb-restore';
|
$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). */
|
/** Small encrypted objects (manifests). */
|
||||||
public function putObject(string $path, string $data): void {
|
public function putObject(string $path, string $data): void {
|
||||||
self::putString($this->backend, $path, $this->cipher->encryptString($data, 'object:' . $path));
|
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}
|
* @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 {
|
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 = [];
|
$used = [];
|
||||||
$ignore = array_flip($ignoreSnapshots); // e.g. snapshots a dry run would forget first
|
$ignore = array_flip($ignoreSnapshots); // e.g. snapshots a dry run would forget first
|
||||||
foreach ($this->listSnapshots() as $sid) {
|
foreach ($this->listSnapshots() as $sid) {
|
||||||
@@ -546,7 +567,12 @@ final class Repository {
|
|||||||
rewind($plain);
|
rewind($plain);
|
||||||
fgets($plain); // meta line
|
fgets($plain); // meta line
|
||||||
while (($line = fgets($plain)) !== false) {
|
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);
|
fclose($plain);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,9 +52,22 @@ class BackupService {
|
|||||||
if ($this->config->getSystemValue('objectstore', null) !== null) {
|
if ($this->config->getSystemValue('objectstore', null) !== null) {
|
||||||
throw new \RuntimeException('Object storage as primary storage is not supported yet');
|
throw new \RuntimeException('Object storage as primary storage is not supported yet');
|
||||||
}
|
}
|
||||||
if ($this->locking->isLocked(PruneService::lockKey($target), ILockingProvider::LOCK_EXCLUSIVE)) {
|
// Check and create the run under the location's exclusive lock, so a cleanup cannot start
|
||||||
throw new \RuntimeException('A cleanup of ' . $target->getName() . ' is running; try again later');
|
// 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);
|
$running = $this->runs->findRunning($target->getId(), self::KIND_FULL);
|
||||||
if ($running !== []) {
|
if ($running !== []) {
|
||||||
return $running[0];
|
return $running[0];
|
||||||
@@ -96,6 +109,14 @@ class BackupService {
|
|||||||
} catch (LockedException) {
|
} catch (LockedException) {
|
||||||
return $run;
|
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 {
|
try {
|
||||||
$run = $this->runs->find($run->getId()); // fresh state after acquiring the lock
|
$run = $this->runs->find($run->getId()); // fresh state after acquiring the lock
|
||||||
if ($run->getStatus() !== Run::RUNNING || $run->getKind() !== self::KIND_FULL) {
|
if ($run->getStatus() !== Run::RUNNING || $run->getKind() !== self::KIND_FULL) {
|
||||||
@@ -140,6 +161,7 @@ class BackupService {
|
|||||||
}
|
}
|
||||||
return $run;
|
return $run;
|
||||||
} finally {
|
} finally {
|
||||||
|
$this->locking->releaseLock($targetLock, ILockingProvider::LOCK_SHARED);
|
||||||
$this->locking->releaseLock($lockKey, ILockingProvider::LOCK_EXCLUSIVE);
|
$this->locking->releaseLock($lockKey, ILockingProvider::LOCK_EXCLUSIVE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class KeyService {
|
|||||||
private const KEY_FINGERPRINT = 'master_key_fingerprint';
|
private const KEY_FINGERPRINT = 'master_key_fingerprint';
|
||||||
private const KEY_DELETE_DELAY = 'delete_delay_days';
|
private const KEY_DELETE_DELAY = 'delete_delay_days';
|
||||||
private const KEY_KIT_VERSION = 'recovery_kit_version';
|
private const KEY_KIT_VERSION = 'recovery_kit_version';
|
||||||
|
private const KEY_KIT_CODE = 'recovery_kit_code';
|
||||||
public const DEFAULT_DELETE_DELAY = 7;
|
public const DEFAULT_DELETE_DELAY = 7;
|
||||||
|
|
||||||
private ?KeyRing $cached = null;
|
private ?KeyRing $cached = null;
|
||||||
@@ -188,6 +189,7 @@ class KeyService {
|
|||||||
'app' => 'NG Backup',
|
'app' => 'NG Backup',
|
||||||
'fingerprint' => $this->fingerprint(),
|
'fingerprint' => $this->fingerprint(),
|
||||||
'kit_version' => $this->kitVersion(),
|
'kit_version' => $this->kitVersion(),
|
||||||
|
'confirmation_code' => $this->kitCode(),
|
||||||
'created' => gmdate('c'),
|
'created' => gmdate('c'),
|
||||||
'wrapped_key' => $this->wrappedKey(),
|
'wrapped_key' => $this->wrappedKey(),
|
||||||
'slots' => $this->slots(),
|
'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 {
|
public function confirmRecoveryKit(string $uid): void {
|
||||||
$this->appConfig->setValueString(Application::APP_ID, self::KEY_CONFIRMED,
|
$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);
|
json_encode(['uid' => $uid, 'time' => time(), 'fingerprint' => $this->fingerprint(), 'kit_version' => $this->kitVersion()], JSON_THROW_ON_ERROR), true);
|
||||||
|
|||||||
@@ -33,6 +33,31 @@ class PruneService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function untrash(Target $target, string $snapshotId): void {
|
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);
|
$repo = $this->targets->repository($target);
|
||||||
if (!array_key_exists($snapshotId, $repo->trash())) {
|
if (!array_key_exists($snapshotId, $repo->trash())) {
|
||||||
throw new \InvalidArgumentException('That snapshot is not in the trash (or was already deleted)');
|
throw new \InvalidArgumentException('That snapshot is not in the trash (or was already deleted)');
|
||||||
@@ -84,14 +109,7 @@ class PruneService {
|
|||||||
if ($target->getAppendOnly()) {
|
if ($target->getAppendOnly()) {
|
||||||
throw new \RuntimeException('Location ' . $target->getName() . ' is append-only: NG Backup does not delete anything there');
|
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) !== []) {
|
$this->lockExclusive($target);
|
||||||
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');
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
$repo = $this->targets->repository($target);
|
$repo = $this->targets->repository($target);
|
||||||
$times = [];
|
$times = [];
|
||||||
|
|||||||
@@ -36,9 +36,25 @@ class RestoreService {
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private TargetService $targets,
|
private TargetService $targets,
|
||||||
private IRootFolder $root,
|
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.
|
* 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}
|
* @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 {
|
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>');
|
throw new \InvalidArgumentException('Expected a path like data/<user>/files/<folder or file>');
|
||||||
}
|
}
|
||||||
[$uid, $rel] = [$m[1], trim($m[2] ?? '', '/')];
|
[$uid, $rel] = [$m[1], trim($m[2] ?? '', '/')];
|
||||||
@@ -127,7 +147,7 @@ class RestoreService {
|
|||||||
if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) {
|
if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) {
|
||||||
throw new \RuntimeException("Cannot create $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 {
|
private function writeFile(Repository $repo, Folder $dest, string $relPath, array $entry): void {
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@ export default {
|
|||||||
trash: (targetId) => call('get', `/targets/${targetId}/trash`),
|
trash: (targetId) => call('get', `/targets/${targetId}/trash`),
|
||||||
untrash: (targetId, snapshotId) => call('post', `/targets/${targetId}/trash/${snapshotId}`),
|
untrash: (targetId, snapshotId) => call('post', `/targets/${targetId}/trash/${snapshotId}`),
|
||||||
kitUrl: () => url('/key/kit'),
|
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'),
|
backends: () => call('get', '/backends'),
|
||||||
addTarget: (target) => call('post', '/targets', target),
|
addTarget: (target) => call('post', '/targets', target),
|
||||||
removeTarget: (id) => call('delete', `/targets/${id}`),
|
removeTarget: (id) => call('delete', `/targets/${id}`),
|
||||||
|
|||||||
@@ -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.') }}
|
: t('ng_backup', 'Before the first backup: download the recovery kit and store it, together with the passphrase, somewhere safe outside this server.') }}
|
||||||
</NcNoteCard>
|
</NcNoteCard>
|
||||||
<NcButton @click="downloadKit">{{ t('ng_backup', 'Download recovery kit') }}</NcButton>
|
<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>
|
<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>
|
</div>
|
||||||
<p v-else>
|
<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() }) }}
|
{{ 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 slotForm = ref(null)
|
||||||
const downloaded = ref(false)
|
const downloaded = ref(false)
|
||||||
const accepted = ref(false)
|
const accepted = ref(false)
|
||||||
|
const code = ref('')
|
||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const kitUrl = api.kitUrl()
|
const kitUrl = api.kitUrl()
|
||||||
@@ -148,18 +151,10 @@ const removeSlot = (slot) => guarded(async () => {
|
|||||||
downloaded.value = accepted.value = false
|
downloaded.value = accepted.value = false
|
||||||
})
|
})
|
||||||
|
|
||||||
async function confirm() {
|
const confirm = () => guarded(async () => {
|
||||||
busy.value = true
|
await api.confirmKit(accepted.value, props.status.confirmationPhrase, code.value)
|
||||||
error.value = ''
|
code.value = ''
|
||||||
try {
|
})
|
||||||
await api.confirmKit(accepted.value, props.status.confirmationPhrase)
|
|
||||||
emit('changed')
|
|
||||||
} catch (e) {
|
|
||||||
error.value = e.message
|
|
||||||
} finally {
|
|
||||||
busy.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -120,4 +120,30 @@ class RepositoryTest extends TestCase {
|
|||||||
$this->expectException(CryptoException::class);
|
$this->expectException(CryptoException::class);
|
||||||
$repo->getObject('db/2');
|
$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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user