Skip to content
Open
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
77 changes: 77 additions & 0 deletions apcupsd_functions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| Shared helpers for request normalization and apcaccess execution. |
+-------------------------------------------------------------------------+
*/

if (!function_exists('apcupsd_normalize_positive_int')) {
function apcupsd_normalize_positive_int($value, $default = 0) {
if (is_int($value)) {
$normalized = $value;
} elseif (is_string($value) && preg_match('/^[0-9]+$/', $value)) {
$normalized = (int)$value;
} else {
$normalized = 0;
}

if ($normalized > 0) {
return $normalized;
}

return (int)$default;
}
}

if (!function_exists('apcupsd_get_site_sql_where')) {
function apcupsd_get_site_sql_where($site_id) {
$site_id = apcupsd_normalize_positive_int($site_id, 0);

if ($site_id > 0) {
return 'site_id = ' . $site_id;
}

return '';
}
}

if (!function_exists('apcupsd_get_autocomplete_rows_limit')) {
function apcupsd_get_autocomplete_rows_limit($rows) {
return apcupsd_normalize_positive_int($rows, 1);
}
}

if (!function_exists('apcupsd_escape_shellcmd')) {
function apcupsd_escape_shellcmd($value) {
if (function_exists('cacti_escapeshellcmd')) {
return cacti_escapeshellcmd($value);
}

return escapeshellcmd($value);
}
}

if (!function_exists('apcupsd_escape_shellarg')) {
function apcupsd_escape_shellarg($value) {
if (function_exists('cacti_escapeshellarg')) {
return cacti_escapeshellarg($value);
}

return escapeshellarg($value);
}
}

if (!function_exists('apcupsd_build_apcaccess_command')) {
function apcupsd_build_apcaccess_command($binary_path, $hostname, $port) {
$hostname = trim((string)$hostname);
$port = apcupsd_normalize_positive_int($port, 0);

if ($binary_path === '' || $hostname === '' || $port < 1 || $port > 65535) {
return false;
}

return apcupsd_escape_shellcmd($binary_path) . ' -u -h ' . apcupsd_escape_shellarg($hostname . ':' . $port);
}
}
13 changes: 11 additions & 2 deletions poller_apcupsd.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
require_once($config['base_path'] . '/lib/template.php');
require_once($config['base_path'] . '/lib/utility.php');
include('./plugins/apcupsd/database.php');
require_once('./plugins/apcupsd/apcupsd_functions.php');

/* process calling arguments */
$parms = $_SERVER['argv'];
Expand Down Expand Up @@ -363,13 +364,22 @@ function collect_ups_data($ups) {
}
}

$command = $found_path . 'apcaccess -u -h ' . $ups['hostname'] . ':' . $ups['port'];
$command = apcupsd_build_apcaccess_command($found_path . 'apcaccess', $ups['hostname'], $ups['port']);

$output = array();
$return = 0;

$ups_status = 1;

if ($command === false) {
db_execute_prepared('UPDATE apcupsd_ups
SET status = 1, error_message = ?
WHERE id = ?',
array(__('Invalid apcupsd hostname or port configuration', 'apcupsd'), $ups['id']));

return 1;
}

$results = exec($command, $output, $return);

