Skip to content

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

Merged
merged 9 commits into from
Jan 8, 2024
39 changes: 36 additions & 3 deletions readthedocs/notifications/messages.py
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 (
Expand All @@ -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):
Expand All @@ -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))
Copy link
Member

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).

Suggested change
if isinstance(value, (str, int))

We should have more tests instead.

Copy link
Member Author

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 and int are the ones we are using.

}

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:
Expand All @@ -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))
Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Member Author

Choose a reason for hiding this comment

The 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 Notification was created with invalid format values, or because we have change the message over time and started required more format values.

Copy link
Member

Choose a reason for hiding this comment

The 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.

Copy link
Member Author

Choose a reason for hiding this comment

The 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:

  1. we have a notification with body="Go to {url} and put {code}." and format_values={"url": "https://..", "code": 1234}
  2. in the future, we change the message to body="Go to {url} and put {code}. Otherwise, contact support at {email}

At this point, all the old notifications with only url and code as format values will start to fail because they are missing the email key. This check here is to protect ourselves against this issue.

Copy link
Member Author

Choose a reason for hiding this comment

The 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.
Expand Down
51 changes: 51 additions & 0 deletions readthedocs/notifications/tests/test_messages.py
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: &lt;p&gt;xss&lt;/p&gt;"
assert message.get_rendered_body() == "XSS: &lt;span&gt;xss&lt;/span&gt;"

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: "
6 changes: 4 additions & 2 deletions readthedocs/oauth/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
MESSAGE_OAUTH_WEBHOOK_NO_ACCOUNT = "oauth:webhook:no-account"
MESSAGE_OAUTH_WEBHOOK_INVALID = "oauth:webhook:invalid"
MESSAGE_OAUTH_BUILD_STATUS_FAILURE = "oauth:status:send-failed"
MESSAGE_OAUTH_DEPLOY_KEY_ATTACHED_SUCCESSFULY = "oauth:deploy-key:attached-successfully"
MESSAGE_OAUTH_DEPLOY_KEY_ATTACHED_SUCCESSFULLY = (
"oauth:deploy-key:attached-successfully"
)
MESSAGE_OAUTH_DEPLOY_KEY_ATTACHED_FAILED = "oauth:deploy-key:attached-failed"

messages = [
Expand Down Expand Up @@ -71,7 +73,7 @@
type=ERROR,
),
Message(
id=MESSAGE_OAUTH_DEPLOY_KEY_ATTACHED_SUCCESSFULY,
id=MESSAGE_OAUTH_DEPLOY_KEY_ATTACHED_SUCCESSFULLY,
header=_("Deploy key added successfully"),
body=_(
textwrap.dedent(
Expand Down
2 changes: 1 addition & 1 deletion readthedocs/subscriptions/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ def for_organizations(cls):
body=_(
textwrap.dedent(
"""
The organization "{instance.name}" is currently disabled. You need to <a href="{{ subscription_url }}">renew your subscription</a> to keep using Read the Docs
The organization "{instance.name}" is currently disabled. You need to <a href="{subscription_url}">renew your subscription</a> to keep using Read the Docs
"""
).strip(),
),
Expand Down