mirror of
https://github.com/Cacti/cacti.git
synced 2026-10-23 03:27:02 +09:00
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:
1 parent
49c95bef63
commit
5f72f1a03c
10 files changed
+282
-2
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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':
|
||||
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user