Skip to content

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

Open
somethingwithproof wants to merge 29 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 29 commits into
Cacti:developfrom
somethingwithproof:security/consolidated-hardening-20260516

Conversation

@somethingwithproof

@somethingwithproof somethingwithproof commented May 17, 2026

Copy link
Copy Markdown
Contributor

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 Outdated
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>
somethingwithproof and others added 6 commits May 17, 2026 01:29
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>
…tags

RPN math evaluator: replace the two eval() sinks in thold_expression_math_rpn()
with native switch dispatch. The binary operator case now computes results
directly (+ 0 coercion and (int) casts preserve eval()'s integer semantics for
% and ^); the unary-function case gains a missing is_numeric($v1) guard before
dispatching to sin()/cos()/sqrt()/etc. Operands were already validated numeric
and operators/functions were already whitelisted, so behavior is unchanged for
legitimate RPN expressions while the code-execution sink is eliminated.

trigger_cmd execution: thold_replace_threshold_tags() gains a $shell flag.
When set (only the three trigger_cmd_high/low/norm exec() callers pass true),
device/user-derived string tags (<DESCRIPTION>, <HOSTNAME>, <LOCATION>, <SITE>,
<THRESHOLDNAME>, <DSNAME>, <NOTES>, <DNOTES>, <DEVICENOTE>, <EXTERNALID>) are
escaped with cacti_escapeshellarg() so they cannot break out of the
admin-configured command line. Email/HTML callers keep the default $shell=false
and are unaffected. Numeric/computed tags remain unescaped.
@somethingwithproof

Copy link
Copy Markdown
Contributor Author

@TheWitness @netniV — requesting a final review on this consolidated security hardening PR when you have a moment.

Latest push (3405133) hardens the two remaining items in thold_functions.php:

  1. RPN evaluator — removed both eval() sinks in thold_expression_math_rpn(). Replaced with native switch dispatch:

    • Binary operators (+ - * / % ^): direct computation; + 0 coercion and (int) casts preserve the integer semantics eval() applied to % and ^.
    • Unary functions (SIN/COS/TAN/ATAN/SQRT/FLOOR/CEIL/DEG2RAD/RAD2DEG/ABS/EXP/LOG): dispatch to the native math functions, plus a newly added is_numeric() guard that was previously missing on this path. Operands were already validated numeric and operators/functions already whitelisted, so legitimate RPN behavior is unchanged.
  2. trigger_cmd_* execution — shell escaping. thold_replace_threshold_tags() gained a $shell flag; only the three trigger_cmd_high/low/norm exec() callers pass true, which wraps device/user-derived string tags (<DESCRIPTION>, <HOSTNAME>, <LOCATION>, <SITE>, <THRESHOLDNAME>, <DSNAME>, <NOTES>, <DNOTES>, <DEVICENOTE>, <EXTERNALID>) in cacti_escapeshellarg(). Email/HTML callers keep the default $shell = false and are unaffected. Numeric/computed tags remain unescaped.

Validation: php -l clean, PHP 7.4-safe (no PHP 8-only syntax), targeting 1.2.31-idiomatic helpers.

Known follow-ups:

  • The branch is currently conflicting with develop and needs a rebase — happy to rebase before merge.
  • thold_expand_string()'s |...| substitutions into the command line remain unescaped (lower risk: admin-set graph titles / DS names); can address in a follow-up if you'd like it in scope.

… 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.
@somethingwithproof

Copy link
Copy Markdown
Contributor Author

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 0e2c54e.

Addressed in 0e2c54e:

  • notify_lists.php — SQL string quoting consistency (the notify_warning_extra/notify_extra clearing queries): converted both from double-quoted PHP strings with escaped \"\" to single-quoted strings, matching the surrounding prepared queries in the same block. Verified the rendered SQL is byte-identical (SET … = '' … AND (tt.notify_templated = "" OR tt.notify_templated IS NULL)), so this is a pure readability change.
  • thold_functions.phpexec() env side-effect: the putenv()/thold_set_environ() note now appears on all three trigger_cmd_high/low/norm branches (it previously existed only on the low branch).

