diff --git a/CHANGELOG.md b/CHANGELOG.md
index 614b7fb..efd102a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
* security: Require POST for the save, remove, mute and card-order actions so csrf-magic covers them, and only let a dashboard's owner save over it
* security: Bind the plugin version lookup and the device_remove cleanup deletes as prepared statements instead of building them from the device list
+* bug: Escape the device description, location, site name and Monitor Text in the List view
* bug: Restore alert audio for down and triggered devices; getMonitorSound() looked for sounds in includes/sounds/ after the controller moved, so the audio element was never rendered
* feature: Replace the single Status column/filter with a multi-select Events filter (additive) and an Events column that shows a short pill per active event (Down, Recovering, Triggered, Down/Triggered M/A, Warn/Alert Ping, Failed Svc), 'None' when clean; the Status scope dropdown now only selects All Monitored / All Devices / Not Monitored / Without Thresholds
* bug: Centre the tile hostname under the glyph and keep it clickable in the Tiles & Time view
diff --git a/includes/render.php b/includes/render.php
index 6870ec4..1e13c57 100644
--- a/includes/render.php
+++ b/includes/render.php
@@ -1463,9 +1463,9 @@ function renderHostList(array $host): string {
}
form_selectable_cell($actions, $host['id'], '1%', 'left');
- form_selectable_cell($host['description'], $host['id'], '', 'left');
- form_selectable_cell($host['location'], $host['id'], '', 'left');
- form_selectable_cell($host['site_name'], $host['id'], '', 'left');
+ form_selectable_ecell($host['description'], $host['id'], '', 'left');
+ form_selectable_ecell($host['location'], $host['id'], '', 'left');
+ form_selectable_ecell($host['site_name'], $host['id'], '', 'left');
form_selectable_cell($host['id'], $host['id'], '', 'left');
form_selectable_cell($host_crit, $host['id'], '', 'left');
form_selectable_cell($events_cell, $host['id'], '', 'center');
@@ -1479,7 +1479,7 @@ function renderHostList(array $host): string {
}
form_selectable_cell($host_warn, $host['id'], '', 'left');
- form_selectable_cell($host_admin, $host['id'], '', 'white-space:pre-wrap;text-align:left');
+ form_selectable_ecell($host_admin, $host['id'], '', 'white-space:pre-wrap;text-align:left');
form_selectable_cell(round($host['availability'], 2) . ' %', $host['id'], '', 'right');
form_selectable_cell($host_avg, $host['id'], '', 'right');
form_selectable_cell($dt, $host['id'], '', 'right');
diff --git a/tests/Unit/MonitorListViewEscapingTest.php b/tests/Unit/MonitorListViewEscapingTest.php
new file mode 100644
index 0000000..7e4267f
--- /dev/null
+++ b/tests/Unit/MonitorListViewEscapingTest.php
@@ -0,0 +1,108 @@
+" . $contents . "\n";
+
+ return true;
+ }
+ }
+
+ if (!function_exists('form_end_row')) {
+ function form_end_row() {
+ print "\n";
+ }
+ }
+
+ if (!function_exists('get_timeinstate')) {
+ function get_timeinstate($host) {
+ return '1m';
+ }
+ }
+
+ $source = file_get_contents($GLOBALS['config']['base_path'] . '/lib/html_utility.php');
+ if (!preg_match('/^function form_selectable_ecell\([^\n]*\n.*?^}/ms', $source, $definition)) {
+ throw new RuntimeException('Cannot load the core escaping cell renderer');
+ }
+ $path = tempnam(sys_get_temp_dir(), 'monitor-core-cell-');
+ try {
+ file_put_contents($path, ' 7,
+ 'status' => 1,
+ 'status_rec_date' => '2026-01-01 00:00:00',
+ 'status_fail_date' => '2026-01-01 00:00:00',
+ 'description' => 'core & "edge"',
+ 'location' => 'rack & \'row\'',
+ 'site_name' => 'HQ & "annex"',
+ 'monitor_text' => 'note & "text"',
+ 'monitor_criticality' => 0,
+ 'availability_method' => 0,
+ 'availability' => 100,
+ ];
+
+ $GLOBALS['monitor_test_ecell_values'] = [];
+ $output = renderHostList($host);
+
+ expect($GLOBALS['monitor_test_ecell_values'])->toBe([$host['description'], $host['location'], $host['site_name'], $host['monitor_text']]);
+
+ expect($output)->toContain('<b>core</b> & "edge"');
+ expect($output)->toContain('<i>rack</i> & 'row'');
+ expect($output)->toContain('<u>HQ</u> & "annex"');
+ expect($output)->toContain('<s>note</s> & "text"');
+
+ foreach (['', '', '', ''] as $markup) {
+ expect($output)->not->toContain($markup);
+ }
+
+ expect($output)->not->toContain('&');
+});