security: harden SQL, output escaping, and unserialize paths - #773
security: harden SQL, output escaping, and unserialize paths#773somethingwithproof wants to merge 41 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
This PR hardens the Thold plugin against multiple security issues (SQL injection via RLIKE concatenation and bulk-action concatenation, XSS via unescaped request vars in URLs and HTML, and unsafe deserialization) and corrects a couple of variable-name bugs in thold_command_execution(). It also introduces a Pest-based test suite with stubs/bootstrap for running outside Cacti, plus PHP lint and PHP 7.4 compatibility smoke tests.
Changes:
- Replace raw RLIKE/SQL string concatenation with
db_qstr()and parameterized queries (db_execute_prepared,db_fetch_assoc_prepared,db_fetch_cell_prepared), and add$sql_paramstoget_allowed_thresholds/get_allowed_threshold_logs. - Wrap URL parameters in
encodeURIComponent, escapeget_request_var('page')withhtml_escapein hidden inputs, replacecacti_unserialize(stripslashes(...))withsanitize_unserialize_selected_items, and adddrp_actionwhitelist + per-branch input validation innotify_lists.php. - Fix two
thold_set_environcalls that incorrectly passedtrigger_cmd_highin the low/norm branches; add Pest test suite, bootstrap stubs,composer.json, andphpunit.xml.
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| notify_lists.php | Convert bulk SQL to prepared statements; add drp_action whitelist and request var filters; encode URL params |
| thold_functions.php | Add $sql_params, switch to prepared SELECTs; fix trigger_cmd_low/norm environ args; annotate eval/exec with nosemgrep |
| thold_graph.php | Use db_qstr for RLIKE; html_escape page var; encodeURIComponent URL params |
| thold.php | Use db_qstr for RLIKE; encodeURIComponent URL params |
| thold_templates.php | encodeURIComponent on filter |
| thold_webapi.php | Switch to sanitize_unserialize_selected_items; encode URL params |
| notify_queue.php | encodeURIComponent URL params |
| setup.php | Use cacti_sizeof instead of count |
| tests/* | New Pest test suite, bootstrap stubs, smoke/security tests |
| composer.json, phpunit.xml | Test tooling config |
Comments suppressed due to low confidence (1)
notify_lists.php:21
$actionsand$assoc_actionsare not visible in this diff hunk, but the existing code branches further below dispatch ondrp_action == '1'/'2'for at least three different contexts (lists, associate, templates, tholds) which may use overlapping numeric keys. Combining them with$actions + $assoc_actionsonly validates membership in the union of keys, which is fine, but please confirm that all four save_* sections (save_list,save_associate,save_templates,save_tholds) only ever receivedrp_actionvalues present in one of those two arrays — otherwise legitimate actions will hitraise_message(40)and redirect. Ifsave_templates/save_tholdsuse a different action map, they will be rejected here.
| http://www.cacti.net/ |
|
@TheWitness @netniV — requesting a final review on this consolidated security hardening PR when you have a moment. Latest push (
Validation: Known follow-ups:
|
|
Thanks for the automated review. Status of each point below — most were already handled in earlier commits on this branch; the two outstanding items are addressed in Addressed in
Already handled earlier on this branch:
No change (with rationale):
|
|
@TheWitness @netniV — this is ready for a final review when you have a moment. Status
CI heads-up
Before merge
Thanks! |
Update: merged latest
|
|
Re-verified: branch is up to date with develop (no rebase needed), all 4 Integration Test checks currently pass. All 8 Copilot review threads on this PR are already marked resolved. No changes needed. |
TheWitness
left a comment
There was a problem hiding this comment.
Use Cacti's composer.json in testing.
TheWitness
left a comment
There was a problem hiding this comment.
No composer.json in plugins.
2e91187 to
917b1e0
Compare
… env comments) notify_lists.php: convert the two notify_warning_extra/notify_extra clearing queries from double-quoted PHP strings (with escaped double quotes) to single-quoted strings, matching the surrounding prepared queries in the same block. The rendered SQL is byte-identical (verified), so this is purely a readability/consistency change that removes the escape-character confusion the review flagged. thold_functions.php: mirror the putenv() side-effect comment onto the trigger_cmd_high and trigger_cmd_norm branches (it previously existed only on trigger_cmd_low) so all three exec() paths document that thold_set_environ() populates the environment exec() inherits.
sanitize_unserialize_selected_items rejects nested wizard arrays and broke thold_new_graphs_save. Use the graphs sanitizer when available, else allowed_classes => false with structure checks. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
The previous suite read the plugin's source as text and asserted on substrings, so it could not distinguish a working fix from a comment. This runs the code instead: tests/Support/CactiStub.php records and programs the Cacti framework functions the plugin calls, and PHPUnit runs against PHP 8.1 in Docker to match the oldest interpreter the CI matrix covers. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Modulo by zero raised an uncaught DivisionByZeroError, and SQRT of a negative or LOG of zero pushed NAN or -INF, which compares false against every bound so the breach went unnoticed. Zero divided by zero broke out of the operator switch before pushing its result, leaving the stack short by two. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
db_qstr_rlike() is core's remediation for GHSA-69gg-xrh3-gp82: on top of quoting it bounds the operand and strips the alternation characters that made the pattern a denial-of-service vector. It arrived in 1.2.31, so the plugin falls back to plain quoting on the 1.2.25 it still declares support for. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
get_filter_request_var() stores drp_action as an int while the allowlist held strings, so the strict in_array() rejected every action and each one redirected without touching the database. The delete also bound $selected_items with its submitted keys intact, which PDO reads as named parameters, and committed through Cacti's db_commit_transaction(), which tests a MariaDB-only system variable and so never commits on MySQL. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
The tests that need Cacti to provide db_qstr_rlike() or get_total_row_data() run in their own process, so defining those functions does not change which branch the rest of the suite takes. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
thold_process_command_output() dispatches on the topic it is passed, and 'thold' matched none of its branches, so a trigger command run outside the notification queue recorded neither its exit status nor its output. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
…sembled Also covers the four exit-status and output combinations the command result logging distinguishes. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Adopts the conventions from Cacti core: tests/bootstrap-unit.php, tests/Helpers for the stubs, a phpunit.xml carrying error_reporting -1 and CACTI_TEST_BOOTSTRAP, and composer lint / test / test:coverage scripts so CI runs the same commands a developer does. The dev toolchain is Cacti's, pinned to the same platform php 8.1.0. Cacti core runs Pest and this suite does not, because pest ^2 does not resolve on PHP 8.1 -- the platform Cacti's own composer.json pins. Releases up to v2.36.0 conflict with phpunit 10.5.62 and later, every earlier 10.x release is blocked by advisory PKSA-z3gr-8qht-p93v, and v2.36.1, which does resolve, requires PHP 8.2. The stack installs on 8.2 and above; 8.1 is the floor this plugin's CI matrix targets. The tests are written in the plain PHPUnit class style that Cacti's tests/Pest.php explicitly supports, so they run unchanged under Pest wherever it is installable. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
27e965a to
9fb2072
Compare
|
Review follow-up: reverified after the latest rebase. Both TheWitness requests are addressed: there is no plugin-local composer.json, composer.lock, or vendor directory, and the Pest workflow uses Cacti’s Composer toolchain. All eight Copilot findings remain addressed and their threads are resolved. Current CI is rerunning on the updated branch. |
|
Both addressed: there is no composer.json in the plugin, and the unit workflow runs Cacti's own |
…or (#791) * test: add the PHP 8.1 unit-test harness Same harness as #773 and #788, with gmp added to the image so the 64-bit counter arithmetic can be tested exactly. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(thold): correct the counter delta and the percent denominator A previous counter reading of exactly zero was treated as no reading at all, so the first interval after a device reboot reported a rate of zero. The wrap modulus was 2^32-1 and 2^64-1 rather than 2^32 and 2^64, losing one count per wrap, and the 64-bit literal exceeded PHP_INT_MAX so it was parsed as a float and lost about eleven bits before the subtraction. The percent-of denominator was cast to int, so a denominator below one truncated to zero and forced the result to zero, keeping any configured low threshold in permanent breach. Refs #785 Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(daemon): store the previous reading in oldvalue, not a timestamp When a data source produced no sample this cycle the daemon wrote $currenttime - $rrd_step into oldvalue, so the next poll computed a delta against a Unix timestamp, took the overflow branch and fabricated a rate in the billions. The non-daemon path already carries the previous oldvalue forward; this matches it. Refs #785 Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * test: follow Cacti's test layout and composer scripts Adopts the conventions from Cacti core: tests/bootstrap-unit.php, tests/Helpers for the stubs, a phpunit.xml carrying error_reporting -1 and CACTI_TEST_BOOTSTRAP, and composer lint / test / test:coverage scripts so CI runs the same commands a developer does. The dev toolchain is Cacti's, pinned to the same platform php 8.1.0. Cacti core runs Pest and this suite does not, because pest ^2 does not resolve on PHP 8.1 -- the platform Cacti's own composer.json pins. Releases up to v2.36.0 conflict with phpunit 10.5.62 and later, every earlier 10.x release is blocked by advisory PKSA-z3gr-8qht-p93v, and v2.36.1, which does resolve, requires PHP 8.2. The stack installs on 8.2 and above; 8.1 is the floor this plugin's CI matrix targets. The tests are written in the plain PHPUnit class style that Cacti's tests/Pest.php explicitly supports, so they run unchanged under Pest wherever it is installable. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * ci: keep plugin PR integration checks on pinned Cacti * fix(counter): handle non-integer 64-bit readings safely * ci: bound package index refreshes --------- Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
…a source (#790) * test: add the PHP 8.1 unit-test harness Same harness as #773 and #788, so whichever lands first the others merge cleanly. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(thold): keep a zero reading through tag substitution thold_str_replace() treated 0 and '0' as absent, so an alert for a value that had dropped to zero rendered as "Current value is " with a blank, and a trigger command invoked as --value <CURRENTVALUE> lost the argument and shifted the ones after it. Refs #787 Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * fix(thold): return zero when the requested data source is absent array_search() reports a miss as false, so a guard written against null let it through and $result['values'][false] read index 0. A lookup for a data source that does not exist returned the first one's value, which the caller then compared against the threshold bounds. Reached today from thold_expression_specialtype_rpn() and the CDEF substitutions, which pass column names such as upper_limit rather than data source names. Those call sites still need to read the real column; this only stops them silently receiving another metric. Refs #787 Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * test: follow Cacti's test layout and composer scripts Adopts the conventions from Cacti core: tests/bootstrap-unit.php, tests/Helpers for the stubs, a phpunit.xml carrying error_reporting -1 and CACTI_TEST_BOOTSTRAP, and composer lint / test / test:coverage scripts so CI runs the same commands a developer does. The dev toolchain is Cacti's, pinned to the same platform php 8.1.0. Cacti core runs Pest and this suite does not, because pest ^2 does not resolve on PHP 8.1 -- the platform Cacti's own composer.json pins. Releases up to v2.36.0 conflict with phpunit 10.5.62 and later, every earlier 10.x release is blocked by advisory PKSA-z3gr-8qht-p93v, and v2.36.1, which does resolve, requires PHP 8.2. The stack installs on 8.2 and above; 8.1 is the floor this plugin's CI matrix targets. The tests are written in the plain PHPUnit class style that Cacti's tests/Pest.php explicitly supports, so they run unchanged under Pest wherever it is installable. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * ci: keep plugin PR integration checks on pinned Cacti * test: match the optional Cacti database stub signature * ci: bound package index refreshes --------- Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Consolidates the hardening from #766, #767, #769, #770, #771 and #772 into one diff.
Security
notify_lists.phpandnotify_queue.phpuse prepared statements withIN (?,?,?)placeholders instead ofarray_to_sql_or()and string concatenation.get_allowed_thresholds()andget_allowed_threshold_logs()bind$graph_idrather than interpolating it. Callers supply values for any?in their own$sql_wherevia a new$sql_paramsargument, appended before the$graph_idplaceholder.rfiltervalue goes throughdb_qstr_rlike()where Cacti provides it. That helper is core's remediation for GHSA-69gg-xrh3-gp82 and bounds the operand as well as quoting it; it arrived in 1.2.31, so the plugin falls back to plain quoting on the 1.2.25 it declares support for.cacti_escapeshellarg().page,idanddrp_actionare escaped where they are printed into hidden inputs, and AJAX filter parameters are wrapped inencodeURIComponent().eval(). Operators dispatch through aswitch; operands were already validated numeric and the operator set was already closed, so the results are unchanged.Bugs this uncovered
Writing tests that run the code rather than grep it turned up four defects, all fixed here:
get_filter_request_var()storesdrp_actionas an int while the allowlist held strings, so the strictin_array()rejected all of them and each action redirected without touching the database. This was introduced by 9eee922 on this branch.db_commit_transaction()gates the commit onSELECT @@in_transaction, which only MariaDB defines; on MySQL the query errors and the commit is skipped, so the open transaction is rolled back at disconnect. Verified againstmysql:8andmariadb:10.6. The plugin now issues the transaction statements directly, which behaves identically on both. Worth a separate core fix, since these helpers have no other caller in 1.2.31.0 0 /corrupted the RPN stack. The zero-divided-by-zero case broke out of the operatorswitchbefore pushing its result, so the stack lost two entries and gained none.%by zero was a fatal, andSQRTof a negative orLOGof zero pushedNAN/-INF. Those compare false against every bound, so a breach went unnoticed. Both now raise the expression's error flag.thold_process_command_output()was passed the topic'thold', which matches none of its branches.Deleting a notification list also no longer skips soft-deleted devices, so a device that is later restored cannot come back pointing at a list that no longer exists.
Tests
The previous suite read the plugin's source as text and asserted on substrings, so it could not tell a working fix from a comment; every one of the bugs above passed it. It is replaced with tests that run the code against a recording stand-in for the Cacti framework functions.
145 tests. Every line this branch changes is covered, which CI enforces per pull request — whole-file coverage would be meaningless when most of the plugin only runs inside a live Cacti.
The suite runs on PHP 8.1 in Docker, matching the oldest interpreter the integration matrix covers.
composer test:dockerruns locally exactly what CI runs.Compatibility
Checked against
release/1.2.31andrelease/1.2.25: no PHP 8-only syntax, and every core function, constant and signature the diff relies on exists in both.