Already handled earlier on this branch:

  • thold_functions.php$sql_params contract: get_allowed_thresholds() / get_allowed_threshold_logs() both carry a docblock noting $sql_where may contain ? placeholders supplied via $sql_params.
  • tests/Smoke/PhpSyntaxTest.php — unquoted $phpBin: the lint command already wraps both $phpBin and $file in escapeshellarg().
  • tests/Security/RlikeInjectionTest.php — readability: the asserted pre-fix patterns are expressed as nowdocs with an explanatory comment.
  • tests/bootstrap.php — stub drift: the sanitize_unserialize_selected_items() stub carries a KEEP IN SYNC with Cacti core comment.
  • deleted = "" behavioral change: documented in the PR description (“What” section) — the original SET thold_host_email = 0 AND deleted="" was a bug; deleted = "" is now correctly in the WHERE clause.

No change (with rationale):

  • notify_lists.phpheader('Location: …'); exit; idiom: this is the established Cacti 1.2.x pattern, used consistently in this same file (e.g. the other form_actions() redirects) and throughout core, so it was left as-is rather than switching to header_redirect().
  • drp_action allowlist coverage (low-confidence note): confirmed all four save_* contexts (save_list, save_associate, save_templates, save_tholds) dispatch only on drp_action 1/2, so the $actions + $assoc_actions union ({1,2}) fully covers them. Type alignment via array_map('strval', …) is noted in the PR description.

php -l is clean on both changed files; changes remain PHP 7.4-safe. Note the branch is still CONFLICTING with develop (single-file conflict in thold_functions.php) — I'll rebase before merge.

@somethingwithproof

Copy link
Copy Markdown
Contributor Author

@TheWitness @netniV — this is ready for a final review when you have a moment.

Status

  • The automated review feedback is fully addressed as of 0e2c54e (per the itemized comment above): SQL quoting consistency in notify_lists.php, exec() env-side-effect comments across all three trigger_cmd_* branches, plus confirmation that the already-handled items (docblocks, test escaping, stub-sync note, the deleted = "" WHERE fix) are in place.

CI heads-up

  • The Plugin Integration Tests workflow has not run on the latest commits — the current head (0e2c54e) has no check runs, and the most recent runs on this branch are from mid-May (on the older commits) and failed. On fork PRs this repo gates workflow runs on maintainer approval, so CI can't validate the current head until one of you approves the run. Could you approve/trigger it? I'll promptly fix anything it surfaces.

Before merge

  • The branch is still conflicting with develop (a single-file conflict in thold_functions.php). I'll rebase once you've had an initial look, so the final review reflects the state you'd merge.

Thanks!

Integrates Cacti#774 (RPN evaluation / SNMP trap / notification bug fixes). The thold_functions.php conflict is resolved by keeping this PR's eval()-free native-switch RPN evaluator on top of Cacti#774's operand guards and division-by-zero handling; queued exec() nosemgrep annotations aligned to develop. No eval() sinks remain; php -l clean.
@somethingwithproof

Copy link
Copy Markdown
Contributor Author

Update: merged latest develop (#774) — now mergeable, hardening verified

This PR is now mergeable against develop as of 592cc7e.

Conflict resolution with #774

develop #774 ("fix 13 logic bugs in RPN evaluation, SNMP traps, and notification handling") rewrote the same thold_expression_math_rpn() evaluator this PR hardens. I merged develop in and resolved thold_functions.php to keep both:

Semantics are identical to the previous eval() (binary computes $v2 <op> $v1 with (int) casts on %/^; unary dispatches sin()/cos()/… — PHP function names are case-insensitive). The two queued exec() annotations were aligned to develop's wording (the call itself is unchanged).

Security changes recap

  • RPN evaluator: eval() → native switch (removes the code-execution sink); added the missing is_numeric guard on the unary path.
  • trigger_cmd_* exec: device/user-derived tags escaped via cacti_escapeshellarg() (new $shell flag on thold_replace_threshold_tags()); email/HTML callers unaffected.
  • Earlier: prepared statements + db_qstr for RLIKE, encodeURIComponent on AJAX filters, sanitize_unserialize_selected_items, and transaction/atomicity fixes.

Validation

  • Pest suite: 53 passed / 0 failed (Security + Smoke), incl. TriggerCmdRegressionTest, RlikeInjectionTest, UnserializeHardeningTest, XssEscapingTest, Php74CompatibilityTest, and php -l across all plugin files.
  • No eval() sinks remain; PHP 7.4-safe.

Known follow-ups (not in this PR)

@TheWitness @netniV — this is ready for review whenever you have a moment; happy to adjust anything.

@somethingwithproof somethingwithproof self-assigned this Jul 14, 2026
Keep this branch workflow-only.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
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>
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