-
Notifications
You must be signed in to change notification settings - Fork 8
hardening: prepared statements, PHP 7.4 idioms, and security fixes #49
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| { | ||
| "name": "cacti/plugin_maint", | ||
| "description": "plugin_maint plugin for Cacti", | ||
| "license": "GPL-2.0-or-later", | ||
| "require-dev": { | ||
| "pestphp/pest": "^1.23" | ||
| }, | ||
| "config": { | ||
| "allow-plugins": { | ||
| "pestphp/pest-plugin": true | ||
| } | ||
| }, | ||
| "autoload-dev": { | ||
| "files": [ | ||
| "tests/bootstrap.php" | ||
| ] | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -117,6 +117,11 @@ function plugin_maint_check_schedule(int $schedule): bool { | |
| case 2: // Recurring | ||
| // past, calculate next | ||
| if ($sc['etime'] < $t) { | ||
| // minterval=0 would produce a zero-duration DateInterval and loop forever (FIND-004) | ||
| if ($sc['minterval'] <= 0) { | ||
| return false; | ||
| } | ||
|
Comment on lines
+120
to
+123
|
||
|
|
||
| // convert start and end to local so that hour stays same for add days across daylight saving time change | ||
| $starttimelocal = (new DateTime('@' . strval($sc['stime'])))->setTimezone(new DateTimeZone(date_default_timezone_get())); | ||
| $endtimelocal = (new DateTime('@' . strval($sc['etime'])))->setTimezone(new DateTimeZone(date_default_timezone_get())); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| <?php | ||
| /* | ||
| +-------------------------------------------------------------------------+ | ||
| | Copyright (C) 2004-2026 The Cacti Group | | ||
| +-------------------------------------------------------------------------+ | ||
| | Cacti: The Complete RRDtool-based Graphing Solution | | ||
| +-------------------------------------------------------------------------+ | ||
| */ | ||
|
|
||
| require_once __DIR__ . '/bootstrap.php'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| <?php | ||
| /* | ||
| +-------------------------------------------------------------------------+ | ||
| | Copyright (C) 2004-2026 The Cacti Group | | ||
| +-------------------------------------------------------------------------+ | ||
| | Cacti: The Complete RRDtool-based Graphing Solution | | ||
| +-------------------------------------------------------------------------+ | ||
| */ | ||
|
|
||
| describe('auth guard presence in maint', function () { | ||
| it('includes auth.php or global.php in all UI entry points', function () { | ||
| $uiFiles = array( | ||
| 'functions.php', | ||
| 'maint.php', | ||
| ); | ||
|
|
||
| foreach ($uiFiles as $relativeFile) { | ||
| $path = realpath(__DIR__ . '/../../' . $relativeFile); | ||
| if ($path === false) continue; | ||
| $contents = file_get_contents($path); | ||
| if ($contents === false) continue; | ||
|
|
||
| // Files that include setup.php or are library files don't need direct auth | ||
| if (strpos($relativeFile, 'include/') === 0 || strpos($relativeFile, 'lib/') === 0) continue; | ||
| if (strpos($relativeFile, 'poller_') === 0) continue; | ||
|
|
||
| $hasAuth = ( | ||
| strpos($contents, 'auth.php') !== false || | ||
| strpos($contents, 'global.php') !== false || | ||
| strpos($contents, 'global_arrays.php') !== false | ||
| ); | ||
|
|
||
| expect($hasAuth)->toBeTrue( | ||
| "File {$relativeFile} does not include auth.php or global.php" | ||
| ); | ||
| } | ||
| }); | ||
|
|
||
| it('validates numeric IDs from request variables before DB queries', function () { | ||
| $uiFiles = array( | ||
| 'functions.php', | ||
| 'maint.php', | ||
| ); | ||
|
|
||
| foreach ($uiFiles as $relativeFile) { | ||
| $path = realpath(__DIR__ . '/../../' . $relativeFile); | ||
| if ($path === false) continue; | ||
| $contents = file_get_contents($path); | ||
| if ($contents === false) continue; | ||
|
|
||
| // Check for get_filter_request_var usage for numeric IDs | ||
| if (preg_match('/get_request_var\s*\(\s*['"]id['"]/', $contents)) { | ||
| // Should use get_filter_request_var for 'id' params | ||
| $hasFilter = ( | ||
| strpos($contents, 'get_filter_request_var') !== false || | ||
| strpos($contents, 'input_validate_input_number') !== false || | ||
| strpos($contents, 'form_input_validate') !== false | ||
| ); | ||
|
|
||
| expect($hasFilter)->toBeTrue( | ||
| "File {$relativeFile} uses get_request_var for IDs without validation" | ||
| ); | ||
| } | ||
| } | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| <?php | ||
| /* | ||
| +-------------------------------------------------------------------------+ | ||
| | Copyright (C) 2004-2026 The Cacti Group | | ||
| +-------------------------------------------------------------------------+ | ||
| | Cacti: The Complete RRDtool-based Graphing Solution | | ||
| +-------------------------------------------------------------------------+ | ||
| */ | ||
|
|
||
| describe('output escaping in maint', function () { | ||
| it('does not interpolate raw variables into HTML attributes', function () { | ||
| $uiFiles = array( | ||
| 'functions.php', | ||
| 'maint.php', | ||
| ); | ||
|
|
||
| foreach ($uiFiles as $relativeFile) { | ||
| $path = realpath(__DIR__ . '/../../' . $relativeFile); | ||
| if ($path === false) continue; | ||
| $contents = file_get_contents($path); | ||
| if ($contents === false) continue; | ||
|
|
||
| $lines = explode("\n", $contents); | ||
| $dangerous = 0; | ||
|
|
||
| foreach ($lines as $line) { | ||
| $trimmed = ltrim($line); | ||
| if (strpos($trimmed, '//') === 0 || strpos($trimmed, '*') === 0) continue; | ||
|
|
||
| // value="$row[...] without html_escape wrapping | ||
| if (preg_match('/value\s*=\s*["\'"]\s*<\?php\s+echo\s+\$/', $line)) { | ||
| $dangerous++; | ||
| } | ||
| // title="<?php print $something without escaping | ||
| if (preg_match('/(?:title|alt|placeholder)\s*=.*print\s+\$(?!_|config)/', $line)) { | ||
| if (strpos($line, 'html_escape') === false && strpos($line, '__esc') === false && strpos($line, 'htmlspecialchars') === false) { | ||
| $dangerous++; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| expect($dangerous)->toBe(0, | ||
| "File {$relativeFile} has unescaped variables in HTML attributes" | ||
| ); | ||
| } | ||
| }); | ||
|
|
||
| it('uses html_escape or __esc for user-controlled output', function () { | ||
| $uiFiles = array( | ||
| 'functions.php', | ||
| 'maint.php', | ||
| ); | ||
|
|
||
| $totalEscapeCalls = 0; | ||
|
|
||
| foreach ($uiFiles as $relativeFile) { | ||
| $path = realpath(__DIR__ . '/../../' . $relativeFile); | ||
| if ($path === false) continue; | ||
| $contents = file_get_contents($path); | ||
| if ($contents === false) continue; | ||
|
|
||
| $totalEscapeCalls += preg_match_all('/html_escape|__esc\(|htmlspecialchars/', $contents); | ||
| } | ||
|
|
||
| // At least some escaping should be present in UI files | ||
| expect($totalEscapeCalls)->toBeGreaterThan(0, | ||
| 'UI files should contain at least one html_escape/__esc call' | ||
| ); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| <?php | ||
| /* | ||
| +-------------------------------------------------------------------------+ | ||
| | Copyright (C) 2004-2026 The Cacti Group | | ||
| +-------------------------------------------------------------------------+ | ||
| | Cacti: The Complete RRDtool-based Graphing Solution | | ||
| +-------------------------------------------------------------------------+ | ||
| */ | ||
|
|
||
| describe('PHP 7.4 compatibility in maint', function () { | ||
| $files = array( | ||
| 'functions.php', | ||
| 'maint.php', | ||
| ); | ||
|
|
||
| it('does not use str_contains (PHP 8.0)', function () use ($files) { | ||
| foreach ($files as $f) { | ||
| $p = realpath(__DIR__ . '/../../' . $f); | ||
| if ($p === false) continue; | ||
| $c = file_get_contents($p); | ||
| if ($c === false) continue; | ||
| expect(preg_match('/\bstr_contains\s*\(/', $c))->toBe(0, "{$f} uses str_contains"); | ||
| } | ||
| }); | ||
|
|
||
| it('does not use str_starts_with (PHP 8.0)', function () use ($files) { | ||
| foreach ($files as $f) { | ||
| $p = realpath(__DIR__ . '/../../' . $f); | ||
| if ($p === false) continue; | ||
| $c = file_get_contents($p); | ||
| if ($c === false) continue; | ||
| expect(preg_match('/\bstr_starts_with\s*\(/', $c))->toBe(0, "{$f} uses str_starts_with"); | ||
| } | ||
| }); | ||
|
|
||
| it('does not use str_ends_with (PHP 8.0)', function () use ($files) { | ||
| foreach ($files as $f) { | ||
| $p = realpath(__DIR__ . '/../../' . $f); | ||
| if ($p === false) continue; | ||
| $c = file_get_contents($p); | ||
| if ($c === false) continue; | ||
| expect(preg_match('/\bstr_ends_with\s*\(/', $c))->toBe(0, "{$f} uses str_ends_with"); | ||
| } | ||
| }); | ||
|
|
||
| it('does not use nullsafe operator (PHP 8.0)', function () use ($files) { | ||
| foreach ($files as $f) { | ||
| $p = realpath(__DIR__ . '/../../' . $f); | ||
| if ($p === false) continue; | ||
| $c = file_get_contents($p); | ||
| if ($c === false) continue; | ||
| expect(preg_match('/\?->/', $c))->toBe(0, "{$f} uses nullsafe operator"); | ||
| } | ||
| }); | ||
|
|
||
| it('does not use match expression (PHP 8.0)', function () use ($files) { | ||
| foreach ($files as $f) { | ||
| $p = realpath(__DIR__ . '/../../' . $f); | ||
| if ($p === false) continue; | ||
| $c = file_get_contents($p); | ||
| if ($c === false) continue; | ||
| // Avoid false positive on preg_match etc | ||
| $c2 = preg_replace('/preg_match|preg_match_all|fnmatch/', '', $c); | ||
| expect(preg_match('/\bmatch\s*\(/', $c2))->toBe(0, "{$f} uses match expression"); | ||
| } | ||
| }); | ||
|
|
||
| it('does not use union type declarations (PHP 8.0)', function () use ($files) { | ||
| foreach ($files as $f) { | ||
| $p = realpath(__DIR__ . '/../../' . $f); | ||
| if ($p === false) continue; | ||
| $c = file_get_contents($p); | ||
| if ($c === false) continue; | ||
| // Match function params/return with union types like string|false | ||
| $hits = preg_match_all('/function\s+\w+\s*\([^)]*\w+\s*\|\s*\w+/', $c); | ||
| expect($hits)->toBe(0, "{$f} uses union types in function signatures"); | ||
| } | ||
| }); | ||
|
|
||
| it('does not use constructor property promotion (PHP 8.0)', function () use ($files) { | ||
| foreach ($files as $f) { | ||
| $p = realpath(__DIR__ . '/../../' . $f); | ||
| if ($p === false) continue; | ||
| $c = file_get_contents($p); | ||
| if ($c === false) continue; | ||
| expect(preg_match('/function\s+__construct\s*\([^)]*\b(public|private|protected|readonly)\s/', $c))->toBe(0, | ||
| "{$f} uses constructor promotion" | ||
| ); | ||
| } | ||
| }); | ||
|
|
||
| it('uses array() not short syntax for new arrays', function () use ($files) { | ||
| // This is a style preference for 1.2.x consistency, not a hard requirement | ||
| // Just verify no mixed styles in the same file | ||
| foreach ($files as $f) { | ||
| $p = realpath(__DIR__ . '/../../' . $f); | ||
| if ($p === false) continue; | ||
| $c = file_get_contents($p); | ||
| if ($c === false) continue; | ||
|
|
||
| $hasArrayFunc = preg_match('/\barray\s*\(/', $c); | ||
| $hasShortArray = preg_match('/=\s*\[/', $c); | ||
|
|
||
| // Flag files that mix both styles | ||
| if ($hasArrayFunc && $hasShortArray) { | ||
| // Allow mixed if the file existed before our changes | ||
| // This is informational, not a hard fail | ||
| } | ||
| } | ||
|
|
||
| expect(true)->toBeTrue(); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Returning
falseonminterval <= 0prevents an infinite loop (good), but it fails silently and leaves the schedule in an expired state with no indication to admins why recurring maintenance stopped. Consider logging a warning (and/or auto-disabling the schedule) when this misconfiguration is detected so it’s observable and actionable.