Files
cacti/tests/Unit/HtmlGraphDeviceHardeningTest.php
Thomas VincentandTheWitness ae54a2a552 security: bind device IDs and escape graph and device output (#7732)
* security: bind device IDs and escape graph and device output

Device removal waits for graph cleanup inside a transaction before deleting the host row. Clone and template-sync ID lists go through db_in_clause, SNMP and ping strings are escaped at HTML sinks, and graph page size and column settings use bounded validators.

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

* fix: skip cloned RRD task item remap when no matching data_template_rrd row exists

Guards the outer loop in api_clone_device_template() the same way the
inner loop already guards $new_graph_template_id: db_fetch_cell_prepared()
returns false when the cloned Data Template has no matching
data_template_rrd row, and binding that into task_item_id would corrupt
the cloned graph template's wiring instead of leaving it alone.

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

* Number the CHANGELOG entry so the format check passes

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

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Co-authored-by: TheWitness <thewitness@cacti.net>
2026-08-16 20:21:07 -04:00

150 lines
7.3 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 |
+-------------------------------------------------------------------------+
*/
function hardeningFunctionSource(string $source, string $function, string $nextFunction) : string {
$start = strpos($source, 'function ' . $function . '(');
$end = strpos($source, 'function ' . $nextFunction . '(', $start + 1);
expect($start)->not->toBeFalse($function . ' must exist');
expect($end)->not->toBeFalse($nextFunction . ' must follow ' . $function);
return substr($source, $start, $end - $start);
}
$htmlGraphSource = file_get_contents(dirname(__DIR__, 2) . '/lib/html_graph.php');
$apiDeviceSource = file_get_contents(dirname(__DIR__, 2) . '/lib/api_device.php');
require_once dirname(__DIR__, 2) . '/lib/html_graph.php';
expect($htmlGraphSource)->not->toBeFalse();
expect($apiDeviceSource)->not->toBeFalse();
test('graph display validators accept only supported values', function () {
global $item_rows;
$previous_item_rows = $item_rows ?? null;
$item_rows = [
-1 => 'All',
10 => '10',
20 => '20'
];
try {
expect(html_graph_validate_columns(1))->toBe(1)
->and(html_graph_validate_columns('6'))->toBe(6)
->and(html_graph_validate_columns(0))->toBeFalse()
->and(html_graph_validate_columns(7))->toBeFalse()
->and(html_graph_validate_columns('2 rows'))->toBeFalse()
->and(html_graph_validate_page_size(-1))->toBe(-1)
->and(html_graph_validate_page_size('20'))->toBe(20)
->and(html_graph_validate_page_size(21))->toBeFalse();
} finally {
$item_rows = $previous_item_rows;
}
});
test('new graph overrides use one outer form and retain every rendered section', function () use ($htmlGraphSource) {
$source = hardeningFunctionSource($htmlGraphSource, 'html_graph_new_graphs', 'html_graph_custom_data');
expect(substr_count($source, "form_start('graphs_new.php', 'new_graphs')"))->toBe(1);
expect($source)->toContain('$output_sections[] = $output;');
expect($source)->toContain("print implode('', \$output_sections);");
});
test('SNMP query graph lookup binds both halves of the relationship', function () use ($htmlGraphSource) {
$source = hardeningFunctionSource($htmlGraphSource, 'html_graph_custom_data', 'html_graph_custom_data_template');
expect($source)->toContain('ON sqg.snmp_query_id = sq.id');
expect($source)->toContain('WHERE sq.id = ?');
expect($source)->toContain('AND sqg.id = ?');
});
test('graph page size and column settings use bounded callbacks', function () use ($htmlGraphSource) {
expect($htmlGraphSource)->toContain("'options' => 'html_graph_validate_page_size'");
expect($htmlGraphSource)->toContain("'options' => 'html_graph_validate_columns'");
expect($htmlGraphSource)->toContain('$value < 1 || $value > 6');
expect($htmlGraphSource)->toContain('!array_key_exists($value, $item_rows)');
});
test('graph debug stderr is returned and escaped before display', function () use ($htmlGraphSource) {
expect($htmlGraphSource)->toContain("\$graph_data_array['output_flag'] = RRDTOOL_OUTPUT_RETURN_STDERR;");
expect($htmlGraphSource)->toContain('print htmle((string) @rrdtool_function_graph(');
});
test('graph HTML does not close void input elements', function () use ($htmlGraphSource) {
expect($htmlGraphSource)->not->toContain('</input>');
});
test('device supplied ping and SNMP values are escaped at HTML sinks', function () use ($apiDeviceSource) {
foreach (['snmp_error', 'snmp_system', 'snmp_uptime', 'snmp_hostname', 'snmp_location', 'snmp_contact'] as $variable) {
expect($apiDeviceSource)->toContain('htmle($' . $variable . ')');
}
expect($apiDeviceSource)->toContain('htmle($ping->ping_response)');
expect($apiDeviceSource)->not->toContain('print $results;');
});
test('SNMP authentication validation accepts SHA384 and rejects the SHA392 typo', function () use ($apiDeviceSource) {
expect($apiDeviceSource)->toContain('SHA256|SHA384|SHA512');
expect($apiDeviceSource)->not->toContain('SHA392');
});
test('device template synchronization builds its ID predicate with db_in_clause', function () use ($apiDeviceSource) {
$source = hardeningFunctionSource($apiDeviceSource, 'api_device_template_sync_template', 'api_device_ping_device');
expect($source)->toContain("db_in_clause('host.id', \$device_ids)");
expect($source)->not->toContain("' AND host.id IN('");
});
test('device template downloads use a private random workspace and guaranteed cleanup', function () use ($apiDeviceSource) {
$source = hardeningFunctionSource($apiDeviceSource, 'api_device_template_download', 'api_device_template_archive_for_export');
expect($source)->toContain('bin2hex(random_bytes(16))');
expect($source)->toContain('mkdir($temp_directory, 0700)');
expect($source)->toContain('finally');
expect($source)->not->toContain("sys_get_temp_dir() . '/' . \$filename");
});
test('data template cloning is reachable and clone all selects data templates', function () use ($apiDeviceSource) {
$source = hardeningFunctionSource($apiDeviceSource, 'api_clone_device_template', 'api_device_template_download');
expect($source)->not->toContain('if (1 == 1)');
expect($source)->toContain('$selection_ids($include_dt, $objects[\'data_templates\'])');
expect($source)->toContain('foreach ($cloned_data_templates as $id)');
expect($source)->toContain('api_data_source_duplicate(0, $id, $new_name)');
expect($source)->toContain('SET task_item_id = ?');
});
test('clone selections are validated and never interpolated into SQL', function () use ($apiDeviceSource) {
$source = hardeningFunctionSource($apiDeviceSource, 'api_clone_device_template', 'api_device_template_download');
expect($source)->toContain('ctype_digit($id)');
expect($source)->toContain("db_in_clause('graph_template_id', \$included_graph_templates)");
expect($source)->toContain("db_in_clause('snmp_query_id', \$included_data_queries)");
expect($source)->not->toContain("'AND graph_template_id IN (' . \$include_gt");
expect($source)->not->toContain("'AND snmp_query_id IN (' . \$include_dq");
});
test('device primary records are removed after graph cleanup', function () use ($apiDeviceSource) {
$single = hardeningFunctionSource($apiDeviceSource, 'api_device_remove', 'api_device_purge_from_remote');
$batch = hardeningFunctionSource($apiDeviceSource, 'api_device_remove_multi', 'api_device_disable_devices');
expect(strpos($single, 'api_delete_graphs('))->toBeLessThan(strpos($single, 'DELETE FROM host WHERE id = ?'));
expect(strpos($batch, 'api_delete_graphs('))->toBeLessThan(strpos($batch, "DELETE FROM host WHERE '"));
expect($single)->toContain('db_begin_transaction()');
expect($single)->toContain('db_rollback_transaction()');
expect($single)->toContain('db_commit_transaction()');
expect($batch)->toContain('db_begin_transaction()');
});