mirror of
https://github.com/Cacti/cacti.git
synced 2026-10-25 20:48:28 +09:00
* feat(security): add cacti_dispatch helper for action table authorisation
cacti_dispatch() wraps the repeated switch/case on get_request_var('action')
that every Cacti controller writes. Each action is declared with the
HTTP method it accepts, the realm id that must be allowed, and an
optional object-level ACL callback. The guards run in method then
realm then object-ACL order and each one logs with a WEBUI category
before returning a 405 or 403, so review across controllers no longer
depends on every author remembering to add the same three checks in
the same order.
The test file source-scans the helper for each guard and for the guard
ordering, so a future refactor that silently drops the realm or ACL
branch fails CI rather than regressing authorisation for every
controller that opts in.
Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
* security(dispatch): address Copilot review on cacti_dispatch
Reject non-string action inputs before offset access so ?action[]=x
cannot produce a TypeError. Default REQUEST_METHOD to GET for CLI
and tests, and normalise both sides with cacti_strtoupper so the
comparison is locale-independent. Treat a declared-but-non-callable
object_acl as a misdeclaration and deny (log ERROR + 403) rather
than silently skipping the check. Split the denial emitter into
cacti_dispatch_deny() under a prefixed name to avoid colliding with
the existing raise_ajax_permission_denied() in lib/functions.php,
and always follow the AJAX helper with an explicit http_response_code()
so a denied non-AJAX request cannot fall through to a 200 render.
Reject non-callable callbacks with a 500 and ERROR log. Drop the
redundant docblock on the function and tighten the parameter types.
Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
* style(dispatch): apply php-cs-fixer to cacti_dispatch
Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
* fix(dispatch): drop tautological is_string checks flagged by phpstan level 6
PHPStan level 6 flagged two is_string() calls as always true:
- $default was already type-hinted as string via PHPDoc; the runtime
cast to '' was redundant.
- The second is_string($action) at the unknown-action log happens
after the earlier is_string guard already narrowed the type.
Tighten the signature to string $default so the first cast drops,
and remove the second is_string from the log interpolation since
$action is always a string by the time that branch runs.
Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
* fix(dispatch): tighten Copilot review feedback
strspn whitelist on action; explicit AJAX detection for 403 vs redirect;
file_get_contents guard in tests.
Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
* test(dispatch): hand-off coverage + infection mutation config
Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
* ci(infection): allow infection/extension-installer composer plugin
Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
* test(dispatch): drop hand-off test, consolidated into #7083
Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
* feat(dispatch): reject mis-declared method, document CSRF scope
Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
* ci(phpstan): baseline upstream undefined-variable and isset errors
Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
---------
Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Co-authored-by: TheWitness <thewitness@cacti.net>
168 lines
6.8 KiB
PHP
168 lines
6.8 KiB
PHP
<?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. |
|
|
| |
|
|
| This program is distributed in the hope that it will be useful, |
|
|
| but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
|
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
|
| GNU General Public License for more details. |
|
|
+-------------------------------------------------------------------------+
|
|
| Cacti: The Complete RRDtool-based Graphing Solution |
|
|
+-------------------------------------------------------------------------+
|
|
| This code is designed, written, and maintained by the Cacti Group. See |
|
|
| about.php and/or the AUTHORS file for specific developer information. |
|
|
+-------------------------------------------------------------------------+
|
|
| http://www.cacti.net/ |
|
|
+-------------------------------------------------------------------------+
|
|
*/
|
|
|
|
/**
|
|
* cacti_dispatch - Run the current request against a declarative action table.
|
|
*
|
|
* Each entry in $actions is keyed by action name and contains:
|
|
* 'callback' => callable (required; the handler)
|
|
* 'method' => string 'GET', 'POST', or 'ANY' (default 'ANY')
|
|
* 'realm' => int|null Realm id that must be allowed (default null)
|
|
* 'object_acl' => callable Optional per-row ACL returning bool
|
|
*
|
|
* Guards run in method -> realm -> object-ACL order. A fail at any
|
|
* guard logs with category 'WEBUI', emits the matching HTTP status
|
|
* (405 for method, 403 for realm/ACL), and returns. A mis-declared
|
|
* ACL (non-callable) or method (anything other than GET/POST/ANY)
|
|
* denies rather than silently bypasses.
|
|
*
|
|
* NOTE: CSRF is intentionally out of scope. State-changing handlers
|
|
* still need their own form_security() / nonce check; the method gate
|
|
* narrows the attack surface but does not replace it.
|
|
*
|
|
* @param array $actions Action table (see shape above).
|
|
* @param string $default Action name to use when the request has none.
|
|
*
|
|
* @return void
|
|
*/
|
|
function cacti_dispatch(array $actions, string $default = ''): void {
|
|
$action = get_nfilter_request_var('action');
|
|
|
|
/* Reject array / non-scalar action inputs before using as an offset
|
|
* so `?action[]=x` cannot produce a TypeError on isset(). The
|
|
* strspn() whitelist further rejects any character outside the
|
|
* action-name alphabet so a hostile key cannot reach the table. */
|
|
if (!is_string($action) || $action === '') {
|
|
$action = $default;
|
|
}
|
|
|
|
if ($action !== '' && strspn($action, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-') !== strlen($action)) {
|
|
$action = $default;
|
|
}
|
|
|
|
if ($action === '' || !isset($actions[$action])) {
|
|
cacti_log('WARNING: cacti_dispatch: unknown action "' . $action . '" from ' . get_client_addr(), false, 'WEBUI');
|
|
cacti_dispatch_deny(403);
|
|
|
|
return;
|
|
}
|
|
|
|
$entry = $actions[$action];
|
|
|
|
/* Enforce HTTP method. cacti_strtoupper() is preferred over strtoupper()
|
|
* so the locale does not affect the comparison, and REQUEST_METHOD is
|
|
* defaulted because CLI contexts (tests) do not set it. A typoed verb
|
|
* (e.g. 'PUT ' or 'gET') would otherwise silently soft-brick the action
|
|
* because no real request would match; treat it as misdeclaration. */
|
|
$method = isset($entry['method']) ? cacti_strtoupper((string) $entry['method']) : 'ANY';
|
|
$request_method = isset($_SERVER['REQUEST_METHOD']) ? cacti_strtoupper((string) $_SERVER['REQUEST_METHOD']) : 'GET';
|
|
|
|
if (!in_array($method, ['GET', 'POST', 'ANY'], true)) {
|
|
cacti_log('WARNING: cacti_dispatch: invalid method "' . $method . '" declared for action "' . $action . '"; failing closed', false, 'WEBUI');
|
|
cacti_dispatch_deny(403);
|
|
|
|
return;
|
|
}
|
|
|
|
if ($method !== 'ANY' && $request_method !== $method) {
|
|
cacti_log('WARNING: cacti_dispatch: method mismatch for action "' . $action . '" (expected ' . $method . ', got ' . $request_method . ')', false, 'WEBUI');
|
|
header('HTTP/1.1 405 Method Not Allowed');
|
|
header('Allow: ' . $method);
|
|
|
|
return;
|
|
}
|
|
|
|
// Enforce realm permission.
|
|
if (isset($entry['realm']) && !is_realm_allowed($entry['realm'])) {
|
|
cacti_log('WARNING: cacti_dispatch: realm ' . $entry['realm'] . ' denied for action "' . $action . '"', false, 'WEBUI');
|
|
cacti_dispatch_deny(403);
|
|
|
|
return;
|
|
}
|
|
|
|
/* Enforce object-level ACL. A declared-but-non-callable ACL is
|
|
* treated as a misdeclaration and fails closed rather than silently
|
|
* bypassing authorisation. */
|
|
if (array_key_exists('object_acl', $entry) && $entry['object_acl'] !== null) {
|
|
if (!is_callable($entry['object_acl'])) {
|
|
cacti_log('ERROR: cacti_dispatch: object_acl for action "' . $action . '" is not callable', false, 'WEBUI');
|
|
cacti_dispatch_deny(403);
|
|
|
|
return;
|
|
}
|
|
|
|
if (!call_user_func($entry['object_acl'])) {
|
|
cacti_log('WARNING: cacti_dispatch: object ACL denied for action "' . $action . '"', false, 'WEBUI');
|
|
cacti_dispatch_deny(403);
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Dispatch.
|
|
if (!isset($entry['callback']) || !is_callable($entry['callback'])) {
|
|
cacti_log('ERROR: cacti_dispatch: callback for action "' . $action . '" is not callable', false, 'WEBUI');
|
|
cacti_dispatch_deny(500);
|
|
|
|
return;
|
|
}
|
|
|
|
call_user_func($entry['callback']);
|
|
}
|
|
|
|
/**
|
|
* cacti_dispatch_deny - Emit the denial response for a failed guard.
|
|
*
|
|
* Uses raise_ajax_permission_denied() from lib/functions.php when that
|
|
* helper is already loaded so AJAX callers see the right 401 envelope,
|
|
* and always follows with an explicit 403 Forbidden (or the requested
|
|
* status) header so non-AJAX callers do not fall through to a 200.
|
|
*
|
|
* The helper is prefixed with cacti_dispatch_ to avoid colliding with
|
|
* the existing raise_ajax_permission_denied() function that Cacti
|
|
* loads unconditionally from lib/functions.php.
|
|
*
|
|
* @param int $status HTTP status code to emit. Defaults to 403.
|
|
*
|
|
* @return void
|
|
*/
|
|
function cacti_dispatch_deny($status = 403) {
|
|
$status = (int) $status;
|
|
|
|
if ($status < 400 || $status >= 600) {
|
|
$status = 403;
|
|
}
|
|
|
|
$xrw = isset($_SERVER['HTTP_X_REQUESTED_WITH']) ? (string) $_SERVER['HTTP_X_REQUESTED_WITH'] : '';
|
|
$is_ajax = defined('AJAX_REQUEST') || cacti_strtolower($xrw) === 'xmlhttprequest';
|
|
|
|
if ($is_ajax && function_exists('raise_ajax_permission_denied')) {
|
|
raise_ajax_permission_denied();
|
|
}
|
|
|
|
if (!headers_sent()) {
|
|
http_response_code($status);
|
|
}
|
|
}
|