toContain('cacti_path_is_within($xml_filename, CACTI_PATH_BASE)');
});
test('data_query_edit rejects a NUL byte before the filesystem call', function () use ($src) {
expect($src)->toContain('strpos($xml_filename, "\0") === false');
});
test('data_query_edit reads existence via is_file on the confined filename', function () use ($src) {
expect($src)->toContain('is_file($xml_filename)');
});
test('data_query_edit no longer uses a raw case-sensitive prefix comparison', function () use ($src) {
expect($src)->not->toContain('strpos($xml_real, $base_real . DIRECTORY_SEPARATOR) === 0');
});
test('NUL-byte guard precedes the cacti_path_is_within() call', function () use ($src) {
$nul_pos = strpos($src, 'strpos($xml_filename, "\0") === false');
$within_pos = strpos($src, 'cacti_path_is_within($xml_filename, CACTI_PATH_BASE)');
expect($nul_pos)->not->toBeFalse()
->and($within_pos)->not->toBeFalse()
->and($nul_pos)->toBeLessThan($within_pos);
});
// ---------------------------------------------------------------------------
// Runtime boundary suite — exercises the real guard logic in isolation
// ---------------------------------------------------------------------------
/*
* Replicates the guard from data_query_edit() so it can be exercised without
* the full Cacti bootstrap (DB, session, page rendering). It calls the real
* cacti_path_is_within() from lib/functions.php, so traversal/symlink/casing
* behaviour is covered by the production helper, not a re-implementation.
*
* Returns true only when $xml_filename is non-empty, contains no NUL byte,
* resolves within $base, and is a regular file.
*/
function dataQueryEditGuardAllowed(string $base, string $xml_filename): bool {
if ($xml_filename === '' || strpos($xml_filename, "\0") !== false) {
return false;
}
return cacti_path_is_within($xml_filename, $base) && is_file($xml_filename);
}
function makeDQEditTempBase(): string {
$tmp = sys_get_temp_dir() . '/cacti_dq_edit_test_' . getmypid() . '_' . bin2hex(random_bytes(4));
mkdir($tmp . '/resource/snmp-queries', 0755, true);
mkdir($tmp . '/outside', 0755, true);
return $tmp;
}
function removeDQEditTempBase(string $base): void {
foreach (['resource/snmp-queries', 'resource', 'outside'] as $sub) {
$path = $base . '/' . $sub;
if (is_file($path)) {
unlink($path);
} elseif (is_dir($path)) {
rmdir($path);
}
}
if (is_dir($base)) {
rmdir($base);
}
}
// --- happy path ---
test('xml file inside the base is located', function () {
$base = makeDQEditTempBase();
$file = $base . '/resource/snmp-queries/interface.xml';
file_put_contents($file, '');
$result = dataQueryEditGuardAllowed($base, $file);
unlink($file);
removeDQEditTempBase($base);
expect($result)->toBeTrue();
});
// --- traversal / out-of-base ---
test('traversal escaping the base is not located', function () {
$tmp = sys_get_temp_dir() . '/cacti_dq_edit_trav_' . getmypid() . '_' . bin2hex(random_bytes(4));
$base = $tmp . '/cacti';
mkdir($base . '/resource/snmp-queries', 0755, true);
mkdir($tmp . '/outside', 0755, true);
$evil = $tmp . '/outside/passwd';
file_put_contents($evil, 'root:x:0:0');
$traversal = $base . '/resource/snmp-queries/../../../outside/passwd';
$result = dataQueryEditGuardAllowed($base, $traversal);
unlink($evil);
rmdir($tmp . '/outside');
rmdir($base . '/resource/snmp-queries');
rmdir($base . '/resource');
rmdir($base);
rmdir($tmp);
expect($result)->toBeFalse();
});
test('an existing absolute path outside the base is not located', function () {
$base = makeDQEditTempBase();
$result = dataQueryEditGuardAllowed($base, sys_get_temp_dir());
removeDQEditTempBase($base);
expect($result)->toBeFalse();
});
test('a non-existent in-base path is not located', function () {
$base = makeDQEditTempBase();
$result = dataQueryEditGuardAllowed($base, $base . '/resource/snmp-queries/missing.xml');
removeDQEditTempBase($base);
expect($result)->toBeFalse();
});
// --- NUL-byte boundary (runs during page rendering, must not throw) ---
test('a NUL byte in the filename is rejected without raising a ValueError', function () {
$base = makeDQEditTempBase();
$file = $base . "/resource/snmp-queries/interface.xml\0.evil";
// On PHP 8+, realpath() throws a ValueError on a NUL byte; the guard must
// short-circuit before reaching cacti_path_is_within()/realpath().
$result = dataQueryEditGuardAllowed($base, $file);
removeDQEditTempBase($base);
expect($result)->toBeFalse();
});
test('an empty filename is not located', function () {
$base = makeDQEditTempBase();
$result = dataQueryEditGuardAllowed($base, '');
removeDQEditTempBase($base);
expect($result)->toBeFalse();
});
// --- symlink pivots ---
test('a symlink inside the base resolving to a file inside the base is located', function () {
$base = makeDQEditTempBase();
$target = $base . '/resource/snmp-queries/interface.xml';
file_put_contents($target, '');
$link = $base . '/resource/snmp-queries/iface-link.xml';
symlink($target, $link);
$result = dataQueryEditGuardAllowed($base, $link);
unlink($link);
unlink($target);
removeDQEditTempBase($base);
expect($result)->toBeTrue();
});
test('a symlink inside the base resolving outside the base is not located', function () {
$base = makeDQEditTempBase();
$external = sys_get_temp_dir() . '/cacti_dq_edit_ext_' . getmypid() . '_' . bin2hex(random_bytes(4));
mkdir($external, 0755, true);
$ext_file = $external . '/secret.txt';
file_put_contents($ext_file, 'secret');
$link = $base . '/resource/snmp-queries/evil-link.xml';
symlink($ext_file, $link);
$result = dataQueryEditGuardAllowed($base, $link);
unlink($link);
unlink($ext_file);
rmdir($external);
removeDQEditTempBase($base);
expect($result)->toBeFalse();
});
// --- directory inside base: is_file() rejects it even though it is contained ---
test('a directory inside the base is not located because is_file rejects it', function () {
$base = makeDQEditTempBase();
$result = dataQueryEditGuardAllowed($base, $base . '/resource/snmp-queries');
removeDQEditTempBase($base);
expect($result)->toBeFalse();
});