Skip to content

Commit 42a9604

Browse files
authored
fix(notifications): use html template instead of dynamic html params where needed (#58658)
After releasing #58227, some email notifications become broken. Example: <img width="692" alt="image" src="https://github.com/getsentry/sentry/assets/1127549/281171a9-74dc-4c2f-b724-f7bc8d7e52a9"> The cause is that `format_html` escaped template parameters that contained HTML code in cases such as: ``` html_params["version"] = f'<a href="{version_url}">{escape(self.version_parsed)}</a>' ``` From security perspective, this was kinda correct behavior, since parameter contains untrusted HTML. When building HTML, we should use HTML-specific templates instead. This PR: * Updates the signature of the `get_description()` method in the base GroupActivityNotification class and in all inherited classes: * old output: template, parameters, html_parameters (optional) * new output: text_template, html_template (optional), parameters * The set of parameters would be the same for both modes -- text and HTML * But we are going to redefine HTML template where needed. * Redefines HTML templates for two cases: * RegressionActivityNotification (fixes the above bug) -- we want to render release link as `<a href>` tag * ResolvedInReleaseActivityNotification -- same * The HTML template for most of cases is None and therefore normal text template is used.
1 parent b5a6f68 commit 42a9604

File tree

9 files changed

+61
-36
lines changed

9 files changed

+61
-36
lines changed

src/sentry/notifications/notifications/activity/assigned.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from typing import Any, Iterable, Mapping
3+
from typing import Any, Iterable, Mapping, Optional
44

55
from sentry.models.activity import Activity
66
from sentry.models.notificationsetting import NotificationSetting
@@ -68,8 +68,8 @@ class AssignedActivityNotification(GroupActivityNotification):
6868
def get_assignee(self) -> str:
6969
return get_assignee_str(self.activity, self.organization)
7070

71-
def get_description(self) -> tuple[str, Mapping[str, Any], Mapping[str, Any]]:
72-
return "{author} assigned {an issue} to {assignee}", {"assignee": self.get_assignee()}, {}
71+
def get_description(self) -> tuple[str, Optional[str], Mapping[str, Any]]:
72+
return "{author} assigned {an issue} to {assignee}", None, {"assignee": self.get_assignee()}
7373

7474
def get_notification_title(
7575
self, provider: ExternalProviders, context: Mapping[str, Any] | None = None

src/sentry/notifications/notifications/activity/base.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import abc
44
from functools import cached_property
5-
from typing import TYPE_CHECKING, Any, Mapping, MutableMapping
5+
from typing import TYPE_CHECKING, Any, Mapping, MutableMapping, Optional
66
from urllib.parse import urlparse, urlunparse
77

88
from django.utils.html import format_html
@@ -81,7 +81,7 @@ def __init__(self, activity: Activity) -> None:
8181
super().__init__(activity)
8282
self.group = activity.group
8383

84-
def get_description(self) -> tuple[str, Mapping[str, Any], Mapping[str, Any]]:
84+
def get_description(self) -> tuple[str, Optional[str], Mapping[str, Any]]:
8585
raise NotImplementedError
8686

8787
def get_group_link(self) -> str:
@@ -121,11 +121,13 @@ def get_context(self) -> MutableMapping[str, Any]:
121121
expensive computation so it should only be called once. Override this
122122
method if the notification does not need HTML/text descriptions.
123123
"""
124-
description, params, html_params = self.get_description()
124+
text_template, html_template, params = self.get_description()
125+
text_description = self.description_as_text(text_template, params)
126+
html_description = self.description_as_html(html_template or text_template, params)
125127
return {
126128
**self.get_base_context(),
127-
"text_description": self.description_as_text(description, params),
128-
"html_description": self.description_as_html(description, html_params or params),
129+
"text_description": text_description,
130+
"html_description": html_description,
129131
}
130132

131133
def get_group_context(self) -> MutableMapping[str, Any]:
@@ -145,7 +147,7 @@ def get_group_context(self) -> MutableMapping[str, Any]:
145147
def get_notification_title(
146148
self, provider: ExternalProviders, context: Mapping[str, Any] | None = None
147149
) -> str:
148-
description, params, _ = self.get_description()
150+
description, _, params = self.get_description()
149151
return self.description_as_text(description, params, True, provider)
150152

151153
def get_subject(self, context: Mapping[str, Any] | None = None) -> str:

src/sentry/notifications/notifications/activity/escalating.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from typing import Any, Mapping
3+
from typing import Any, Mapping, Optional
44

55
from sentry.services.hybrid_cloud.actor import RpcActor
66
from sentry.types.integrations import ExternalProviders
@@ -19,26 +19,26 @@ def get_notification_title(
1919

2020
return self.title
2121

22-
def get_description(self) -> tuple[str, Mapping[str, Any], Mapping[str, Any]]:
22+
def get_description(self) -> tuple[str, Optional[str], Mapping[str, Any]]:
2323
forecast = int(self.activity.data.get("forecast", 0))
2424
expired_snooze = self.activity.data.get("expired_snooze")
2525

2626
if forecast:
2727
return (
2828
"Sentry flagged this issue as escalating because over {forecast} {event} happened in an hour.",
29+
None,
2930
{"forecast": forecast, "event": "event" if forecast == 1 else "events"},
30-
{},
3131
)
3232

3333
if expired_snooze:
3434
return (
3535
"Sentry flagged this issue as escalating because your archive condition has expired.",
36-
{},
36+
None,
3737
{},
3838
)
3939

4040
# Return a default basic message
41-
return ("Sentry flagged this issue as escalating.", {}, {})
41+
return ("Sentry flagged this issue as escalating.", None, {})
4242

4343
def get_message_description(self, recipient: RpcActor, provider: ExternalProviders) -> Any:
4444
return self.get_context()["text_description"]

src/sentry/notifications/notifications/activity/note.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from typing import Any, Mapping
3+
from typing import Any, Mapping, Optional
44

55
from sentry.services.hybrid_cloud.actor import RpcActor
66
from sentry.types.integrations import ExternalProviders
@@ -13,10 +13,10 @@ class NoteActivityNotification(GroupActivityNotification):
1313
metrics_key = "note_activity"
1414
template_path = "sentry/emails/activity/note"
1515

16-
def get_description(self) -> tuple[str, Mapping[str, Any], Mapping[str, Any]]:
16+
def get_description(self) -> tuple[str, Optional[str], Mapping[str, Any]]:
1717
# Notes may contain {} characters so we should escape them.
1818
text = str(self.activity.data["text"]).replace("{", "{{").replace("}", "}}")
19-
return text, {}, {}
19+
return text, None, {}
2020

2121
@property
2222
def title(self) -> str:

src/sentry/notifications/notifications/activity/regression.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from __future__ import annotations
22

3-
from html import escape
4-
from typing import Any, Mapping
3+
from typing import Any, Mapping, Optional
54
from urllib.parse import urlencode
65

76
from sentry_relay.processing import parse_release
@@ -21,8 +20,8 @@ def __init__(self, activity: Activity) -> None:
2120
self.version = self.activity.data.get("version", "")
2221
self.version_parsed = parse_release(self.version)["description"]
2322

24-
def get_description(self) -> tuple[str, Mapping[str, Any], Mapping[str, Any]]:
25-
message, params, html_params = "{author} marked {an issue} as a regression", {}, {}
23+
def get_description(self) -> tuple[str, Optional[str], Mapping[str, Any]]:
24+
text_message, html_message, params = "{author} marked {an issue} as a regression", None, {}
2625

2726
if self.version:
2827
version_url = self.organization.absolute_url(
@@ -32,11 +31,13 @@ def get_description(self) -> tuple[str, Mapping[str, Any], Mapping[str, Any]]:
3231
),
3332
)
3433

35-
message += " in {version}"
34+
html_message = text_message + ' in <a href="{url}">{version}</a>'
35+
text_message += " in {version}"
36+
37+
params["url"] = version_url
3638
params["version"] = self.version_parsed
37-
html_params["version"] = f'<a href="{version_url}">{escape(self.version_parsed)}</a>'
3839

39-
return message, params, html_params
40+
return text_message, html_message, params
4041

4142
def get_notification_title(
4243
self, provider: ExternalProviders, context: Mapping[str, Any] | None = None
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from typing import Any, Mapping
3+
from typing import Any, Mapping, Optional
44

55
from .base import GroupActivityNotification
66

@@ -9,5 +9,5 @@ class ResolvedActivityNotification(GroupActivityNotification):
99
metrics_key = "resolved_activity"
1010
title = "Resolved Issue"
1111

12-
def get_description(self) -> tuple[str, Mapping[str, Any], Mapping[str, Any]]:
13-
return "{author} marked {an issue} as resolved", {}, {}
12+
def get_description(self) -> tuple[str, Optional[str], Mapping[str, Any]]:
13+
return "{author} marked {an issue} as resolved", None, {}

src/sentry/notifications/notifications/activity/resolved_in_release.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from __future__ import annotations
22

3-
from html import escape
4-
from typing import Any, Mapping
3+
from typing import Any, Mapping, Optional
54
from urllib.parse import urlencode
65

76
from sentry_relay.processing import parse_release
@@ -21,7 +20,7 @@ def __init__(self, activity: Activity) -> None:
2120
self.version = self.activity.data.get("version", "")
2221
self.version_parsed = parse_release(self.version)["description"]
2322

24-
def get_description(self) -> tuple[str, Mapping[str, Any], Mapping[str, Any]]:
23+
def get_description(self) -> tuple[str, Optional[str], Mapping[str, Any]]:
2524
if self.version:
2625
url = self.organization.absolute_url(
2726
f"/organizations/{self.organization.slug}/releases/{self.version}/",
@@ -34,12 +33,17 @@ def get_description(self) -> tuple[str, Mapping[str, Any], Mapping[str, Any]]:
3433
),
3534
)
3635

36+
params = {
37+
"url": url,
38+
"version": self.version_parsed,
39+
}
40+
3741
return (
3842
"{author} marked {an issue} as resolved in {version}",
39-
{"version": self.version_parsed},
40-
{"version": f'<a href="{url}">{escape(self.version_parsed)}</a>'},
43+
'{author} marked {an issue} as resolved in <a href="{url}">{version}</a>',
44+
params,
4145
)
42-
return "{author} marked {an issue} as resolved in an upcoming release", {}, {}
46+
return "{author} marked {an issue} as resolved in an upcoming release", None, {}
4347

4448
def get_notification_title(
4549
self, provider: ExternalProviders, context: Mapping[str, Any] | None = None

src/sentry/notifications/notifications/activity/unassigned.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from typing import Any, Mapping
3+
from typing import Any, Mapping, Optional
44

55
from sentry.types.integrations import ExternalProviders
66

@@ -11,8 +11,8 @@ class UnassignedActivityNotification(GroupActivityNotification):
1111
metrics_key = "unassigned_activity"
1212
title = "Unassigned"
1313

14-
def get_description(self) -> tuple[str, Mapping[str, Any], Mapping[str, Any]]:
15-
return "{author} unassigned {an issue}", {}, {}
14+
def get_description(self) -> tuple[str, Optional[str], Mapping[str, Any]]:
15+
return "{author} unassigned {an issue}", None, {}
1616

1717
def get_notification_title(
1818
self, provider: ExternalProviders, context: Mapping[str, Any] | None = None

tests/sentry/notifications/test_notifications.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,15 @@
2121
from sentry.models.options.user_option import UserOption
2222
from sentry.models.rule import Rule
2323
from sentry.notifications.notifications.activity.assigned import AssignedActivityNotification
24+
from sentry.notifications.notifications.activity.regression import RegressionActivityNotification
2425
from sentry.silo import SiloMode
2526
from sentry.tasks.post_process import post_process_group
2627
from sentry.testutils.cases import APITestCase
2728
from sentry.testutils.helpers.datetime import before_now, iso_format
2829
from sentry.testutils.helpers.eventprocessing import write_event_to_cache
2930
from sentry.testutils.silo import assume_test_silo_mode, control_silo_test
3031
from sentry.testutils.skips import requires_snuba
32+
from sentry.types.activity import ActivityType
3133
from sentry.utils import json
3234

3335
pytestmark = [requires_snuba]
@@ -197,6 +199,22 @@ def test_html_escape(self):
197199
assert "&lt;b&gt;test&lt;/b&gt;" in html
198200
assert "<b>test</b>" not in html
199201

202+
@responses.activate
203+
def test_regression_html_link(self):
204+
notification = RegressionActivityNotification(
205+
Activity(
206+
project=self.project,
207+
group=self.group,
208+
user_id=self.user.id,
209+
type=ActivityType.SET_REGRESSION,
210+
data={"version": "777"},
211+
)
212+
)
213+
context = notification.get_context()
214+
215+
assert "as a regression in 777" in context["text_description"]
216+
assert "as a regression in <a href=" in context["html_description"]
217+
200218
@responses.activate
201219
@patch("sentry.analytics.record")
202220
def test_sends_resolution_notification(self, record_analytics):

0 commit comments

Comments
 (0)