Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 68 additions & 1 deletion setup.php
Original file line number Diff line number Diff line change
Expand Up @@ -1608,6 +1608,30 @@ function syslog_utilities_action($action) {
}

if ($action == 'purge_syslog_hosts') {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
cacti_log('WARNING: syslog purge blocked -- non-POST request', false, 'SYSLOG');
raise_message('syslog_method_error', __('Invalid request. This action requires a CSRF protected POST.', 'syslog'), MESSAGE_LEVEL_ERROR);
header('Location: utilities.php');
exit;
}

// csrf_check($fatal) returns bool; $fatal=false tells the helper not to
// die/exit on failure so we can log and redirect with a user-visible
// message ourselves.
if (function_exists('csrf_check')) {
if (!csrf_check(false)) {
cacti_log('WARNING: syslog purge blocked -- CSRF token validation failed', false, 'SYSLOG');
raise_message('syslog_csrf_error', __('Invalid request. This action requires a CSRF protected POST.', 'syslog'), MESSAGE_LEVEL_ERROR);
header('Location: utilities.php');
exit;
}
} else {
cacti_log('WARNING: syslog purge blocked -- CSRF validation unavailable', false, 'SYSLOG');
raise_message('syslog_csrf_unavailable', __('Invalid request. Please try again.', 'syslog'), MESSAGE_LEVEL_ERROR);
header('Location: utilities.php');
exit;
}
Comment on lines +1621 to +1633
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description mentions an "__csrf_magic fallback if csrf helper is unavailable", but the current else-branch when csrf_check() is unavailable hard-blocks the purge action. Either implement the documented fallback behavior (so purge can still work on supported environments without csrf_check) or update the PR description/tests to reflect the intentional fail-closed behavior.

Copilot uses AI. Check for mistakes.

$records = 0;

syslog_db_execute('DELETE FROM syslog_hosts
Expand Down Expand Up @@ -1660,7 +1684,50 @@ function syslog_utilities_list() {

<tr class='even'>
<td>
<a class='hyperLink' href='utilities.php?action=purge_syslog_hosts'><?php print __('Purge Syslog Devices', 'syslog'); ?></a>
<input id='syslog_purge_hosts' type='button' value='<?php print __esc('Purge Syslog Devices', 'syslog');?>'>
<div id='syslog_purge_dialog' style='display:none;'>
<p><?php print __esc('Are you sure you want to purge stale Syslog devices?', 'syslog');?></p>
</div>
<script type='text/javascript'>
$(function() {
$('#syslog_purge_hosts').on('click', function() {
$('#syslog_purge_dialog').dialog({
title: <?php print json_encode(__('Confirm Purge', 'syslog'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);?>,
minHeight: 80,
minWidth: 400,
resizable: false,
draggable: true,
buttons: {
'Cancel': {
text: <?php print json_encode(__('Cancel', 'syslog'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);?>,
id: 'btnPurgeCancel',
click: function() {
$(this).dialog('close');
}
},
'Continue': {
text: <?php print json_encode(__('Continue', 'syslog'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);?>,
id: 'btnPurgeContinue',
click: function() {
$(this).dialog('close');

var postData = {
action: 'purge_syslog_hosts',
__csrf_magic: csrfMagicToken
};

if (typeof postUrl == 'function') {
postUrl({url: 'utilities.php', noState: true}, postData);
} else {
loadPageUsingPost('utilities.php', postData);
}
}
}
}
});
});
});
</script>
</td>
<td>
<?php print __('This menu pick provides a means to remove Devices that are no longer reporting into Cacti\'s syslog server.', 'syslog'); ?>
Expand Down
10 changes: 5 additions & 5 deletions syslog.php
Original file line number Diff line number Diff line change
Expand Up @@ -1184,11 +1184,11 @@ function syslog_filter($sql_where, $tab) {
?>
<script type='text/javascript'>
initSyslogMain({
pageTab: '<?php print get_request_var('tab'); ?>',
placeHolder: '<?php print __esc('Enter a search term', 'syslog'); ?>',
noneSelectedText: '<?php print __esc('Select Device(s)', 'syslog'); ?>',
devicesSelectedText: '<?php print __esc('Devices Selected', 'syslog'); ?>',
allDevicesText: '<?php print __esc('All Devices Selected', 'syslog'); ?>'
pageTab: <?php print json_encode(get_request_var('tab'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);?>,
placeHolder: <?php print json_encode(__('Enter a search term', 'syslog'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);?>,
noneSelectedText: <?php print json_encode(__('Select Device(s)', 'syslog'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);?>,
devicesSelectedText: <?php print json_encode(__('Devices Selected', 'syslog'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);?>,
allDevicesText: <?php print json_encode(__('All Devices Selected', 'syslog'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);?>
});
</script>
<?php
Expand Down
4 changes: 2 additions & 2 deletions syslog_alerts.php
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ function form_actions() {
<td align='right' class='saveRow'>
<input type='hidden' name='action' value='actions'>
<input type='hidden' name='selected_items' value='" . (isset($alert_array) ? serialize($alert_array) : '') . "'>
<input type='hidden' name='drp_action' value='" . get_request_var('drp_action') . "'>
<input type='hidden' name='drp_action' value='" . html_escape(get_request_var('drp_action')) . "'>
$save_html
</td>
</tr>";
Expand Down Expand Up @@ -856,7 +856,7 @@ function syslog_alerts() {
'user' => [__('By User', 'syslog'), 'DESC']
];

$nav = html_nav_bar('syslog_alerts.php?filter=' . get_request_var('filter'), MAX_DISPLAY_PAGES, get_request_var('page'), $rows, $total_rows, cacti_sizeof($display_text) + 1, __('Alerts', 'syslog'), 'page', 'main');
$nav = html_nav_bar('syslog_alerts.php?filter=' . rawurlencode(get_request_var('filter')), MAX_DISPLAY_PAGES, get_request_var('page'), $rows, $total_rows, cacti_sizeof($display_text) + 1, __('Alerts', 'syslog'), 'page', 'main');

form_start('syslog_alerts.php', 'chk');

Expand Down
4 changes: 2 additions & 2 deletions syslog_removal.php
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ function form_actions() {
<td class='saveRow'>
<input type='hidden' name='action' value='actions'>
<input type='hidden' name='selected_items' value='" . (isset($removal_array) ? serialize($removal_array) : '') . "'>
<input type='hidden' name='drp_action' value='" . get_request_var('drp_action') . "'>
<input type='hidden' name='drp_action' value='" . html_escape(get_request_var('drp_action')) . "'>
$save_html
</td>
</tr>";
Expand Down Expand Up @@ -667,7 +667,7 @@ function syslog_removal() {

form_start('syslog_removal.php', 'chk');

$nav = html_nav_bar('syslog_removal.php?filter=' . get_request_var('filter'), MAX_DISPLAY_PAGES, get_request_var('page'), $rows, $total_rows, cacti_sizeof($display_text) + 1, __('Rules', 'syslog'), 'page', 'main');
$nav = html_nav_bar('syslog_removal.php?filter=' . rawurlencode(get_request_var('filter')), MAX_DISPLAY_PAGES, get_request_var('page'), $rows, $total_rows, cacti_sizeof($display_text) + 1, __('Rules', 'syslog'), 'page', 'main');

print $nav;

Expand Down
4 changes: 2 additions & 2 deletions syslog_reports.php
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ function form_actions() {
<td align='right' class='saveRow'>
<input type='hidden' name='action' value='actions'>
<input type='hidden' name='selected_items' value='" . (isset($report_array) ? serialize($report_array) : '') . "'>
<input type='hidden' name='drp_action' value='" . get_request_var('drp_action') . "'>
<input type='hidden' name='drp_action' value='" . html_escape(get_request_var('drp_action')) . "'>
$save_html
</td>
</tr>\n";
Expand Down Expand Up @@ -704,7 +704,7 @@ function syslog_report() {
'user' => [__('By User', 'syslog'), 'DESC']
];

$nav = html_nav_bar('syslog_reports.php?filter=' . get_request_var('filter'), MAX_DISPLAY_PAGES, get_request_var('page'), $rows, $total_rows, cacti_sizeof($display_text) + 1, __('Reports', 'syslog'), 'page', 'main');
$nav = html_nav_bar('syslog_reports.php?filter=' . rawurlencode(get_request_var('filter')), MAX_DISPLAY_PAGES, get_request_var('page'), $rows, $total_rows, cacti_sizeof($display_text) + 1, __('Reports', 'syslog'), 'page', 'main');

form_start('syslog_reports.php', 'chk');

Expand Down
120 changes: 120 additions & 0 deletions tests/regression/issue259_csrf_purge_test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<?php
/*
* Source-scan lint for the #259 purge-syslog-hosts CSRF hardening.
*
* This is a lint, NOT a behavioral test. It asserts that the expected
* CSRF-enforcement snippets exist in setup.php so regressions that
* silently delete the POST/csrf_check/JS-post-flow are caught at CI
* time. A full behavioral test would require bootstrapping Cacti's
* session, CSRF token, and database, which this plugin's regression
* harness cannot currently provide.
*
* If the guard is refactored in a way that preserves the strings but
* breaks the logic, this lint will NOT catch it — follow-up issue for
* real behavioral coverage once a DB-backed test harness exists.
*/

$setup = file_get_contents(dirname(__DIR__, 2) . '/setup.php');

if ($setup === false) {
fwrite(STDERR, "Failed to read setup.php\n");
exit(1);
}

$required = array(
"if (\$_SERVER['REQUEST_METHOD'] !== 'POST')",
"if (function_exists('csrf_check'))",
"if (!csrf_check(false))",
"__csrf_magic: csrfMagicToken"
);

foreach ($required as $snippet) {
if (strpos($setup, $snippet) === false) {
fwrite(STDERR, "Missing expected CSRF hardening snippet: $snippet\n");
exit(1);
}
}

if (strpos($setup, "href='utilities.php?action=purge_syslog_hosts'") !== false) {
fwrite(STDERR, "Legacy GET purge link still present.\n");
exit(1);
}

// Verify fail-closed: the else branch (csrf_check unavailable) must reject, not fall through.
// Assert globally safe properties rather than parsing the else block via brittle regex.

// The fallback path must not attempt manual token checking
if (strpos($setup, "\$_POST['__csrf_magic']") !== false) {
fwrite(STDERR, "Fallback CSRF branch must not check token presence; must fail closed.\n");
exit(1);
}

// The fallback path must log the blocked attempt
if (strpos($setup, "cacti_log('WARNING: syslog purge blocked") === false) {
fwrite(STDERR, "Fail-closed branch must call cacti_log() to audit blocked purge attempts.\n");
exit(1);
}

// Log message must name the specific failure reason for incident response
if (strpos($setup, 'CSRF validation unavailable') === false) {
fwrite(STDERR, "Log message must specify 'CSRF validation unavailable' for operational clarity.\n");
exit(1);
}

// Verify JS confirm() uses json_encode, not __esc() inside JS string
if (preg_match("/confirm\(\s*'/", $setup)) {
fwrite(STDERR, "JS confirm() must use json_encode() for safe encoding, not __esc() in a quoted string.\n");
exit(1);
}

if (strpos($setup, 'json_encode(__(') === false) {
fwrite(STDERR, "Expected json_encode(__(...)) for JS-safe encoding of confirm message.\n");
exit(1);
}

// Verify json_encode uses JSON_HEX_TAG to prevent </script> breakout in HTML script context
if (strpos($setup, 'JSON_HEX_TAG') === false) {
fwrite(STDERR, "json_encode() must use JSON_HEX_TAG to prevent script-context breakout.\n");
exit(1);
}

if (strpos($setup, 'JSON_HEX_AMP') === false) {
fwrite(STDERR, "json_encode() must use JSON_HEX_AMP to escape ampersands in script context.\n");
exit(1);
}

if (strpos($setup, 'JSON_HEX_APOS') === false) {
fwrite(STDERR, "json_encode() must use JSON_HEX_APOS.\n");
exit(1);
}

if (strpos($setup, 'JSON_HEX_QUOT') === false) {
fwrite(STDERR, "json_encode() must use JSON_HEX_QUOT.\n");
exit(1);
}

// Verify user-facing messages do not expose CSRF internals (log messages may use "CSRF")
if (preg_match("/raise_message\\('syslog_[a-z_]*', __\\('CSRF/", $setup)) {
fwrite(STDERR, "User-facing raise_message must not expose CSRF internals to end users.\n");
exit(1);
}

// Verify generic user-facing message is present
if (strpos($setup, "Invalid request. Please try again.") === false) {
fwrite(STDERR, "Fail-closed branch must use generic 'Invalid request. Please try again.' message.\n");
exit(1);
}

// Verify fail-closed raise_message uses MESSAGE_LEVEL_ERROR severity
if (strpos($setup, "raise_message('syslog_csrf_unavailable', __('Invalid request. Please try again.', 'syslog'), MESSAGE_LEVEL_ERROR)") === false) {
fwrite(STDERR, "Fail-closed branch raise_message must use MESSAGE_LEVEL_ERROR severity.\n");
exit(1);
}

// Verify log message does not expose internal function name
if (strpos($setup, 'csrf_check() unavailable') !== false) {
fwrite(STDERR, "Log message must not name internal validation function.\n");
exit(1);
}

echo "issue259_csrf_purge_test passed\n";
58 changes: 58 additions & 0 deletions tests/regression/issue279_bulk_form_and_nav_encoding_test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

$targets = array(
'syslog_removal.php' => file_get_contents(__DIR__ . '/../../syslog_removal.php'),
'syslog_alerts.php' => file_get_contents(__DIR__ . '/../../syslog_alerts.php'),
'syslog_reports.php' => file_get_contents(__DIR__ . '/../../syslog_reports.php'),
'syslog.php' => file_get_contents(__DIR__ . '/../../syslog.php'),
);

foreach ($targets as $file => $contents) {
if ($contents === false) {
fwrite(STDERR, "Unable to read $file\n");
exit(1);
}
}

foreach (array('syslog_removal.php', 'syslog_alerts.php', 'syslog_reports.php') as $file) {
if (strpos($targets[$file], "html_escape(get_request_var('drp_action'))") === false) {
fwrite(STDERR, "Expected escaped drp_action hidden field in $file\n");
exit(1);
}

if (strpos($targets[$file], "rawurlencode(get_request_var('filter'))") === false) {
fwrite(STDERR, "Expected URL-encoded filter nav value in $file\n");
exit(1);
}

if (strpos($targets[$file], "<input type='hidden' name='drp_action' value='\" . get_request_var('drp_action') . \"'>") !== false) {
fwrite(STDERR, "Legacy raw drp_action hidden field remains in $file\n");
exit(1);
}
}

$syslog = $targets['syslog.php'];

if (strpos($syslog, "pageTab: <?php print json_encode(get_request_var('tab'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);?>,") === false) {
fwrite(STDERR, "Expected JSON-encoded syslog pageTab value\n");
exit(1);
}

foreach (array(
"json_encode(__('Enter a search term', 'syslog'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)",
"json_encode(__('Select Device(s)', 'syslog'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)",
"json_encode(__('Devices Selected', 'syslog'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)",
"json_encode(__('All Devices Selected', 'syslog'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)",
) as $needle) {
if (strpos($syslog, $needle) === false) {
fwrite(STDERR, "Expected JS-safe initSyslogMain text encoding\n");
exit(1);
}
}

if (strpos($syslog, "pageTab: '<?php print get_request_var('tab'); ?>'") !== false) {
fwrite(STDERR, "Legacy raw pageTab JS assignment still present\n");
exit(1);
}

echo "OK\n";
Loading