Skip to content

Commit a5d76d9

Browse files
Sanitize MarkupField content with nh3 (#3067)
Co-authored-by: tonghuaroot (童话) <23011166+tonghuaroot@users.noreply.github.com>
1 parent ea19a80 commit a5d76d9

6 files changed

Lines changed: 154 additions & 1 deletion

File tree

‎apps/events/tests/test_importer.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ def test_modified_event(self):
5959
e = Event.objects.get(uid="8ceqself979pphq4eu7l5e2db8@google.com")
6060
self.assertEqual(e.calendar.url, EVENTS_CALENDAR_URL)
6161
self.assertEqual(
62-
e.description.rendered, '<a href="https://www.barcamptools.eu/pycamp201604">PythonCamp Cologne 2016</a>'
62+
e.description.rendered,
63+
'<a href="https://www.barcamptools.eu/pycamp201604" rel="noopener noreferrer">PythonCamp Cologne 2016</a>',
6364
)
6465
self.assertTrue(e.next_or_previous_time.all_day)
6566
self.assertEqual(make_aware(datetime(year=2016, month=4, day=2)), e.next_or_previous_time.dt_start)

‎apps/nominations/tests/test_models.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import datetime
22

3+
from django.conf import settings
34
from django.test import TestCase
45

56
from apps.nominations.models import DEFAULT_ACCENT_COLOR, Election, ElectionKind
@@ -50,3 +51,22 @@ def test_accent_color_falls_back_after_kind_deleted(self):
5051

5152
self.assertIsNone(self.election.kind)
5253
self.assertEqual(self.election.accent_color, DEFAULT_ACCENT_COLOR)
54+
55+
56+
class MarkupSanitizationTests(TestCase):
57+
def _render(self, markup_type, text):
58+
renderers = {entry[0]: entry[1] for entry in settings.MARKUP_FIELD_TYPES}
59+
return renderers[markup_type](text)
60+
61+
def test_markdown_strips_javascript_uri(self):
62+
rendered = self._render("markdown", "[x](javascript:alert(document.domain))")
63+
self.assertNotIn("javascript:", rendered)
64+
65+
def test_markdown_preserves_safe_links_and_formatting(self):
66+
rendered = self._render("markdown", "[ok](https://www.python.org) **bold**")
67+
self.assertIn('href="https://www.python.org"', rendered)
68+
self.assertIn("<strong>bold</strong>", rendered)
69+
70+
def test_restructuredtext_strips_javascript_uri(self):
71+
rendered = self._render("restructuredtext", "`x <javascript:alert(1)>`_")
72+
self.assertNotIn("javascript:", rendered)

‎pydotorg/markup.py‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""Sanitized markup renderers for MarkupField content.
2+
3+
django-markupfield marks the rendered markdown/RST output safe and templates
4+
print it with ``|safe``. The bundled renderers preserve link schemes such as
5+
``javascript:`` in hrefs, and the site sends no Content-Security-Policy, so an
6+
unsafe scheme in a markup link would execute in the browser of anyone viewing
7+
the page.
8+
9+
Each default renderer is wrapped so its HTML passes through ``nh3.clean`` with
10+
an http/https/mailto URL-scheme allowlist (plus a tag/attribute allowlist)
11+
before it is marked safe. Wiring this through ``MARKUP_FIELD_TYPES`` covers
12+
every MarkupField (nominations, jobs, success stories, comments) at once.
13+
"""
14+
15+
import nh3
16+
from markupfield.markup import DEFAULT_MARKUP_TYPES
17+
18+
ALLOWED_TAGS = {
19+
"a",
20+
"abbr",
21+
"b",
22+
"blockquote",
23+
"br",
24+
"caption",
25+
"code",
26+
"col",
27+
"colgroup",
28+
"dd",
29+
"del",
30+
"div",
31+
"dl",
32+
"dt",
33+
"em",
34+
"figcaption",
35+
"figure",
36+
"h1",
37+
"h2",
38+
"h3",
39+
"h4",
40+
"h5",
41+
"h6",
42+
"hr",
43+
"i",
44+
"img",
45+
"ins",
46+
"kbd",
47+
"li",
48+
"ol",
49+
"p",
50+
"pre",
51+
"s",
52+
"span",
53+
"strong",
54+
"sub",
55+
"sup",
56+
"table",
57+
"tbody",
58+
"td",
59+
"tfoot",
60+
"th",
61+
"thead",
62+
"tr",
63+
"ul",
64+
}
65+
66+
ALLOWED_ATTRIBUTES = {
67+
"*": {"class", "id", "title"},
68+
"a": {"href"},
69+
"img": {"src", "alt", "width", "height"},
70+
"td": {"align", "colspan", "rowspan"},
71+
"th": {"align", "colspan", "rowspan", "scope"},
72+
}
73+
74+
ALLOWED_URL_SCHEMES = {"http", "https", "mailto"}
75+
76+
77+
def sanitize(html):
78+
"""Drop links and attributes whose scheme/name is not allowlisted."""
79+
return nh3.clean(
80+
html,
81+
tags=ALLOWED_TAGS,
82+
attributes=ALLOWED_ATTRIBUTES,
83+
url_schemes=ALLOWED_URL_SCHEMES,
84+
)
85+
86+
87+
def _sanitizing(render):
88+
def sanitizing_render(markup):
89+
return sanitize(render(markup))
90+
91+
return sanitizing_render
92+
93+
94+
MARKUP_FIELD_TYPES = [(name, _sanitizing(render), *rest) for name, render, *rest in DEFAULT_MARKUP_TYPES]

‎pydotorg/settings/base.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from dj_database_url import parse as dj_database_url_parser
77
from django.contrib.messages import constants
88

9+
from pydotorg.markup import MARKUP_FIELD_TYPES # noqa: F401 - read by django-markupfield via settings
910
from pydotorg.settings.pipeline import PIPELINE # noqa: F401 - accessed by django-pipeline via settings
1011

1112
### Basic config

‎pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ name = "pythondotorg"
1010
version = "0.1.0"
1111
requires-python = ">=3.14"
1212
dependencies = [
13+
"nh3==0.3.6",
1314
"dj-database-url==0.5.0",
1415
"django-pipeline==4.1.0",
1516
"django-sitetree==1.18.0",

‎uv.lock‎

Lines changed: 36 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)