Skip to content

fix(security): consolidated hardening — SQL injection, XSS, unserialize, trigger_cmd#773

Open
somethingwithproof wants to merge 19 commits into
Cacti:developfrom
somethingwithproof:security/consolidated-hardening-20260516
Open

fix(security): consolidated hardening — SQL injection, XSS, unserialize, trigger_cmd#773
somethingwithproof wants to merge 19 commits into
Cacti:developfrom
somethingwithproof:security/consolidated-hardening-20260516

Conversation

@somethingwithproof
Copy link
Copy Markdown

@somethingwithproof somethingwithproof commented May 17, 2026

What

Consolidates security hardening from PRs #766, #767, #769, #770, #771, #772 into a single reviewable diff.

SQL injection — bulk form actions (notify_lists.php)

  • Replaced array_to_sql_or() and direct $selected_items[$i] string concatenation with db_execute_prepared() and IN (?,?,?) placeholders built via implode(',', array_fill(0, cacti_sizeof($selected_items), '?')).
  • drp_action allowlist check used array_keys() (returns ints) with in_array(..., true) (strict); added array_map('strval', ...) to align types.
  • All four bulk action blocks are wrapped in db_begin_transaction() / db_commit_transaction(). Every db_execute_prepared() return is tracked in $ok; false calls db_rollback_transaction() and breaks per-item loops immediately.
  • The flat delete block chains all eight statements with && so the first failure short-circuits the rest rather than running all statements before rollback.
  • thold_template_update_thresholds() moved after commit so the cascade does not participate in the transaction boundary.
  • Original code had SET thold_host_email = 0 AND deleted="" (MySQL parsed AND deleted="" as part of the SET expression, a bug). Corrected: deleted = "" moved to WHERE clause.

SQL injection — graph filter (thold_functions.php)

  • get_allowed_thresholds() and get_allowed_threshold_logs(): $graph_id was interpolated directly into the WHERE clause. Changed to gl.id = ? placeholder with $sql_params[] accumulator. Both db_fetch_assoc_prepared() and db_fetch_cell_prepared() now receive bound params.

RLIKE injection (notify_lists.php, thold.php, thold_graph.php)

  • rfilter validated via FILTER_VALIDATE_IS_REGEX before reaching any SQL. db_qstr() escapes the already-validated value for use in RLIKE clauses.

XSS — hidden inputs and AJAX URL params

  • get_request_var('page') in thold.php and thold_graph.php hidden inputs wrapped with html_escape().
  • AJAX filter URL parameters wrapped with encodeURIComponent() across thold.php, thold_graph.php, notify_lists.php, thold_templates.php, thold_webapi.php.

Object injection (thold_webapi.php)

  • sanitize_unserialize_selected_items() applied to selected_graphs_array; raw unserialize() call removed.

PHP 7.4 compatibility (setup.php)

  • count($selected_items) changed to cacti_sizeof($selected_items).

Why

All six original PRs addressed overlapping sets of vulnerabilities without complete test coverage or atomic transaction boundaries. Pre-authentication SQL injection via bulk form actions was the highest-severity issue: array_to_sql_or() constructed WHERE clauses from unsanitized POST data. The RLIKE injection via rfilter and the XSS via the page parameter were secondary. Atomicity was missing entirely — partial deletes across 8 tables could leave orphaned host references.

Test plan

  • vendor/bin/pest tests/ — 53 tests, all passing
  • PreparedStatementTest.php — prepared-statement usage, transaction count, rollback presence, $ok tracking, loop break guards, template cascade position
  • RlikeInjectionTest.phpFILTER_VALIDATE_IS_REGEX presence, db_qstr() usage, no raw $rfilter interpolation
  • XssEscapingTest.phphtml_escape() on page inputs, encodeURIComponent() in AJAX filters
  • UnserializeHardeningTest.phpsanitize_unserialize_selected_items() applied, object injection blocked
  • Php74CompatibilityTest.php — no PHP 8.0+ syntax across 9 plugin files
  • PreparedStatementConsistencyTest.php — no single-line interpolated raw db_*() calls in hardened files
  • PhpSyntaxTest.php (smoke) — all plugin PHP files lint clean

…dening

RLIKE SQL injection: rfilter request variable was concatenated directly into
RLIKE patterns across thold_graph.php (4 instances), thold.php, and
notify_lists.php (3 instances including the notification list filter).
Replaced with db_qstr() which SQL-escapes and quotes the value.

XSS: get_request_var('page') was printed raw into hidden input value
attributes. Wrapped with html_escape().

Unserialize: thold_webapi.php called cacti_unserialize(stripslashes(...))
on POST selected_graphs_array. Replaced with sanitize_unserialize_selected_items()
which validates the result is an array of integers only.

trigger_cmd: thold_set_environ() was called with trigger_cmd_high in both
the low-breach and norm-restoration branches. Corrected to trigger_cmd_low
and trigger_cmd_norm respectively.

No intval() casts added: host_id/site_id go through FILTER_VALIDATE_INT in
the request validation arrays; adding casts after validated request vars is
redundant per Cacti convention.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
25 tests covering RLIKE injection, XSS output escaping, unserialize
hardening, and trigger_cmd variable confusion. All tests are source-level
static checks — no Cacti bootstrap or database required.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Split the combined OR-match in TriggerCmdRegressionTest into two
independent preg_match assertions so a revert of either branch
is caught independently.

