Files
cacti/tests/Unit/Security/Csrf/ReorderPostAuthorizationTest.php
Thomas Vincent c749790d84 fix(security): [develop] require CSRF-protected POST for link and plugin reordering (#8278)
* security: require CSRF-protected POST for link and plugin reordering (#7454)

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

* fix(csrf): share POST and token guards for reorder actions

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

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
2026-10-13 07:52:20 -04:00

60 lines
3.0 KiB
PHP

<?php
// Copyright (C) 2026 The Cacti Group. GPL version 2 or later.
test('link and plugin reorders reject GET and invalid tokens before writing (#7454)', function () {
$root = dirname(__DIR__, 4);
$parser = (new PhpParser\ParserFactory())->createForNewestSupportedVersion();
$finder = new PhpParser\NodeFinder();
$printer = new PhpParser\PrettyPrinter\Standard();
$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');
expect($helper)->not->toBeNull();
$guard = $printer->prettyPrint([$helper]);
foreach (['links.php', 'plugins.php'] as $file) {
$nodes = $parser->parse(file_get_contents($root . '/' . $file));
$case = $finder->findFirst($nodes, static fn ($node) => $node instanceof PhpParser\Node\Stmt\Case_
&& $node->cond instanceof PhpParser\Node\Scalar\String_ && $node->cond->value === 'ajax_dnd');
expect($case)->not->toBeNull();
$body = $printer->prettyPrint($case->stmts);
foreach ([['GET', null, 405], ['GET', 'valid', 405], ['HEAD', 'valid', 405], ['PUT', 'valid', 405], [null, 'valid', 405], ['POST', null, 403], ['POST', 'invalid', 403], ['POST', 'valid', 302]] as $scenario) {
[$method, $token, $status] = $scenario;
$seed = var_export(['method' => $method, 'token' => $token], true);
$script = '<?php $seed = ' . $seed . ';' . <<<'PHP'
$_SERVER['REQUEST_METHOD'] = $seed['method'];
http_response_code(200);
$writes = 0;
register_shutdown_function(function () { echo json_encode(array('status' => http_response_code(), 'writes' => $GLOBALS['writes'])); });
function csrf_check($fatal) { return $GLOBALS['seed']['token'] === 'valid'; }
function gnrv($name) { return array(2, 1); }
function links_reorder($order) { $GLOBALS['writes']++; }
function api_plugin_reorder($order) { $GLOBALS['writes']++; }
PHP;
$script .= "\n" . $guard . "\nswitch ('ajax_dnd') { case 'ajax_dnd':\n" . $body . "\n}";
$process = proc_open([PHP_BINARY], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
fwrite($pipes[0], $script);
fclose($pipes[0]);
$result = json_decode(stream_get_contents($pipes[1]), true);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
expect(proc_close($process))->toBe(0);
expect($stderr)->toBe('');
expect($result)->toBe(['status' => $status, 'writes' => $status === 302 ? 1 : 0]);
}
}
});
test('reorder clients submit their action and serialized order through the CSRF POST helper', function () {
$root = dirname(__DIR__, 4);
foreach (['links.php', 'plugins.php'] as $file) {
$source = file_get_contents($root . '/' . $file);
expect($source)->toContain("cactiPreparePostRequest('" . $file . "', 'action=ajax_dnd&' + $.tableDnD.serialize())");
expect($source)->toContain('postUrl({url: request.url}, request.data)');
expect($source)->not->toContain("loadUrl({url:'" . $file . '?action=ajax_dnd');
}
});