Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions openedx/core/djangoapps/notifications/base_notification.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""
Base setup for Notification Apps and Types.
"""
from django.utils.html import escape
from django.utils.translation import gettext_lazy as _

from .email_notifications import EmailCadence
Expand All @@ -11,6 +12,13 @@

FILTER_AUDIT_EXPIRED_USERS_WITH_NO_ROLE = 'filter_audit_expired_users_with_no_role'

# Context keys whose values are used as HTML tag names by content_templates
# (e.g. `<{p}>...<{strong}>{post_title}</{strong}></{p}>`). These must NOT be
# HTML-escaped before `template.format(**context)`; every other context value
# must be, since it typically comes from user input (thread title, username,
# etc.). See get_notification_content below.
_STRUCTURAL_CONTEXT_KEYS = frozenset({'p', 'strong'})

COURSE_NOTIFICATION_TYPES = {
'new_comment_on_response': {
'notification_app': 'discussion',
Expand Down Expand Up @@ -524,8 +532,16 @@ def get_notification_content(notification_type, context):
context = context_function(context)

if template:
# Handle grouped templates differently by modifying the context using a different function.
return template.format(**context)
# HTML-escape every context value except the structural tag-name
# keys, so that user-controlled input (post_title, replier_name,
# etc.) cannot inject `<style>` / `<script>` / other HTML into
# notification.content — which is rendered with `|safe` in the
# digest and batched email templates.
safe_context = {
key: value if key in _STRUCTURAL_CONTEXT_KEYS else escape(value)
for key, value in context.items()
}
return template.format(**safe_context)

return ''

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""
Tests for base_notification
"""
import pytest

from common.djangoapps.student.tests.factories import UserFactory
from openedx.core.djangoapps.notifications import base_notification, models
from openedx.core.djangoapps.notifications.models import (
Expand Down Expand Up @@ -298,3 +300,40 @@ def test_validate_non_core_notification_types(self):
assert isinstance(notification_type[key], str)
for key in bool_keys:
assert isinstance(notification_type[key], bool)


@pytest.mark.parametrize(
('user_input', 'escaped'),
[
('<style>body{background:red}</style>evil', '&lt;style&gt;body{background:red}&lt;/style&gt;evil'),
('<script>alert(1)</script>', '&lt;script&gt;alert(1)&lt;/script&gt;'),
('AT&T "quoted"', 'AT&amp;T &quot;quoted&quot;'),
],
)
def test_get_notification_content_escapes_user_input(user_input, escaped):
"""
Regression test for GHSA-rv5w-f4r5-h77g: user-controlled context values
must be HTML-escaped before being interpolated into a content_template
via `str.format`. Structural context keys (`p`, `strong`) are exempt so
the template can still emit real <p>/<strong> tags.
"""
context = {'replier_name': 'alice', 'post_title': user_input}
content = base_notification.get_notification_content('new_response', context)
assert '<style>' not in content
assert '<script>' not in content
assert escaped in content


def test_get_notification_content_preserves_structural_tags():
"""
Companion to test_get_notification_content_escapes_user_input: verify
that the structural `p` and `strong` keys still produce real HTML tags
after the escape pass, and that innocuous user input renders as plain
text alongside them.
"""
context = {'replier_name': 'alice', 'post_title': 'Hello world'}
content = base_notification.get_notification_content('new_response', context)
assert '<p>' in content
assert '</p>' in content
assert '<strong>alice</strong>' in content
assert '<strong>Hello world</strong>' in content
Loading