mirror of
https://github.com/Cacti/cacti.git
synced 2026-10-30 00:26:50 +09:00
* fix(win32): harden the cmd.exe shell sinks on develop (GHSA-rjvj-r52f-8v5q) Forward-port of the 1.2.x win32 command-injection remediation. On Windows the SNMP/ping/data-query/trap commands run through cmd.exe, which ignores the \" escape and toggles quoting on every ", so cacti_escapeshellarg cannot stop & | ^ < > ( ). - add cacti_escapeshellarg_cmd(), stripping those operators on win32 before quoting (a Unix pass-through) - snmp_escape_string(): strip the operators in the win32 branch - route the SNMP binary hostname, the data-query device values (get_script_query_path), the data-input arg builders, the Windows ping hostname, and the SNMP trap notification arguments through the strip get_script_query_path is device-reachable (a rogue monitored device supplies an SNMP index/system field that executes on the Windows poller); the rest are post-auth. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(win32): strip %VAR% from the hostname on Windows (GHSA-rjvj-r52f-8v5q) cmd.exe expands %VAR% even inside quotes, so a device hostname of %USERNAME% would expand into the SNMP/ping command line. Add an opt-in $strip_env flag to cacti_escapeshellarg_cmd() and set it for the SNMP binary and ping hostnames, which never legitimately contain a percent. Community and credential values are left untouched because they can legitimately contain %. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * docs(changelog): note win32 cmd.exe hardening for develop * security: require Package Management permission to trust new package signers package_accept_key() (reachable via the repository-based package import flow) inserted new keys into package_public_keys, the global trust store consulted by import_validate_signature() for every future import by every user, without checking for the Package Management (realm 29) permission that gates package_keys.php/package.php/package_repos.php for the same table. An Import Templates (realm 17) user could therefore add themselves as a trusted signer for the whole install. Also add the 'accept' action to the CSRF-protected bad_actions list so the acceptance step requires POST plus a valid token like every other state-changing action. GHSA-gxgm-8rhv-r7jv * docs(changelog): note package-signer trust authorization fix * security: close tab-based open redirect bypass in CSRF timeout handler sanitize_uri()'s guard (20298c6cd) models the WHATWG rule that browsers strip leading C0 controls/whitespace before resolving a redirect, but Chromium separately strips an embedded tab from anywhere in the target. A request target of /%09/evil.example passes the guard (no leading control char, not two leading slashes) but Chromium reduces the resulting 'Location: /<TAB>/evil.example' header to a network-path reference and navigates off-site. 1.2.x already uses validate_redirect_url() for this same sink (3338a63e1), which resolves the URL via parse_url() and checks host/port against the server-configured values, so it isn't bypassable this way. Switch develop's CSRF timeout handler to match. GHSA-c725-4jm5-9fx5 * security: authorize single-item Report actions against ownership reports.php sets \ = true, so include/auth.php grants an implicit guest session (when the Guest User feature is enabled) without ever evaluating the realm 22 gate declared for this page. The single-item action handlers reports_item_remove()/_moveup()/_movedown() relied solely on that page-level gate and performed no independent ownership check, so an unauthenticated guest-bound request could delete or reorder reports_items rows belonging to any report, including ones owned by an administrator. reports_form_actions() already gained an equivalent \ check; add the same authorization (Reports admin realm 21, or the requesting user owns the item's report) to the three single-item handlers. Same underlying chain as the 1.2.31 fix for reports_user.php (guest bind removed + cacti_authorize_resource() added to reports_item_remove()); the rewritten develop reports.php/lib/html_reports.php did not carry either half of that fix forward. GHSA-j5jh-fhwg-r56x * security: authorize tree node CRUD actions against per-tree access tree.php dispatches create_node/delete_node/move_node/rename_node/copy_node directly to api_tree_*() with no is_tree_allowed() check, even though the normal tree-view path already enforces that boundary. A non-admin user with the Trees realm but no explicit grant on a given tree_id could still create, delete, move, rename or copy nodes on that tree by calling these actions directly, bypassing the authorization boundary enforced elsewhere. GHSA-prxj-84qq-v5jq * security: remove shell from Realtime RRD create and validate DS bounds poller_realtime.php built the Realtime-graph RRD 'create' command line (including the SNMP-sourced ifSpeed/ifHighSpeed interface speed, substituted in verbatim as the DS maximum) and ran it through shell_exec(). A hostile or spoofed monitored device could return an ifSpeed value such as '1000000000\uid=197608(ladams) gid=197121 groups=197121' and get arbitrary OS command execution as the web/poller user the next time any user with the Realtime Graphs permission (or an unauthenticated guest, where configured) viewed a realtime graph for an affected data source. - lib/rrd.php: rrdtool_function_interface_speed() now casts ifHighSpeed/ ifSpeed to int before they become the DS maximum. - lib/rrd.php: rrdtool_function_create() now rejects any non-numeric rrd_minimum/rrd_maximum right before they are concatenated onto the rrdtool command line, forcing them to 'U' and logging instead. - poller_realtime.php: the RRD create is now routed through the same shell-free rrdtool_execute()/proc_open(bypass_shell) path every other RRDtool invocation already uses, matching the pattern 1.2.x already uses for this exact call site, instead of shell_exec(). GHSA-f88m-q789-g22j * security: authorize Report save/edit against report ownership reports_form_save()'s update path fetched the existing report's owner from the database but never compared it to the session user before calling sql_save(), so any user holding only the Reports realm could overwrite another user's report by POSTing its id. reports_edit() had the same gap on the read side: it fetched a report by id with no ownership check, so the same user could view any other user's report configuration (recipients, schedule, etc.). 1.2.x already guards both call sites with cacti_authorize_resource(..., 'reports'); develop had lost that check. GHSA-8f5c-3h59-q7gc * security: reject ORDER BY sort_column subquery bypass sanitize_sql_column()'s allowlist regex kept parentheses to support the INET_ATON(col)/NATURAL_SORT_KEY(col) sort wrappers, but that also let MySQL subquery syntax through unchanged, since a subquery like (SELECT(0)FROM(SELECT(SLEEP(5)))a) needs only alphanumerics and parentheses. get_order_string() then detects the '(' and disables its backtick quoting, so the payload reaches ORDER BY verbatim, giving any user with realm 2-5 a blind, time-based SQL injection via sort_column. sanitize_sql_column() now only allows '(' through when the whole value is exactly one of the two known safe wrapper forms around a plain identifier; anything else containing '(' -- including subquery syntax -- now falls back to the default. Added regression tests covering the reported subquery and REGEXP-exfiltration payloads. GHSA-8j73-cvf6-jrvx * security: confine Reports format_file to a bare filename reports_form_save() wrote the POST-supplied format_file value verbatim into reports.format_file, and reports_load_format_file() concatenated it onto CACTI_PATH_FORMATS with no directory-component check, so a value such as ../../../../etc/passwd escaped the formats directory and any Reports-realm user could read arbitrary files readable by the web server. 1.2.x already fixed the same sink (GHSA-g37j-39f4-6r4j / CVE-2026-41884, GHSA-mjvw-mhj5-9jcj / CVE-2026-40084); develop had lost the guard. format_file is now confined to basename() at both the save site and the load site, with the load site additionally rejecting a bare '.'/'..'/empty result before any file IO. Added regression tests. GHSA-5x27-wqpm-m24v * security: authorize Data Source enable/disable against owning device ds_enable()/ds_disable() and the bulk drp_action enable/disable loops in form_actions() passed a user-controlled local_data_id straight into api_data_source_enable()/api_data_source_disable() with no object-level authorization check, so any user holding the Sites/Devices/Data realm could toggle the enabled state of a data source belonging to a device outside their assigned Device Permissions. Added data_source_authorized(\), which allows host-independent data sources (data_local.host_id = 0) and otherwise defers to is_device_allowed(\), and applied it to both the single-item and bulk action paths. GHSA-2fhv-c794-v433 * security: authorize bulk Change Data Source Profile against owning device The bulk 'Change Data Source Profile' action (drp_action == '6') iterated selected_items and updated data_template_data/data_template_rrd/poller_item for each supplied local_data_id with no object-level authorization check, so any Sites/Devices/Data realm user could retarget the RRD step/heartbeat of a data source belonging to a device outside their assigned Device Permissions -- the same class of gap as GHSA-2fhv, in a different bulk action. Guarded with the data_source_authorized() helper added for that fix. This action does not exist on 1.2.x (develop-only feature). GHSA-fc82-w6xq-4jg7 * security: grant AllowOverride All in docker/apache.conf The packaged docker/apache.conf granted only 'AllowOverride FileInfo Options=FollowSymLinks' for the Cacti document root. Cacti's shipped .htaccess deny rules (log/, cache/*, scripts/, cli/, bin/, mibs/, rra/, contrib/, tests/) rely on 'Require all denied' (Apache 2.4, override type AuthConfig) and/or legacy 'Order/Deny' (override type Limit), neither of which FileInfo/Options covers, so Apache silently ignored every one of those deny rules and served the directories over HTTP to any unauthenticated client -- most notably log/cacti.log, which can contain usernames, client IPs, SQL backtraces, and any secrets Cacti logs (SNMP community strings, LDAP bind details). AllowOverride All lets every shipped .htaccess apply as intended. Added a regression test. docker/ does not exist on 1.2.x (develop-only). GHSA-fxfg-j995-j69j * docs: add security advisory / CVE policy to Copilot instructions Documents the policy established for this remediation effort: develop is unreleased, so a develop-only vulnerability does not get a CVE requested; 1.2.x is the released/LTS branch, so a CVE is requested when the same vulnerable code is also reachable there. Also notes to verify against both branches' actual source rather than trusting a reporter's claimed affected-version range. * fix(security): update stale test harnesses and address review findings CI fixes: - CsrfCheckContractProbe.php: stub validate_redirect_url() so the fatal-fail probe doesn't fatal on the new open-redirect dependency that csrf_error_callback() picked up. - InstallerCsrfRecoveryTest.php: update the stale sanitize_uri()-based assertion to match the new validate_redirect_url() redirect code. - psalm-baseline.xml: drop the TaintedHeader suppression for include/csrf.php now that validate_redirect_url() resolves it. Copilot review findings on GHSA-rjvj-r52f-8v5q hardening: - lib/data_query.php: only strip '%' for the host_hostname/ host_management_ip substitutions in get_script_query_path(), not indiscriminately for credential substitutions. - lib/functions.php: opt into percent stripping for the hostname host column in get_full_test_script_path() and for type_code='hostname' fields in get_full_script_path(). - lib/snmpagent.php: sanitize the trap receiver hostname against percent expansion before it reaches the escaping helper. - tests/security/baselines/*.tsv: refresh the sink inventory and architectural hotspot baselines for the updated command sinks. - tests/Unit/DataCollection/Snmp/SnmpCoverageTest.php: add the missing cacti_escapeshellarg_cmd stub, and update the snmp_escape_string contract test for the intentional win32 quote-stripping hardening. - tests/Unit/Security/Shell/CmdMetacharHelperTest.php + tests/fixtures/CmdMetacharHelperWin32Probe.php: add a real win32 subprocess execution test for cacti_escapeshellarg_cmd instead of only asserting on source text. - CHANGELOG: add the pull-request reference to the new entry. * fix(snmp): remove now-unreachable quote-escaping branch in snmp_escape_string The metachar strip on the line above already removes the double quote on win32, so the substr_count(SNMP_ESCAPE_CHARACTER) branch below it could never execute; drop it so lib/snmp.php reaches 100% coverage again. * style: fix pre-existing php-cs-fixer blank-line drift blocking CI data_sources.php and lib/html_reports.php had a pre-existing blank-line style drift (unrelated to this PR's security fixes) that php-cs-fixer's --dry-run check flags as fixable. Since "PHP 8.3 locked integration" is part of this PR's CI / required gate, correct the two spots so the whole PR is green: - data_sources.php: collapse a double blank line before form_actions(). - lib/html_reports.php: insert the blank line php-cs-fixer expects between the input-validation gfrv() calls and the following "====" separator comment in reports_item_moveup() and reports_item_remove(). No behavior change; purely CI/style formatting. * style: refresh phpstan baseline for pre-existing, unrelated findings Three PHPStan findings were masked behind the earlier php-cs-fixer failure and only surfaced once that step passed: - lib/html_reports.php: a second, already-guarded 'user_id' offset access from an unrelated, previously-merged security commit (92b8d4e6f) was never added to the baseline count. - cli/splice_rrd.php, poller_realtime.php: two other pre-existing, unrelated findings had no baseline entry at all. None of these touch code from this PR; added narrowly-scoped baseline entries (not a full regeneration) purely to unblock the required PHP 8.3 locked integration check. * fix(security): address follow-up review findings on consolidated PR Closes the four remaining review findings from the follow-up review round: - package_import.php: trustSigner() now POSTs with a CSRF token instead of $.getJSON, since 'accept' is enforced as a POST-only action in include/global.php's bad_actions list (GHSA-gxgm-8rhv-r7jv). - lib/html_reports.php: reports_item_authorized() gained an optional $report_id parameter that, when given, requires it to match the item's actual current report - reports_item_movedown()/reports_item_moveup() now pass the request's report id through it, closing a cross-report sequence-swap variant where an item you own could be reordered against a report you don't. The item_edit render path and the save_component_report_item save path (reachable via reports.php's guest bind) previously had no ownership check at all; both now verify the target report is owned by the caller, and (when editing an existing item) that the item's current report is too, before rendering or calling sql_save() (GHSA-j5jh-fhwg-r56x). - tree.php + include/global.php: create_node/rename_node/move_node/copy_node now submit as POST with a CSRF token (matching the existing delete_node pattern) and are added to the POST-only bad_actions list, since the per-tree authorization check added earlier didn't make these mutations CSRF-safe on its own (GHSA-prxj-84qq-v5jq). Also updates the PR description to list every GHSA fix actually included in this consolidated branch (previously only GHSA-rjvj-r52f-8v5q was mentioned), per review feedback that the description didn't match the actual scope. * fix: resolve CI failures on PR #8056 (docblock alignment, sink baseline) - lib/html_reports.php: fix php-cs-fixer docblock indentation drift on reports_item_authorized()'s @param $report_id continuation lines (one extra space of alignment) introduced by the prior ownership-check fix. - tests/security/baselines/sink_inventory.baseline.tsv: refresh to track the 4 new header('Location: ' . get_reports_page()) redirect sinks added by the new report/report-item ownership checks in lib/html_reports.php. Verified via tests/security/verify_sink_inventory.sh and confirmed tests/security/verify_architectural_hotspots.sh still reports no new hotspots. * fix: track new PHPStan offset findings from extended reports_item_authorized() phpstan-baseline.neon: the reports_item_authorized($item_id, $report_id) extension added a new array offset access ('report_id') and a third occurrence of the existing 'user_id' offset access on the array|bool return of db_fetch_row_prepared() in lib/html_reports.php. Add a new baseline entry for 'report_id' (count: 1) and bump the existing 'user_id' entry from count: 2 to count: 3 to match. Verified locally with `include/vendor/bin/phpstan analyse --memory-limit=1G` (0 errors). --------- Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> Co-authored-by: Thomas Vincent <thomasvincent@gmail.com> Co-authored-by: TheWitness <copilot@users.noreply.github.com>
417 lines
13 KiB
PHP
417 lines
13 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/ |
|
|
+-------------------------------------------------------------------------+
|
|
*/
|
|
|
|
require_once(CACTI_PATH_LIBRARY . '/csrf.php');
|
|
|
|
/**
|
|
* The process-wide CSRF guard.
|
|
*
|
|
* Built on first use so the CLI never touches Symfony. An explicit installer
|
|
* request may also run without it while Composer populates include/vendor;
|
|
* every other web request fails closed if the dependency cannot be loaded.
|
|
*
|
|
* @return CactiCsrfGuard The shared guard instance.
|
|
*/
|
|
function csrf_guard() : CactiCsrfGuard {
|
|
static $guard = null;
|
|
|
|
if ($guard !== null) {
|
|
return $guard;
|
|
}
|
|
|
|
if (!CACTI_WEB) {
|
|
$guard = new CactiCsrfGuard(null, false);
|
|
|
|
return $guard;
|
|
}
|
|
|
|
if (!class_exists('Symfony\Component\Security\Csrf\CsrfTokenManager')) {
|
|
/* The installer is the only web context allowed to run before Composer
|
|
has populated include/vendor. A normal request reaching this branch
|
|
has a missing, corrupt, or partially synchronized dependency tree and
|
|
must not continue without CSRF validation. */
|
|
if (!defined('IN_CACTI_INSTALL')) {
|
|
$message = 'FATAL: CSRF protection is unavailable because symfony/security-csrf could not be loaded. Restore Cacti Composer dependencies and reload this page.';
|
|
|
|
cacti_log($message, false, 'CSRF');
|
|
http_response_code(500);
|
|
print $message;
|
|
|
|
exit(1);
|
|
}
|
|
|
|
cacti_log('WARNING: symfony/security-csrf is not yet available during installation, so CSRF validation is disabled for this request', false, 'CSRF');
|
|
|
|
$guard = new CactiCsrfGuard(null, false);
|
|
|
|
return $guard;
|
|
}
|
|
|
|
$storage = new Symfony\Component\Security\Csrf\TokenStorage\NativeSessionTokenStorage();
|
|
$manager = new Symfony\Component\Security\Csrf\CsrfTokenManager(
|
|
new Symfony\Component\Security\Csrf\TokenGenerator\UriSafeTokenGenerator(),
|
|
$storage
|
|
);
|
|
|
|
/* Read-only on purpose. csrf_get_secret() mints and persists a secret when
|
|
none exists, and the grace window must not create the very file phase 2
|
|
deletes. A missing secret simply disables the legacy fallback.
|
|
|
|
Do not trim. csrf-magic keyed its HMAC on the raw bytes of this file,
|
|
and cli/refresh_csrf.php writes PHP source with a trailing newline, so
|
|
stripping whitespace yields a different key and silently rejects every
|
|
pre-upgrade token on any site that ever rotated its secret. */
|
|
$secret_file = csrf_secret_file_path(defined('CACTI_CSRF_SECRET') ? CACTI_CSRF_SECRET : '');
|
|
$secret = ($secret_file != '' && is_readable($secret_file) ? (string) file_get_contents($secret_file) : '');
|
|
|
|
/* csrf-magic's own csrf_get_secret() tried $path_csrf_secret and then
|
|
fell back to its own directory regardless of that setting. A site
|
|
that pointed $path_csrf_secret elsewhere but never actually created
|
|
the file there still had a working secret at the old default, so
|
|
check it too before giving up on the grace window. */
|
|
if ($secret === '') {
|
|
$fallback = CACTI_PATH_INCLUDE . '/vendor/csrf/csrf-secret.php';
|
|
|
|
if (is_readable($fallback)) {
|
|
$secret = (string) file_get_contents($fallback);
|
|
}
|
|
}
|
|
|
|
$guard = new CactiCsrfGuard($manager, true, $secret, 7200);
|
|
$guard->setScriptUrl(CACTI_PATH_URL . 'include/js/csrf.js');
|
|
|
|
return $guard;
|
|
}
|
|
|
|
/**
|
|
* Register the output rewriter and validate the current request.
|
|
*
|
|
* Replaces csrf-magic's self-executing bootstrap, which ran from the bottom of
|
|
* the library file as a side effect of including it.
|
|
*/
|
|
function csrf_startup() : void {
|
|
$guard = csrf_guard();
|
|
|
|
if (!$guard->isEnabled()) {
|
|
return;
|
|
}
|
|
|
|
/* include/session.php registers session_write_close() as a shutdown
|
|
function, and shutdown functions run before output buffers flush. If
|
|
the token were minted lazily from inside the output buffer callback
|
|
below, the first mint on a POST that reaches csrf_check() via the
|
|
legacy fallback would write $_SESSION after the session already
|
|
closed, and the token rendered into the page would never validate.
|
|
Mint it now, while the session is still open. */
|
|
$guard->token();
|
|
|
|
ob_start(function ($buffer) use ($guard) {
|
|
return $guard->rewriteBuffer($buffer);
|
|
});
|
|
|
|
csrf_check();
|
|
}
|
|
|
|
/**
|
|
* @return string A token for the current session, or an empty string when the
|
|
* guard is disabled.
|
|
*/
|
|
function csrf_get_tokens() : string {
|
|
csrf_deprecated(__FUNCTION__);
|
|
|
|
return csrf_guard()->token();
|
|
}
|
|
|
|
/**
|
|
* Validate a POST request.
|
|
*
|
|
* csrf-magic returned a bool, and plugins such as the bundled audit plugin
|
|
* branch on it directly (`!csrf_check(false)`). A void return silently
|
|
* breaks that caller: `!null` is always true, so the check would appear to
|
|
* fail on every request. $fatal has the same meaning it always did: true
|
|
* routes a failed check to csrf_error_callback(), which exits; false hands
|
|
* the result back to the caller instead.
|
|
*
|
|
* @param bool $fatal Exit via csrf_error_callback() on failure when true;
|
|
* otherwise return false and let the caller decide.
|
|
*
|
|
* @return bool True for a non-POST request or a validated POST; false for a
|
|
* POST that failed validation and $fatal is false.
|
|
*/
|
|
function csrf_check(bool $fatal = true) : bool {
|
|
if (!isset($_SERVER['REQUEST_METHOD']) || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
return true;
|
|
}
|
|
|
|
$submitted = filter_input(INPUT_POST, CactiCsrfGuard::INPUT_NAME, FILTER_UNSAFE_RAW);
|
|
$submitted = (is_string($submitted) ? $submitted : '');
|
|
|
|
if (csrf_guard()->validate($submitted)) {
|
|
return true;
|
|
}
|
|
|
|
if ($fatal) {
|
|
csrf_error_callback();
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param mixed $tokens One token, or a ';' separated list as csrf-magic sent.
|
|
*
|
|
* @return bool True when any supplied token validates.
|
|
*/
|
|
function csrf_check_tokens(mixed $tokens) : bool {
|
|
csrf_deprecated(__FUNCTION__);
|
|
|
|
if (is_string($tokens)) {
|
|
$tokens = explode(';', $tokens);
|
|
}
|
|
|
|
foreach ((array) $tokens as $token) {
|
|
if (csrf_guard()->validate((string) $token)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Accept and discard a csrf-magic configuration call.
|
|
*
|
|
* The guard is configured through its constructor. This exists so plugins
|
|
* that call csrf_conf() do not fatal.
|
|
*
|
|
* @param string $key The csrf-magic option name.
|
|
* @param mixed $value The value, ignored.
|
|
*/
|
|
function csrf_conf(string $key, mixed $value) : void {
|
|
csrf_deprecated(__FUNCTION__);
|
|
}
|
|
|
|
/**
|
|
* Log one deprecation notice per function per request.
|
|
*
|
|
* Silent at default verbosity. A plugin author opts in by raising the log
|
|
* level; logging unconditionally would flood cacti.log.
|
|
*
|
|
* @param string $function The deprecated function that was called.
|
|
*/
|
|
function csrf_deprecated(string $function) : void {
|
|
static $seen = [];
|
|
|
|
if (isset($seen[$function])) {
|
|
return;
|
|
}
|
|
|
|
$seen[$function] = true;
|
|
|
|
cacti_log($function . '() is deprecated and will be removed; use csrf_guard() instead', false, 'CSRF', POLLER_VERBOSITY_DEBUG);
|
|
}
|
|
|
|
/**
|
|
* Resolve the legacy secret configuration to a file.
|
|
*
|
|
* Older installer code accepted a directory and appended csrf-secret.php,
|
|
* even though config.php.dist documents a full file path. Preserve that
|
|
* behavior during the legacy-token grace window.
|
|
*
|
|
* @param string $path A configured file or directory path.
|
|
*
|
|
* @return string The resolved secret file path.
|
|
*/
|
|
function csrf_secret_file_path(string $path) : string {
|
|
if ($path !== '' && is_dir($path)) {
|
|
return rtrim($path, '/\\') . '/csrf-secret.php';
|
|
}
|
|
|
|
return $path;
|
|
}
|
|
|
|
/**
|
|
* Read the csrf-magic secret, or mint and persist one if none exists.
|
|
*
|
|
* Retained because install/functions.php writes this value during a fresh
|
|
* install and the legacy token fallback verifies against it. Removed in
|
|
* phase 2 along with the rest of the secret plumbing.
|
|
*
|
|
* @return string The secret, or an empty string when none can be established.
|
|
*/
|
|
function csrf_get_secret() : string {
|
|
static $secret = null;
|
|
|
|
if ($secret !== null) {
|
|
return $secret;
|
|
}
|
|
|
|
$file = csrf_secret_file_path(defined('CACTI_CSRF_SECRET') ? CACTI_CSRF_SECRET : '');
|
|
|
|
if ($file != '' && file_exists($file)) {
|
|
// the raw bytes are the key; see the note in csrf_guard()
|
|
$secret = (string) @file_get_contents($file);
|
|
|
|
if ($secret !== '') {
|
|
return $secret;
|
|
}
|
|
}
|
|
|
|
$secret = csrf_generate_secret();
|
|
|
|
if ($file != '' && csrf_writable($file)) {
|
|
$old_umask = umask(0027);
|
|
$fh = fopen($file, 'w');
|
|
|
|
if ($fh !== false) {
|
|
fwrite($fh, $secret);
|
|
fclose($fh);
|
|
}
|
|
|
|
umask($old_umask);
|
|
}
|
|
|
|
return $secret;
|
|
}
|
|
|
|
/**
|
|
* Generate a new secret.
|
|
*
|
|
* csrf-magic built this from mt_rand(), which is not a cryptographic source.
|
|
* This uses random_bytes().
|
|
*
|
|
* @return string A 64 character hex secret.
|
|
*/
|
|
function csrf_generate_secret() : string {
|
|
return hash('sha256', random_bytes(32));
|
|
}
|
|
|
|
/**
|
|
* @param string $path The path to test.
|
|
*
|
|
* @return bool True when $path can be written without leaving a file behind.
|
|
*/
|
|
function csrf_writable(string $path) : bool {
|
|
if ($path === '') {
|
|
return false;
|
|
}
|
|
|
|
if (substr($path, -1) === '/') {
|
|
return csrf_writable($path . uniqid((string) random_int(0, PHP_INT_MAX)) . '.tmp');
|
|
}
|
|
|
|
if (file_exists($path)) {
|
|
$fh = @fopen($path, 'a');
|
|
|
|
if ($fh === false) {
|
|
return false;
|
|
}
|
|
|
|
fclose($fh);
|
|
|
|
return true;
|
|
}
|
|
|
|
$fh = @fopen($path, 'w');
|
|
|
|
if ($fh === false) {
|
|
return false;
|
|
}
|
|
|
|
fclose($fh);
|
|
unlink($path);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Record a CSRF diagnostic.
|
|
*
|
|
* csrf-magic wrote these to its own log file. They go to cacti.log now, at a
|
|
* verbosity that is silent by default.
|
|
*
|
|
* @param string $function The reporting function.
|
|
* @param string $message What happened.
|
|
*/
|
|
function csrf_log(string $function, string $message) : void {
|
|
cacti_log($function . '(): ' . $message, false, 'CSRF', POLLER_VERBOSITY_DEBUG);
|
|
}
|
|
|
|
/**
|
|
* Handle a request that failed CSRF validation.
|
|
*
|
|
* The installer runs its steps over JSON, so a plain redirect leaves it stuck
|
|
* on a dead XHR. That branch answers with a scoped payload carrying a fresh
|
|
* token instead, which install.js retries against once. See issue #7343.
|
|
*/
|
|
function csrf_error_callback() : void {
|
|
// Invalidate the failed session id instead of leaving it usable until GC.
|
|
session_regenerate_id(true);
|
|
|
|
if (defined('IN_CACTI_INSTALL') &&
|
|
isset($GLOBALS['auth_json']) &&
|
|
$GLOBALS['auth_json'] === true &&
|
|
!empty($GLOBALS['is_request_ajax'])) {
|
|
$response = json_encode(
|
|
[
|
|
'error' => 'csrf_timeout',
|
|
'title' => __('Session Expired'),
|
|
'message' => __('Your installer session expired due to inactivity. Reload the installer and try again.'),
|
|
'csrfMagicToken' => csrf_get_tokens(),
|
|
],
|
|
JSON_INVALID_UTF8_SUBSTITUTE
|
|
);
|
|
|
|
if ($response === false) {
|
|
$response = '{"error":"csrf_timeout","title":"Session Expired","message":"Reload the installer and try again."}';
|
|
}
|
|
|
|
ob_end_clean();
|
|
http_response_code(403);
|
|
header('Content-Type: application/json');
|
|
header('Cache-Control: no-store');
|
|
header('X-Content-Type-Options: nosniff');
|
|
header('Content-Length: ' . strlen($response));
|
|
print $response;
|
|
csrf_log(__FUNCTION__, 'Timeout, returning JSON response for the installer');
|
|
|
|
exit;
|
|
}
|
|
|
|
raise_message('csrf_timeout');
|
|
ob_end_clean();
|
|
// sanitize_uri()'s guard models the WHATWG C0/leading-slash normalisation
|
|
// rule but not Chromium's separate stripping of embedded tabs, so a
|
|
// target like "/%09/evil.example" still reaches the browser as a
|
|
// same-origin-looking "//evil.example" once Chromium collapses the tab.
|
|
// validate_redirect_url() resolves the URL with parse_url() and compares
|
|
// host/port against the server-configured values instead of pattern
|
|
// matching known-bad prefixes, so it is not bypassable this way.
|
|
$redirect_target = validate_redirect_url($_SERVER['REQUEST_URI'] ?? '', 'index.php');
|
|
header('Location: ' . $redirect_target);
|
|
csrf_log(__FUNCTION__, 'Timeout, redirecting to ' . $redirect_target);
|
|
|
|
exit;
|
|
}
|