Files
cacti/tests/Unit/PathContainmentTest.php
TheWitness 433af99661 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.
2026-10-15 14:14:19 -04:00

148 lines
7.2 KiB
PHP

<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
| of the License, or (at your option) any later version. |
| |
| This program is distributed in the hope that it will be useful, |
| but WITHOUT ANY WARRANTY; without even the implied warranty of |
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| GNU General Public License for more details. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
| This code is designed, written, and maintained by the Cacti Group. See |
| about.php and/or the AUTHORS file for specific developer information. |
+-------------------------------------------------------------------------+
| http://www.cacti.net/ |
+-------------------------------------------------------------------------+
*/
require_once dirname(__DIR__, 2) . '/lib/functions.php';
beforeEach(function () : void {
$this->base = sys_get_temp_dir() . '/cacti_containment_' . bin2hex(random_bytes(6));
mkdir($this->base . '/nested', 0755, true);
file_put_contents($this->base . '/nested/present.txt', 'x');
$this->outside = $this->base . '_outside/pwned.txt';
});
afterEach(function () : void {
foreach (['/nested/present.txt', '/nested/link', '/nested/dangling.php', '/link', '/dangling', '/nested', ''] as $leaf) {
$path = $this->base . $leaf;
if (is_link($path)) {
unlink($path);
} elseif (is_file($path)) {
unlink($path);
} elseif (is_dir($path)) {
rmdir($path);
}
}
});
test('a plain relative path resolves inside the base', function () : void {
expect(validate_relative_path_within('nested/present.txt', $this->base))
->toBe(realpath($this->base) . '/nested/present.txt');
});
test('a path whose parent exists but file does not is still accepted', function () : void {
expect(validate_relative_path_within('nested/new.txt', $this->base))
->toBe(realpath($this->base) . '/nested/new.txt');
});
test('traversal out of the base is rejected', function () : void {
expect(validate_relative_path_within('../escape.php', $this->base))->toBeFalse();
expect(validate_relative_path_within('nested/../../escape.php', $this->base))->toBeFalse();
expect(validate_relative_path_within('./nested/present.txt', $this->base))->toBeFalse();
});
test('absolute paths and drive letters are rejected', function () : void {
expect(validate_relative_path_within('/etc/passwd', $this->base))->toBeFalse();
expect(validate_relative_path_within('C:/windows/system32', $this->base))->toBeFalse();
expect(validate_relative_path_within('\\\\server\\share', $this->base))->toBeFalse();
});
test('empty, non string and null byte paths are rejected', function () : void {
expect(validate_relative_path_within('', $this->base))->toBeFalse();
expect(validate_relative_path_within(null, $this->base))->toBeFalse();
expect(validate_relative_path_within(['a'], $this->base))->toBeFalse();
expect(validate_relative_path_within("nested/pre\0sent.txt", $this->base))->toBeFalse();
expect(validate_relative_path_within('nested//present.txt', $this->base))->toBeFalse();
});
test('a missing base directory is rejected', function () : void {
expect(validate_relative_path_within('a.txt', $this->base . '/does_not_exist'))->toBeFalse();
});
test('a symlinked segment under the base is rejected', function () : void {
symlink(sys_get_temp_dir(), $this->base . '/link');
expect(validate_relative_path_within('link/anything.txt', $this->base))->toBeFalse();
});
test('a dangling symlink is rejected as the final or an intermediate segment', function () : void {
if (DIRECTORY_SEPARATOR === '\\') {
$this->markTestSkipped('Symlink creation is not consistently available on Windows.');
}
if (!@symlink($this->outside, $this->base . '/nested/dangling.php') || !@symlink($this->outside, $this->base . '/dangling')) {
$this->markTestSkipped('Symlink creation is not permitted in this environment.');
}
expect(validate_relative_path_within('nested/dangling.php', $this->base))->toBeFalse()
->and(validate_relative_path_within('dangling/child.php', $this->base))->toBeFalse()
->and(validate_relative_path_within('nested/present.txt', $this->base))
->toBe(realpath($this->base) . '/nested/present.txt');
});
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 {
expect(cacti_path_is_within($this->base . '/nested', $this->base))->toBeTrue();
expect(cacti_path_is_within($this->base, $this->base))->toBeTrue();
});
test('cacti_path_is_within rejects unresolvable paths and outside paths', function () : void {
expect(cacti_path_is_within($this->base . '/nope', $this->base))->toBeFalse();
expect(cacti_path_is_within($this->base, $this->base . '/nope'))->toBeFalse();
expect(cacti_path_is_within(sys_get_temp_dir(), $this->base))->toBeFalse();
});
test('windows comparison rules can be forced on any platform', function () : void {
expect(cacti_path_is_within($this->base . '/nested', $this->base, true))->toBeTrue();
expect(cacti_path_is_within(sys_get_temp_dir(), $this->base, true))->toBeFalse();
});
test('windows path normalization folds case, separators and long prefixes', function () : void {
expect(cacti_normalize_windows_path('C:\\Cacti\\Plugins\\'))->toBe('c:/cacti/plugins');
expect(cacti_normalize_windows_path('\\\\?\\C:\\Cacti'))->toBe('c:/cacti');
expect(cacti_normalize_windows_path('\\\\?\\UNC\\server\\share'))->toBe('//server/share');
expect(cacti_normalize_windows_path('/'))->toBe('/');
expect(cacti_normalize_windows_path(null))->toBe('');
});