Skip to content

Commit a2dde87

Browse files
authored
Notifications: small fixes found after reviewer (#10996)
* Notifications: small fixes found after reviewer Use single `{}` since we are using `.format()` to format these strings. * Handle missing `.format()` key Avoid internal error and log the exception so we can figure it out how to solve it. This happens when the `Notification` does not have _all_ the required `format_values`. * Protection agasint XSS when rendering notifications See #10922 (comment) * Test for missing key in format values and XSS protection * Update common/ * Lint * Document we only support `str` and `int` for now in `format_values` We don't support nested dictionaries in `format_values` or random objects. Only `str` and `int`. That should be enough for now. Skip all the values that are not `str` or `int` from the format values to render the messages. * Typo
1 parent 585869f commit a2dde87

File tree

4 files changed

+92
-6
lines changed

4 files changed

+92
-6
lines changed

readthedocs/notifications/messages.py

+36-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import textwrap
2+
from collections import defaultdict
23

4+
import structlog
5+
from django.utils.html import escape
36
from django.utils.translation import gettext_noop as _
47

58
from readthedocs.doc_builder.exceptions import (
@@ -13,6 +16,8 @@
1316

1417
from .constants import ERROR, INFO, NOTE, TIP, WARNING
1518

19+
log = structlog.get_logger(__name__)
20+
1621

1722
class Message:
1823
def __init__(self, id, header, body, type, icon_classes=None):
@@ -29,8 +34,26 @@ def __repr__(self):
2934
def __str__(self):
3035
return f"Message: {self.id} | {self.header}"
3136

37+
def _escape_format_values(self, format_values):
38+
"""
39+
Escape all potential HTML tags included in format values.
40+
41+
This is a protection against rendering potential values defined by the user.
42+
It uses the Django's util function ``escape`` (similar to ``|escape`` template tag filter)
43+
to convert HTML characters into regular characters.
44+
45+
NOTE: currently, we don't support values that are not ``str`` or ``int``.
46+
If we want to support other types or nested dictionaries,
47+
we will need to iterate recursively to apply the ``escape`` function.
48+
"""
49+
return {
50+
key: escape(value)
51+
for key, value in format_values.items()
52+
if isinstance(value, (str, int))
53+
}
54+
3255
def set_format_values(self, format_values):
33-
self.format_values = format_values or {}
56+
self.format_values = self._escape_format_values(format_values)
3457

3558
def get_display_icon_classes(self):
3659
if self.icon_classes:
@@ -55,10 +78,20 @@ def get_display_icon_classes(self):
5578
return " ".join(classes)
5679

5780
def get_rendered_header(self):
58-
return self.header.format(**self.format_values)
81+
try:
82+
return self.header.format(**self.format_values)
83+
except KeyError:
84+
# There was a key missing
85+
log.exception("There was a missing key when formating a header's Message.")
86+
return self.header.format_map(defaultdict(str, **self.format_values))
5987

6088
def get_rendered_body(self):
61-
return self.body.format(**self.format_values)
89+
try:
90+
return self.body.format(**self.format_values)
91+
except KeyError:
92+
# There was a key missing
93+
log.exception("There was a missing key when formating a body's Message.")
94+
return self.body.format_map(defaultdict(str, **self.format_values))
6295

6396

6497
# TODO: review the copy of these notifications/messages on PR review and adapt them.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
from readthedocs.notifications.constants import INFO
2+
from readthedocs.notifications.messages import Message
3+
4+
5+
class TestMessage:
6+
def test_xss_protection(self):
7+
message = Message(
8+
id="test",
9+
header="XSS: {header}",
10+
body="XSS: {body}",
11+
type=INFO,
12+
)
13+
message.set_format_values(
14+
{
15+
"header": "<p>xss</p>",
16+
"body": "<span>xss</span>",
17+
}
18+
)
19+
20+
assert message.get_rendered_header() == "XSS: &lt;p&gt;xss&lt;/p&gt;"
21+
assert message.get_rendered_body() == "XSS: &lt;span&gt;xss&lt;/span&gt;"
22+
23+
def test_invalid_format_values_type(self):
24+
message = Message(
25+
id="test",
26+
header="Header: {dict}",
27+
body="Body: {dict}",
28+
type=INFO,
29+
)
30+
message.set_format_values(
31+
{
32+
"dict": {
33+
"key": "value",
34+
},
35+
}
36+
)
37+
38+
# The rendered version skips the ``dict`` because it's not supported
39+
assert message.get_rendered_header() == "Header: "
40+
assert message.get_rendered_body() == "Body: "
41+
42+
def test_missing_key_format_values(self):
43+
message = Message(
44+
id="test",
45+
header="Missing format value: {header}",
46+
body="Missing format value: {body}",
47+
type=INFO,
48+
)
49+
50+
assert message.get_rendered_header() == "Missing format value: "
51+
assert message.get_rendered_body() == "Missing format value: "

readthedocs/oauth/notifications.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
MESSAGE_OAUTH_WEBHOOK_NO_ACCOUNT = "oauth:webhook:no-account"
1212
MESSAGE_OAUTH_WEBHOOK_INVALID = "oauth:webhook:invalid"
1313
MESSAGE_OAUTH_BUILD_STATUS_FAILURE = "oauth:status:send-failed"
14-
MESSAGE_OAUTH_DEPLOY_KEY_ATTACHED_SUCCESSFULY = "oauth:deploy-key:attached-successfully"
14+
MESSAGE_OAUTH_DEPLOY_KEY_ATTACHED_SUCCESSFULLY = (
15+
"oauth:deploy-key:attached-successfully"
16+
)
1517
MESSAGE_OAUTH_DEPLOY_KEY_ATTACHED_FAILED = "oauth:deploy-key:attached-failed"
1618

1719
messages = [
@@ -71,7 +73,7 @@
7173
type=ERROR,
7274
),
7375
Message(
74-
id=MESSAGE_OAUTH_DEPLOY_KEY_ATTACHED_SUCCESSFULY,
76+
id=MESSAGE_OAUTH_DEPLOY_KEY_ATTACHED_SUCCESSFULLY,
7577
header=_("Deploy key added successfully"),
7678
body=_(
7779
textwrap.dedent(

readthedocs/subscriptions/notifications.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ def for_organizations(cls):
9393
body=_(
9494
textwrap.dedent(
9595
"""
96-
The organization "{instance.name}" is currently disabled. You need to <a href="{{ subscription_url }}">renew your subscription</a> to keep using Read the Docs
96+
The organization "{instance.name}" is currently disabled. You need to <a href="{subscription_url}">renew your subscription</a> to keep using Read the Docs
9797
"""
9898
).strip(),
9999
),

0 commit comments

Comments
 (0)