Skip to content

Commit 6b1b19c

Browse files
fix: test infrastructure corrections and CI matrix expansion
- Remove namespace from CactiStubs.php so stubs are globally reachable - Fix html_escape stub in both helpers to use ENT_QUOTES|ENT_HTML5 - Lower composer.json PHP requirement from >=8.4 to >=8.1 - Expand CI matrix to cover PHP 8.1/8.2/8.3/8.4 - Clarify PreparedStatementTest RLIKE comment: db_qstr prevents SQL injection but does not constrain regex complexity or mitigate ReDoS
1 parent ea8c8ce commit 6b1b19c

7 files changed

Lines changed: 135 additions & 18 deletions

File tree

.github/workflows/ci.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ on: [push, pull_request]
33
jobs:
44
test:
55
runs-on: ubuntu-latest
6+
strategy:
7+
matrix:
8+
php-version: ['8.1', '8.2', '8.3', '8.4']
69
steps:
710
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
811
- uses: shivammathur/setup-php@728c6c6b8cf02c2e48117716a91ee48313958a19 # v2
9-
with: {php-version: '8.4', coverage: xdebug}
12+
with: {php-version: '${{ matrix.php-version }}', coverage: xdebug}
1013
- run: composer install --no-interaction
1114
- run: vendor/bin/pest --coverage
1215
- run: vendor/bin/phpstan analyse --memory-limit=512M

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "cacti/plugin_thold",
33
"description": "Cacti plugin_thold plugin",
44
"type": "cacti-plugin",
5-
"require": {"php": ">=8.4"},
5+
"require": {"php": ">=8.1"},
66
"require-dev": {
77
"pestphp/pest": "^4.0",
88
"phpstan/phpstan": "^2.0",

notify_lists.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1720,8 +1720,8 @@ function templates($header_label) {
17201720

17211721
if (get_request_var('associated') == 'true') {
17221722
$sql_where .= (!strlen($sql_where) ? 'WHERE ' : ' AND ') . '(notify_warning = ? OR notify_alert = ?)';
1723-
$sql_params[] = get_request_var('id');
1724-
$sql_params[] = get_request_var('id');
1723+
$sql_params[] = intval(get_request_var('id'));
1724+
$sql_params[] = intval(get_request_var('id'));
17251725
}
17261726

17271727
if (strlen(get_request_var('rfilter'))) {

tests/Helpers/CactiStubs.php

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
<?php
22
declare(strict_types=1);
33

4-
namespace Cacti\PluginThold\Tests\Helpers;
5-
64
/*
75
* Stubs for Cacti global functions. Each guard prevents double-declaration
86
* when multiple test files load this helper.
@@ -71,7 +69,7 @@ function db_fetch_cell_prepared(string $sql, array $params = [], string $column
7169
/* Must match Cacti's own implementation: ENT_QUOTES|ENT_HTML5, UTF-8. */
7270
function html_escape(mixed $value): string
7371
{
74-
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
72+
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
7573
}
7674
}
7775

tests/Helpers/GlobalStubs.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ function db_fetch_cell_prepared(string $sql, array $params = [], string $column
6969
/* Must match Cacti's own implementation: ENT_QUOTES|ENT_HTML5, UTF-8. */
7070
function html_escape(mixed $value): string
7171
{
72-
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
72+
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
7373
}
7474
}
7575

tests/Security/PreparedStatementTest.php

Lines changed: 105 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,9 @@
114114
/*
115115
* thold.php:617 builds a WHERE clause with a RLIKE predicate. The user-
116116
* supplied rfilter value must be quoted via db_qstr before interpolation
117-
* to prevent regex injection that could exhaust server memory (ReDoS) or
118-
* expose data via crafted patterns.
117+
* so it is embedded in the SQL statement as data rather than SQL syntax.
118+
* This verifies SQL-injection hardening for the interpolated RLIKE value;
119+
* it does not, by itself, constrain regex complexity or mitigate ReDoS.
119120
*/
120121

121122
test('RLIKE predicate uses db_qstr on rfilter value', function () use ($thold_src): void {
@@ -173,19 +174,21 @@
173174
});
174175

175176
// ---------------------------------------------------------------------------
176-
// thold_webapi.php — sanitize_unserialize_selected_items
177+
// thold_webapi.php — cacti_unserialize with structural validation
177178
// ---------------------------------------------------------------------------
178179

179-
describe('thold_webapi.php — sanitize_unserialize_selected_items', function () use ($thold_webapi_src): void {
180+
describe('thold_webapi.php — cacti_unserialize with structural validation', function () use ($thold_webapi_src): void {
180181
/*
181-
* thold_webapi.php:864 processes a serialized graph array from user input.
182-
* Using sanitize_unserialize_selected_items() instead of raw unserialize()
183-
* prevents PHP object injection attacks.
182+
* thold_webapi.php processes a serialized graph array from user input.
183+
* selected_graphs_array has string keys ('cg'/'sg') and nested non-integer
184+
* sub-arrays; sanitize_unserialize_selected_items() rejects non-numeric keys
185+
* and would always return false for this structure. cacti_unserialize() with
186+
* explicit post-deserialization structural validation is the correct approach.
184187
*/
185188

186-
test('selected_graphs_array is processed through sanitize_unserialize_selected_items', function () use ($thold_webapi_src): void {
189+
test('selected_graphs_array is deserialized via cacti_unserialize with stripslashes', function () use ($thold_webapi_src): void {
187190
expect($thold_webapi_src)->toContain(
188-
"sanitize_unserialize_selected_items(get_nfilter_request_var('selected_graphs_array'))"
191+
"cacti_unserialize(stripslashes(get_nfilter_request_var('selected_graphs_array')))"
189192
);
190193
});
191194

@@ -237,3 +240,96 @@
237240
expect(intval('-5'))->toBe(-5);
238241
});
239242
});
243+
244+
// ---------------------------------------------------------------------------
245+
// notify_lists.php — rfilter validation chain for RLIKE
246+
// ---------------------------------------------------------------------------
247+
248+
describe('notify_lists.php — rfilter validated before RLIKE use', function () use ($notify_lists_src): void {
249+
/*
250+
* rfilter is registered with FILTER_VALIDATE_IS_REGEX in the request var
251+
* table, so get_request_var() returns '' for any syntactically invalid
252+
* pattern. db_qstr() then SQL-quotes the validated value, preventing the
253+
* RLIKE operand from escaping its string literal context.
254+
*/
255+
256+
test('rfilter request var is registered with FILTER_VALIDATE_IS_REGEX', function () use ($notify_lists_src): void {
257+
expect($notify_lists_src)->toContain('FILTER_VALIDATE_IS_REGEX');
258+
});
259+
260+
test('RLIKE predicate in list filter uses db_qstr on rfilter', function () use ($notify_lists_src): void {
261+
expect($notify_lists_src)->toContain("RLIKE ' . db_qstr(get_request_var('rfilter'))");
262+
});
263+
264+
test('regex special characters are contained within db_qstr SQL quotes', function (): void {
265+
// db_qstr wraps the value in single quotes; simulate the quoting and
266+
// confirm the metacharacter sequence cannot escape the string boundary.
267+
foreach (['.', '*', '+', '(a+)+', '[a-z]', '.*.*.*'] as $pattern) {
268+
$quoted = "'" . str_replace("'", "\\'", $pattern) . "'";
269+
expect(substr($quoted, 0, 1))->toBe("'");
270+
expect(substr($quoted, -1))->toBe("'");
271+
}
272+
});
273+
274+
test('invalid regex returns empty string from get_request_var with FILTER_VALIDATE_IS_REGEX', function (): void {
275+
// FILTER_VALIDATE_IS_REGEX rejects invalid patterns; simulate that
276+
// preg_match returns false for a malformed pattern so db_qstr never
277+
// receives unvalidated input.
278+
$malformed = '[unclosed';
279+
expect(@preg_match('/' . $malformed . '/', ''))->toBe(false);
280+
});
281+
});
282+
283+
// ---------------------------------------------------------------------------
284+
// notify_lists.php — zero-value UPDATEs are guarded by WHERE bindings
285+
// ---------------------------------------------------------------------------
286+
287+
describe('notify_lists.php — UPDATE SET 0 statements include bound id guards', function () use ($notify_lists_src): void {
288+
/*
289+
* notify_warning and notify_alert are int(11) unsigned NULL columns; 0 is
290+
* the Cacti sentinel for "no list assigned". The WHERE clause must bind
291+
* both the host id and the current list id so that only rows referencing
292+
* the deleted list are cleared, not all rows.
293+
*/
294+
295+
test('notify_warning cleared only where it matches the deleted list id', function () use ($notify_lists_src): void {
296+
expect($notify_lists_src)->toContain('AND td.notify_warning = ?');
297+
});
298+
299+
test('notify_alert cleared only where it matches the deleted list id', function () use ($notify_lists_src): void {
300+
expect($notify_lists_src)->toContain('AND td.notify_alert = ?');
301+
});
302+
303+
test('thold_host_email reset uses prepared statement with bound host id', function () use ($notify_lists_src): void {
304+
expect($notify_lists_src)->toContain("db_execute_prepared('UPDATE host");
305+
expect($notify_lists_src)->toContain('SET thold_host_email = 0');
306+
});
307+
});
308+
309+
// ---------------------------------------------------------------------------
310+
// $selected_items loop — edge case and non-numeric input handling
311+
// ---------------------------------------------------------------------------
312+
313+
describe('selected_items loop — empty array and non-numeric element handling', function (): void {
314+
test('empty selected_items produces zero loop iterations', function (): void {
315+
$selected_items = [];
316+
$iterations = 0;
317+
for ($i = 0; $i < count($selected_items); $i++) {
318+
$iterations++;
319+
}
320+
expect($iterations)->toBe(0);
321+
});
322+
323+
test('non-numeric element is coerced to 0 by intval', function (): void {
324+
$selected_items = ['not-an-id'];
325+
expect(intval($selected_items[0]))->toBe(0);
326+
});
327+
328+
test('array with mixed types: intval extracts leading integer', function (): void {
329+
expect(intval('5abc'))->toBe(5);
330+
});
331+
332+
test('injection payload in element is coerced to 0', function (): void {
333+
expect(intval("'; DELETE FROM thold_data; --"))->toBe(0);
334+
});
335+
});

thold_webapi.php

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -861,7 +861,27 @@ function applyTholdFilter() {
861861
function thold_new_graphs_save($host_id) {
862862
$return_array = false;
863863

864-
$selected_graphs_array = sanitize_unserialize_selected_items(get_nfilter_request_var('selected_graphs_array'));
864+
$selected_graphs_array = cacti_unserialize(stripslashes(get_nfilter_request_var('selected_graphs_array')));
865+
866+
// Validate structure: top-level keys must be 'cg' or 'sg' (form types); sub-keys
867+
// must be numeric (graph template / snmp query IDs). Rejects object injection and
868+
// unexpected payloads that sanitize_unserialize_selected_items would also reject.
869+
if (is_array($selected_graphs_array)) {
870+
foreach ($selected_graphs_array as $k => $v) {
871+
if (!in_array($k, ['cg', 'sg'], true) || !is_array($v)) {
872+
$selected_graphs_array = false;
873+
break;
874+
}
875+
foreach ($v as $sk => $sv) {
876+
if (!is_numeric($sk)) {
877+
$selected_graphs_array = false;
878+
break 2;
879+
}
880+
}
881+
}
882+
} else {
883+
$selected_graphs_array = false;
884+
}
865885

866886
$values = [];
867887

0 commit comments

Comments
 (0)