Files
cacti/tests/Unit/Security/PathTraversal/DataSourcePathContainmentTest.php
T
Thomas Vincent c749790d84 fix(security): [develop] require CSRF-protected POST for link and plugin reordering (#8278)
* security: require CSRF-protected POST for link and plugin reordering (#7454)

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* fix(csrf): share POST and token guards for reorder actions

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
2026-10-13 07:52:20 -04:00

202 lines
8.5 KiB
PHP

<?php
declare(strict_types = 1);
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/
/**
* Regression test for GHSA-9qff-xccr-rrc5.
*
* data_source_path is stored without path validation and used verbatim as the
* RRD filename. data_source_path_within_rra() is the containment applied where
* every consumer resolves the path, keeping the write inside the RRA directory,
* including against a symlink pivot below an otherwise-contained ancestor.
*
* @group regression
*/
require_once dirname(__DIR__, 4) . '/lib/functions.php';
if (!defined('CACTI_PATH_RRA')) {
define('CACTI_PATH_RRA', sys_get_temp_dir() . '/cacti_rra_' . bin2hex(random_bytes(6)));
}
// realpath()-based checks below require CACTI_PATH_RRA to be a real, existing
// directory. Another test file loaded earlier in the same process (e.g.
// RrdProxyProtocolTest.php) may already have defined the constant to a purely
// illustrative, non-existent path, so make sure it actually exists on disk
// here regardless of who defined it, and skip the fixture-dependent
// assertions below if this process cannot create it (e.g. no permission).
$rra_ready = is_dir(CACTI_PATH_RRA . '/1') || @mkdir(CACTI_PATH_RRA . '/1', 0755, true);
if ($rra_ready) {
file_put_contents(CACTI_PATH_RRA . '/host_ds.rrd', 'x');
file_put_contents(CACTI_PATH_RRA . '/1/host_ds.rrd', 'x');
$rra_ready = realpath(CACTI_PATH_RRA) !== false;
}
function data_source_path_test_base() : string {
return (string) realpath(CACTI_PATH_RRA);
}
test('a file directly under the RRA directory is contained', function () use ($rra_ready) : void {
if (!$rra_ready) {
$this->markTestSkipped('CACTI_PATH_RRA does not resolve to a directory this process can create fixtures under');
}
expect(data_source_path_within_rra(data_source_path_test_base() . '/host_ds.rrd'))->toBeTrue()
->and(data_source_path_within_rra(data_source_path_test_base() . '/1/host_ds.rrd'))->toBeTrue();
});
test('a not-yet-created file under the RRA directory is still contained', function () use ($rra_ready) : void {
if (!$rra_ready) {
$this->markTestSkipped('CACTI_PATH_RRA does not resolve to a directory this process can create fixtures under');
}
expect(data_source_path_within_rra(data_source_path_test_base() . '/1/new_ds.rrd'))->toBeTrue();
});
test('a not-yet-created intermediate directory under the RRA directory is still contained', function () use ($rra_ready) : void {
if (!$rra_ready) {
$this->markTestSkipped('CACTI_PATH_RRA does not resolve to a directory this process can create fixtures under');
}
$missing = data_source_path_test_base() . '/does_not_exist_' . bin2hex(random_bytes(4)) . '/4242.rrd';
expect(is_dir(dirname($missing)))->toBeFalse()
->and(data_source_path_within_rra($missing))->toBeTrue();
});
test('redundant slashes from a trailing-slash path_rra are contained, not an escape', function () use ($rra_ready) : void {
if (!$rra_ready) {
$this->markTestSkipped('CACTI_PATH_RRA does not resolve to a directory this process can create fixtures under');
}
// A path_rra saved with a trailing slash makes the '<path_rra>/' -> rra . '/'
// expansion produce a doubled slash (rra//0/x.rrd); the file is still inside
// the RRA directory and must not be reported as escaping it.
expect(data_source_path_within_rra(data_source_path_test_base() . '//host_ds.rrd'))->toBeTrue()
->and(data_source_path_within_rra(data_source_path_test_base() . '//1/host_ds.rrd'))->toBeTrue()
->and(data_source_path_within_rra(data_source_path_test_base() . '/1//new_ds.rrd'))->toBeTrue();
});
test('a CACTI_PATH_RRA defined with a trailing slash still contains its files', function () use ($rra_ready) : void {
if (!$rra_ready) {
$this->markTestSkipped('CACTI_PATH_RRA does not resolve to a directory this process can create fixtures under');
}
if (!function_exists('shell_exec') || !defined('PHP_BINARY') || PHP_BINARY === '') {
$this->markTestSkipped('shell_exec()/PHP_BINARY unavailable, cannot exercise an isolated process');
}
// CACTI_PATH_RRA is an immutable constant, already defined in this process
// without a trailing slash (and realpath() would strip one anyway), so the
// trailing-slash form - the actual trigger, where the base needs rtrim() and
// not just the target - can only be exercised in a fresh process that
// defines the constant that way.
$rra = data_source_path_test_base();
$functions = dirname(__DIR__, 4) . '/lib/functions.php';
$autoload = dirname(__DIR__, 4) . '/vendor/autoload.php';
$code = sprintf(
'<?php $a = %s; if (is_file($a)) { require $a; } define(%s, %s); require %s; ' .
'echo (data_source_path_within_rra(%s) === true ' .
'&& data_source_path_within_rra(%s) === true ' .
'&& data_source_path_within_rra(%s) === false) ? "PASS" : "FAIL";',
var_export($autoload, true),
var_export('CACTI_PATH_RRA', true),
var_export($rra . '/', true),
var_export($functions, true),
var_export($rra . '/1/host_ds.rrd', true),
var_export($rra . '//1/host_ds.rrd', true),
var_export($rra . '/../resource/x.rrd', true)
);
$script = tempnam(sys_get_temp_dir(), 'cacti_rra_trailing_');
file_put_contents($script, $code);
try {
$output = shell_exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($script) . ' 2>&1');
} finally {
@unlink($script);
}
expect(trim((string) $output))->toBe('PASS');
});
test('an absolute path elsewhere is rejected', function () : void {
expect(data_source_path_within_rra('/var/www/html/cacti/resource/x.php'))->toBeFalse()
->and(data_source_path_within_rra('/tmp/x.rrd'))->toBeFalse();
});
test('traversal out of the RRA directory is rejected', function () : void {
expect(data_source_path_within_rra(data_source_path_test_base() . '/../resource/x.rrd'))->toBeFalse()
->and(data_source_path_within_rra(data_source_path_test_base() . '/a/../../etc/x'))->toBeFalse();
});
test('traversal is still rejected when wrapped in redundant slashes', function () : void {
expect(data_source_path_within_rra(data_source_path_test_base() . '//..//resource/x.rrd'))->toBeFalse()
->and(data_source_path_within_rra(data_source_path_test_base() . '/a//..//../etc/x'))->toBeFalse();
});
test('a relative path or a lookalike prefix is rejected', function () : void {
expect(data_source_path_within_rra('1/host_ds.rrd'))->toBeFalse()
->and(data_source_path_within_rra(data_source_path_test_base() . '_evil/x.rrd'))->toBeFalse();
});
test('empty and null-byte paths are rejected', function () : void {
expect(data_source_path_within_rra(''))->toBeFalse()
->and(data_source_path_within_rra("/tmp/x\0.rrd"))->toBeFalse();
});
test('a symlink pivot below the RRA directory is rejected even for a not-yet-created file', function () use ($rra_ready) : void {
if (!$rra_ready) {
$this->markTestSkipped('CACTI_PATH_RRA does not resolve to a directory this process can create fixtures under');
}
$outside = sys_get_temp_dir() . '/cacti_rra_outside_' . bin2hex(random_bytes(6));
mkdir($outside, 0755, true);
$link = data_source_path_test_base() . '/linked';
if (!@symlink($outside, $link)) {
$this->markTestSkipped('symlink() is not available in this environment');
}
try {
expect(data_source_path_within_rra($link . '/escaped.rrd'))->toBeFalse();
} finally {
unlink($link);
rmdir($outside);
}
});
test('a dangling symlink below the RRA directory is rejected even for a not-yet-created file', function () use ($rra_ready) : void {
if (!$rra_ready) {
$this->markTestSkipped('CACTI_PATH_RRA does not resolve to a directory this process can create fixtures under');
}
// A dangling symlink (target does not exist) is false for file_exists() but
// true for is_link(); the containment must reject a path through it so the
// link cannot be pointed outside the RRA root and have its target created
// before the recursive directory/RRD build materializes the path.
$missing_target = sys_get_temp_dir() . '/cacti_rra_dangling_' . bin2hex(random_bytes(6));
$link = data_source_path_test_base() . '/dangling';
if (!@symlink($missing_target, $link)) {
$this->markTestSkipped('symlink() is not available in this environment');
}
try {
expect(is_link($link))->toBeTrue()
->and(file_exists($link))->toBeFalse()
->and(data_source_path_within_rra($link . '/escaped.rrd'))->toBeFalse();
} finally {
unlink($link);
}
});