Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "cacti/plugin_flowview",
"description": "plugin_flowview 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"
]
}
}
45 changes: 33 additions & 12 deletions flowview_devices.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
include('./include/auth.php');
include_once($config['base_path'] . '/plugins/flowview/setup.php');
include_once($config['base_path'] . '/plugins/flowview/functions.php');
include_once($config['base_path'] . '/plugins/flowview/flowview_security.php');

flowview_connect();

Expand Down Expand Up @@ -292,13 +293,23 @@ function save_device() {
$save['cmethod'] = get_nfilter_request_var('cmethod');
$save['bind_address'] = get_filter_request_var('bind_address', FILTER_VALIDATE_IP);
$save['allowfrom'] = get_filter_request_var('allowfrom', FILTER_VALIDATE_REGEXP, array('options' => array('regexp' => '/^([0-9\.\/ ,]+)$/')));
$save['port'] = get_filter_request_var('port');
$save['port'] = flowview_normalize_listener_port(get_nfilter_request_var('port'));
$save['protocol'] = get_nfilter_request_var('protocol');
$save['enabled'] = isset_request_var('enabled') ? 'on':'';

if ($save['port'] === false) {
raise_message(2, __('The listener port must be a numeric value between 1 and 65535.', 'flowview'), MESSAGE_LEVEL_ERROR);

header('Location: flowview_devices.php?header=false&action=edit&id=' . get_request_var('id'));
exit;
}

$id = flowview_sql_save($save, 'plugin_flowview_devices', 'id', true);

$pid = db_fetch_cell('SELECT pid FROM processes WHERE tasktype="flowview" AND taskname="master"');
$pid = db_fetch_cell_prepared('SELECT pid
FROM processes
WHERE tasktype = ?
AND taskname = ?', array('flowview', 'master'));

if (is_error_message()) {
raise_message(2);
Expand All @@ -320,10 +331,10 @@ function save_device() {
}

function restart_services() {
$pid = db_fetch_cell('SELECT pid
$pid = db_fetch_cell_prepared('SELECT pid
FROM processes
WHERE tasktype="flowview"
AND taskname="master"');
WHERE tasktype = ?
AND taskname = ?', array('flowview', 'master'));

if ($pid > 0) {
if (!defined('SIGHUP')) {
Expand Down Expand Up @@ -887,18 +898,29 @@ function clearFilter() {

if (cacti_sizeof($result)) {
foreach ($result as $row) {
$status = '';
$parts = array();

if ($os == 'freebsd') {
$status = shell_exec("netstat -an | grep '." . $row['port'] . " '");
$status_cmd = flowview_build_listener_status_command($os, $row['port']);
$column = 3;
$scolumn = -1;
} else {
$status = shell_exec("ss -lntu | grep ':" . $row['port'] . " '");
$status_cmd = flowview_build_listener_status_command($os, $row['port']);
$column = 4;
$scolumn = 2;
if (empty($status)) {
$status = shell_exec("netstat -an | grep ':" . $row['port'] . " '");
$column = 3;
$scolumn = 1;
}

if ($status_cmd !== false) {
$status = shell_exec($status_cmd);

if ($os != 'freebsd' && empty($status)) {
$status_cmd = flowview_build_listener_status_command($os, $row['port'], true);
if ($status_cmd !== false) {
$status = shell_exec($status_cmd);
$column = 3;
$scolumn = 1;
}
}
}

Expand Down Expand Up @@ -974,4 +996,3 @@ function clearFilter() {

form_end();
}

42 changes: 42 additions & 0 deletions flowview_security.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/

function flowview_normalize_listener_port($value) {
if (is_int($value)) {
$port = $value;
} elseif (is_string($value) && preg_match('/^[0-9]+$/', $value)) {
$port = (int) $value;
} else {
return false;
}

if ($port < 1 || $port > 65535) {
return false;
}

return $port;
}

function flowview_build_listener_status_command($os, $port, $use_fallback = false) {
$port = flowview_normalize_listener_port($port);

if ($port === false) {
return false;
}

if ($os == 'freebsd') {
return "netstat -an | grep '." . $port . " '";
}

if ($use_fallback) {
return "netstat -an | grep ':" . $port . " '";
}

return "ss -lntu | grep ':" . $port . " '";
}
10 changes: 6 additions & 4 deletions setup.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,9 @@ function plugin_flowview_check_upgrade($force = false) {
$info = plugin_flowview_version();
$current = $info['version'];

$old = db_fetch_cell('SELECT version
$old = db_fetch_cell_prepared('SELECT version
FROM plugin_config
WHERE directory="flowview"');
WHERE directory = ?', array('flowview'));

if ($current != $old || $force) {
$php_binary = read_config_option('path_php_binary');
Expand Down Expand Up @@ -321,7 +321,10 @@ function flowview_global_settings_update() {
}

if ($hup_process) {
$pid = db_fetch_cell('SELECT pid FROM processes WHERE tasktype="flowview" AND taskname="master"');
$pid = db_fetch_cell_prepared('SELECT pid
FROM processes
WHERE tasktype = ?
AND taskname = ?', array('flowview', 'master'));

if ($pid > 0) {
if (!defined('SIGHUP')) {
Expand Down Expand Up @@ -1309,4 +1312,3 @@ function flowview_graph_button($data) {
}
}
}

22 changes: 22 additions & 0 deletions tests/E2E/ListenerPortSecurityRegressionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/

describe('listener port security regression wiring', function () {
it('does not leave raw row port interpolation in shell_exec calls', function () {
$path = realpath(__DIR__ . '/../../flowview_devices.php');
expect($path)->not->toBeFalse();

$contents = file_get_contents($path);
expect($contents)->not->toBeFalse();

expect($contents)->not->toContain("shell_exec(\"netstat -an | grep '.\" . \$row['port'] . \" '\")");
expect($contents)->not->toContain("shell_exec(\"ss -lntu | grep ':\" . \$row['port'] . \" '\")");
expect($contents)->toContain("\$status_cmd = flowview_build_listener_status_command(\$os, \$row['port'])");
});
});
24 changes: 24 additions & 0 deletions tests/Integration/ListenerStatusCommandSecurityTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/

describe('listener status command security', function () {
it('validates ports before saving and before shell execution', function () {
$path = realpath(__DIR__ . '/../../flowview_devices.php');
expect($path)->not->toBeFalse();

$contents = file_get_contents($path);
expect($contents)->not->toBeFalse();

expect($contents)->toContain("include_once(\$config['base_path'] . '/plugins/flowview/flowview_security.php');");
expect($contents)->toContain("\$save['port'] = flowview_normalize_listener_port(get_nfilter_request_var('port'));");
expect($contents)->toContain("if (\$save['port'] === false)");
expect($contents)->toContain("flowview_build_listener_status_command(\$os, \$row['port'])");
expect($contents)->toContain("flowview_build_listener_status_command(\$os, \$row['port'], true)");
});
});
10 changes: 10 additions & 0 deletions tests/Pest.php
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';
66 changes: 66 additions & 0 deletions tests/Security/AuthGuardTest.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 flowview', function () {
it('includes auth.php or global.php in all UI entry points', function () {
$uiFiles = array(
'flowview_devices.php',
'tests/test_prepared_statements.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(
'flowview_devices.php',
'tests/test_prepared_statements.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"
);
}
}
});
});
70 changes: 70 additions & 0 deletions tests/Security/OutputEscapingTest.php
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 flowview', function () {
it('does not interpolate raw variables into HTML attributes', function () {
$uiFiles = array(
'flowview_devices.php',
'tests/test_prepared_statements.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(
'flowview_devices.php',
'tests/test_prepared_statements.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'
);
});
});
Loading