Fix plugin archive restore skipping not-yet-created nested directories (#8398)

Restoring a plugin from an archive, or installing one downloaded from
GitHub into the database, extracts the tgz to a temp file and writes each
entry under plugins/<name>/. validate_relative_path_within() anchored a
not-yet-created entry on its immediate parent directory, so every entry in
a subdirectory the restore had not created yet (includes/, css/, tests/,
docs/, ...) resolved against a missing path, failed the containment check
and was skipped. The plugin was left with only its top-level files and
setup.php then fatally required the missing includes/database.php.

Judge a not-yet-created entry by its deepest existing ancestor instead,
mirroring data_source_path_within_rra(). Every path segment is already a
validated plain name and no walked segment may be a symlink, so the full
candidate still stays within the base; traversal, absolute, drive-letter,
null-byte and symlink entries remain rejected.
This commit is contained in:
TheWitness authored and GitHub committed 2026-10-15 14:14:19 -04:00
1 parent 4e09327660
commit 433af99661
2 files changed
+40 -14

No files matched your search

+21 -12
View File
@@ -10779,7 +10779,10 @@ function cacti_path_is_within(string $candidate, string $base, ?bool $windows =
* validate_relative_path_within - validates an untrusted relative path
*
* Rejects absolute paths, drive letters, empty or dot segments, and symlinked
* segments under the base, then confirms the result resolves inside the base.
* segments under the base, then confirms the deepest existing ancestor resolves
* inside the base. A not-yet-created entry (including nested directories that
* an archive restore has not built yet) is allowed, mirroring
* data_source_path_within_rra().
*
* @param mixed $path The untrusted relative path
* @param string $base_dir The base directory the path must stay within
@@ -10815,29 +10818,35 @@ function validate_relative_path_within(mixed $path, string $base_dir) : mixed {
$candidate = $base_real . '/' . implode('/', $parts);
// block symlink pivots under writable base paths
$walk = $base_real;
// block symlink pivots under writable base paths, and remember the deepest
// ancestor that actually exists
$walk = $base_real;
$deepest_existing = $base_real;
foreach ($parts as $part) {
$walk .= '/' . $part;
// is_link() before file_exists(): file_exists() follows the link and is
// false for a dangling symlink, so a symlink segment whose target does
// not exist yet would otherwise be accepted.
if (is_link($walk)) {
return false;
}
if (file_exists($walk)) {
$deepest_existing = $walk;
}
}
/**
* An entry that does not exist yet is judged by its parent directory.
* cacti_path_is_within() already fails closed when realpath() cannot
* resolve either side, so both cases share one check.
* A not-yet-created entry is judged by its deepest existing ancestor, so a
* nested archive directory that has not been created yet is not mistaken
* for an escape. Every segment is a plain name and no walked segment is a
* symlink, so the full candidate stays within the base.
*/
$anchor = file_exists($candidate) ? $candidate : dirname($candidate);
$resolved = realpath($deepest_existing);
if (!cacti_path_is_within($anchor, $base_real)) {
return false;
}
return $candidate;
return $resolved !== false && cacti_path_is_within($resolved, $base_real) ? $candidate : false;
}
/**
+19 -2
View File
@@ -101,8 +101,25 @@ test('a dangling symlink is rejected as the final or an intermediate segment', f
->toBe(realpath($this->base) . '/nested/present.txt');
});
test('a path whose parent cannot be resolved is rejected', function () : void {
expect(validate_relative_path_within('missing_dir/child/file.txt', $this->base))->toBeFalse();
test('a not-yet-created nested path is accepted so archive directories can be built', function () : void {
// Regression: a plugin restore writes entries such as includes/database.php
// before those directories exist. The deepest existing ancestor (the base)
// is inside the base, so the entry must be accepted, not skipped.
expect(validate_relative_path_within('missing_dir/child/file.txt', $this->base))
->toBe(realpath($this->base) . '/missing_dir/child/file.txt');
expect(validate_relative_path_within('includes/database.php', $this->base))
->toBe(realpath($this->base) . '/includes/database.php');
});
test('a nested path is still rejected when it traverses or pivots through a symlink', function () : void {
// The relaxed not-yet-created handling must not weaken traversal or symlink
// containment for nested entries.
expect(validate_relative_path_within('missing_dir/../../escape.php', $this->base))->toBeFalse();
symlink(sys_get_temp_dir(), $this->base . '/link');
expect(validate_relative_path_within('link/missing/child.txt', $this->base))->toBeFalse();
});
test('cacti_path_is_within accepts the base itself and children', function () : void {