Add FILTER_VALIDATE_IS_REGEX test to RlikeInjectionTest documenting
that rfilter goes through regex validation before any RLIKE clause,
mitigating ReDoS at the MySQL engine level.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
notify_lists.php: replace array_to_sql_or() and direct $selected_items
concatenation with db_execute_prepared() + IN (?,?,?) placeholders for
all bulk delete, associate, and disassociate operations. Also replace
count() with cacti_sizeof() per Cacti 1.2.x idiom.

thold_functions.php: parameterise $graph_id in get_allowed_thresholds()
and get_allowed_threshold_logs() using ? placeholders; switch to
db_fetch_assoc_prepared() and db_fetch_cell_prepared().

Add PreparedStatementTest.php, Php74CompatibilityTest.php, and
Smoke/PhpSyntaxTest.php to cover these patterns. 41 tests pass.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Prevents open redirect via URL manipulation in JS filter forms.
Affects thold.php, thold_graph.php, notify_lists.php, notify_queue.php,
thold_templates.php, and thold_webapi.php.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
PhpSyntaxTest.php used str_starts_with() (PHP 8.0+) which would fatal
under PHP 7.4 before any assertion ran. Use strncmp() instead.

Also remove redundant (int) cast and double in_array check on
drp_action in notify_lists.php: get_filter_request_var() already
validated the value; the second clause was dead and confusing.

Replace count() with cacti_sizeof() in setup.php bulk loop.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
array_keys($actions + $assoc_actions) returns integer keys; POST values
are always strings. Without the strval() cast, in_array(..., true) with
strict comparison always fails, making every bulk form action unreachable.

Adds regression test asserting the strval() cast is present.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
…ndlers

All three action-save blocks (save_associate, save_templates, save_tholds)
now call get_filter_request_var() for id, notification_action,
notification_warning_action, and notification_alert_action before
consuming those values via get_request_var() in prepared-statement params.

Add inline comment on all RLIKE db_qstr() sites documenting the dual
guard: FILTER_VALIDATE_IS_REGEX pre-validates; db_qstr() SQL-escapes.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Copilot AI review requested due to automatic review settings May 17, 2026 07:28
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_params to get_allowed_thresholds/get_allowed_threshold_logs.
  • Wrap URL parameters in encodeURIComponent, escape get_request_var('page') with html_escape in hidden inputs, replace cacti_unserialize(stripslashes(...)) with sanitize_unserialize_selected_items, and add drp_action whitelist + per-branch input validation in notify_lists.php.
  • Fix two thold_set_environ calls that incorrectly passed trigger_cmd_high in the low/norm branches; add Pest test suite, bootstrap stubs, composer.json, and phpunit.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

  • $actions and $assoc_actions are not visible in this diff hunk, but the existing code branches further below dispatch on drp_action == '1' / '2' for at least three different contexts (lists, associate, templates, tholds) which may use overlapping numeric keys. Combining them with $actions + $assoc_actions only 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 receive drp_action values present in one of those two arrays — otherwise legitimate actions will hit raise_message(40) and redirect. If save_templates/save_tholds use a different action map, they will be rejected here.
 | http://www.cacti.net/                                                   |

Comment thread notify_lists.php
Comment thread notify_lists.php Outdated
Comment thread tests/Security/RlikeInjectionTest.php Outdated
Comment thread tests/Smoke/PhpSyntaxTest.php Outdated
Comment thread thold_functions.php
Comment thread thold_functions.php
Comment thread tests/bootstrap.php
Comment thread notify_lists.php
Verifies that poller_thold.php, setup.php, thold.php, and thold_graph.php
contain no single-line raw db_*() calls with interpolated variables.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
get_total_row_data accepts $sql_params since Cacti 1.2.x (lib/auth.php:3120).
Both call sites now carry a comment referencing the function signature
so future callers understand the API assumption.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
All four form_actions() bulk write blocks now use db_begin_transaction()
/ db_commit_transaction() so a partial failure cannot leave notification
routing state inconsistent across plugin_notification_lists, host,
thold_data, and thold_template tables.

Adds db_begin/commit/rollback_transaction stubs to test bootstrap
and a regression test asserting all four blocks carry transaction guards.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Track $ok across all db_execute_prepared calls in each of the four
bulk action blocks. Call db_rollback_transaction() when any statement
returns false instead of committing a partial write.

Tests: add rollback-count assertion (4), $ok-flag presence check.
Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
…r commit

- Add if (!ok) { break; } at end of each per-item loop in save_associate,
  save_templates, and save_tholds so failed iterations halt immediately.
- Move thold_template_update_thresholds calls to after db_commit_transaction()
  so the cascade does not participate in the transaction boundary.
- Apply html_escape() to get_filter_request_var('page') output in thold.php
  hidden input (mirrors thold_graph.php pattern).
- Add tests: loop-break guard count, template-cascade-after-commit, thold.php
  page XSS fix.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
…notes

escapeshellarg PHP_BINARY path in PhpSyntaxTest shell command to handle
paths with spaces. Note in thold_functions that thold_set_environ uses
putenv side-effects that exec() inherits. Note in bootstrap stub that
sanitize_unserialize_selected_items must stay in sync with Cacti core.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
…tract

Use named variables for the pre-fix vulnerable pattern strings in
RlikeInjectionTest to make them easier to verify. Add comments
above get_allowed_thresholds/logs noting that sql_params must be
supplied when sql_where contains ? placeholders.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Single-quoted string literals with embedded single quotes produced invalid
PHP syntax. Nowdoc avoids quoting issues entirely.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
…t-circuit delete block

PreparedStatementTest regex [^)]+ stopped at first ) inside argument lists
and never matched the && $ok chain. Replaced with substr_count.

Delete block now chains all eight db_execute_prepared calls with && so the
first failure stops execution rather than running all subsequent statements.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants