fix(csrf): require POST tokens for state-changing actions (#7991)

* fix(csrf): require POST tokens for item actions

* test(csrf): cover every guarded action path

* test(csrf): keep legacy guard contract extensible

* fix(csrf): hide POST-only targets from crawlers

* test(csrf): reject every crawlable state-action link

* fix(csrf): fail closed without logging crawler probes

* fix(csrf): post data-query item actions safely

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

* fix(csrf): normalize stripped token separators

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

* fix(csrf): distinguish method and token failures

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

* fix(csrf): escape POST action URL attributes

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

* fix(csrf): delegate POST action events

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

* fix(csrf): post data template add action

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

* style(html): align POST button class

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

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
This commit is contained in:
Thomas Vincent authored and GitHub committed 2026-09-12 15:09:25 -04:00
1 parent 64f9c5903e
commit a2c8df612e
21 files changed
+529 -65

No files matched your search

@@ -0,0 +1,62 @@
name: CSRF Action Coverage
on:
push:
branches: [develop]
paths:
- '.github/workflows/csrf-action-coverage.yml'
- 'include/global.php'
- 'include/layout.js'
- '**/*.php'
- 'tests/Unit/Security/Csrf/ItemActionPostOnlyTest.php'
- 'tests/js/csrf_post_actions.test.js'
pull_request:
branches: [develop]
paths:
- '.github/workflows/csrf-action-coverage.yml'
- 'include/global.php'
- 'include/layout.js'
- '**/*.php'
- 'tests/Unit/Security/Csrf/ItemActionPostOnlyTest.php'
- 'tests/js/csrf_post_actions.test.js'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: csrf-action-coverage-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
csrf-actions:
name: POST guard and browser submission
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false
- name: Setup PHP 8.4
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
with:
php-version: '8.4'
coverage: none
extensions: intl, mysql, gd, ldap, gmp, xml, curl, json, mbstring
- name: Setup Node 22
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # 4.4.0
with:
node-version: '22'
- name: Install PHP dependencies
run: composer install --prefer-dist --no-progress --no-interaction
- name: Verify guarded actions and links
run: include/vendor/bin/pest tests/Unit/Security/Csrf/ItemActionPostOnlyTest.php
- name: Verify browser POST construction
run: node --test tests/js/csrf_post_actions.test.js
+1
View File
@@ -181,6 +181,7 @@ Cacti CHANGELOG
-issue#7355: Add a JUNIPER-MIB jnxOperatingTable script data query for RE and FPC health -issue#7355: Add a JUNIPER-MIB jnxOperatingTable script data query for RE and FPC health
-issue#7355: Add an F5-BIGIP-SYSTEM-MIB script data query for per-CPU utilization -issue#7355: Add an F5-BIGIP-SYSTEM-MIB script data query for per-CPU utilization
-issue#7904: Device changes are silently skipped when --bulk_walk is passed to change_device.php -issue#7904: Device changes are silently skipped when --bulk_walk is passed to change_device.php
-issue#7981: Require POST tokens for state-changing item, tree and RRD actions
-issue#7809: Avoid reading a missing source key on output-only data query fields -issue#7809: Avoid reading a missing source key on output-only data query fields
-feature#7884: Manage front-end JavaScript libraries with npm and Dependabot -feature#7884: Manage front-end JavaScript libraries with npm and Dependabot
-feature#7884: Build release artifacts from managed dependencies, including DOMPurify 3.4.14 and phpseclib 4, and a reduced runtime tree -feature#7884: Build release artifacts from managed dependencies, including DOMPurify 3.4.14 and phpseclib 4, and a reduced runtime tree
+2 -2
View File
@@ -806,13 +806,13 @@ function automation_snmp_edit() : void {
if (read_config_option('drag_and_drop') == '') { if (read_config_option('drag_and_drop') == '') {
if ($i < $total_items) { if ($i < $total_items) {
$form_data .= '<a class="pic ti ti-caret-down-filled moveArrow" href="' . htmle('automation_snmp.php?action=item_movedown&item_id=' . $item['id'] . '&id=' . $item['snmp_id']) . '" title="' . __esc('Move Down') . '"></a>'; $form_data .= '<a class="pic ti ti-caret-down-filled moveArrow cactiPostAction" href="#" data-url="' . html_escape_url('automation_snmp.php?action=item_movedown&item_id=' . $item['id'] . '&id=' . $item['snmp_id']) . '" title="' . __esc('Move Down') . '"></a>';
} else { } else {
$form_data .= '<span class="moveArrowNone"></span>'; $form_data .= '<span class="moveArrowNone"></span>';
} }
if ($i > 1 && $i <= $total_items) { if ($i > 1 && $i <= $total_items) {
$form_data .= '<a class="pic ti ti-caret-up-filled moveArrow" href="' . htmle('automation_snmp.php?action=item_moveup&item_id=' . $item['id'] . '&id=' . $item['snmp_id']) . '" title="' . __esc('Move Up') . '"></a>'; $form_data .= '<a class="pic ti ti-caret-up-filled moveArrow cactiPostAction" href="#" data-url="' . html_escape_url('automation_snmp.php?action=item_moveup&item_id=' . $item['id'] . '&id=' . $item['snmp_id']) . '" title="' . __esc('Move Up') . '"></a>';
} else { } else {
$form_data .= '<span class="moveArrowNone"></span>'; $form_data .= '<span class="moveArrowNone"></span>';
} }
+6 -6
View File
@@ -1189,13 +1189,13 @@ function template_edit() : void {
if (!$dnd) { if (!$dnd) {
if ($i != cacti_sizeof($graph_rules) - 1) { if ($i != cacti_sizeof($graph_rules) - 1) {
$action .= '<a class="pic ti ti-caret-down-filled moveArrow" href="' . htmle('automation_templates.php?action=item_movedown&template_id=' . grv('id') . '&id=' . $rule['id'] . '&rule_type=1') . '" title="' . __esc('Move Down') . '"></a>'; $action .= '<a class="pic ti ti-caret-down-filled moveArrow cactiPostAction" href="#" data-url="' . html_escape_url('automation_templates.php?action=item_movedown&template_id=' . grv('id') . '&id=' . $rule['id'] . '&rule_type=1') . '" title="' . __esc('Move Down') . '"></a>';
} else { } else {
$action .= '<a href="#" class="moveArrowNone"></a>'; $action .= '<a href="#" class="moveArrowNone"></a>';
} }
if ($i > 0) { if ($i > 0) {
$action .= '<a class="pic ti ti-caret-up-filled moveArrow" href="' . htmle('automation_templates.php?action=item_moveup&template_id=' . grv('id') . '&id=' . $rule['id'] . '&rule_type=1') . '" title="' . __esc('Move Up') . '"></a>'; $action .= '<a class="pic ti ti-caret-up-filled moveArrow cactiPostAction" href="#" data-url="' . html_escape_url('automation_templates.php?action=item_moveup&template_id=' . grv('id') . '&id=' . $rule['id'] . '&rule_type=1') . '" title="' . __esc('Move Up') . '"></a>';
} else { } else {
$action .= '<a href="#" class="moveArrowNone"></a>'; $action .= '<a href="#" class="moveArrowNone"></a>';
} }
@@ -1300,13 +1300,13 @@ function template_edit() : void {
if (!$dnd) { if (!$dnd) {
if ($i != cacti_sizeof($tree_rules) - 1) { if ($i != cacti_sizeof($tree_rules) - 1) {
$action .= '<a class="pic ti ti-caret-down-filled moveArrow" href="' . htmle('automation_templates.php?action=item_movedown&template_id=' . grv('id') . '&id=' . $rule['id'] . '&rule_type=2') . '" title="' . __esc('Move Down') . '"></a>'; $action .= '<a class="pic ti ti-caret-down-filled moveArrow cactiPostAction" href="#" data-url="' . html_escape_url('automation_templates.php?action=item_movedown&template_id=' . grv('id') . '&id=' . $rule['id'] . '&rule_type=2') . '" title="' . __esc('Move Down') . '"></a>';
} else { } else {
$action .= '<a href="#" class="moveArrowNone"></a>'; $action .= '<a href="#" class="moveArrowNone"></a>';
} }
if ($i > 0) { if ($i > 0) {
$action .= '<a class="pic ti ti-caret-up-filled moveArrow" href="' . htmle('automation_templates.php?action=item_moveup&template_id=' . grv('id') . '&id=' . $rule['id'] . '&rule_type=2') . '" title="' . __esc('Move Up') . '"></a>'; $action .= '<a class="pic ti ti-caret-up-filled moveArrow cactiPostAction" href="#" data-url="' . html_escape_url('automation_templates.php?action=item_moveup&template_id=' . grv('id') . '&id=' . $rule['id'] . '&rule_type=2') . '" title="' . __esc('Move Up') . '"></a>';
} else { } else {
$action .= '<a href="#" class="moveArrowNone"></a>'; $action .= '<a href="#" class="moveArrowNone"></a>';
} }
@@ -1664,13 +1664,13 @@ function template() : void {
$add_text = ''; $add_text = '';
if ($i < $total_items && $total_items > 1) { if ($i < $total_items && $total_items > 1) {
$add_text .= '<a class="pic ti ti-caret-down-filled moveArrow" href="' . htmle('automation_templates.php?action=movedown&id=' . $dt['id']) . '" title="' . __esc('Move Down') . '"></a>'; $add_text .= '<a class="pic ti ti-caret-down-filled moveArrow cactiPostAction" href="#" data-url="' . html_escape_url('automation_templates.php?action=movedown&id=' . $dt['id']) . '" title="' . __esc('Move Down') . '"></a>';
} else { } else {
$add_text .= '<span class="moveArrowNone"></span>'; $add_text .= '<span class="moveArrowNone"></span>';
} }
if ($i > 1 && $i <= $total_items) { if ($i > 1 && $i <= $total_items) {
$add_text .= '<a class="pic ti ti-caret-up-filled moveArrow" href="' . htmle('automation_templates.php?action=moveup&id=' . $dt['id']) . '" title="' . __esc('Move Up') . '"></a>'; $add_text .= '<a class="pic ti ti-caret-up-filled moveArrow cactiPostAction" href="#" data-url="' . html_escape_url('automation_templates.php?action=moveup&id=' . $dt['id']) . '" title="' . __esc('Move Up') . '"></a>';
} else { } else {
$add_text .= '<span class="moveArrowNone"></span>'; $add_text .= '<span class="moveArrowNone"></span>';
} }
+2 -2
View File
@@ -611,13 +611,13 @@ function cdef_edit() : void {
if (read_config_option('drag_and_drop') == '') { if (read_config_option('drag_and_drop') == '') {
if ($i < $total_items) { if ($i < $total_items) {
$actions .= '<a class="pic ti ti-caret-down-filled moveArrow" href="' . htmle('cdef.php?action=item_movedown&id=' . $cdef_item['id'] . '&cdef_id=' . $cdef_item['cdef_id']) . '" title="' . __esc('Move Down') . '"></a>'; $actions .= '<a class="pic ti ti-caret-down-filled moveArrow cactiPostAction" href="#" data-url="' . html_escape_url('cdef.php?action=item_movedown&id=' . $cdef_item['id'] . '&cdef_id=' . $cdef_item['cdef_id']) . '" title="' . __esc('Move Down') . '"></a>';
} else { } else {
$actions .= '<span class="moveArrowNone"></span>'; $actions .= '<span class="moveArrowNone"></span>';
} }
if ($i > 1 && $i <= $total_items) { if ($i > 1 && $i <= $total_items) {
$actions .= '<a class="pic ti ti-caret-up-filled moveArrow" href="' . htmle('cdef.php?action=item_moveup&id=' . $cdef_item['id'] . '&cdef_id=' . $cdef_item['cdef_id']) . '" title="' . __esc('Move Up') . '"></a>'; $actions .= '<a class="pic ti ti-caret-up-filled moveArrow cactiPostAction" href="#" data-url="' . html_escape_url('cdef.php?action=item_moveup&id=' . $cdef_item['id'] . '&cdef_id=' . $cdef_item['cdef_id']) . '" title="' . __esc('Move Up') . '"></a>';
} else { } else {
$actions .= '<span class="moveArrowNone"></span>'; $actions .= '<span class="moveArrowNone"></span>';
} }
+2 -2
View File
@@ -156,13 +156,13 @@ function draw_color_template_items_list(array $item_list, string $filename, stri
if (read_config_option('drag_and_drop') == '') { if (read_config_option('drag_and_drop') == '') {
if ($i < $total_items) { if ($i < $total_items) {
print '<a class="pic ti ti-caret-down-filled moveArrow" href="' . htmle('color_templates.php?action=item_movedown&color_template_item_id=' . $item['color_template_item_id'] . '&color_template_id=' . $item['color_template_id']) . '" title="' . __esc('Move Down') . '"></a>'; print '<a class="pic ti ti-caret-down-filled moveArrow cactiPostAction" href="#" data-url="' . html_escape_url('color_templates.php?action=item_movedown&color_template_item_id=' . $item['color_template_item_id'] . '&color_template_id=' . $item['color_template_id']) . '" title="' . __esc('Move Down') . '"></a>';
} else { } else {
print '<span class="moveArrowNone"></span>'; print '<span class="moveArrowNone"></span>';
} }
if ($i > 1 && $i <= $total_items) { if ($i > 1 && $i <= $total_items) {
print '<a class="pic ti ti-caret-up-filled moveArrow" href="' . htmle('color_templates.php?action=item_moveup&color_template_item_id=' . $item['color_template_item_id'] . '&color_template_id=' . $item['color_template_id']) . '" title="' . __esc('Move Up') . '"></a>'; print '<a class="pic ti ti-caret-up-filled moveArrow cactiPostAction" href="#" data-url="' . html_escape_url('color_templates.php?action=item_moveup&color_template_item_id=' . $item['color_template_item_id'] . '&color_template_id=' . $item['color_template_id']) . '" title="' . __esc('Move Up') . '"></a>';
} else { } else {
print '<span class="moveArrowNone"></span>'; print '<span class="moveArrowNone"></span>';
} }
+10 -9
View File
@@ -802,12 +802,12 @@ function data_query_item_edit() : void {
</td> </td>
<td class='center'> <td class='center'>
<?php if ($show_down) {?> <?php if ($show_down) {?>
<a class='remover ti ti-caret-down-filled moveArrow' title='<?php print __('Move Down'); ?>' href='<?php print htmle('data_queries.php?action=item_movedown_gsv&snmp_query_graph_id=' . grv('id') . '&id=' . $suggested_value['id'] . '&snmp_query_id=' . grv('snmp_query_id') . '&field_name=' . $suggested_value['field_name']); ?>'></a> <a class='remover ti ti-caret-down-filled moveArrow' title='<?php print __('Move Down'); ?>' href='#' data-url='<?php print html_escape_url('data_queries.php?action=item_movedown_gsv&snmp_query_graph_id=' . grv('id') . '&id=' . $suggested_value['id'] . '&snmp_query_id=' . grv('snmp_query_id') . '&field_name=' . $suggested_value['field_name']); ?>'></a>
<?php } else {?> <?php } else {?>
<span class='moveArrowNone'></span> <span class='moveArrowNone'></span>
<?php } ?> <?php } ?>
<?php if ($show_up) {?> <?php if ($show_up) {?>
<a class='remover ti ti-caret-up-filled moveArrow' title='<?php print __('Move Up'); ?>' href='<?php print htmle('data_queries.php?action=item_moveup_gsv&snmp_query_graph_id=' . grv('id') . '&id=' . $suggested_value['id'] . '&snmp_query_id=' . grv('snmp_query_id') . '&field_name=' . $suggested_value['field_name']); ?>'></a> <a class='remover ti ti-caret-up-filled moveArrow' title='<?php print __('Move Up'); ?>' href='#' data-url='<?php print html_escape_url('data_queries.php?action=item_moveup_gsv&snmp_query_graph_id=' . grv('id') . '&id=' . $suggested_value['id'] . '&snmp_query_id=' . grv('snmp_query_id') . '&field_name=' . $suggested_value['field_name']); ?>'></a>
<?php } else {?> <?php } else {?>
<span class='moveArrowNone'></span> <span class='moveArrowNone'></span>
<?php } ?> <?php } ?>
@@ -816,7 +816,7 @@ function data_query_item_edit() : void {
<?php print htmle($suggested_value['text']); ?> <?php print htmle($suggested_value['text']); ?>
</td> </td>
<td class='right'> <td class='right'>
<a class='remover deleteMarker ti ti-x' title='<?php print htmle(__('Delete')); ?>' href='<?php print htmle('data_queries.php?action=item_remove_gsv&snmp_query_graph_id=' . grv('id') . '&id=' . $suggested_value['id'] . '&snmp_query_id=' . grv('snmp_query_id')); ?>'></a> <a class='remover deleteMarker ti ti-x' title='<?php print htmle(__('Delete')); ?>' href='#' data-url='<?php print html_escape_url('data_queries.php?action=item_remove_gsv&snmp_query_graph_id=' . grv('id') . '&id=' . $suggested_value['id'] . '&snmp_query_id=' . grv('snmp_query_id')); ?>'></a>
</td> </td>
<?php <?php
@@ -910,12 +910,12 @@ function data_query_item_edit() : void {
</td> </td>
<td class='center'> <td class='center'>
<?php if ($show_down) {?> <?php if ($show_down) {?>
<a class='remover ti ti-caret-down-filled moveArrow' title='<?php print __('Move Down'); ?>' href='<?php print htmle('data_queries.php?action=item_movedown_dssv&snmp_query_graph_id=' . grv('id') . '&id=' . $suggested_value['id'] . '&snmp_query_id=' . grv('snmp_query_id') . '&data_template_id=' . $data_template['id'] . '&field_name=' . $suggested_value['field_name']); ?>'></a> <a class='remover ti ti-caret-down-filled moveArrow' title='<?php print __('Move Down'); ?>' href='#' data-url='<?php print html_escape_url('data_queries.php?action=item_movedown_dssv&snmp_query_graph_id=' . grv('id') . '&id=' . $suggested_value['id'] . '&snmp_query_id=' . grv('snmp_query_id') . '&data_template_id=' . $data_template['id'] . '&field_name=' . $suggested_value['field_name']); ?>'></a>
<?php } else {?> <?php } else {?>
<span class='moveArrowNone'></span> <span class='moveArrowNone'></span>
<?php } ?> <?php } ?>
<?php if ($show_up) {?> <?php if ($show_up) {?>
<a class='remover ti ti-caret-up-filled moveArrow' title='<?php print __('Move Up'); ?>' href='<?php print htmle('data_queries.php?action=item_moveup_dssv&snmp_query_graph_id=' . grv('id') . '&id=' . $suggested_value['id'] . '&snmp_query_id=' . grv('snmp_query_id') . '&data_template_id=' . $data_template['id'] . '&field_name=' . $suggested_value['field_name']); ?>'></a> <a class='remover ti ti-caret-up-filled moveArrow' title='<?php print __('Move Up'); ?>' href='#' data-url='<?php print html_escape_url('data_queries.php?action=item_moveup_dssv&snmp_query_graph_id=' . grv('id') . '&id=' . $suggested_value['id'] . '&snmp_query_id=' . grv('snmp_query_id') . '&data_template_id=' . $data_template['id'] . '&field_name=' . $suggested_value['field_name']); ?>'></a>
<?php } else {?> <?php } else {?>
<span class='moveArrowNone'></span> <span class='moveArrowNone'></span>
<?php } ?> <?php } ?>
@@ -924,7 +924,7 @@ function data_query_item_edit() : void {
<?php print htmle($suggested_value['text']); ?> <?php print htmle($suggested_value['text']); ?>
</td> </td>
<td class='right'> <td class='right'>
<a class='remover deleteMarker ti ti-x' title='<?php print __('Delete'); ?>' href='<?php print htmle('data_queries.php?action=item_remove_dssv&snmp_query_graph_id=' . grv('id') . '&id=' . $suggested_value['id'] . '&snmp_query_id=' . grv('snmp_query_id') . '&data_template_id=' . $data_template['id']); ?>'></a> <a class='remover deleteMarker ti ti-x' title='<?php print __('Delete'); ?>' href='#' data-url='<?php print html_escape_url('data_queries.php?action=item_remove_dssv&snmp_query_graph_id=' . grv('id') . '&id=' . $suggested_value['id'] . '&snmp_query_id=' . grv('snmp_query_id') . '&data_template_id=' . $data_template['id']); ?>'></a>
</td> </td>
<?php <?php
@@ -978,10 +978,11 @@ function data_query_item_edit() : void {
<script type='text/javascript'> <script type='text/javascript'>
var graph_template_id_prev=<?php print (int) $item; ?>; var graph_template_id_prev=<?php print (int) $item; ?>;
$('.remover').click(function(event) { $('.remover').on('click', function(event) {
event.preventDefault(); event.preventDefault();
href=$(this).attr('href'); var href = $(this).data('url') || $(this).attr('href');
$.get(href) var request = cactiPreparePostRequestFromUrl(href);
$.post(request.url, request.data)
.done(function(data) { .done(function(data) {
$('form[action="data_queries.php"]').unbind(); $('form[action="data_queries.php"]').unbind();
$('#main').html(data); $('#main').html(data);
+2 -2
View File
@@ -1266,7 +1266,7 @@ function ds_edit() : void {
print "<div class='tabs' style='float:left;'><nav><ul role='tablist'>"; print "<div class='tabs' style='float:left;'><nav><ul role='tablist'>";
foreach ($template_data_rrds as $template_data_rrd) { foreach ($template_data_rrds as $template_data_rrd) {
print "<li class='subTab'><a " . (($template_data_rrd['id'] == grv('view_rrd')) ? "class='pic selected'" : "class='pic'") . " href='" . htmle('data_sources.php?action=ds_edit&id=' . grv('id') . '&view_rrd=' . $template_data_rrd['id']) . "'>$i: " . htmle($template_data_rrd['data_source_name']) . '</a>' . ($use_data_template == false ? " <a class='pic deleteMarker ti ti-x' href='" . htmle('data_sources.php?action=rrd_remove&id=' . $template_data_rrd['id'] . '&local_data_id=' . grv('id')) . "' title='" . __esc('Delete') . "'></a>" : '') . '</li>'; print "<li class='subTab'><a " . (($template_data_rrd['id'] == grv('view_rrd')) ? "class='pic selected'" : "class='pic'") . " href='" . htmle('data_sources.php?action=ds_edit&id=' . grv('id') . '&view_rrd=' . $template_data_rrd['id']) . "'>$i: " . htmle($template_data_rrd['data_source_name']) . '</a>' . ($use_data_template == false ? " <a class='pic deleteMarker ti ti-x cactiPostAction' href='#' data-url='" . html_escape_url('data_sources.php?action=rrd_remove&id=' . $template_data_rrd['id'] . '&local_data_id=' . grv('id')) . "' title='" . __esc('Delete') . "'></a>" : '') . '</li>';
$i++; $i++;
} }
@@ -1284,7 +1284,7 @@ function ds_edit() : void {
" . __esc('Data Source Item %s', $header_label) . " " . __esc('Data Source Item %s', $header_label) . "
</div> </div>
<div class='tableSubHeaderColumn right'> <div class='tableSubHeaderColumn right'>
" . ((!ierv('id') && (empty($data_template['id']))) ? "<a class='linkOverDark' href='" . htmle('data_sources.php?action=rrd_add&id=' . grv('id')) . "'>" . __('New') . '</a>&nbsp;' : '') . ' " . ((!ierv('id') && (empty($data_template['id']))) ? "<a class='linkOverDark cactiPostAction' href='#' data-url='" . html_escape_url('data_sources.php?action=rrd_add&id=' . grv('id')) . "'>" . __('New') . '</a>&nbsp;' : '') . '
</div> </div>
</div>'; </div>';
+7 -2
View File
@@ -806,7 +806,7 @@ function template_edit() : void {
print "<div class='tabs' style='float:left;'><nav><ul role='tablist'>"; print "<div class='tabs' style='float:left;'><nav><ul role='tablist'>";
foreach ($template_data_rrds as $template_data_rrd) { foreach ($template_data_rrds as $template_data_rrd) {
print "<li class='subTab'><a " . (($template_data_rrd['id'] == grv('view_rrd')) ? "class='pic selected'" : "class='pic'") . " href='" . htmle('data_templates.php?action=template_edit&id=' . grv('id') . '&view_rrd=' . $template_data_rrd['id']) . "'>" . ($i + 1) . ': ' . htmle($template_data_rrd['data_source_name']) . '</a>' . ($template_data['data_sources'] == 0 ? "<a class='pic deleteMarker ti ti-x' title='" . __esc('Delete') . "' href='" . htmle('data_templates.php?action=rrd_remove&id=' . $template_data_rrd['id'] . '&data_template_id=' . grv('id')) . "'></a>" : "<a class='deleteMarkerDisabled ti ti-x' href='#' title='" . __esc('Data Templates in use can not be modified') . "'></a>") . '</li>'; print "<li class='subTab'><a " . (($template_data_rrd['id'] == grv('view_rrd')) ? "class='pic selected'" : "class='pic'") . " href='" . htmle('data_templates.php?action=template_edit&id=' . grv('id') . '&view_rrd=' . $template_data_rrd['id']) . "'>" . ($i + 1) . ': ' . htmle($template_data_rrd['data_source_name']) . '</a>' . ($template_data['data_sources'] == 0 ? "<a class='pic deleteMarker ti ti-x cactiPostAction' title='" . __esc('Delete') . "' href='#' data-url='" . html_escape_url('data_templates.php?action=rrd_remove&id=' . $template_data_rrd['id'] . '&data_template_id=' . grv('id')) . "'></a>" : "<a class='deleteMarkerDisabled ti ti-x' href='#' title='" . __esc('Data Templates in use can not be modified') . "'></a>") . '</li>';
$i++; $i++;
} }
@@ -828,7 +828,12 @@ function template_edit() : void {
} }
if (!$isSNMPGet && !$readOnly) { if (!$isSNMPGet && !$readOnly) {
html_start_box(__('Data Source Item [%s]', (isset($template_rrd) ? htmle($template_rrd['data_source_name']) : '')), '100%', true, 3, 'center', (!ierv('id') ? 'data_templates.php?action=rrd_add&id=' . grv('id') : ''), __('New')); $add_button = !ierv('id') ? [[
'data_url' => 'data_templates.php?action=rrd_add&id=' . grv('id'),
'title' => __('New')
]] : '';
html_start_box(__('Data Source Item [%s]', (isset($template_rrd) ? htmle($template_rrd['data_source_name']) : '')), '100%', true, 3, 'center', $add_button, __('New'));
} else { } else {
html_start_box(__('Data Source Item [%s]', (isset($template_rrd) ? htmle($template_rrd['data_source_name']) : '')), '100%', true, 3, 'center', '', ''); html_start_box(__('Data Source Item [%s]', (isset($template_rrd) ? htmle($template_rrd['data_source_name']) : '')), '100%', true, 3, 'center', '', '');
} }
+27 -14
View File
@@ -792,23 +792,36 @@ if ($config['is_web']) {
if (isrv('action')) { if (isrv('action')) {
$action = gnrv('action'); $action = gnrv('action');
// State-changing actions must arrive by POST with a CSRF token. The // State-changing actions must arrive by POST with a CSRF token. Their
// delete actions below were reachable by GET, so a cross-origin <img> // links use cactiPostAction so the query fields and token are submitted
// or link could delete a tree node, graph template, data query or // in the request body instead of remaining cross-origin GET targets.
// automation rule using only the victim's session cookie. $bad_actions = [
// 'save', 'update_data', 'changepassword',
// 'remove' and 'change_leaf' belong to the three automation pages and 'item_remove', 'item_moveup', 'item_movedown',
// no page links either by GET, so a request carrying them was always a 'item_remove_gsv', 'item_remove_dssv',
// crafted one. item_remove, item_moveup and item_movedown are the same 'item_moveup_gsv', 'item_moveup_dssv',
// class but are still linked by GET from roughly fifteen pages; adding 'item_movedown_gsv', 'item_movedown_dssv',
// them here before those links post would break the delete they are 'delete_node', 'gt_remove', 'query_remove', 'remove', 'change_leaf',
// meant to protect. 'moveup', 'movedown', 'tree_up', 'tree_down',
$bad_actions = ['save', 'update_data', 'changepassword', 'delete_node', 'gt_remove', 'query_remove', 'remove', 'change_leaf']; 'move_page_up', 'move_page_down', 'rrd_add', 'rrd_remove',
];
foreach ($bad_actions as $bad) { foreach ($bad_actions as $bad) {
if ($action == $bad && !isset($_POST['__csrf_magic'])) { if ($action == $bad && ($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
cacti_log(sprintf('WARNING: Attempt to use GET method for POST operations in page %s from IP %s', $filename, get_client_addr()), false, 'WEBUI'); // Preserve the legacy warning for form actions. Item-action links
// can also come from plugins outside this repository; crawlers may
// discover those URLs, but they must still fail closed.
if (in_array($bad, ['save', 'update_data', 'changepassword'], true)) {
cacti_log(sprintf('WARNING: Attempt to use GET method for POST operations in page %s from IP %s', $filename, get_client_addr()), false, 'WEBUI');
}
header('Allow: POST');
http_response_code(405);
exit;
}
if ($action == $bad && !isset($_POST['__csrf_magic'])) {
http_response_code(403);
exit; exit;
} }
} }
+68 -1
View File
@@ -2615,6 +2615,56 @@ function loadPageNoHeader(href, scroll, force) {
}); });
} }
function cactiPreparePostRequest(href, postData) {
var target = new URL(href, window.location.href);
if (target.origin !== window.location.origin) {
throw new Error('Refusing to send a CSRF token to a different origin');
}
if (typeof postData === 'string') {
postData = postData.replace(/(^|&)__csrf_magic=[^&]*/g, '').replace(/&{2,}/g, '&').replace(/^&|&$/g, '');
postData = '__csrf_magic=' + encodeURIComponent(csrfMagicToken) + (postData === '' ? '' : '&' + postData);
} else {
postData = $.extend({__csrf_magic: csrfMagicToken}, postData || {});
postData.__csrf_magic = csrfMagicToken;
}
return {
url: target.pathname + target.search,
data: postData
};
}
function cactiPreparePostRequestFromUrl(href) {
var target = new URL(href, window.location.href);
if (target.origin !== window.location.origin) {
throw new Error('Refusing to send a CSRF token to a different origin');
}
var fields = [];
target.searchParams.forEach(function (value, name) {
if (name !== '__csrf_magic') {
fields.push({name: name, value: value});
}
});
return cactiPreparePostRequest(target.pathname, $.param(fields));
}
function submitPageUsingPost(href) {
var request = cactiPreparePostRequestFromUrl(href);
var form = $('<form>', {method: 'post', action: request.url});
new URLSearchParams(request.data).forEach(function (value, name) {
form.append($('<input>', {type: 'hidden', name: name, value: value}));
});
form.appendTo(document.body);
form[0].submit();
}
function loadPage(href, force) { function loadPage(href, force) {
var stack = ''; //getStackTrace(); // new Error().stack; var stack = ''; //getStackTrace(); // new Error().stack;
console.error("Function loadPage is now deprecated, use loadUrl instead\n" + stack); console.error("Function loadPage is now deprecated, use loadUrl instead\n" + stack);
@@ -3009,7 +3059,24 @@ function getPresentHTTPErrorOrRedirect(data, url) {
function ajaxAnchors() { function ajaxAnchors() {
var page = basename(location.pathname); var page = basename(location.pathname);
$('a.pic, a.linkOverDark, a.linkEditMain, a.console, a.hyperLink, a.tab').not('[href^="http"], [href^="https"], [href^="#"], [href^="mailto"], [target="_blank"]').off('click').on('click', function (event) { $(document).off('click.cactiPostAction', 'a.cactiPostAction').on('click.cactiPostAction', 'a.cactiPostAction', function (event) {
if (!shouldCaptureClick(event)) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
var href = $(this).data('url') || $(this).attr('href');
if (href != null && href != '#') {
submitPageUsingPost(href);
}
return false;
});
$('a.pic, a.linkOverDark, a.linkEditMain, a.console, a.hyperLink, a.tab').not('[href^="http"], [href^="https"], [href^="#"], [href^="mailto"], [target="_blank"]').not('.cactiPostAction').off('click').on('click', function (event) {
if (!shouldCaptureClick(event)) { if (!shouldCaptureClick(event)) {
return; return;
} }
+4 -4
View File
@@ -1518,21 +1518,21 @@ function display_graph_rule_items(string $title, array &$rule, int $rule_type, s
$form_data = ''; $form_data = '';
if ($i != cacti_sizeof($items) - 1) { if ($i != cacti_sizeof($items) - 1) {
$form_data .= '<a class="pic ti ti-caret-down-filled moveArrow" href="' . htmle($module . '?action=item_movedown&item_id=' . $item['id'] . '&id=' . $rule_id . '&rule_type=' . $rule_type) . '" title="' . __esc('Move Down') . '"></a>'; $form_data .= '<a class="pic ti ti-caret-down-filled moveArrow cactiPostAction" href="#" data-url="' . html_escape_url($module . '?action=item_movedown&item_id=' . $item['id'] . '&id=' . $rule_id . '&rule_type=' . $rule_type) . '" title="' . __esc('Move Down') . '"></a>';
} else { } else {
$form_data .= '<span class="moveArrowNone"></span>'; $form_data .= '<span class="moveArrowNone"></span>';
} }
if ($i > 0) { if ($i > 0) {
$form_data .= '<a class="pic ti ti-caret-up-filled moveArrow" href="' . htmle($module . '?action=item_moveup&item_id=' . $item['id'] . '&id=' . $rule_id . '&rule_type=' . $rule_type) . '" title="' . __esc('Move Up') . '"></a>'; $form_data .= '<a class="pic ti ti-caret-up-filled moveArrow cactiPostAction" href="#" data-url="' . html_escape_url($module . '?action=item_moveup&item_id=' . $item['id'] . '&id=' . $rule_id . '&rule_type=' . $rule_type) . '" title="' . __esc('Move Up') . '"></a>';
} else { } else {
$form_data .= '<span class="moveArrowNone"></span>'; $form_data .= '<span class="moveArrowNone"></span>';
} }
form_selectable_cell($form_data, $i, '32px', 'right nowrap'); form_selectable_cell($form_data, $i, '32px', 'right nowrap');
$form_data = '<a class="pic deleteMarker ti ti-x" $form_data = '<a class="pic deleteMarker ti ti-x cactiPostAction"
href="' . htmle($module . '?action=item_remove&item_id=' . $item['id'] . '&id=' . $rule_id . '&rule_type=' . $rule_type) . '" title="' . __esc('Delete') . '"></a>'; href="#" data-url="' . html_escape_url($module . '?action=item_remove&item_id=' . $item['id'] . '&id=' . $rule_id . '&rule_type=' . $rule_type) . '" title="' . __esc('Delete') . '"></a>';
form_selectable_cell($form_data, $i, '16px', 'right nowrap'); form_selectable_cell($form_data, $i, '16px', 'right nowrap');
+12 -4
View File
@@ -162,13 +162,21 @@ function html_start_box(string $title, string $width, bool $div, int $cell_paddi
$href = '#'; $href = '#';
} }
if (isset($icon['data_url'])) {
$href = '#';
$data_url = " data-url='" . html_escape_url($icon['data_url']) . "'";
$classo .= ' linkOverDark cactiPostAction';
} else {
$data_url = '';
}
if (isset($icon['title'])) { if (isset($icon['title'])) {
$title = $icon['title']; $title = $icon['title'];
} else { } else {
$title = $add_label; $title = $add_label;
} }
print "<span class='cactiFilterAdd' title='$title'><a" . (isset($icon['id']) ? " id='" . $icon['id'] . "'" : '') . " class='$classo' href='$href'><i class='$classi'></i></a></span>"; print "<span class='cactiFilterAdd' title='$title'><a" . (isset($icon['id']) ? " id='" . $icon['id'] . "'" : '') . " class='" . trim($classo) . "' href='$href'$data_url><i class='$classi'></i></a></span>";
} }
} }
} else { } else {
@@ -1804,18 +1812,18 @@ function draw_graph_items_list(array $item_list, string $filename, string $url_d
print "<td class='right nowrap'>"; print "<td class='right nowrap'>";
if ($i != cacti_sizeof($item_list) - 1) { if ($i != cacti_sizeof($item_list) - 1) {
print "<span><a class='moveArrow ti ti-caret-down-filled' title='" . __esc('Move Down') . "' href='" . htmle("$filename?action=item_movedown&id=" . $item['id'] . "&$url_data") . "'></a></span>"; print "<span><a class='moveArrow ti ti-caret-down-filled cactiPostAction' title='" . __esc('Move Down') . "' href='#' data-url='" . html_escape_url("$filename?action=item_movedown&id=" . $item['id'] . "&$url_data") . "'></a></span>";
} else { } else {
print "<span class='moveArrowNone'></span>"; print "<span class='moveArrowNone'></span>";
} }
if ($i > 0) { if ($i > 0) {
print "<span><a class='moveArrow ti ti-caret-up-filled' title='" . __esc('Move Up') . "' href='" . htmle("$filename?action=item_moveup&id=" . $item['id'] . "&$url_data") . "'></a></span>"; print "<span><a class='moveArrow ti ti-caret-up-filled cactiPostAction' title='" . __esc('Move Up') . "' href='#' data-url='" . html_escape_url("$filename?action=item_moveup&id=" . $item['id'] . "&$url_data") . "'></a></span>";
} else { } else {
print "<span class='moveArrowNone'></span>"; print "<span class='moveArrowNone'></span>";
} }
print "<a class='deleteMarker ti ti-x' title='" . __esc('Delete') . "' href='" . htmle("$filename?action=item_remove&id=" . $item['id'] . "&nostate=true&$url_data") . "'></a>"; print "<a class='deleteMarker ti ti-x cactiPostAction' title='" . __esc('Delete') . "' href='#' data-url='" . html_escape_url("$filename?action=item_remove&id=" . $item['id'] . "&nostate=true&$url_data") . "'></a>";
print '</td>'; print '</td>';
} }
+4 -4
View File
@@ -1868,14 +1868,14 @@ function display_reports_items(int $report_id) : void {
$form_data .= '<td>' . $size . '</td>'; $form_data .= '<td>' . $size . '</td>';
if ($i == 1) { if ($i == 1) {
$form_data .= '<td class="right nowrap"><a class="pic remover ti ti-caret-down-filled moveArrow" style="padding:3px" title="' . __esc('Move Down') . '" href="' . htmle(get_reports_page() . '?action=item_movedown&item_id=' . $item['id'] . '&id=' . $report_id) . '"></a>' . '<span style="padding:5ps" class="moveArrowNone"></span>'; $form_data .= '<td class="right nowrap"><a class="pic remover ti ti-caret-down-filled moveArrow cactiPostAction" style="padding:3px" title="' . __esc('Move Down') . '" href="#" data-url="' . html_escape_url(get_reports_page() . '?action=item_movedown&item_id=' . $item['id'] . '&id=' . $report_id) . '"></a>' . '<span style="padding:5ps" class="moveArrowNone"></span>';
} elseif ($i < cacti_sizeof($items)) { } elseif ($i < cacti_sizeof($items)) {
$form_data .= '<td class="right nowrap"><a class="pic remover ti ti-caret-down-filled moveArrow" style="padding:3px" title="' . __esc('Move Down') . '" href="' . htmle(get_reports_page() . '?action=item_movedown&item_id=' . $item['id'] . '&id=' . $report_id) . '"></a>' . '<a class="remover ti ti-caret-up-filled moveArrow" style="padding:3px" title="' . __esc('Move Up') . '" href="' . htmle(get_reports_page() . '?action=item_moveup&item_id=' . $item['id'] . '&id=' . $report_id) . '"></a>'; $form_data .= '<td class="right nowrap"><a class="pic remover ti ti-caret-down-filled moveArrow cactiPostAction" style="padding:3px" title="' . __esc('Move Down') . '" href="#" data-url="' . html_escape_url(get_reports_page() . '?action=item_movedown&item_id=' . $item['id'] . '&id=' . $report_id) . '"></a>' . '<a class="remover ti ti-caret-up-filled moveArrow cactiPostAction" style="padding:3px" title="' . __esc('Move Up') . '" href="#" data-url="' . html_escape_url(get_reports_page() . '?action=item_moveup&item_id=' . $item['id'] . '&id=' . $report_id) . '"></a>';
} else { } else {
$form_data .= '<td class="right nowrap"><span style="padding:3px" class="moveArrowNone"></span>' . '<a class="remover ti ti-caret-up-filled moveArrow" style="padding:3px" title="' . __esc('Move Up') . '" href="' . htmle(get_reports_page() . '?action=item_moveup&item_id=' . $item['id'] . '&id=' . $report_id) . '"></a>'; $form_data .= '<td class="right nowrap"><span style="padding:3px" class="moveArrowNone"></span>' . '<a class="remover ti ti-caret-up-filled moveArrow cactiPostAction" style="padding:3px" title="' . __esc('Move Up') . '" href="#" data-url="' . html_escape_url(get_reports_page() . '?action=item_moveup&item_id=' . $item['id'] . '&id=' . $report_id) . '"></a>';
} }
$form_data .= '<a class="pic deleteMarker ti ti-x" style="padding:3px" href="' . htmle(get_reports_page() . '?action=item_remove&item_id=' . $item['id'] . '&id=' . $report_id) . '" title="' . __esc('Delete') . '"></a>' . '</td></tr>'; $form_data .= '<a class="pic deleteMarker ti ti-x cactiPostAction" style="padding:3px" href="#" data-url="' . html_escape_url(get_reports_page() . '?action=item_remove&item_id=' . $item['id'] . '&id=' . $report_id) . '" title="' . __esc('Delete') . '"></a>' . '</td></tr>';
print $form_data; print $form_data;
+2 -2
View File
@@ -349,7 +349,7 @@ function pages() : void {
if (grv('sort_column') == 'sortorder') { if (grv('sort_column') == 'sortorder') {
if ($i != 0) { if ($i != 0) {
$sort = '<a class="pic ti ti-caret-up-filled moveArrow" href="' . htmle('links.php?action=move_page_up&order=' . $page['sortorder'] . '&id=' . $page['id']) . '"></a>'; $sort = '<a class="pic ti ti-caret-up-filled moveArrow cactiPostAction" href="#" data-url="' . html_escape_url('links.php?action=move_page_up&order=' . $page['sortorder'] . '&id=' . $page['id']) . '"></a>';
} else { } else {
$sort = '<span class="moveArrowNone"></span>'; $sort = '<span class="moveArrowNone"></span>';
} }
@@ -357,7 +357,7 @@ function pages() : void {
if ($i == cacti_sizeof($pages) - 1) { if ($i == cacti_sizeof($pages) - 1) {
$sort .= '<span class="moveArrowNone"></span>'; $sort .= '<span class="moveArrowNone"></span>';
} else { } else {
$sort .= '<a class="pic ti ti-caret-down-filled moveArrow" href="' . htmle('links.php?action=move_page_down&order=' . $page['sortorder'] . '&id=' . $page['id']) . '"></a>'; $sort .= '<a class="pic ti ti-caret-down-filled moveArrow cactiPostAction" href="#" data-url="' . html_escape_url('links.php?action=move_page_down&order=' . $page['sortorder'] . '&id=' . $page['id']) . '"></a>';
} }
form_selectable_cell($sort, $page['id'], '', 'center'); form_selectable_cell($sort, $page['id'], '', 'center');
+2 -2
View File
@@ -1489,13 +1489,13 @@ function format_plugin_row(array $plugin, bool $last_plugin, bool $include_order
$row .= "<td class='nowrap right'>"; $row .= "<td class='nowrap right'>";
if (!$first_plugin) { if (!$first_plugin) {
$row .= "<a class='pic ti ti-caret-up-filled moveArrow' href='" . htmle(CACTI_PATH_URL . 'plugins.php?action=moveup&plugin=' . $plugin['plugin']) . "' title='" . __esc('Order Before Previous Plugin') . "'></a>"; $row .= "<a class='pic ti ti-caret-up-filled moveArrow cactiPostAction' href='#' data-url='" . html_escape_url(CACTI_PATH_URL . 'plugins.php?action=moveup&plugin=' . $plugin['plugin']) . "' title='" . __esc('Order Before Previous Plugin') . "'></a>";
} else { } else {
$row .= '<span class="moveArrowNone"></span>'; $row .= '<span class="moveArrowNone"></span>';
} }
if ($last_plugin === false) { if ($last_plugin === false) {
$row .= "<a class='pic ti ti-caret-down-filled moveArrow' href='" . htmle(CACTI_PATH_URL . 'plugins.php?action=movedown&plugin=' . $plugin['plugin']) . "' title='" . __esc('Order After Next Plugin') . "'></a>"; $row .= "<a class='pic ti ti-caret-down-filled moveArrow cactiPostAction' href='#' data-url='" . html_escape_url(CACTI_PATH_URL . 'plugins.php?action=movedown&plugin=' . $plugin['plugin']) . "' title='" . __esc('Order After Next Plugin') . "'></a>";
} else { } else {
$row .= '<span class="moveArrowNone"></span>'; $row .= '<span class="moveArrowNone"></span>';
} }
+6 -3
View File
@@ -1,6 +1,6 @@
<?php <?php
/* /*
+-------------------------------------------------------------------------+ +-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group | | Copyright (C) 2004-2026 The Cacti Group |
| | | |
| This program is free software; you can redistribute it and/or | | This program is free software; you can redistribute it and/or |
@@ -24,8 +24,11 @@ $root = dirname(__DIR__, 2);
test('the server rejects these delete actions on GET without a token', function () use ($root) { test('the server rejects these delete actions on GET without a token', function () use ($root) {
$src = file_get_contents($root . '/include/global.php'); $src = file_get_contents($root . '/include/global.php');
// the GET-method block now covers the delete actions // The GET-method block covers the original delete actions even as the
expect($src)->toContain("\$bad_actions = ['save', 'update_data', 'changepassword', 'delete_node', 'gt_remove', 'query_remove', 'remove', 'change_leaf']"); // protected list grows to include other state-changing actions.
foreach (['save', 'update_data', 'changepassword', 'delete_node', 'gt_remove', 'query_remove', 'remove', 'change_leaf'] as $action) {
expect($src)->toContain("'$action'");
}
// the block still requires the csrf token on POST // the block still requires the csrf token on POST
expect($src)->toContain("!isset(\$_POST['__csrf_magic'])"); expect($src)->toContain("!isset(\$_POST['__csrf_magic'])");
}); });
@@ -0,0 +1,224 @@
<?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 |
+-------------------------------------------------------------------------+
*/
/*
* Item, tree, RRA, and ordering actions change state and were reachable by GET,
* so a cross-origin request could delete or reorder objects using the victim's
* authentication state.
*
* The guard and the links have to move together: adding the actions to
* $bad_actions while a page still links them by GET breaks the delete it is
* meant to protect. These two assertions fail in opposite directions, so
* neither half can land alone.
*/
/**
* Every GET reachable action whose handler mutates state. Read-only actions are
* deliberately absent: item_edit, readme, changelog, latest, avail and the
* *_confirm dialogs render a page and change nothing.
*
* @return array<int, string> Action names.
*/
function guarded_actions() {
return [
'item_remove', 'item_moveup', 'item_movedown',
'item_remove_gsv', 'item_remove_dssv',
'item_moveup_gsv', 'item_moveup_dssv',
'item_movedown_gsv', 'item_movedown_dssv',
'delete_node', 'gt_remove', 'query_remove', 'remove', 'change_leaf',
'moveup', 'movedown',
'tree_up', 'tree_down', 'move_page_up', 'move_page_down',
'rrd_add', 'rrd_remove',
];
}
/**
* A pattern matching any guarded action, longest name first so that
* item_remove_gsv is not matched as item_remove.
*
* @return string A regex alternation.
*/
function guarded_action_pattern() {
$actions = guarded_actions();
usort($actions, function ($a, $b) {
return strlen($b) - strlen($a);
});
return 'action=(?:' . implode('|', array_map('preg_quote', $actions)) . ')(?![a-z_])';
}
function item_action_files() {
$root = dirname(__DIR__, 4);
$found = [];
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS));
foreach ($it as $file) {
$path = $file->getPathname();
if (substr($path, -4) !== '.php') {
continue;
}
if (strpos($path, '/include/vendor/') !== false || strpos($path, '/tests/') !== false) {
continue;
}
$src = file_get_contents($path);
if ($src !== false && preg_match('/' . guarded_action_pattern() . '/', $src)) {
$found[] = substr($path, strlen($root) + 1);
}
}
sort($found);
return $found;
}
test('every state changing action is refused without a POST token', function () {
$src = file_get_contents(dirname(__DIR__, 4) . '/include/global.php');
expect($src)->not->toBeFalse();
preg_match('/\$bad_actions\s*=\s*(?:array\(|\[)(.*?)(?:\);|\];)/s', $src, $matches);
expect($matches)->toHaveKey(1);
$list = $matches[1];
$missing = [];
foreach (guarded_actions() as $action) {
if (strpos($list, "'" . $action . "'") === false) {
$missing[] = $action;
}
}
expect($missing)->toBe([]);
expect($src)->toContain("(\$_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST'")
->and($src)->toContain("header('Allow: POST')")
->and($src)->toContain('http_response_code(405)')
->and($src)->toContain('http_response_code(403)');
});
test('no page still links a state changing action by GET', function () {
$files = item_action_files();
expect($files)->not->toBe([]);
$unconverted = [];
foreach ($files as $file) {
$src = file_get_contents(dirname(__DIR__, 4) . '/' . $file);
/* Anchors are emitted across two lines in places, so collapse the file
before pairing an href with the class that posts it. */
$flat = preg_replace('/\s+/', ' ', $src);
if (preg_match_all('/<a\b[^>]*?' . guarded_action_pattern() . '[^>]*>/', $flat, $matches)) {
foreach ($matches[0] as $anchor) {
$has_safe_href = strpos($anchor, "href='#'") !== false
|| strpos($anchor, 'href="#"') !== false;
$uses_global_handler = strpos($anchor, 'cactiPostAction') !== false;
$uses_page_handler = strpos($anchor, 'remover') !== false
&& strpos($src, "$('.remover').on('click'") !== false
&& strpos($src, 'cactiPreparePostRequestFromUrl') !== false;
if (!$has_safe_href || (!$uses_global_handler && !$uses_page_handler)) {
$unconverted[] = $file;
break;
}
}
}
}
expect($unconverted)->toBe([]);
});
test('the cactiPostAction handler posts the token and refuses another origin', function () {
$js = file_get_contents(dirname(__DIR__, 4) . '/include/layout.js');
expect($js)->not->toBeFalse();
/* The class has to be bound on its own, not only through the ajaxAnchors
selector, because several tagged anchors carry none of the classes that
selector names. */
expect($js)->toContain("$(document).off('click.cactiPostAction', 'a.cactiPostAction')")
->and($js)->toContain(".on('click.cactiPostAction', 'a.cactiPostAction'")
->and($js)->toContain(".not('.cactiPostAction').off('click')")
->and($js)->toContain('submitPageUsingPost')
->and($js)->toContain('cactiPreparePostRequestFromUrl');
$start = strpos($js, 'function cactiPreparePostRequest(');
expect($start)->not->toBeFalse();
$body = substr($js, $start, 600);
expect($body)->toContain('csrfMagicToken')
->and($body)->toContain('__csrf_magic')
->and($body)->toContain('Refusing to send a CSRF token to a different origin');
});
test('state action URLs use attribute-safe escaping and the page handler posts', function () {
$data_queries = file_get_contents(dirname(__DIR__, 4) . '/data_queries.php');
$html = file_get_contents(dirname(__DIR__, 4) . '/lib/html.php');
expect($data_queries)->not->toBeFalse()
->and($html)->not->toBeFalse()
->and(substr_count($data_queries, "data-url='<?php print html_escape_url("))->toBeGreaterThanOrEqual(6)
->and(substr_count($html, "data-url='\" . html_escape_url("))->toBeGreaterThanOrEqual(3)
->and($data_queries)->toContain("$('.remover').on('click'")
->and($data_queries)->toContain('cactiPreparePostRequestFromUrl(href)')
->and($data_queries)->toContain('$.post(request.url, request.data)');
});
test('every guarded data URL uses attribute-safe URL escaping', function () {
$unsafe = [];
foreach (item_action_files() as $file) {
$lines = file(dirname(__DIR__, 4) . '/' . $file);
foreach ($lines as $line_number => $line) {
$attribute_start = strpos($line, 'data-url=');
if ($attribute_start === false || !preg_match('/' . guarded_action_pattern() . '/', $line)) {
continue;
}
$attribute = substr($line, $attribute_start);
if (strpos($attribute, 'html_escape_url(') === false
|| strpos($attribute, 'htmle(') !== false
|| strpos($attribute, 'htmlspecialchars(') !== false) {
$unsafe[] = $file . ':' . ($line_number + 1);
}
}
}
expect($unsafe)->toBe([]);
});
test('the data template add button submits its guarded action by POST', function () {
$data_templates = file_get_contents(dirname(__DIR__, 4) . '/data_templates.php');
$html = file_get_contents(dirname(__DIR__, 4) . '/lib/html.php');
expect($data_templates)->toContain("'data_url' => 'data_templates.php?action=rrd_add&id='")
->and($html)->toContain("isset(\$icon['data_url'])")
->and($html)->toContain("html_escape_url(\$icon['data_url'])")
->and($html)->toContain("linkOverDark cactiPostAction");
});
+80
View File
@@ -0,0 +1,80 @@
/*
+-------------------------------------------------------------------------+
| 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 |
+-------------------------------------------------------------------------+
*/
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const vm = require('node:vm');
function extractFunction(source, name) {
const start = source.indexOf(`function ${name}(`);
assert.notEqual(start, -1, `${name}() must exist`);
const bodyStart = source.indexOf('{', start);
let depth = 0;
for (let offset = bodyStart; offset < source.length; offset++) {
if (source[offset] === '{') depth++;
if (source[offset] === '}' && --depth === 0) return source.slice(start, offset + 1);
}
throw new Error(`Unable to extract ${name}()`);
}
const source = fs.readFileSync(path.join(__dirname, '..', '..', 'include', 'layout.js'), 'utf8');
const context = {
URL,
URLSearchParams,
csrfMagicToken: 'trusted-token',
window: {location: {href: 'https://cacti.example/cacti/', origin: 'https://cacti.example'}},
$: {
extend: (target, sourceValue) => Object.assign(target, sourceValue),
param: (fields) => new URLSearchParams(fields.map(({name, value}) => [name, value])).toString(),
},
};
vm.runInNewContext(`${extractFunction(source, 'cactiPreparePostRequest')}\n${extractFunction(source, 'cactiPreparePostRequestFromUrl')}`, context);
test('state-changing URL fields move into a same-origin POST body with the trusted token', () => {
const request = context.cactiPreparePostRequestFromUrl('/cacti/cdef.php?action=item_remove&id=7&__csrf_magic=attacker');
const fields = new URLSearchParams(request.data);
assert.equal(request.url, '/cacti/cdef.php');
assert.equal(fields.get('action'), 'item_remove');
assert.equal(fields.get('id'), '7');
assert.equal(fields.get('__csrf_magic'), 'trusted-token');
});
test('an explicit POST body cannot override the trusted token', () => {
const request = context.cactiPreparePostRequest('/cacti/tree.php', 'action=tree_up&__csrf_magic=attacker&id=9');
const fields = new URLSearchParams(request.data);
assert.equal(fields.get('__csrf_magic'), 'trusted-token');
assert.equal(fields.get('action'), 'tree_up');
assert.equal(fields.get('id'), '9');
assert.doesNotMatch(request.data, /&&/);
});
test('cross-origin targets are rejected before a token is exposed', () => {
assert.throws(
() => context.cactiPreparePostRequestFromUrl('https://attacker.example/delete?action=item_remove'),
/different origin/,
);
});
test('the state-action handler survives element-level unbind calls', () => {
assert.match(source, /\$\(document\)\.off\('click\.cactiPostAction', 'a\.cactiPostAction'\)\.on\('click\.cactiPostAction', 'a\.cactiPostAction'/);
assert.doesNotMatch(source, /\$\('a\.cactiPostAction'\)\.off\('click\.cactiPostAction'/);
});
+4 -4
View File
@@ -2196,14 +2196,14 @@ function tree() : void {
$sequence .= '<span class="moveArrowNone"></span>'; $sequence .= '<span class="moveArrowNone"></span>';
$sequence .= '<span class="moveArrowNone"></span>'; $sequence .= '<span class="moveArrowNone"></span>';
} elseif ($i == 1) { } elseif ($i == 1) {
$sequence .= '<a class="pic ti ti-caret-down-filled moveArrow" href="' . htmlspecialchars('tree.php?action=tree_down&id=' . $tree['id']) . '" title="' . __esc('Move Down') . '"></a>'; $sequence .= '<a class="pic ti ti-caret-down-filled moveArrow cactiPostAction" href="#" data-url="' . html_escape_url('tree.php?action=tree_down&id=' . $tree['id']) . '" title="' . __esc('Move Down') . '"></a>';
$sequence .= '<span class="moveArrowNone"></span>'; $sequence .= '<span class="moveArrowNone"></span>';
} elseif ($i == cacti_sizeof($trees)) { } elseif ($i == cacti_sizeof($trees)) {
$sequence .= '<span class="moveArrowNone"></span>'; $sequence .= '<span class="moveArrowNone"></span>';
$sequence .= '<a class="pic ti ti-caret-up-filled moveArrow" href="' . htmle('tree.php?action=tree_up&id=' . $tree['id']) . '" title="' . __esc('Move Up') . '"></a>'; $sequence .= '<a class="pic ti ti-caret-up-filled moveArrow cactiPostAction" href="#" data-url="' . html_escape_url('tree.php?action=tree_up&id=' . $tree['id']) . '" title="' . __esc('Move Up') . '"></a>';
} else { } else {
$sequence .= '<a class="pic ti ti-caret-down-filled moveArrow" href="' . htmle('tree.php?action=tree_down&id=' . $tree['id']) . '" title="' . __esc('Move Down') . '"></a>'; $sequence .= '<a class="pic ti ti-caret-down-filled moveArrow cactiPostAction" href="#" data-url="' . html_escape_url('tree.php?action=tree_down&id=' . $tree['id']) . '" title="' . __esc('Move Down') . '"></a>';
$sequence .= '<a class="pic ti ti-caret-up-filled moveArrow" href="' . htmle('tree.php?action=tree_up&id=' . $tree['id']) . '" title="' . __esc('Move Up') . '"></a>'; $sequence .= '<a class="pic ti ti-caret-up-filled moveArrow cactiPostAction" href="#" data-url="' . html_escape_url('tree.php?action=tree_up&id=' . $tree['id']) . '" title="' . __esc('Move Up') . '"></a>';
} }
} }
+2 -2
View File
@@ -623,13 +623,13 @@ function vdef_edit() : void {
if (read_config_option('drag_and_drop') == '') { if (read_config_option('drag_and_drop') == '') {
if ($i < $total_items) { if ($i < $total_items) {
$actions .= '<a class="pic ti ti-caret-down-filled moveArrow" href="' . htmle('vdef.php?action=item_movedown&id=' . $vdef_item['id'] . '&vdef_id=' . $vdef_item['vdef_id']) . '" title="' . __esc('Move Down') . '"></a>'; $actions .= '<a class="pic ti ti-caret-down-filled moveArrow cactiPostAction" href="#" data-url="' . html_escape_url('vdef.php?action=item_movedown&id=' . $vdef_item['id'] . '&vdef_id=' . $vdef_item['vdef_id']) . '" title="' . __esc('Move Down') . '"></a>';
} else { } else {
$actions .= '<span class="moveArrowNone"></span>'; $actions .= '<span class="moveArrowNone"></span>';
} }
if ($i > 1 && $i <= $total_items) { if ($i > 1 && $i <= $total_items) {
$actions .= '<a class="pic ti ti-caret-up-filled moveArrow" href="' . htmle('vdef.php?action=item_moveup&id=' . $vdef_item['id'] . '&vdef_id=' . $vdef_item['vdef_id']) . '" title="' . __esc('Move Up') . '"></a>'; $actions .= '<a class="pic ti ti-caret-up-filled moveArrow cactiPostAction" href="#" data-url="' . html_escape_url('vdef.php?action=item_moveup&id=' . $vdef_item['id'] . '&vdef_id=' . $vdef_item['vdef_id']) . '" title="' . __esc('Move Up') . '"></a>';
} else { } else {
$actions .= '<span class="moveArrowNone"></span>'; $actions .= '<span class="moveArrowNone"></span>';
} }