Files
cacti/tests/Unit/Security/PathTraversal/DataQueryXmlPathHardeningTest.php
Sean Mancini 96033267cb refactor(tests): reorganize unit tests into domain category folders (#7733)
* refactor(tests): reorganize unit tests into domain category folders

- Consolidate per-advisory GHSA regression tests into Security/ subfolders by vuln class
- Group all 186 test files into domain folders: DataCollection/, Ui/, Core/,
  Database/, Installer/, Scripts/, Security/, Plugin/
- Strip GitHub issue/GHSA numbers from filenames; keep IDs in test descriptions
- Fix all __DIR__ relative paths to match new folder nesting depth
- Update CI workflow path triggers and phpunit XML configs for new file locations
- Update .github/copilot-instructions.md with unit test organization guidelines
- Add tests/vendor/ to .gitignore

* add gaurds

* fix up guard for unit tests

* fix redeclaration of cacti_log in tests

* fix updates to unit tests

* fix test patjhs

* fix: update test __DIR__ paths for reorganized test structure

* fix: conditionally define read_user_setting to prevent redeclaration in tests

* fix: conditionally define functions to prevent redeclaration in tests

- Wrap clean_up_lines, raise_message, cacti_debug_backtrace, and get_debug_prefix
  with function_exists() checks to prevent conflicts when test stubs are loaded
  before lib/functions.php in the same PHP process

* fix: add unit test bootstrap to prevent function redeclaration errors

- Create tests/bootstrap-unit.php to load core libraries before tests
- Ensures lib/functions.php loads before test stubs define stub functions
- Test stubs use function_exists() checks, so they detect pre-loaded functions
- Update phpunit.xml to use bootstrap-unit.php for Unit testsuite
- Fixes 'Cannot redeclare read_user_setting()' errors when tests run in same process

* fix: load test stubs before global.php in unit test bootstrap

- Reorder bootstrap to load CactiStubs.php and UnitStubs.php first
- Ensures stub functions like __() are available when global.php loads
- global.php calls get_cacti_version_text() which needs __() for translations
- Fixes 'Call to undefined function __()' error during bootstrap

* fix: remove function conflicts from test stubs

- Remove stub definitions for functions already in lib/functions.php:
  cacti_sizeof, cacti_log, cacti_strtolower, read_config_option,
  read_user_setting, cacti_count
- Keep translation function stubs (__(), __esc()) needed by global.php
- Update bootstrap to load lib/functions.php after stubs
- This prevents function redeclaration errors while maintaining test isolation

* fix: prevent constant and variable redeclaration in bootstrap

- Remove explicit PHP_TESTING and CACTI_TEST_BOOTSTRAP defines from bootstrap
  (CactiStubs.php now handles this with guards)
- Add guards to CactiStubs.php to prevent redefinition of PHP_TESTING
- Only set CACTI_TEST_BOOTSTRAP env vars if not already set
- Fixes PHP warnings about 'Constant X already defined'

* fix: remove redundant lib/functions.php load from bootstrap

include/global.php already loads lib/functions.php at lines 287-288.
The bootstrap was trying to load it again after global.php, but global.php
needs format_cacti_version() to be available on lines 293-294 to define
CACTI_VERSION and CACTI_VERSION_FULL constants.

Solution: Let global.php handle loading lib/functions.php itself, and don't
load it separately in the bootstrap.

* fix: remove more conflicting function stubs from test helpers

- Remove stubs for functions also defined in lib/functions.php:
  raise_message, clean_up_lines, cacti_debug_backtrace, get_debug_prefix
- Remove POLLER_ID constant stub (defined unconditionally by global.php)
- Keep only translation functions and test-specific helpers
- This prevents function/constant redeclaration errors

* fix: minimize test stubs to only translation functions

Remove ALL helper functions and constants from UnitStubs.php that are:
- Defined in lib/functions.php (raise_message, clean_up_lines, etc.)
- Defined in lib/html_utility.php (srv, set_request_var)
- Defined in global_constants.php (POLLER_VERBOSITY_*, MESSAGE_LEVEL_*)
- Defined in global.php (POLLER_ID)

Keep ONLY translation functions (__(), __esc()) that are:
- Called by global.php during initialization
- Not defined elsewhere
- Required for proper bootstrap sequence

This eliminates all constant and function redeclaration errors.

* fix: remove __esc stub - already defined in global_languages.php

Keep only __() translation function stub, which is needed by global.php
and not defined elsewhere in Cacti core.

* fix: handle no-arg case in __() translation stub

The __() function may be called with just a string and no format arguments.
Previously, vsprintf() would fail with 'arguments array must contain 2 items'.
Now, if no args are provided, return the text as-is.
2026-08-16 19:23:31 -07:00

599 lines
22 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. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/
/**
* Tests for the data query XML path traversal hardening in get_data_query_array().
*
* The guard resolves xml_path with realpath() and verifies it falls within
* CACTI_PATH_BASE before loading the file, blocking traversal payloads such as
* '<path_cacti>/../../etc/passwd' that resolve outside the allowed base after
* token substitution.
*
* Two test suites:
* 1. Source-scan: verifies the guard structure is textually present.
* 2. Runtime boundary: exercises the guard logic in isolation using a real
* temporary directory tree, providing actual execution-path coverage.
*/
// ---------------------------------------------------------------------------
// Source-scan suite (secondary lint — does NOT substitute for runtime tests)
// ---------------------------------------------------------------------------
$src = file_get_contents(__DIR__ . '/../../../../lib/data_query.php');
test('get_data_query_array checks file existence before calling realpath', function () use ($src) {
expect($src)->toContain('file_exists($xml_file_path)');
});
test('get_data_query_array calls realpath on the resolved xml_file_path', function () use ($src) {
expect($src)->toContain('realpath($xml_file_path)');
});
test('get_data_query_array calls realpath on CACTI_PATH_BASE for boundary anchor', function () use ($src) {
expect($src)->toContain('realpath(CACTI_PATH_BASE)');
});
test('realpath boundary check uses str_starts_with with DIRECTORY_SEPARATOR', function () use ($src) {
expect($src)->toContain('str_starts_with($resolved . DIRECTORY_SEPARATOR, rtrim($allowed_base, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR)');
});
test('file_exists check precedes realpath boundary check', function () use ($src) {
$exists_pos = strpos($src, 'file_exists($xml_file_path)');
$realpath_pos = strpos($src, 'realpath($xml_file_path)');
expect($exists_pos)->not->toBeFalse()
->and($realpath_pos)->not->toBeFalse()
->and($exists_pos)->toBeLessThan($realpath_pos);
});
test('$allowed_base is initialised with realpath() before the boundary comparison uses it', function () use ($src) {
// Guards against symlink-diverged installs where CACTI_PATH_BASE is a symlink:
// if $allowed_base were set from the raw constant, $resolved (canonicalized)
// and $allowed_base would diverge, blocking every legitimate path.
$init_pos = strpos($src, '$allowed_base = realpath(');
$compare_pos = strpos($src, 'str_starts_with($resolved . DIRECTORY_SEPARATOR, rtrim($allowed_base,');
expect($init_pos)->not->toBeFalse()
->and($compare_pos)->not->toBeFalse()
->and($init_pos)->toBeLessThan($compare_pos);
});
test('get_data_query_array returns empty array when xml_path resolves outside base', function () use ($src) {
$guard_pos = strpos($src, '!str_starts_with($resolved . DIRECTORY_SEPARATOR, rtrim($allowed_base, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR)');
// After the TOCTOU fix, the actual I/O uses the validated $resolved path, not the raw $xml_file_path.
$file_pos = strpos($src, 'file($resolved)');
expect($guard_pos)->not->toBeFalse()
->and($file_pos)->not->toBeFalse()
->and($guard_pos)->toBeLessThan($file_pos);
});
test('boundary check writes to the persistent SECURITY audit log', function () use ($src) {
expect($src)->toContain("cacti_log('SECURITY: data query XML path outside Cacti base:");
});
test('boundary check logs a SECURITY message to the debug timer', function () use ($src) {
expect($src)->toContain("'SECURITY: data query XML path outside Cacti base:");
});
test('log message uses $safe_path not raw $xml_file_path to prevent log injection', function () use ($src) {
// $safe_path must appear in the cacti_log call; raw concatenation of $xml_file_path must not.
$log_pos = strpos($src, "cacti_log('SECURITY: data query XML path outside Cacti base: '");
$safe_pos = strpos($src, '$safe_path', $log_pos ?: 0);
$raw_concat = strpos($src, '. $xml_file_path', $log_pos ?: 0);
$newline_strip = strpos($src, 'str_replace(["\r", "\n"], [\'\\\\r\', \'\\\\n\'], $xml_file_path)');
expect($log_pos)->not->toBeFalse()
->and($safe_pos)->not->toBeFalse()
->and($raw_concat)->toBeFalse()
->and($newline_strip)->not->toBeFalse();
});
test('file_exists early-return log uses $safe_path not raw $xml_file_path', function () use ($src) {
// $safe_path must be defined before the file_exists check so both early-return
// paths (missing file and boundary violation) sanitise log output consistently.
// Window of 80 chars covers just the tail of the log call (ends ~25 chars
// past the search string) without reaching realpath($xml_file_path) ~150
// chars later.
$safe_def = strpos($src, '$safe_path');
$exists_log = strpos($src, "Could not find data query XML file at");
$block = substr($src, $exists_log ?: 0, 80);
$raw_in_log = strpos($block, '$xml_file_path)');
expect($safe_def)->not->toBeFalse()
->and($exists_log)->not->toBeFalse()
->and($safe_def)->toBeLessThan($exists_log)
->and($raw_in_log)->toBeFalse();
});
// ---------------------------------------------------------------------------
// Runtime boundary suite — exercises the actual guard logic in isolation
// ---------------------------------------------------------------------------
/*
* Replicates the guard from get_data_query_array() so it can be exercised
* without the full Cacti bootstrap (DB, config, constants).
*
* Returns true when $xml_file_path resolves within $base_dir, false otherwise.
*/
function dataQueryBoundaryAllowed(string $base_dir, string $xml_file_path): bool {
$allowed_base = realpath($base_dir);
$resolved = realpath($xml_file_path);
if ($allowed_base === false || $resolved === false) {
return false;
}
return str_starts_with($resolved . DIRECTORY_SEPARATOR, rtrim($allowed_base, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR);
}
/*
* Create a minimal temp tree mirroring CACTI_PATH_BASE:
* $tmp/resource/snmp-queries/ (legitimate XML location)
* $tmp/outside/ (must not be reachable via traversal)
*/
function makeDQTempBase(): string {
// Random suffix so concurrent or post-failure test runs do not collide on
// a stale directory from a previous iteration.
$tmp = sys_get_temp_dir() . '/cacti_dq_test_' . getmypid() . '_' . bin2hex(random_bytes(4));
mkdir($tmp . '/resource/snmp-queries', 0755, true);
mkdir($tmp . '/outside', 0755, true);
return $tmp;
}
function removeDQTempBase(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_path inside resource/snmp-queries is allowed', function () {
$base = makeDQTempBase();
$file = $base . '/resource/snmp-queries/interface.xml';
file_put_contents($file, '<query/>');
$result = dataQueryBoundaryAllowed($base, $file);
unlink($file);
removeDQTempBase($base);
expect($result)->toBeTrue();
});
// --- traversal attacks ---
test('traversal via ../ from resource to outside/ is blocked', function () {
// Structure:
// $tmp/ <- parent container, NOT the Cacti base
// cacti/ <- CACTI_PATH_BASE equivalent
// resource/
// snmp-queries/
// outside/ <- sibling of cacti/, outside the allowed base
// passwd
//
// Three levels up from snmp-queries/ exits cacti/ into $tmp, then into outside/.
// This mirrors '<path_cacti>/resource/snmp-queries/../../../outside/passwd'.
$tmp = sys_get_temp_dir() . '/cacti_traversal_test_' . 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 = dataQueryBoundaryAllowed($base, $traversal);
unlink($evil);
rmdir($tmp . '/outside');
rmdir($base . '/resource/snmp-queries');
rmdir($base . '/resource');
rmdir($base);
rmdir($tmp);
expect($result)->toBeFalse();
});
test('absolute path outside base is blocked even if the path exists', function () {
$base = makeDQTempBase();
$result = dataQueryBoundaryAllowed($base, sys_get_temp_dir());
removeDQTempBase($base);
expect($result)->toBeFalse();
});
test('deep traversal escaping base directory is blocked', function () {
$base = makeDQTempBase();
$result = dataQueryBoundaryAllowed($base, $base . '/resource/' . str_repeat('../', 10) . 'etc/passwd');
removeDQTempBase($base);
expect($result)->toBeFalse();
});
// --- non-existent file: realpath() returns false ---
test('non-existent xml_path is blocked because realpath returns false', function () {
$base = makeDQTempBase();
$result = dataQueryBoundaryAllowed($base, $base . '/resource/snmp-queries/does-not-exist.xml');
removeDQTempBase($base);
expect($result)->toBeFalse();
});
// --- log injection sanitisation ---
test('newline in xml_file_path is encoded before logging', function () {
$raw = "/var/www/cacti/resource/snmp-queries/iface.xml\nSECURITY: forged line";
$safe = str_replace(["\r", "\n"], ['\\r', '\\n'], $raw);
expect($safe)->not->toContain("\n")
->and($safe)->toContain('\\n');
});
test('carriage return in xml_file_path is encoded before logging', function () {
$raw = "/var/www/cacti/resource/snmp-queries/iface.xml\rinjected";
$safe = str_replace(["\r", "\n"], ['\\r', '\\n'], $raw);
expect($safe)->not->toContain("\r")
->and($safe)->toContain('\\r');
});
test('path without control characters is unchanged by sanitisation', function () {
$raw = '/var/www/cacti/resource/snmp-queries/interface.xml';
$safe = str_replace(["\r", "\n"], ['\\r', '\\n'], $raw);
expect($safe)->toBe($raw);
});
// --- edge case: path exactly matching the allowed base ---
test('path exactly matching allowed base passes str_starts_with boundary check', function () {
$base = makeDQTempBase();
// $resolved === $allowed_base: str_starts_with($base.'/', $base.'/') must be true.
// This confirms the DIRECTORY_SEPARATOR trick does not block the base itself.
$result = dataQueryBoundaryAllowed($base, $base);
removeDQTempBase($base);
expect($result)->toBeTrue();
});
// --- edge case: '..' segments that resolve inside base after canonicalisation ---
test('path with dot-dot that resolves inside base is allowed after canonicalisation', function () {
$base = makeDQTempBase();
$file = $base . '/resource/snmp-queries/interface.xml';
file_put_contents($file, '<query/>');
// Goes up two levels then back down — net resolved path is still inside base.
$with_dotdot = $base . '/resource/snmp-queries/../../resource/snmp-queries/interface.xml';
$result = dataQueryBoundaryAllowed($base, $with_dotdot);
unlink($file);
removeDQTempBase($base);
expect($result)->toBeTrue();
});
// --- edge case: special characters (spaces, dots) in path components ---
test('path with spaces and dots inside base is allowed', function () {
$base = makeDQTempBase();
$subdir = $base . '/resource/snmp-queries/my.dir with spaces';
mkdir($subdir, 0755, true);
$file = $subdir . '/my query.xml';
file_put_contents($file, '<query/>');
$result = dataQueryBoundaryAllowed($base, $file);
unlink($file);
rmdir($subdir);
removeDQTempBase($base);
expect($result)->toBeTrue();
});
// --- expanded control-character sanitisation ---
test('null byte in xml_file_path is stripped before logging; content after null byte is not suppressed', function () {
$raw = "/var/www/cacti/resource/snmp-queries/iface.xml\x00injected";
$safe = preg_replace('/[\x00-\x1F\x7F]/', '', str_replace(["\r", "\n"], ['\\r', '\\n'], $raw));
// preg_replace removes the null byte only; bytes after it remain.
// The sanitisation goal is newline/control-char injection defense in log
// output, not null-byte smuggling prevention. Path traversal is blocked
// by the realpath() boundary check, not this sanitiser.
expect($safe)->not->toContain("\x00")
->and($safe)->toContain('injected');
});
test('other ASCII control characters are stripped before logging', function () {
// BEL (\x07), BS (\x08), ESC (\x1B) — not exploitable for newline injection
// but stripped defensively to prevent terminal control sequence injection.
$raw = "/var/www/cacti/resource/\x07\x08\x1Bsnmp-queries/iface.xml";
$safe = preg_replace('/[\x00-\x1F\x7F]/', '', str_replace(["\r", "\n"], ['\\r', '\\n'], $raw));
expect($safe)->not->toContain("\x07")
->and($safe)->not->toContain("\x08")
->and($safe)->not->toContain("\x1B");
});
// --- edge case: symlink whose target resolves inside base ---
test('symlink inside base resolving to file inside base is allowed', function () {
$base = makeDQTempBase();
$target = $base . '/resource/snmp-queries/interface.xml';
file_put_contents($target, '<query/>');
$link = $base . '/resource/snmp-queries/iface-link.xml';
symlink($target, $link);
$result = dataQueryBoundaryAllowed($base, $link);
unlink($link);
unlink($target);
removeDQTempBase($base);
// realpath() resolves the symlink; the canonical target is still inside base.
expect($result)->toBeTrue();
});
// --- edge case: symlink whose target resolves outside base ---
test('symlink inside base resolving to file outside base is blocked', function () {
$base = makeDQTempBase();
$external = sys_get_temp_dir() . '/cacti_dq_ext_' . getmypid();
mkdir($external, 0755);
$ext_file = $external . '/secret.txt';
file_put_contents($ext_file, 'secret');
$link = $base . '/resource/snmp-queries/evil-link.xml';
symlink($ext_file, $link);
$result = dataQueryBoundaryAllowed($base, $link);
unlink($link);
unlink($ext_file);
rmdir($external);
removeDQTempBase($base);
// realpath() follows the symlink to its canonical target outside base; rejected.
expect($result)->toBeFalse();
});
// --- edge case: path exceeding system PATH_MAX ---
test('extremely long path is blocked because realpath returns false', function () {
$base = makeDQTempBase();
// 4096 repetitions of 'a' pushes the total path length beyond PATH_MAX on
// both Linux (4096) and macOS (1024), so realpath() returns false.
$longpath = $base . '/resource/snmp-queries/' . str_repeat('a', 4096) . '.xml';
$result = dataQueryBoundaryAllowed($base, $longpath);
removeDQTempBase($base);
expect($result)->toBeFalse();
});
// --- edge case: trailing slash on xml_file_path ---
test('path with trailing slash inside base is allowed after realpath normalises it', function () {
$base = makeDQTempBase();
// realpath() strips the trailing slash; the directory exists so it resolves correctly.
$result = dataQueryBoundaryAllowed($base, $base . '/resource/snmp-queries/');
removeDQTempBase($base);
expect($result)->toBeTrue();
});
// --- source scan: is_file guard ---
test('get_data_query_array checks is_file before calling file() to reject directories', function () use ($src) {
$isfile_pos = strpos($src, 'is_file($resolved)');
$read_pos = strpos($src, 'file($resolved)');
expect($isfile_pos)->not->toBeFalse()
->and($read_pos)->not->toBeFalse()
->and($isfile_pos)->toBeLessThan($read_pos);
});
// --- edge case: directory that resolves within allowed base ---
test('directory inside base is allowed by boundary check (is_file guard in production rejects it)', function () {
$base = makeDQTempBase();
// The boundary function mirrors only the realpath/str_starts_with guard.
// A directory inside the base passes that guard; the production is_file()
// check (not replicated here) is what rejects it at read time.
$result = dataQueryBoundaryAllowed($base, $base . '/resource/snmp-queries');
removeDQTempBase($base);
expect($result)->toBeTrue();
});
// --- edge case: empty string as xml_file_path ---
test('empty string as xml_file_path is blocked because realpath returns false', function () {
$base = makeDQTempBase();
$result = dataQueryBoundaryAllowed($base, '');
removeDQTempBase($base);
expect($result)->toBeFalse();
});
// --- edge case: invalid UTF-8 sequences in path sanitisation ---
test('invalid UTF-8 sequence in xml_file_path does not cause sanitisation to error', function () {
// Byte sequence \xC3\x28 is invalid UTF-8 (continuation byte missing).
// The sanitiser must not return null or throw; it should return a string.
$raw = "/var/www/cacti/resource/snmp-queries/\xC3\x28iface.xml";
$safe = preg_replace('/[\x00-\x1F\x7F]/', '', str_replace(["\r", "\n"], ['\\r', '\\n'], $raw));
// preg_replace without the /u flag operates on raw bytes, so invalid UTF-8
// is passed through unchanged rather than causing a failure.
expect($safe)->toBeString()
->and($safe)->not->toBeNull();
});
// --- edge case: multiple consecutive slashes in path ---
test('path with multiple consecutive slashes inside base is allowed after realpath normalises it', function () {
$base = makeDQTempBase();
$file = $base . '/resource/snmp-queries/interface.xml';
file_put_contents($file, '<query/>');
// realpath() collapses consecutive slashes to a single slash; the canonical
// path is still inside $base so the boundary check must allow it.
$doubled = $base . '//resource//snmp-queries//interface.xml';
$result = dataQueryBoundaryAllowed($base, $doubled);
unlink($file);
removeDQTempBase($base);
expect($result)->toBeTrue();
});
// --- edge case: relative path from failed str_replace substitution ---
test('relative path is blocked when str_replace substitution fails to produce an absolute path', function () {
$base = makeDQTempBase();
// If the <path_cacti> token is absent, str_replace returns the literal
// xml_path unchanged, which may be a relative path such as
// 'resource/snmp-queries/interface.xml'. realpath() resolves it against CWD.
// Unless CWD happens to equal $base (a temp directory), the resolved path
// is outside the allowed base and must be rejected. If the file does not
// exist relative to CWD, realpath() returns false, which is also rejected.
$result = dataQueryBoundaryAllowed($base, 'resource/snmp-queries/interface.xml');
removeDQTempBase($base);
expect($result)->toBeFalse();
});
// --- edge case: trailing slash or special characters in CACTI_PATH_BASE ---
test('CACTI_PATH_BASE with trailing slash: in-bounds path is still allowed', function () {
$base = makeDQTempBase();
$file = $base . '/resource/snmp-queries/interface.xml';
file_put_contents($file, '<query/>');
// realpath() on $base.'/' strips the trailing slash before the boundary
// comparison, so rtrim() in the guard is a second layer of defence.
// Both must agree: a file genuinely inside the base must remain allowed.
$result = dataQueryBoundaryAllowed($base . '/', $file);
unlink($file);
removeDQTempBase($base);
expect($result)->toBeTrue();
});
test('CACTI_PATH_BASE with trailing slash: out-of-bounds path is still blocked', function () {
$tmp = sys_get_temp_dir() . '/cacti_trailing_base_test_' . getmypid();
$base = $tmp . '/cacti';
mkdir($base . '/resource/snmp-queries', 0755, true);
mkdir($tmp . '/outside', 0755, true);
$evil = $tmp . '/outside/secret.txt';
file_put_contents($evil, 'secret');
// Trailing slash on the base must not widen the allowed region.
// A file outside the canonical base must still be rejected.
$result = dataQueryBoundaryAllowed($base . '/', $evil);
unlink($evil);
rmdir($tmp . '/outside');
rmdir($base . '/resource/snmp-queries');
rmdir($base . '/resource');
rmdir($base);
rmdir($tmp);
expect($result)->toBeFalse();
});
test('CACTI_PATH_BASE with spaces in directory name: in-bounds path is allowed', function () {
$base = sys_get_temp_dir() . '/cacti dq base ' . getmypid();
mkdir($base . '/resource/snmp-queries', 0755, true);
$file = $base . '/resource/snmp-queries/interface.xml';
file_put_contents($file, '<query/>');
// str_starts_with operates on plain strings; spaces are not special.
// A base path with spaces must not confuse the boundary check.
$result = dataQueryBoundaryAllowed($base, $file);
unlink($file);
rmdir($base . '/resource/snmp-queries');
rmdir($base . '/resource');
rmdir($base);
expect($result)->toBeTrue();
});
test('CACTI_PATH_BASE with spaces in directory name: out-of-bounds path is blocked', function () {
$tmp = sys_get_temp_dir() . '/cacti dq outer ' . getmypid();
$base = $tmp . '/cacti base';
mkdir($base . '/resource/snmp-queries', 0755, true);
mkdir($tmp . '/outside', 0755, true);
$evil = $tmp . '/outside/secret.txt';
file_put_contents($evil, 'secret');
$result = dataQueryBoundaryAllowed($base, $evil);
unlink($evil);
rmdir($tmp . '/outside');
rmdir($base . '/resource/snmp-queries');
rmdir($base . '/resource');
rmdir($base);
rmdir($tmp);
expect($result)->toBeFalse();
});
// --- TOCTOU mitigation: all post-boundary I/O uses the canonical $resolved path ---
test('is_file check after boundary guard uses $resolved not raw $xml_file_path', function () use ($src) {
// Between file_exists() and the final file() read, a symlink swap could
// redirect I/O to a different target. Using $resolved (the realpath()-
// canonicalised path) for every subsequent operation closes this window:
// the path passed to is_file() must be the same value that passed the guard.
$boundary_pos = strpos($src, '!str_starts_with($resolved . DIRECTORY_SEPARATOR');
$isfile_pos = strpos($src, 'is_file($resolved)');
expect($boundary_pos)->not->toBeFalse()
->and($isfile_pos)->not->toBeFalse()
->and($isfile_pos)->toBeGreaterThan($boundary_pos);
});
test('raw $xml_file_path is not used for any file read after boundary check', function () use ($src) {
// Confirmed absence of file($xml_file_path) ensures that even if a symlink
// is swapped between the boundary check and the read, the I/O path cannot
// diverge from the path that was validated.
expect($src)->not->toContain('file($xml_file_path)');
});