From 0197e7c2cfd7d3b908dfc72c8363da63e797cbb2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:29:44 +0000 Subject: [PATCH] fix: escape notification HTML device fields Co-authored-by: jokob-sk <96159884+jokob-sk@users.noreply.github.com> --- server/models/notification_instance.py | 57 +++++++++++++++------ test/backend/test_notification_templates.py | 50 ++++++++++++++++++ 2 files changed, 92 insertions(+), 15 deletions(-) diff --git a/server/models/notification_instance.py b/server/models/notification_instance.py index 4423a506..9a20b34c 100755 --- a/server/models/notification_instance.py +++ b/server/models/notification_instance.py @@ -1,8 +1,10 @@ +import html import json import re import uuid import socket from yattag import indent +from yattag.indentation import XMLTokenError from json2table import convert # Register NetAlertX modules @@ -146,20 +148,7 @@ class NotificationInstance: mail_html, conf.REPORT_DASHBOARD_URL + "/deviceDetails.php?mac=" ) - # Add preheader for inbox preview after all links have been generated. - # Invisible padding prevents email clients from showing the start of the email body. - preheader = " • ".join(preheaders) - - padding = (" ‌ " * 47) - - mail_html = mail_html.replace( - "PREHEADER", - preheader + padding, - ) - - final_html = indent( - mail_html, indentation=" ", newline="\r\n", indent_text=True - ) + final_html = finalize_html(mail_html, preheaders) send_api(self.JSON, final_text, final_html) @@ -335,8 +324,9 @@ def construct_notifications(JSON, section): text = tableTitle + "\n---------\n" # Convert a JSON into an HTML table + html_data = escape_html_rows(jsn) html = convert( - {"data": jsn}, + {"data": html_data}, build_direction=build_direction, table_attributes=table_attributes, ) @@ -398,6 +388,43 @@ def format_table(html, thValue, props, newThValue=""): ) +# ----------------------------------------------------------------------------- +# Escape free-text values before embedding them into notification HTML +def escape_html_rows(rows): + return [ + { + key: html.escape(value) if isinstance(value, str) else value + for key, value in row.items() + } + for row in rows + ] + + +# ----------------------------------------------------------------------------- +# Finalize HTML and tolerate pretty-print failures +def finalize_html(mail_html, preheaders): + # Add preheader for inbox preview after all links have been generated. + # Invisible padding prevents email clients from showing the start of the email body. + preheader = html.escape(" • ".join(preheaders)) + padding = (" ‌ " * 47) + + mail_html = mail_html.replace( + "PREHEADER", + preheader + padding, + ) + + try: + return indent( + mail_html, indentation=" ", newline="\r\n", indent_text=True + ) + except XMLTokenError as err: + mylog( + "warn", + f"[Notification] Failed to pretty-print HTML report, sending unindented HTML instead: {err}", + ) + return mail_html + + # ----------------------------------------------------------------------------- # Pre-header Preview def build_preheader(tableTitle, jsn, headers): diff --git a/test/backend/test_notification_templates.py b/test/backend/test_notification_templates.py index 1e8b8d9a..3a1614ad 100644 --- a/test/backend/test_notification_templates.py +++ b/test/backend/test_notification_templates.py @@ -295,6 +295,56 @@ class TestConstructNotificationsTemplates(unittest.TestCase): self.assertEqual(html_without, html_with) + # ----------------------------------------------------------------- + # HTML output escapes free-text device values while text stays raw + # ----------------------------------------------------------------- + @patch("models.notification_instance.get_setting_value") + def test_html_escapes_free_text_values(self, mock_setting): + from models.notification_instance import construct_notifications + + mock_setting.side_effect = self._setting_factory({ + "NTFPRCS_TEXT_SECTION_HEADERS": True, + "NTFPRCS_TEXT_TEMPLATE_new_devices": "", + }) + + devices = [ + { + "devName": "Meta Quest ", html) + self.assertIn("Meta Quest PREHEADERbroken < content", + ["Meta Quest