Notifications - Escape only the diff variables before Jinja2 renders them into the template #4121

This commit is contained in:
dgtlmoon
2026-05-05 18:04:42 +02:00
parent 88f4beb08f
commit 028e62f91c
2 changed files with 73 additions and 7 deletions
+13 -7
View File
@@ -382,6 +382,19 @@ def process_notification(n_object: NotificationContextData, datastore):
n_object['llm_summary'] = _llm_change_summary or (n_object.get('_llm_result') or {}).get('summary', '')
n_object['llm_intent'] = n_object.get('_llm_intent', '')
# Re #3529: diff content from text/plain pages may contain raw '<' chars that break HTML emails.
# Escape only the diff variables before Jinja2 renders them into the template, so the user's
# own HTML in the notification body (e.g. <a href="{{watch_url}}">) is never touched.
# Diff placemarkers (e.g. @removed_PLACEMARKER_OPEN) contain no HTML chars so they survive
# html_escape and are still replaced with <span> tags by apply_service_tweaks later.
watch_mime_type = n_object.get('watch_mime_type')
if (watch_mime_type and 'text/' in watch_mime_type.lower() and 'html' not in watch_mime_type.lower()
and 'html' in requested_output_format):
from markupsafe import escape as html_escape
for key in [k for k in notification_parameters if k.startswith('diff') or k == 'raw_diff']:
if notification_parameters.get(key):
notification_parameters[key] = str(html_escape(str(notification_parameters[key])))
with (apprise.LogCapture(level=apprise.logging.DEBUG) as logs):
for url in n_object['notification_urls']:
@@ -399,13 +412,6 @@ def process_notification(n_object: NotificationContextData, datastore):
logger.info(f">> Process Notification: AppRise start notifying '{url}'")
url = jinja_render(template_str=url, **notification_parameters)
# If it's a plaintext document, and they want HTML type email/alerts, so it needs to be escaped
watch_mime_type = n_object.get('watch_mime_type')
if watch_mime_type and 'text/' in watch_mime_type.lower() and not 'html' in watch_mime_type.lower():
if 'html' in requested_output_format:
from markupsafe import escape
n_body = str(escape(n_body))
if 'html' in requested_output_format:
# Since the n_body is always some kind of text from the 'diff' engine, attempt to preserve whitespaces that get sent to the HTML output
# But only where its more than 1 consecutive whitespace, otherwise "and this" becomes "and&nbsp;this" etc which is too much.
@@ -635,3 +635,63 @@ def test_html_color_notifications(client, live_server, measure_memory_usage, dat
_test_color_notifications(client, '{{diff}}',datastore_path=datastore_path)
_test_color_notifications(client, '{{diff_full}}',datastore_path=datastore_path)
def test_plaintext_watch_custom_html_in_notification_body_not_escaped(client, live_server, measure_memory_usage, datastore_path):
"""
#4121 - When the watched URL returns text/plain content-type and the notification body
contains custom HTML (e.g. <a href="{{watch_url}}">), that HTML must NOT be HTML-escaped
in the output. Only the diff content (from the text/plain page) should be escaped.
"""
set_original_response(datastore_path=datastore_path)
if os.path.isfile(os.path.join(datastore_path, "notification.txt")):
os.unlink(os.path.join(datastore_path, "notification.txt"))
test_notification_url = url_for('test_notification_endpoint', _external=True).replace('http://', 'post://')
# Watch a URL that returns text/plain content-type
test_url = url_for('test_endpoint', content_type="text/plain", _external=True)
res = client.post(
url_for("settings.settings_page"),
data={
"application-fetch_backend": "html_requests",
"application-minutes_between_check": 180,
# Custom HTML in notification body — these tags must NOT be escaped in the output
"application-notification_body": '<a href="{{watch_url}}">Watch Link</a> had changes\n\n{{diff}}',
"application-notification_format": "htmlcolor",
"application-notification_urls": test_notification_url,
"application-notification_title": "Change detected",
},
follow_redirects=True
)
assert b'Settings updated' in res.data
res = client.post(
url_for("ui.ui_views.form_quick_watch_add"),
data={"url": test_url, "tags": ''},
follow_redirects=True
)
assert b"Watch added" in res.data
wait_for_all_checks(client)
set_modified_response(datastore_path=datastore_path)
res = client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
assert b'Queued 1 watch for rechecking.' in res.data
wait_for_all_checks(client)
wait_for_notification_endpoint_output(datastore_path=datastore_path)
with open(os.path.join(datastore_path, "notification.txt"), 'r') as f:
x = f.read()
# The custom <a href="..."> from the notification template must NOT be HTML-escaped
assert '&lt;a href=' not in x, "Custom HTML <a> tag in notification body was incorrectly HTML-escaped"
assert '<a href=' in x, "Custom HTML <a> tag not found unescaped in notification output"
# The diff color spans must still be present (placemarkers correctly replaced with HTML spans)
assert '<span' in x, "Expected color <span> tags from htmlcolor format not found in notification"
client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True)