Files
cacti/tests/Unit/ImportDataSourceItemValidationTest.php
Thomas Vincent 668333b7c9 security(import): validate Data Source item fields coming from Template XML (#7720)
* security(import): validate Data Source item fields coming from Template XML

data_source_name, rrd_minimum and rrd_maximum reach an RRDtool command line
but never pass through the data_sources.php form on the import path, so the
same field rules are applied at the XML handoff instead.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* chore: add the CHANGELOG entry

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* fix(import): name the rejection and keep the value out of the log lines

Code 7 reads as an XML parse error, which this is not, and the value comes
from the file being imported.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* style: use a line comment to satisfy php-cs-fixer

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* test: resolve includes through Cacti path constants

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* ci: restore changelog entry ordering

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* fix(changelog): order the 7720 issue entry before features and drop a duplicate

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
2026-08-31 17:08:26 -04:00

302 lines
11 KiB
PHP

<?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. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/
/*
* Tests for import_validate_data_source_item() in lib/import.php.
*
* Template XML never passes through the data_sources.php form, so the three
* free text data_template_rrd columns reach RRDtool unchecked. The rules here
* have to accept everything the shipped install/templates packages contain.
* lib/import.php only defines functions at load time, so it can be included
* without a database or a full Cacti bootstrap.
*
* xml_to_data_template() is driven below in preview mode, which is the one way
* to reach the data_template_rrd item loop without a live database. The loop
* itself is shared with the write path, so the guard runs identically; the
* ordering against sql_save() is pinned by a source assertion instead.
*/
if (!defined('POLLER_VERBOSITY_LOW')) {
require_once CACTI_PATH_INCLUDE . '/global_constants.php';
}
if (!defined('CACTI_VERSION')) {
define('CACTI_VERSION', '1.3.0');
}
if (!function_exists('cacti_sizeof')) {
function cacti_sizeof($a) {
return is_array($a) ? count($a) : 0;
}
}
if (!function_exists('cacti_count')) {
function cacti_count($a) {
return is_array($a) ? count($a) : 0;
}
}
if (!function_exists('cacti_log')) {
function cacti_log($message, $stdout = false, $environ = 'CMDPHP', $level = 0) {
return true;
}
}
if (!function_exists('clean_up_lines')) {
function clean_up_lines($string) {
return is_string($string) ? preg_replace('/\s*[\r\n]+\s*/', ' ', $string) : $string;
}
}
if (!function_exists('cacti_version_compare')) {
function cacti_version_compare($version1, $version2, $operator = '>') {
return version_compare($version1, $version2, $operator);
}
}
if (!function_exists('db_fetch_cell_prepared')) {
function db_fetch_cell_prepared($sql, $params = [], $log = true) {
return '';
}
}
if (!function_exists('db_fetch_cell')) {
function db_fetch_cell($sql, $col = '', $log = true) {
return '';
}
}
if (!function_exists('db_fetch_row_prepared')) {
function db_fetch_row_prepared($sql, $params = [], $log = true) {
return [];
}
}
require_once CACTI_PATH_LIBRARY . '/import.php';
/* Pull every data_source_name, rrd_minimum and rrd_maximum out of the packages
Cacti ships, so the rules are measured against real template content. */
function shipped_data_source_item_values() : array {
static $values = null;
if ($values !== null) {
return $values;
}
$values = ['data_source_name' => [], 'rrd_minimum' => [], 'rrd_maximum' => []];
foreach (glob(CACTI_PATH_BASE . '/install/templates/*.xml.gz') as $package) {
$blob = file_get_contents($package);
if ($blob === false) {
continue;
}
$raw = gzdecode($blob);
if ($raw === false || !preg_match_all('#<data>([A-Za-z0-9+/=\s]*)</data>#s', $raw, $blocks)) {
continue;
}
foreach ($blocks[1] as $encoded) {
/* the capture allows the wrapping whitespace that strict decoding refuses */
$xml = base64_decode(preg_replace('/\s+/', '', $encoded), true);
if ($xml === false || strpos($xml, '<data_source_name>') === false) {
continue;
}
foreach (array_keys($values) as $field) {
if (preg_match_all('#<' . $field . '>(.*?)</' . $field . '>#s', $xml, $found)) {
foreach ($found[1] as $value) {
$values[$field][$value] = basename($package);
}
}
}
}
}
return $values;
}
test('every data source name in the shipped packages is accepted', function () {
$names = shipped_data_source_item_values();
expect(count($names['data_source_name']))->toBeGreaterThan(500);
foreach ($names['data_source_name'] as $name => $package) {
expect(import_validate_data_source_item('data_source_name', (string) $name))
->toBeTrue('rejected ' . $name . ' from ' . $package);
}
});
test('every minimum and maximum in the shipped packages is accepted', function () {
$values = shipped_data_source_item_values();
/* |query_ifSpeed| is a real rrd_maximum in ArubaOS_switch, so the rule
cannot be a plain numeric check. */
expect($values['rrd_maximum'])->toHaveKey('|query_ifSpeed|');
expect($values['rrd_maximum'])->toHaveKey('U');
foreach (['rrd_minimum', 'rrd_maximum'] as $field) {
foreach ($values[$field] as $value => $package) {
expect(import_validate_data_source_item($field, (string) $value))
->toBeTrue('rejected ' . $field . ' ' . $value . ' from ' . $package);
}
}
});
test('the remaining item fields are left to their column types', function () {
expect(import_validate_data_source_item('data_source_type_id', '1'))->toBeTrue();
expect(import_validate_data_source_item('rrd_heartbeat', '600'))->toBeTrue();
expect(import_validate_data_source_item('data_input_field_id', '17'))->toBeTrue();
});
test('shell metacharacters in a data source name are refused', function () {
expect(import_validate_data_source_item('data_source_name', 'ds;id'))->toBeFalse();
expect(import_validate_data_source_item('data_source_name', 'ds`id`'))->toBeFalse();
expect(import_validate_data_source_item('data_source_name', 'ds$(id)'))->toBeFalse();
expect(import_validate_data_source_item('data_source_name', 'ds|id'))->toBeFalse();
expect(import_validate_data_source_item('data_source_name', 'ds id'))->toBeFalse();
expect(import_validate_data_source_item('data_source_name', ''))->toBeFalse();
/* varchar(19) truncation would otherwise silently keep the leading bytes */
expect(import_validate_data_source_item('data_source_name', str_repeat('a', 20)))->toBeFalse();
});
test('shell metacharacters in a minimum or maximum are refused', function () {
expect(import_validate_data_source_item('rrd_maximum', '0;id;'))->toBeFalse();
expect(import_validate_data_source_item('rrd_maximum', '100 -x'))->toBeFalse();
expect(import_validate_data_source_item('rrd_maximum', '$(id)'))->toBeFalse();
expect(import_validate_data_source_item('rrd_maximum', '|query_evil|'))->toBeFalse();
expect(import_validate_data_source_item('rrd_minimum', '0`id`'))->toBeFalse();
expect(import_validate_data_source_item('rrd_minimum', 'U;id'))->toBeFalse();
});
test('a trailing newline does not slip past the anchors', function () {
expect(import_validate_data_source_item('data_source_name', "traffic_in\n"))->toBeFalse();
expect(import_validate_data_source_item('data_source_name', "traffic_in\nid"))->toBeFalse();
expect(import_validate_data_source_item('rrd_maximum', "U\n"))->toBeFalse();
expect(import_validate_data_source_item('rrd_maximum', "100\nid"))->toBeFalse();
expect(import_validate_data_source_item('rrd_minimum', "0\n"))->toBeFalse();
});
test('the data template import calls the validator before it saves', function () {
$src = file_get_contents(CACTI_PATH_LIBRARY . '/import.php');
$decode = strpos($src, '$save[$field_name] = xml_character_decode($item_array[$field_name]);');
$guard = strpos($src, 'if (!import_validate_data_source_item($field_name, $save[$field_name])) {');
$write = strpos($src, "sql_save(\$save, 'data_template_rrd')");
expect($decode)->not->toBeFalse();
expect($guard)->not->toBeFalse();
expect($write)->not->toBeFalse();
expect($guard)->toBeGreaterThan($decode);
expect($guard)->toBeLessThan($write);
});
test('a rejected data template aborts the import instead of adding false to the hash cache', function () {
$src = file_get_contents(CACTI_PATH_LIBRARY . '/import.php');
expect($src)->toContain('$cache_add = xml_to_data_template(');
expect($src)->not->toContain('$hash_cache += xml_to_data_template(');
});
/* Drive the real xml_to_data_template() item loop with the database stubbed out. */
function import_one_data_template(array $item) : mixed {
global $struct_data_source, $struct_data_source_item, $preview_only, $legacy_template;
global $hash_type_codes, $cacti_version_codes, $import_messages, $import_debug_info, $config;
/* keeps the rejection's cacti_log() call off the database */
$config['is_web'] = false;
$config['config_options_array'] = [
'log_verbosity' => POLLER_VERBOSITY_NONE,
'log_destination' => 1,
'selective_debug' => '',
'selective_plugin_debug' => ''
];
$preview_only = true;
$legacy_template = false;
$import_messages = [];
$import_debug_info = [];
$hash_type_codes = ['data_template_item' => '08'];
$cacti_version_codes = [CACTI_VERSION => '0400'];
$struct_data_source = [];
$struct_data_source_item = [
'data_source_name' => [],
'rrd_minimum' => [],
'rrd_maximum' => [],
'data_source_type_id' => [],
'rrd_heartbeat' => [],
'data_input_field_id' => []
];
$item_hash = 'hash_080400' . str_repeat('a', 32);
$xml_array = ['name' => 'Test Template', 'ds' => [], 'items' => [$item_hash => $item]];
$hash_cache = [];
return xml_to_data_template(str_repeat('b', 32), $xml_array, $hash_cache, true, 1);
}
function import_item(array $overrides) : array {
return $overrides + [
'data_source_name' => 'traffic_in',
'rrd_minimum' => '0',
'rrd_maximum' => 'U',
'data_source_type_id' => '2',
'rrd_heartbeat' => '600'
];
}
test('the driven field list still matches global_form.php', function () {
$form = file_get_contents(CACTI_PATH_INCLUDE . '/global_form.php');
$item = substr($form, strpos($form, '$struct_data_source_item = ['));
$item = substr($item, 0, strpos($item, "\n];"));
foreach (['data_source_name', 'rrd_minimum', 'rrd_maximum', 'data_source_type_id', 'rrd_heartbeat', 'data_input_field_id'] as $field) {
expect($item)->toContain("'" . $field . "' => [");
}
});
test('a clean data source item is accepted by the item loop', function () {
$result = import_one_data_template(import_item(['rrd_maximum' => '|query_ifSpeed|']));
expect($result)->toBeArray();
expect($result)->toHaveKey('data_template_item');
expect($GLOBALS['import_messages'])->toBe([]);
});
test('an injected data source name aborts the item loop', function () {
$result = import_one_data_template(import_item(['data_source_name' => 'ds;id;']));
expect($result)->toBeFalse();
expect($GLOBALS['import_messages'])->toContain(45);
});
test('an injected maximum aborts the item loop', function () {
$result = import_one_data_template(import_item(['rrd_maximum' => "U\n; touch /tmp/pwned"]));
expect($result)->toBeFalse();
expect($GLOBALS['import_messages'])->toContain(45);
});
test('an injected minimum aborts the item loop', function () {
$result = import_one_data_template(import_item(['rrd_minimum' => '0`id`']));
expect($result)->toBeFalse();
expect($GLOBALS['import_messages'])->toContain(45);
});