fix(security): [develop] require protected POSTs for administrative mutation endpoints (#8331)

* fix(csrf): require POST for administrative mutation endpoints

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

* test(csrf): support token-checking mutation helper

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-10-12 15:41:29 -07:00
1 parent 49c95bef63
commit 5f72f1a03c
10 files changed
+282 -2

No files matched your search

+8
View File
@@ -67,6 +67,14 @@ set_default_action();
api_plugin_hook('device_top');
if (in_array(grv('action'), [
'reindex', 'gt_add', 'query_add',
'query_change', 'query_reload', 'query_verbose',
'enable_debug', 'disable_debug', 'repopulate',
], true)) {
csrf_require_post();
}
switch (grv('action')) {
case 'export':
host_export();
+80
View File
@@ -2897,6 +2897,78 @@ function loadPage(href, force) {
});
}
function cactiIsPostActionUrl(href) {
var target;
try {
target = new URL(href, window.location.href);
} catch (error) {
return false;
}
if (target.origin !== window.location.origin) return false;
var actions = {
"utilities.php": [
"clear_poller_cache",
"rebuild_resource_cache",
"clear_logfile",
"purge_logfile",
"purge_data_source_statistics",
"rebuild_snmpagent_cache"
],
"tree.php": [
"sortasc",
"sortdesc",
"ajax_dnd",
"lock",
"unlock",
"set_host_sort",
"set_branch_sort"
],
"links.php": [
"delete_page",
"ajax_dnd"
],
"reports.php": [
"send",
"remove_history",
"ajax_dnd"
],
"plugins.php": [
"load",
"latest",
"install",
"uninstall",
"remove",
"remove_data",
"disable",
"enable",
"check",
"moveup",
"movedown",
"remote_enable",
"remote_disable",
"restore",
"delete",
"archive",
"ajax_dnd"
],
"host.php": [
"reindex",
"gt_add",
"query_add",
"query_change",
"query_reload",
"query_verbose",
"enable_debug",
"disable_debug",
"repopulate"
]
};
var page = target.pathname.split('/').pop();
var root = typeof urlPath === 'undefined' ? new URL('.', window.location.href).href : new URL(urlPath, window.location.href).href;
if (target.pathname !== new URL(page, root).pathname) return false;
return (actions[page] || []).indexOf(target.searchParams.get('action')) !== -1;
}
function loadUrl(options) {
setCactiTabCookie();
@@ -2944,6 +3016,14 @@ function loadUrl(options) {
$.ajaxQ.abortAll();
if (cactiIsPostActionUrl(options.url)) {
var request = cactiPreparePostRequestFromUrl(options.url);
options.url = request.url;
return $.post(options.url, request.data, undefined, options.json ? 'json' : undefined)
.done(function (html) { handleAjaxResponse(html, options); return false; })
.fail(function (html) { getPresentHTTPError(html); });
}
if (options.json == true) {
return $.getJSON(options.url)
.done(function (html) {
+6
View File
@@ -32,6 +32,12 @@ $actions = [
set_default_action();
if (in_array(grv('action'), [
'delete_page', 'ajax_dnd',
], true)) {
csrf_require_post();
}
switch (grv('action')) {
case 'actions':
form_actions();
+11
View File
@@ -180,6 +180,17 @@ if (isrv('plugin')) {
$plugin = '';
}
if (in_array(grv('action'), [
'load', 'latest', 'install',
'uninstall', 'remove', 'remove_data',
'disable', 'enable', 'check',
'moveup', 'movedown', 'remote_enable',
'remote_disable', 'restore', 'delete',
'archive', 'ajax_dnd',
], true)) {
csrf_require_post();
}
switch($action) {
case 'list':
case 'avail':
+6
View File
@@ -49,6 +49,12 @@ ini_set('max_execution_time', '300');
// set default action
set_default_action();
if (in_array(grv('action'), [
'send', 'remove_history', 'ajax_dnd',
], true)) {
csrf_require_post();
}
switch (grv('action')) {
case 'save':
reports_form_save();
@@ -0,0 +1,37 @@
<?php
dataset('mutation request methods', function () {
$root = dirname(__DIR__, 4);
$source = file_get_contents($root . '/include/layout.js');
preg_match('/var actions = (\{.*?\});/s', $source, $match);
$actions = json_decode($match[1], true, 512, JSON_THROW_ON_ERROR);
foreach ($actions as $file => $names) {
foreach ($names as $action) {
foreach (['GET', 'HEAD', 'PUT', 'POST'] as $method) {
yield "$file $action $method" => [$file, $action, $method];
}
}
}
});
test('mutation endpoint guards reject other methods before dispatch', function (string $file, string $action, string $method) {
$root = dirname(__DIR__, 4);
$fixture = dirname(__DIR__, 2) . '/fixtures/mutation_guard_probe.php';
$process = proc_open([PHP_BINARY, $fixture, $root, $file, $action, $method], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
expect($process)->not->toBeFalse();
$output = stream_get_contents($pipes[1]);
$error = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$status = proc_close($process);
expect($error)->toBe('')->and($status)->toBe(0);
$result = json_decode($output, true, 512, JSON_THROW_ON_ERROR);
expect($result)->toBe(['status' => $method === 'POST' ? 200 : 405, 'reached' => $method === 'POST']);
})->with('mutation request methods');
test('the web bootstrap validates CSRF tokens before page handlers dispatch', function () {
$source = file_get_contents(dirname(__DIR__, 4) . '/include/global.php');
expect($source)->toContain('csrf_startup();');
expect(strpos($source, 'csrf_startup();'))->toBeLessThan(strpos($source, '$bad_actions ='));
});
@@ -0,0 +1,43 @@
<?php
$root = $argv[1];
$file = $argv[2];
$action = $argv[3];
$method = $argv[4];
require $root . '/include/vendor/autoload.php';
$parser = (new PhpParser\ParserFactory())->createForNewestSupportedVersion();
$finder = new PhpParser\NodeFinder();
$printer = new PhpParser\PrettyPrinter\Standard();
$nodes = $parser->parse(file_get_contents($root . '/' . $file));
$guard = $finder->findFirst($nodes, static fn ($node) => $node instanceof PhpParser\Node\Stmt\If_
&& count($node->stmts) === 1
&& $node->stmts[0] instanceof PhpParser\Node\Stmt\Expression
&& $node->stmts[0]->expr instanceof PhpParser\Node\Expr\FuncCall
&& $node->stmts[0]->expr->name instanceof PhpParser\Node\Name
&& $node->stmts[0]->expr->name->toString() === 'csrf_require_post');
if (!$guard) {
throw new RuntimeException('Missing mutation guard in ' . $file);
}
$helper = $finder->findFirst($parser->parse(file_get_contents($root . '/include/csrf.php')),
static fn ($node) => $node instanceof PhpParser\Node\Stmt\Function_ && $node->name->toString() === 'csrf_require_post');
if (!$helper) {
throw new RuntimeException('Missing POST helper');
}
$_SERVER['REQUEST_METHOD'] = $method;
$reached = false;
http_response_code(200);
register_shutdown_function(static function () {
print json_encode(['status' => http_response_code(), 'reached' => $GLOBALS['reached']]);
});
function grv($name) {
return $GLOBALS['action'];
}
// csrf_startup() has already accepted the POST token before page dispatch.
// The helper also checks it when combined with the reorder hardening change.
function csrf_check(bool $fatal = true): bool {
return true;
}
eval($printer->prettyPrint([$helper, $guard]));
$reached = true;
+74
View File
@@ -0,0 +1,74 @@
'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');
const root = path.join(__dirname, '../..');
const source = fs.readFileSync(path.join(root, 'include/layout.js'), 'utf8');
const start = source.indexOf('function cactiIsPostActionUrl(');
const end = source.indexOf('function loadUrl(', start);
const context = {URL, urlPath:'/cacti/', window:{location:{href:'https://cacti.example/cacti/host.php', origin:'https://cacti.example'}}};
vm.runInNewContext(source.slice(start, end), context);
const actions = JSON.parse(source.slice(start, end).match(/var actions = (\{[\s\S]*?\});/)[1]);
for (const [file, names] of Object.entries(actions)) {
test(`${file} browser transport matches its server-side guard`, () => {
const php = fs.readFileSync(path.join(root, file), 'utf8');
const guard = php.match(/if \(in_array\(grv\('action'\), \[([\s\S]*?)\], true\)\) \{\s*csrf_require_post\(\);/);
assert.ok(guard, file);
const serverActions = Array.from(guard[1].matchAll(/'([^']+)'/g), match => match[1]);
assert.deepEqual(names, serverActions);
for (const action of names) assert.equal(context.cactiIsPostActionUrl(`/cacti/${file}?action=${action}&id=11`), true, action);
});
}
test('read-only, external and plugin endpoints retain GET transport', () => {
for (const href of ['/cacti/host.php?action=edit', '/cacti/plugins.php?action=readme', '/cacti/tree.php?action=get_node', '/cacti/utilities.php?action=view_logfile', '/cacti/plugins/fixture/tree.php?action=sortasc', 'https://external.example/cacti/links.php?action=delete_page', 'http://[invalid']) {
assert.equal(context.cactiIsPostActionUrl(href), false, href);
}
});
test('loadUrl converts mutation query fields to a token-bearing POST after the unsaved-form check', () => {
const body = source.slice(source.indexOf('function loadUrl('), source.indexOf('function postUrl('));
assert.ok(body.indexOf('checkFormStatus(') < body.indexOf('cactiIsPostActionUrl('));
assert.match(body, /cactiPreparePostRequestFromUrl\(options\.url\)/);
assert.match(body, /\$\.post\(options\.url, request\.data/);
});
function transportFixture(allow = true) {
const calls = [];
const chain = {done() {return this;}, fail() {return this;}};
const $ = () => ({empty() {return this;}, remove() {return this;}});
$.toast = () => ({reset() {}});
$.ajaxQ = {abortAll() {}};
$.extend = (target, fields) => Object.assign(target, fields);
$.param = fields => new URLSearchParams(fields.map(({name, value}) => [name, value])).toString();
$.post = (url, data, callback, type) => {calls.push({method:'POST', url, data, type}); return chain;};
$.get = url => {calls.push({method:'GET', url}); return chain;};
$.getJSON = $.get;
const env = {...context, $, URLSearchParams, csrfMagicToken:'trusted-token', setCactiTabCookie() {}, clearAllTimeouts() {}, closeDateFilters() {}, handleAjaxResponse() {}, getPresentHTTPError() {}, checkFormStatus:() => allow, sanitizeAjaxOptions:options => ({funcStart:'', loadType:'noheader', ...options})};
const prepare = source.slice(source.indexOf('function cactiPreparePostRequest('), source.indexOf('function submitPageUsingPost('));
const transport = source.slice(source.indexOf('function loadUrl('), source.indexOf('function postUrl('));
vm.runInNewContext(prepare + source.slice(start, end) + transport, env);
return {env, calls};
}
test('the production loadUrl sends action fields and the trusted token in a POST body', () => {
const {env, calls} = transportFixture();
env.loadUrl({url:'/cacti/plugins.php?action=uninstall&plugin=fixture&__csrf_magic=stale'});
assert.equal(calls.length, 1);
assert.equal(calls[0].method, 'POST');
assert.equal(calls[0].url, '/cacti/plugins.php');
const fields = new URLSearchParams(calls[0].data);
assert.equal(fields.get('action'), 'uninstall');
assert.equal(fields.get('plugin'), 'fixture');
assert.equal(fields.get('__csrf_magic'), 'trusted-token');
});
test('cancelled navigation sends nothing and read-only navigation still uses GET', () => {
const cancelled = transportFixture(false);
cancelled.env.loadUrl({url:'/cacti/utilities.php?action=clear_logfile'});
assert.equal(cancelled.calls.length, 0);
const read = transportFixture();
read.env.loadUrl({url:'/cacti/utilities.php?action=view_logfile'});
assert.equal(read.calls[0].method, 'GET');
});
+10 -2
View File
@@ -84,6 +84,14 @@ if (grv('action') != '') {
// ================= input validation =================
}
if (in_array(grv('action'), [
'sortasc', 'sortdesc', 'ajax_dnd',
'lock', 'unlock', 'set_host_sort',
'set_branch_sort',
], true)) {
csrf_require_post();
}
switch (grv('action')) {
case 'save':
form_save();
@@ -1141,7 +1149,7 @@ function tree_edit(bool $partial = false) : void {
}
function setBranchSortOrder(type, nodeid) {
$.get('tree.php?action=set_branch_sort&type='+type+'&nodeid='+nodeid)
$.post('tree.php', {action: 'set_branch_sort', type: type, nodeid: nodeid, __csrf_magic: csrfMagicToken})
.done(function(data) {
branchSortInfo[nodeid] = type;
})
@@ -1151,7 +1159,7 @@ function tree_edit(bool $partial = false) : void {
}
function setHostSortOrder(type, nodeid) {
$.get('tree.php?action=set_host_sort&type='+type+'&nodeid='+nodeid)
$.post('tree.php', {action: 'set_host_sort', type: type, nodeid: nodeid, __csrf_magic: csrfMagicToken})
.done(function(data) {
hostSortInfo[nodeid] = type;
})
+7
View File
@@ -34,6 +34,13 @@ require_once(CACTI_PATH_LIBRARY . '/clog_webapi.php');
// set default action
set_default_action();
if (in_array(grv('action'), [
'clear_poller_cache', 'rebuild_resource_cache', 'clear_logfile',
'purge_logfile', 'purge_data_source_statistics', 'rebuild_snmpagent_cache',
], true)) {
csrf_require_post();
}
switch (grv('action')) {
case 'clear_poller_cache':
$running = is_process_running('pushout', 'rmaster', 0);