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 075f2fac6f
commit 8ef9dc0f35
14 changed files with 226 additions and 40 deletions
+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);
}