mirror of
https://github.com/Cacti/cacti.git
synced 2026-10-23 03:27:02 +09:00
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:
1 parent
4e09327660
commit
433af99661
2 files changed
+40
-14
No files matched your search
+21
-12
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user