Skip to content

Commit d673ffd

Browse files
fix: allowlist path traversal guard, MIME validation, and XSS test robustness
- Add strpos '..' guard after redirect URL allowlist to block same-origin path traversal (a/../syslog_alerts.php?action=purge bypassed allowlist) - Add urldecode() before allowlist so percent-encoded characters are normalised before pattern matching - Add mime_content_type() check on uploaded XML files to reject non-XML content before file_get_contents; fopen guard already present - issue252_xss_output_test: replace file_get_contents with php_strip_whitespace() for PHP files so commented-out html_escape() calls cannot satisfy the assertion and mask a real XSS regression - Partition pruning: break on invalid PARTITION_NAME instead of continue; partitions are age-ordered so an invalid name means subsequent entries cannot be safely dropped
1 parent 6807d6b commit d673ffd

2 files changed

Lines changed: 42 additions & 21 deletions

File tree

functions.php

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -182,13 +182,21 @@ function syslog_apply_selected_items_action($selected_items, $drp_action, $actio
182182
}
183183

184184
function syslog_get_import_xml_payload($redirect_url) {
185+
/* Decode percent-encoding before allowlist validation so that encoded
186+
characters (e.g. %2F, %3A) cannot smuggle past the regex. */
187+
$redirect_url = urldecode($redirect_url);
188+
185189
/* Allow only relative paths that start with a filename character. Schemes
186190
(http://), protocol-relative URLs (//), and backslash prefixes (\, /\)
187191
all fail to match the allowlist and collapse to the safe default. */
188192
if (!preg_match('/^[a-zA-Z0-9_\-][a-zA-Z0-9_\-\.\/]*(?:\?[a-zA-Z0-9_\-&=%\.+]*)?$/', $redirect_url)) {
189193
$redirect_url = 'index.php';
190194
}
191195

196+
if (strpos($redirect_url, '..') !== false) {
197+
$redirect_url = 'index.php';
198+
}
199+
192200
if (trim(get_nfilter_request_var('import_text')) != '') {
193201
/* textbox input */
194202
return get_nfilter_request_var('import_text');
@@ -216,6 +224,15 @@ function syslog_get_import_xml_payload($redirect_url) {
216224
exit;
217225
}
218226

227+
/* Reject non-XML uploads based on MIME sniffing of the actual bytes,
228+
not the browser-supplied Content-Type which is attacker-controlled. */
229+
$mime = function_exists('mime_content_type') ? mime_content_type($tmp_name) : '';
230+
if ($mime !== 'text/xml' && $mime !== 'application/xml') {
231+
cacti_log('SYSLOG ERROR: Uploaded import file is not XML (detected: ' . $mime . ')', false, 'SYSTEM');
232+
header('Location: ' . $redirect_url);
233+
exit;
234+
}
235+
219236
$fp = fopen($tmp_name, 'rb');
220237

221238
if ($fp === false) {
@@ -433,11 +450,11 @@ function syslog_partition_remove($table) {
433450
binding for DDL statements. */
434451
if (!preg_match('/^d\d{8}$/', $oldest['PARTITION_NAME'])) {
435452
cacti_log("SYSLOG ERROR: Unexpected partition name format '" . $oldest['PARTITION_NAME'] . "' for table '$table', skipping, cannot prune past this entry", false, 'SYSTEM');
436-
$i++;
437-
/* Do NOT decrement $user_partitions: no partition was dropped,
438-
so the actual count is unchanged. The upper bound on $i
439-
prevents an infinite loop when all remaining names are invalid. */
440-
continue;
453+
/* Stop immediately: partitions are ordered by age, so an invalid
454+
name means we cannot safely drop any further entries. Breaking
455+
here also ensures the loop terminates even if all remaining
456+
names are invalid. */
457+
break;
441458
}
442459

443460
cacti_log("SYSLOG: Removing old partition '" . $oldest['PARTITION_NAME'] . "' from table '$table'", false, 'SYSTEM');

tests/regression/issue252_xss_output_test.php

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,25 @@
11
<?php
22

3-
$syslogPhp = file_get_contents(dirname(__DIR__, 2) . '/syslog.php');
4-
$reportsPhp = file_get_contents(dirname(__DIR__, 2) . '/syslog_reports.php');
5-
$removalPhp = file_get_contents(dirname(__DIR__, 2) . '/syslog_removal.php');
6-
$functionsJs = file_get_contents(dirname(__DIR__, 2) . '/js/functions.js');
7-
8-
if ($syslogPhp === false || $reportsPhp === false || $removalPhp === false || $functionsJs === false) {
9-
fwrite(STDERR, "Failed to load one or more plugin files for issue252 checks.\n");
10-
exit(1);
11-
}
3+
$syslog_path = dirname(__DIR__, 2) . '/syslog.php';
4+
$reports_path = dirname(__DIR__, 2) . '/syslog_reports.php';
5+
$removal_path = dirname(__DIR__, 2) . '/syslog_removal.php';
6+
$alerts_path = dirname(__DIR__, 2) . '/syslog_alerts.php';
7+
$functions_js = dirname(__DIR__, 2) . '/js/functions.js';
8+
9+
foreach ([$syslog_path, $reports_path, $removal_path, $alerts_path, $functions_js] as $path) {
10+
if (!file_exists($path)) {
11+
fwrite(STDERR, "Failed to load required file for issue252 checks: $path\n");
12+
exit(1);
13+
}
14+
}
15+
16+
/* php_strip_whitespace() removes all PHP comments before asserting, so a
17+
commented-out html_escape() call cannot satisfy the check and mask XSS. */
18+
$syslogPhp = php_strip_whitespace($syslog_path);
19+
$reportsPhp = php_strip_whitespace($reports_path);
20+
$removalPhp = php_strip_whitespace($removal_path);
21+
$alertsPhp = php_strip_whitespace($alerts_path);
22+
$functionsJs = file_get_contents($functions_js);
1223

1324
if (substr_count($syslogPhp, "html_escape(\$host['host'])") < 2) {
1425
fwrite(STDERR, "Expected escaped host rendering in syslog.php output paths.\n");
@@ -25,13 +36,6 @@
2536
exit(1);
2637
}
2738

28-
$alertsPhp = file_get_contents(dirname(__DIR__, 2) . '/syslog_alerts.php');
29-
30-
if ($alertsPhp === false) {
31-
fwrite(STDERR, "Failed to load syslog_alerts.php for issue252 checks.\n");
32-
exit(1);
33-
}
34-
3539
if (strpos($alertsPhp, "html_escape(\$alert_info)") === false) {
3640
fwrite(STDERR, "Expected escaped alert confirmation list entries.\n");
3741
exit(1);

0 commit comments

Comments
 (0)