-
-
Notifications
You must be signed in to change notification settings - Fork 3.6k
Notifications: small fixes found after reviewer #10996
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
bd1e101
fc9f1ca
6e3ae2c
a72a31e
b269562
2030e3d
24bd75d
6e7b0b4
68b669f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,8 @@ | ||
import textwrap | ||
from collections import defaultdict | ||
|
||
import structlog | ||
from django.utils.html import escape | ||
from django.utils.translation import gettext_noop as _ | ||
|
||
from readthedocs.doc_builder.exceptions import ( | ||
|
@@ -13,6 +16,8 @@ | |
|
||
from .constants import ERROR, INFO, NOTE, TIP, WARNING | ||
|
||
log = structlog.get_logger(__name__) | ||
|
||
|
||
class Message: | ||
def __init__(self, id, header, body, type, icon_classes=None): | ||
|
@@ -29,8 +34,26 @@ def __repr__(self): | |
def __str__(self): | ||
return f"Message: {self.id} | {self.header}" | ||
|
||
def _escape_format_values(self, format_values): | ||
""" | ||
Escape all potential HTML tags included in format values. | ||
|
||
This is a protection against rendering potential values defined by the user. | ||
It uses the Django's util function ``escape`` (similar to ``|escape`` template tag filter) | ||
to convert HTML characters into regular characters. | ||
|
||
NOTE: currently, we don't support values that are not ``str`` or ``int``. | ||
If we want to support other types or nested dictionaries, | ||
we will need to iterate recursively to apply the ``escape`` function. | ||
""" | ||
return { | ||
key: escape(value) | ||
for key, value in format_values.items() | ||
if isinstance(value, (str, int)) | ||
} | ||
|
||
def set_format_values(self, format_values): | ||
self.format_values = format_values or {} | ||
self.format_values = self._escape_format_values(format_values) | ||
|
||
def get_display_icon_classes(self): | ||
if self.icon_classes: | ||
|
@@ -55,10 +78,20 @@ def get_display_icon_classes(self): | |
return " ".join(classes) | ||
|
||
def get_rendered_header(self): | ||
return self.header.format(**self.format_values) | ||
try: | ||
return self.header.format(**self.format_values) | ||
except KeyError: | ||
# There was a key missing | ||
log.exception("There was a missing key when formating a header's Message.") | ||
return self.header.format_map(defaultdict(str, **self.format_values)) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since we have control over the notification, I think this should fail hard, our tests should catch these instead of silently failing. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We can't catch this by tests since this rendering happens at runtime. This could happen because a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The idea here would be to test for each notification we generate on each part of the code base. If we generate a notification when the user does something, we should have a test for it. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's fine, we can test the current state, but it could fail still in the future:
At this point, all the old notifications with only There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We will find some edge cases as we go that we can improve and write test cases for them and protections like this one against them. |
||
|
||
def get_rendered_body(self): | ||
return self.body.format(**self.format_values) | ||
try: | ||
return self.body.format(**self.format_values) | ||
except KeyError: | ||
# There was a key missing | ||
log.exception("There was a missing key when formating a body's Message.") | ||
return self.body.format_map(defaultdict(str, **self.format_values)) | ||
|
||
|
||
# TODO: review the copy of these notifications/messages on PR review and adapt them. | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
from readthedocs.notifications.constants import INFO | ||
from readthedocs.notifications.messages import Message | ||
|
||
|
||
class TestMessage: | ||
def test_xss_protection(self): | ||
message = Message( | ||
id="test", | ||
header="XSS: {header}", | ||
body="XSS: {body}", | ||
type=INFO, | ||
) | ||
message.set_format_values( | ||
{ | ||
"header": "<p>xss</p>", | ||
"body": "<span>xss</span>", | ||
} | ||
) | ||
|
||
assert message.get_rendered_header() == "XSS: <p>xss</p>" | ||
assert message.get_rendered_body() == "XSS: <span>xss</span>" | ||
|
||
def test_invalid_format_values_type(self): | ||
message = Message( | ||
id="test", | ||
header="Header: {dict}", | ||
body="Body: {dict}", | ||
type=INFO, | ||
) | ||
message.set_format_values( | ||
{ | ||
"dict": { | ||
"key": "value", | ||
}, | ||
} | ||
) | ||
|
||
# The rendered version skips the ``dict`` because it's not supported | ||
assert message.get_rendered_header() == "Header: " | ||
assert message.get_rendered_body() == "Body: " | ||
|
||
def test_missing_key_format_values(self): | ||
message = Message( | ||
id="test", | ||
header="Missing format value: {header}", | ||
body="Missing format value: {body}", | ||
type=INFO, | ||
) | ||
|
||
assert message.get_rendered_header() == "Missing format value: " | ||
assert message.get_rendered_body() == "Missing format value: " |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is fine without this check, there are more built-in types that can be included (like bool).
We should have more tests instead.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, we can expand to other types in the future. I want to avoid running
escape(dict)
and returning 500. For now,str
andint
are the ones we are using.