diff --git a/.github/workflows/plugin-ci-workflow.yml b/.github/workflows/plugin-ci-workflow.yml index 86237dc..d22ab2d 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/functions.php b/functions.php index 1e54c13..4452a34 100644 --- a/functions.php +++ b/functions.php @@ -144,15 +144,25 @@ function syslog_sendemail($to, $from, $subject, $message, $smsmessage = '') { } } +const SYSLOG_IMPORT_MAX_BYTES = 5 * 1024 * 1024; + function syslog_get_import_xml_payload($redirect_url) { - if (trim(get_nfilter_request_var('import_text')) != '') { + $import_text = (string) get_nfilter_request_var('import_text'); + + if (trim($import_text) !== '') { // textbox input - return get_nfilter_request_var('import_text'); + if (strlen($import_text) > SYSLOG_IMPORT_MAX_BYTES) { + cacti_log('SYSLOG ERROR: Text import payload exceeds the maximum size', false, 'SYSTEM'); + header('Location: ' . $redirect_url); + exit; + } + + return $import_text; } if (isset($_FILES['import_file']['tmp_name']) && - $_FILES['import_file']['tmp_name'] != 'none' && - $_FILES['import_file']['tmp_name'] != '') { + $_FILES['import_file']['tmp_name'] !== 'none' && + $_FILES['import_file']['tmp_name'] !== '') { // file upload $tmp_name = $_FILES['import_file']['tmp_name']; @@ -166,6 +176,14 @@ function syslog_get_import_xml_payload($redirect_url) { exit; } + $size = (int) ($_FILES['import_file']['size'] ?? filesize($tmp_name)); + + if ($size <= 0 || $size > SYSLOG_IMPORT_MAX_BYTES) { + cacti_log('SYSLOG ERROR: Uploaded import file has an invalid size', false, 'SYSTEM'); + header('Location: ' . $redirect_url); + exit; + } + $fp = fopen($tmp_name, 'rb'); if ($fp === false) { @@ -174,7 +192,7 @@ function syslog_get_import_xml_payload($redirect_url) { exit; } - $xml_data = fread($fp, filesize($tmp_name)); + $xml_data = fread($fp, $size); fclose($fp); if ($xml_data === false) { @@ -190,6 +208,22 @@ function syslog_get_import_xml_payload($redirect_url) { exit; } +function syslog_csv_cell(mixed $value): string { + $value = (string) $value; + + if ($value === '' || str_starts_with($value, "'")) { + return $value; + } + + $trimmed = ltrim($value, ' '); + + if ($trimmed !== '' && in_array($trimmed[0], ['=', '+', '-', '@', "\t", "\r"], true)) { + return "'" . $value; + } + + return $value; +} + function syslog_is_partitioned() { global $syslogdb_default; @@ -894,19 +928,16 @@ function syslog_export($tab) { $severity = 'Unknown'; } - $host = trim($message['host'], ' =+-@'); - $logmsg = trim($message['logmsg'], ' =+-@'); - - $line = [ + $line = array_map('syslog_csv_cell', [ $message['name'], $severity, $message['logtime'], - $logmsg, - $host, + $message['logmsg'], + $message['host'], ucfirst($message['facility']), ucfirst($message['priority']), $message['count'] - ]; + ]); fputcsv($fp, $line); } diff --git a/syslog.php b/syslog.php index 0d1c45c..2429ff3 100644 --- a/syslog.php +++ b/syslog.php @@ -219,7 +219,7 @@ function syslog_view_alarm() { WHERE seq = ?", [get_request_var('id')]); - print trim($html, "' "); + print nl2br(html_escape(trim($html, "' "))); print ''; diff --git a/tests/regression/issue277_import_payload_loader_test.php b/tests/regression/issue277_import_payload_loader_test.php index e77ba1d..c1f9a14 100644 --- a/tests/regression/issue277_import_payload_loader_test.php +++ b/tests/regression/issue277_import_payload_loader_test.php @@ -31,12 +31,13 @@ exit(1); } -if (strpos($functions, 'function syslog_get_import_xml_payload(') === false) { +if (!str_contains($functions, 'function syslog_get_import_xml_payload(')) { fwrite(STDERR, "Shared import payload loader helper is missing.\n"); exit(1); } -if (strpos($functions, "trim(get_nfilter_request_var('import_text')) != ''") === false) { +if (!str_contains($functions, '$import_text = (string) get_nfilter_request_var(\'import_text\')') || + !str_contains($functions, 'trim($import_text) !== \'\'')) { fwrite(STDERR, "Shared import payload loader is missing trimmed text handling.\n"); exit(1); } diff --git a/tests/regression/issue318_output_import_hardening_test.php b/tests/regression/issue318_output_import_hardening_test.php new file mode 100644 index 0000000..465c7b0 --- /dev/null +++ b/tests/regression/issue318_output_import_hardening_test.php @@ -0,0 +1,51 @@ + SYSLOG_IMPORT_MAX_BYTES', + '$size <= 0 || $size > SYSLOG_IMPORT_MAX_BYTES', + 'function syslog_csv_cell(mixed $value): string', + "array_map('syslog_csv_cell'", +] as $needle) { + if (!str_contains($functions, $needle)) { + fwrite(STDERR, "Missing import/export hardening: $needle\n"); + exit(1); + } +} + +if (!preg_match('/function\s+syslog_csv_cell\s*\([^)]*\)\s*:\s*string\s*\{.*?\n\}/s', $functions, $match)) { + fwrite(STDERR, "Unable to extract syslog_csv_cell()\n"); + exit(1); +} + +eval(str_replace('function syslog_csv_cell', 'function issue318_csv_cell', $match[0])); + +foreach ([ + ['=SUM(A1)', "'=SUM(A1)"], + ["\tevil", "'\tevil"], + ["\revil", "'\revil"], + [' =SUM(A1)', "' =SUM(A1)"], + ["'=SUM(A1)", "'=SUM(A1)"], + ['router-01', 'router-01'], +] as [$input, $expected]) { + if (issue318_csv_cell($input) !== $expected) { + fwrite(STDERR, 'CSV formula hardening failed for ' . var_export($input, true) . "\n"); + exit(1); + } +} + +if (!str_contains($syslog, 'nl2br(html_escape(trim($html, "\' ")))')) { + fwrite(STDERR, "Alert viewer must escape stored HTML\n"); + exit(1); +} + +print "issue318_output_import_hardening_test passed\n";