if ($return > 0) {
Expand Down Expand Up @@ -464,4 +474,3 @@ function display_help() {
print "usage: \n";
print "poller_apcups.php [--force] [--debug]\n";
}

59 changes: 59 additions & 0 deletions tests/e2e/test_security_wiring.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| End-to-end wiring checks for the apcupsd security fixes. |
| |
| Run: php tests/e2e/test_security_wiring.php |
+-------------------------------------------------------------------------+
*/

$pass = 0;
$fail = 0;

function assert_true($label, $value) {
global $pass, $fail;

if ($value) {
echo "PASS $label\n";
$pass++;
} else {
echo "FAIL $label\n";
$fail++;
}
}

$poller_source = file_get_contents(__DIR__ . '/../../poller_apcupsd.php');
$ui_source = file_get_contents(__DIR__ . '/../../upses.php');

assert_true(
'poller uses the shared apcaccess command builder',
strpos($poller_source, "apcupsd_build_apcaccess_command(") !== false
);

assert_true(
'poller rejects invalid apcupsd host or port before exec',
strpos($poller_source, "Invalid apcupsd hostname or port configuration") !== false
);

assert_true(
'ajax host filters use integer-normalized site clauses',
substr_count($ui_source, "apcupsd_get_site_sql_where(get_request_var('site_id'))") === 2
);

assert_true(
'UI enforces numeric validation for APCUPSD and SNMP ports',
strpos($ui_source, "form_input_validate(get_nfilter_request_var('port'), 'port', '^[0-9]+$', true, 3)") !== false &&
strpos($ui_source, "form_input_validate(get_nfilter_request_var('snmp_port'), 'snmp_port', '^[0-9]+$', true, 3)") !== false
);

assert_true(
'autocomplete limit is normalized before entering SQL',
strpos($ui_source, "apcupsd_get_autocomplete_rows_limit(read_config_option('autocomplete_rows'))") !== false
);

echo "\n";
echo "Results: $pass passed, $fail failed\n";

exit($fail > 0 ? 1 : 0);
51 changes: 51 additions & 0 deletions tests/integration/test_poller_command_security.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| Integration checks for the poller command construction path. |
| |
| Run: php tests/integration/test_poller_command_security.php |
+-------------------------------------------------------------------------+
*/

$pass = 0;
$fail = 0;

function assert_true($label, $value) {
global $pass, $fail;

if ($value) {
echo "PASS $label\n";
$pass++;
} else {
echo "FAIL $label\n";
$fail++;
}
}

require_once __DIR__ . '/../../apcupsd_functions.php';

$valid = apcupsd_build_apcaccess_command('/usr/sbin/apcaccess', 'rack-ups.example.com', '3551');
$invalid_host = apcupsd_build_apcaccess_command('/usr/sbin/apcaccess', '', '3551');
$invalid_port = apcupsd_build_apcaccess_command('/usr/sbin/apcaccess', 'rack-ups.example.com', '3551;touch /tmp/pwned');

assert_true(
'valid poller command stays available',
$valid !== false && strpos($valid, 'rack-ups.example.com:3551') !== false
);

assert_true(
'blank host is rejected before exec',
$invalid_host === false
);

assert_true(
'non-numeric port is rejected before exec',
$invalid_port === false
);

echo "\n";
echo "Results: $pass passed, $fail failed\n";

exit($fail > 0 ? 1 : 0);
65 changes: 65 additions & 0 deletions tests/test_layout_wrapper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| Regression checks for shared layout wrapper routing in upses.php |
| |
| Run: php tests/test_layout_wrapper.php |
+-------------------------------------------------------------------------+
*/

$pass = 0;
$fail = 0;
$events = array();

function assert_true($label, $value) {
global $pass, $fail;

if ($value) {
echo "PASS $label\n";
$pass++;
} else {
echo "FAIL $label\n";
$fail++;
}
}

function top_header() {
global $events;
$events[] = 'top_header';
}

function bottom_footer() {
global $events;
$events[] = 'bottom_footer';
}

require_once __DIR__ . '/../ui_helpers.php';

$events = array();
$layout_invocations = 0;

apcupsd_render_with_layout(function () use (&$events, &$layout_invocations) {
$layout_invocations++;
$events[] = 'content';
});

assert_true('layout callback executes exactly once', $layout_invocations === 1);
assert_true('layout wrapper call order is top->content->bottom', $events === array('top_header', 'content', 'bottom_footer'));

$upses_source = file_get_contents(__DIR__ . '/../upses.php');

assert_true(
"edit action uses shared layout helper",
strpos($upses_source, "apcupsd_render_with_layout('ups_edit');") !== false
);
assert_true(
"default action uses shared layout helper",
strpos($upses_source, "apcupsd_render_with_layout('upses');") !== false
);

echo "\n";
echo "Results: $pass passed, $fail failed\n";

exit($fail > 0 ? 1 : 0);
70 changes: 70 additions & 0 deletions tests/unit/test_security_helpers.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| Unit checks for apcupsd security helpers. |
| |
| Run: php tests/unit/test_security_helpers.php |
+-------------------------------------------------------------------------+
*/

$pass = 0;
$fail = 0;

function assert_true($label, $value) {
global $pass, $fail;

if ($value) {
echo "PASS $label\n";
$pass++;
} else {
echo "FAIL $label\n";
$fail++;
}
}

function cacti_escapeshellcmd($value) {
return '[cmd]' . $value . '[/cmd]';
}

function cacti_escapeshellarg($value) {
return '[arg]' . $value . '[/arg]';
}

require_once __DIR__ . '/../../apcupsd_functions.php';

assert_true(
'positive int normalization keeps valid integers',
apcupsd_normalize_positive_int('3551', 0) === 3551
);

assert_true(
'positive int normalization falls back on invalid data',
apcupsd_normalize_positive_int('0 OR 1=1', 7) === 7
);

assert_true(
'site filter rejects mixed numeric payloads',
apcupsd_get_site_sql_where('9 OR 1=1') === ''
);

assert_true(
'autocomplete rows limit never returns zero',
apcupsd_get_autocomplete_rows_limit('0') === 1
);

assert_true(
'apcaccess command escapes the binary and the host target',
apcupsd_build_apcaccess_command('/usr/bin/apcaccess', 'ups.example.com', '3551') === '[cmd]/usr/bin/apcaccess[/cmd] -u -h [arg]ups.example.com:3551[/arg]'
);

assert_true(
'apcaccess command rejects invalid ports',
apcupsd_build_apcaccess_command('/usr/bin/apcaccess', 'ups.example.com', '3551;id') === false
);

echo "\n";
echo "Results: $pass passed, $fail failed\n";

exit($fail > 0 ? 1 : 0);
19 changes: 19 additions & 0 deletions ui_helpers.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
| of the License, or (at your option) any later version. |
+-------------------------------------------------------------------------+
*/

if (!function_exists('apcupsd_render_with_layout')) {
function apcupsd_render_with_layout($renderer) {
top_header();
call_user_func($renderer);
bottom_footer();
}
}
Loading