diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index 86237dcf..d22ab2d8 100644 --- a/.github/workflows/plugin-ci-workflow.yml +++ b/.github/workflows/plugin-ci-workflow.yml @@ -40,6 +40,7 @@ jobs: uses: actions/checkout@v4 with: repository: Cacti/cacti + ref: release/1.2.31 path: cacti - name: Checkout Syslog Plugin @@ -57,7 +58,7 @@ jobs: - name: Check PHP Syntax (Lint) run: | cd cacti/plugins/syslog - if find . -name '*.php' -not -path './vendor/*' -exec php -l {} 2>&1 \; | grep -iv 'no syntax errors detected'; then + if find . -name '*.php' -not -path './vendor/*' -exec php -l {} \; 2>&1 | grep -iv 'no syntax errors detected'; then echo "Syntax errors found!" exit 1 fi @@ -122,6 +123,7 @@ jobs: uses: actions/checkout@v4 with: repository: Cacti/cacti + ref: release/1.2.31 path: cacti - name: Checkout Syslog Plugin @@ -143,7 +145,7 @@ jobs: run: sudo apt-get update - name: Install System Dependencies - run: sudo apt-get install -y apache2 snmp snmpd rrdtool fping libapache2-mod-php${{ matrix.php }} + run: sudo apt-get install -y apache2 snmp snmpd rrdtool fping - name: Start SNMPD Agent and Test run: | @@ -163,16 +165,15 @@ jobs: echo -e "[client]\nuser = root\npassword = cactiroot\nhost = 127.0.0.1\n" > ~/.my.cnf - name: Initialize Cacti Database - env: - MYSQL_AUTH_USR: '--defaults-file=~/.my.cnf' run: | - mysql $MYSQL_AUTH_USR -e 'CREATE DATABASE IF NOT EXISTS cacti;' - mysql $MYSQL_AUTH_USR -e "CREATE USER IF NOT EXISTS 'cactiuser'@'localhost' IDENTIFIED BY 'cactiuser';" - mysql $MYSQL_AUTH_USR -e "GRANT ALL PRIVILEGES ON cacti.* TO 'cactiuser'@'localhost';" - mysql $MYSQL_AUTH_USR -e "GRANT SELECT ON mysql.time_zone_name TO 'cactiuser'@'localhost';" - mysql $MYSQL_AUTH_USR -e "FLUSH PRIVILEGES;" - mysql $MYSQL_AUTH_USR cacti < ${{ github.workspace }}/cacti/cacti.sql - mysql $MYSQL_AUTH_USR -e "INSERT INTO settings (name, value) VALUES ('path_php_binary', '/usr/bin/php')" cacti + MYSQL_AUTH_USR="--defaults-file=$HOME/.my.cnf" + mysql "$MYSQL_AUTH_USR" -e 'CREATE DATABASE IF NOT EXISTS cacti;' + mysql "$MYSQL_AUTH_USR" -e "CREATE USER IF NOT EXISTS 'cactiuser'@'localhost' IDENTIFIED BY 'cactiuser';" + mysql "$MYSQL_AUTH_USR" -e "GRANT ALL PRIVILEGES ON cacti.* TO 'cactiuser'@'localhost';" + mysql "$MYSQL_AUTH_USR" -e "GRANT SELECT ON mysql.time_zone_name TO 'cactiuser'@'localhost';" + mysql "$MYSQL_AUTH_USR" -e "FLUSH PRIVILEGES;" + mysql "$MYSQL_AUTH_USR" cacti < ${{ github.workspace }}/cacti/cacti.sql + mysql "$MYSQL_AUTH_USR" -e "INSERT INTO settings (name, value) VALUES ('path_php_binary', '/usr/bin/php')" cacti - name: Validate composer files run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a22b427..6be2679b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ * issue#252: hardening: escape device hostname output in syslog view; parameterize alert API functions * issue#256: hardening: prevent CSV formula injection and malformed CSV output in exports * issue#258: Execute CREATE TABLE SQL correctly during replication sync +* issue#259: hardening: require POST and a CSRF token for the purge syslog devices utility * issue#260: hardening: replace eval-based callback execution in syslog autocomplete JS * issue#278: Extract duplicated alert command execution paths in syslog_process_alerts * issue#278: Extract alert command execution into shared helper in functions.php; command tokenization now uses preg_split (handles tabs and consecutive spaces); /bin/sh fallback for non-executable command templates removed (use absolute paths with execute bit set) diff --git a/functions.php b/functions.php index 1e54c132..032ae97c 100644 --- a/functions.php +++ b/functions.php @@ -47,6 +47,18 @@ function syslog_include_js() { block, because the browser does + * not HTML-decode there. The value has to arrive as a JSON literal. + * + * @param mixed $value + * + * @return string + */ +function syslog_json_safe($value) { + return json_encode($value, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR); +} + function syslog_allow_edits() { global $config; diff --git a/setup.php b/setup.php index 46d0333d..275eb087 100644 --- a/setup.php +++ b/setup.php @@ -1607,7 +1607,31 @@ function syslog_utilities_action($action) { return; } - if ($action == 'purge_syslog_hosts') { + if ($action === 'purge_syslog_hosts') { + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + cacti_log('WARNING: syslog purge blocked -- non-POST request', false, 'SYSLOG'); + raise_message('syslog_method_error', __('Invalid request. Please try again.', 'syslog'), MESSAGE_LEVEL_ERROR); + header('Location: utilities.php?header=false'); + exit; + } + + // csrf_check($fatal) returns bool; $fatal=false tells the helper not to + // die/exit on failure so we can log and redirect with a user-visible + // message ourselves. + if (!function_exists('csrf_check')) { + cacti_log('WARNING: syslog purge blocked -- CSRF validation unavailable', false, 'SYSLOG'); + raise_message('syslog_csrf_unavailable', __('Invalid request. Please try again.', 'syslog'), MESSAGE_LEVEL_ERROR); + header('Location: utilities.php?header=false'); + exit; + } + + if (!csrf_check(false)) { + cacti_log('WARNING: syslog purge blocked -- CSRF token validation failed', false, 'SYSLOG'); + raise_message('syslog_csrf_error', __('Invalid request. Please try again.', 'syslog'), MESSAGE_LEVEL_ERROR); + header('Location: utilities.php?header=false'); + exit; + } + $records = 0; syslog_db_execute('DELETE FROM syslog_hosts @@ -1642,7 +1666,7 @@ function syslog_utilities_action($action) { raise_message('syslog_info', __('There were %s Device records removed from the Syslog database', $records, 'syslog'), MESSAGE_LEVEL_INFO); - header('Location: utilities.php'); + header('Location: utilities.php?header=false'); exit; } @@ -1660,7 +1684,52 @@ function syslog_utilities_list() { - + '> + + diff --git a/syslog.php b/syslog.php index 0d1c45c9..633b9f9c 100644 --- a/syslog.php +++ b/syslog.php @@ -1184,11 +1184,11 @@ function syslog_filter($sql_where, $tab) { ?> must not close the block it sits in. + */ +$payload = '\'"&'; + +$harness = <<<'HARNESS' +&1'; + + return shell_exec($command); +} + +/* the encoder, on its own */ + +require_once $root . '/functions.php'; + +$encoded = syslog_json_safe($payload); + +if (json_decode($encoded) !== $payload) { + issue259_fail('syslog_json_safe() does not round-trip through json_decode()'); +} + +foreach (['<', '>', '&', '"', '\''] as $raw) { + if (strpos(substr($encoded, 1, -1), $raw) !== false) { + issue259_fail("syslog_json_safe() left a raw $raw in the JS literal"); + } +} + +/* the utilities fragment, rendered */ + +$render = issue259_run($sandbox, 'render', $payload); + +if (strpos($render, 'REACHED_END') === false) { + issue259_fail("syslog_utilities_list() did not complete:\n$render"); +} + +if (stripos($render, '') !== 1) { + issue259_fail('A translated string escaped its script block'); +} + +if (!preg_match('/title:\s*("[^"]*"),/', $render, $title)) { + issue259_fail('The dialog title is not a JSON literal'); +} + +if (json_decode($title[1]) !== $payload) { + issue259_fail('The dialog title does not decode back to the translated text'); +} + +if (strpos($render, "'utilities.php?header=false'") === false) { + issue259_fail('The purge post must target the headerless utilities page'); +} + +if (strpos($render, 'json.__csrf_magic = csrfMagicToken;') === false) { + issue259_fail('The purge post must carry the CSRF token'); +} + +/* the guard, driven */ + +$blocked = [ + 'action_get' => 'non-POST request', + 'action_no_csrf' => 'CSRF validation unavailable', + 'action_bad_token' => 'CSRF token validation failed' +]; + +foreach ($blocked as $scenario => $reason) { + $output = issue259_run($sandbox, $scenario, $payload); + + if (strpos($output, 'DBEXEC') !== false) { + issue259_fail("$scenario reached the purge deletes"); + } + + if (strpos($output, "LOG:WARNING: syslog purge blocked -- $reason") === false) { + issue259_fail("$scenario did not audit the block as '$reason':\n$output"); + } + + if (!preg_match('/^MSG:(\S+)\|([^|]*)\|(\d+)$/m', $output, $message)) { + issue259_fail("$scenario did not raise a user-visible message:\n$output"); + } + + if ($message[3] != MESSAGE_LEVEL_ERROR) { + issue259_fail("$scenario raised the block at level $message[3], not error"); + } + + if ($message[2] !== 'Invalid request. Please try again.') { + issue259_fail("$scenario used the wrong user-facing text: $message[2]"); + } + + if (stripos($message[2], 'csrf') !== false) { + issue259_fail("$scenario leaked CSRF internals to the user"); + } +} + +$allowed = issue259_run($sandbox, 'action_valid_token', $payload); + +if (substr_count($allowed, 'DBEXEC') !== 3) { + issue259_fail("A POST with a valid token must run all three deletes:\n$allowed"); +} + +if (strpos($allowed, 'MSG:syslog_info|') === false) { + issue259_fail("A completed purge must report the record count:\n$allowed"); +} + +/* source scan for what the CLI SAPI cannot observe */ + +$setup = file_get_contents($root . '/setup.php'); + +if ($setup === false) { + issue259_fail('Failed to load setup.php'); +} + +if (str_contains($setup, "href='utilities.php?action=purge_syslog_hosts'")) { + issue259_fail('The GET purge link is still present'); +} + +if (preg_match("/header\('Location: utilities\.php'\)/", $setup)) { + issue259_fail('A purge redirect target is missing header=false'); +} + +if (substr_count($setup, "header('Location: utilities.php?header=false');") !== 4) { + issue259_fail('Every purge exit path must redirect to the headerless page'); +} + +$syslog = file_get_contents($root . '/syslog.php'); + +if ($syslog === false) { + issue259_fail('Failed to load syslog.php'); +} + +foreach ([ + "pageTab: ,", + "syslog_json_safe(__('Enter a search term', 'syslog'))", + "syslog_json_safe(__('Select Device(s)', 'syslog'))", + "syslog_json_safe(__('Devices Selected', 'syslog'))", + "syslog_json_safe(__('All Devices Selected', 'syslog'))" +] as $snippet) { + if (!str_contains($syslog, $snippet)) { + issue259_fail("initSyslogMain is not JS-encoded: $snippet"); + } +} + +print "$name passed\n";