diff --git a/apps/events/tests/test_importer.py b/apps/events/tests/test_importer.py
index 1bb0f4f22..c4d787923 100644
--- a/apps/events/tests/test_importer.py
+++ b/apps/events/tests/test_importer.py
@@ -59,7 +59,8 @@ def test_modified_event(self):
e = Event.objects.get(uid="8ceqself979pphq4eu7l5e2db8@google.com")
self.assertEqual(e.calendar.url, EVENTS_CALENDAR_URL)
self.assertEqual(
- e.description.rendered, 'PythonCamp Cologne 2016'
+ e.description.rendered,
+ 'PythonCamp Cologne 2016',
)
self.assertTrue(e.next_or_previous_time.all_day)
self.assertEqual(make_aware(datetime(year=2016, month=4, day=2)), e.next_or_previous_time.dt_start)
diff --git a/apps/nominations/tests/test_models.py b/apps/nominations/tests/test_models.py
index 069e25015..b6ccf3172 100644
--- a/apps/nominations/tests/test_models.py
+++ b/apps/nominations/tests/test_models.py
@@ -1,5 +1,6 @@
import datetime
+from django.conf import settings
from django.test import TestCase
from apps.nominations.models import DEFAULT_ACCENT_COLOR, Election, ElectionKind
@@ -50,3 +51,22 @@ def test_accent_color_falls_back_after_kind_deleted(self):
self.assertIsNone(self.election.kind)
self.assertEqual(self.election.accent_color, DEFAULT_ACCENT_COLOR)
+
+
+class MarkupSanitizationTests(TestCase):
+ def _render(self, markup_type, text):
+ renderers = {entry[0]: entry[1] for entry in settings.MARKUP_FIELD_TYPES}
+ return renderers[markup_type](text)
+
+ def test_markdown_strips_javascript_uri(self):
+ rendered = self._render("markdown", "[x](javascript:alert(document.domain))")
+ self.assertNotIn("javascript:", rendered)
+
+ def test_markdown_preserves_safe_links_and_formatting(self):
+ rendered = self._render("markdown", "[ok](https://www.python.org) **bold**")
+ self.assertIn('href="https://www.python.org"', rendered)
+ self.assertIn("bold", rendered)
+
+ def test_restructuredtext_strips_javascript_uri(self):
+ rendered = self._render("restructuredtext", "`x `_")
+ self.assertNotIn("javascript:", rendered)
diff --git a/pydotorg/markup.py b/pydotorg/markup.py
new file mode 100644
index 000000000..3ec266527
--- /dev/null
+++ b/pydotorg/markup.py
@@ -0,0 +1,94 @@
+"""Sanitized markup renderers for MarkupField content.
+
+django-markupfield marks the rendered markdown/RST output safe and templates
+print it with ``|safe``. The bundled renderers preserve link schemes such as
+``javascript:`` in hrefs, and the site sends no Content-Security-Policy, so an
+unsafe scheme in a markup link would execute in the browser of anyone viewing
+the page.
+
+Each default renderer is wrapped so its HTML passes through ``nh3.clean`` with
+an http/https/mailto URL-scheme allowlist (plus a tag/attribute allowlist)
+before it is marked safe. Wiring this through ``MARKUP_FIELD_TYPES`` covers
+every MarkupField (nominations, jobs, success stories, comments) at once.
+"""
+
+import nh3
+from markupfield.markup import DEFAULT_MARKUP_TYPES
+
+ALLOWED_TAGS = {
+ "a",
+ "abbr",
+ "b",
+ "blockquote",
+ "br",
+ "caption",
+ "code",
+ "col",
+ "colgroup",
+ "dd",
+ "del",
+ "div",
+ "dl",
+ "dt",
+ "em",
+ "figcaption",
+ "figure",
+ "h1",
+ "h2",
+ "h3",
+ "h4",
+ "h5",
+ "h6",
+ "hr",
+ "i",
+ "img",
+ "ins",
+ "kbd",
+ "li",
+ "ol",
+ "p",
+ "pre",
+ "s",
+ "span",
+ "strong",
+ "sub",
+ "sup",
+ "table",
+ "tbody",
+ "td",
+ "tfoot",
+ "th",
+ "thead",
+ "tr",
+ "ul",
+}
+
+ALLOWED_ATTRIBUTES = {
+ "*": {"class", "id", "title"},
+ "a": {"href"},
+ "img": {"src", "alt", "width", "height"},
+ "td": {"align", "colspan", "rowspan"},
+ "th": {"align", "colspan", "rowspan", "scope"},
+}
+
+ALLOWED_URL_SCHEMES = {"http", "https", "mailto"}
+
+
+def sanitize(html):
+ """Drop links and attributes whose scheme/name is not allowlisted."""
+ return nh3.clean(
+ html,
+ tags=ALLOWED_TAGS,
+ attributes=ALLOWED_ATTRIBUTES,
+ url_schemes=ALLOWED_URL_SCHEMES,
+ )
+
+
+def _sanitizing(render):
+ def sanitizing_render(markup):
+ return sanitize(render(markup))
+
+ return sanitizing_render
+
+
+MARKUP_FIELD_TYPES = [(name, _sanitizing(render), *rest) for name, render, *rest in DEFAULT_MARKUP_TYPES]
diff --git a/pydotorg/settings/base.py b/pydotorg/settings/base.py
index aabaccc71..66a644ece 100644
--- a/pydotorg/settings/base.py
+++ b/pydotorg/settings/base.py
@@ -6,6 +6,7 @@
from dj_database_url import parse as dj_database_url_parser
from django.contrib.messages import constants
+from pydotorg.markup import MARKUP_FIELD_TYPES # noqa: F401 - read by django-markupfield via settings
from pydotorg.settings.pipeline import PIPELINE # noqa: F401 - accessed by django-pipeline via settings
### Basic config
diff --git a/pyproject.toml b/pyproject.toml
index 61087f66f..4652a297a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -10,6 +10,7 @@ name = "pythondotorg"
version = "0.1.0"
requires-python = ">=3.14"
dependencies = [
+ "nh3==0.3.6",
"dj-database-url==0.5.0",
"django-pipeline==4.1.0",
"django-sitetree==1.18.0",
diff --git a/uv.lock b/uv.lock
index ff078ae9b..0e37321cb 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1011,6 +1011,40 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/ac/686789b9145413f1a61878c407210e41bfdb097976864e0913078b24098c/myst_parser-5.0.0-py3-none-any.whl", hash = "sha256:ab31e516024918296e169139072b81592336f2fef55b8986aa31c9f04b5f7211", size = 84533, upload-time = "2026-01-15T09:08:16.788Z" },
]
+[[package]]
+name = "nh3"
+version = "0.3.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5e/1b/ef84624f14954d270f74060a19fc550dd4f06656399447569afb584d8c06/nh3-0.3.6.tar.gz", hash = "sha256:f3736c9dd3d1856f80cd031715b84ca75cda2bbb1ac802c3da26bfce590838d7", size = 24684, upload-time = "2026-06-22T00:47:02.008Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/99/3e/6506aa4f23dc7b7993a2d0a45dca3ce864ec48380adfe15a173e643c63e8/nh3-0.3.6-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:2411e8c3cee81a1ddd62c2a5d50585c28aa5566d373ad1db92536b95ddb24ef2", size = 1421679, upload-time = "2026-06-22T00:46:20.248Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/e1/e96e7864a7a53bd6b6fab7e9632467382a2a2c1f3fed951918ad131542fb/nh3-0.3.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e196fa70c2ff2eb4de7d3df3108f8f358c1d69dff20d45b11f20a5aa227ffb6d", size = 792570, upload-time = "2026-06-22T00:46:22.179Z" },
+ { url = "https://files.pythonhosted.org/packages/59/62/5b6108bedaef2b2637fed04c87bdbcb5967b9961758b41f0e466ef22a022/nh3-0.3.6-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:34d2b0d934156b87ee114f599a3ba9b8b9e17b5d79652ba3a13fa50903de965e", size = 842243, upload-time = "2026-06-22T00:46:23.801Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/4a/526f199626bfcb496bc01a268051b44737962005553b158e985ed7e64865/nh3-0.3.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2f14b7ae1fca99c4a66c981aac3974e7fbc1ca30a12673d223ae1df76680917", size = 1001468, upload-time = "2026-06-22T00:46:25.481Z" },
+ { url = "https://files.pythonhosted.org/packages/49/09/0d8e3101636d9ad88cdefb2914e764cb8e876ebdbb4286bfc251277d9c67/nh3-0.3.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:889932a97fb4abb6f95fef1914c0d269ebfb60011e67121c1163059b9449dbb4", size = 1082933, upload-time = "2026-06-22T00:46:27.15Z" },
+ { url = "https://files.pythonhosted.org/packages/09/a1/ea83abe738a3fbaa203dfdb836ca7cbab0e7e9609faaee4fe1d4652599c0/nh3-0.3.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:edb2b4a1a27523e6cc7c417f8d21ce3d005243548b93e56b762b66b0c7f589f9", size = 1043120, upload-time = "2026-06-22T00:46:28.89Z" },
+ { url = "https://files.pythonhosted.org/packages/66/69/0654482b8635012fbae67826bd6c381abb05d841ac7388b9b4666300fdad/nh3-0.3.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:43bc1ed3fa0716295fabee29ba42b2667e4a51d140b0a68e092170a765474fa6", size = 1023824, upload-time = "2026-06-22T00:46:30.453Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/a6/1f7285ffadc8307c4dbeb08d21b920536d5117785056d1079e998c4dfa44/nh3-0.3.6-cp314-cp314t-win32.whl", hash = "sha256:597a8e843bea00b2eb5520658dc24a9bb032e7fc9e7c2c0c4cd29420220c9796", size = 599253, upload-time = "2026-06-22T00:46:32.072Z" },
+ { url = "https://files.pythonhosted.org/packages/36/ea/5542f3c45da4c00290d9d67a65e996702e23e613c4b627de3e09cb9fe357/nh3-0.3.6-cp314-cp314t-win_amd64.whl", hash = "sha256:4713502748f564fee0633b37b3403783ce0a3af3a3d148ad91025a5bdadb7bc6", size = 612553, upload-time = "2026-06-22T00:46:33.53Z" },
+ { url = "https://files.pythonhosted.org/packages/66/35/26bd47e6af5915a628281dccdac354ddf4e32f7397047894270acd8c9870/nh3-0.3.6-cp314-cp314t-win_arm64.whl", hash = "sha256:69bbb92865a693d909db3a700d3c01537533844d0948c1e9323561ce06ecda41", size = 595151, upload-time = "2026-06-22T00:46:34.878Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/ab/a7653bce9a3b204be6a6931767a9e23595807bb84790ce6685e4d7e5bd08/nh3-0.3.6-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a43ebd7543555c3ac1bc353023d0794e75cb76f6f18f19c32e95441496c0cc25", size = 1443564, upload-time = "2026-06-22T00:46:36.66Z" },
+ { url = "https://files.pythonhosted.org/packages/41/21/e1084ab18eb589506335c7c7576f2d4643e9a0c0e33983ef0e549a256b96/nh3-0.3.6-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1b160831c9cdb06a6c79c2f9cdb11386602938f9af260d1c457a85add4f6f69", size = 838002, upload-time = "2026-06-22T00:46:38.101Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/94/f48d08e6f72a406300fa11d8acd929fea1a80d4bf750fa292cb10785f126/nh3-0.3.6-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d14bf7982e7a77c0c775634c29c07ce08b38a046df73e1c1f139b3e82f18a38e", size = 823045, upload-time = "2026-06-22T00:46:39.495Z" },
+ { url = "https://files.pythonhosted.org/packages/25/bb/431615ba1d1d3eb63cde0f974f2114edf863a8a3f6049a12fed23fc241d3/nh3-0.3.6-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:44673b27010051ab5a5e438a86ec31bbda61d4a77d7e900af6b7be3037c1abae", size = 1093171, upload-time = "2026-06-22T00:46:41.21Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/24/a0d80182a18919665fefd19c1c06f1d1df1c9a6455d0252de40c034a0bc3/nh3-0.3.6-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6b7beece07525dc6e6b0fc2f104442de2ba328360ad00e50cbe2e1fd620447d", size = 1049217, upload-time = "2026-06-22T00:46:42.804Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/13/6f1e302ca674ac74362e150848ad56a1be5145391204f74facdb8e94df12/nh3-0.3.6-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:455469a29951edc92bc48b47ac2281c3f2609e6c4f6a047056449f8c2c23facf", size = 917372, upload-time = "2026-06-22T00:46:44.495Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/67/314f6151bad77a93d751978a344033e1fc890822f05f0416079338e34231/nh3-0.3.6-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:905f877dc66dd7aea4a76e54bcb26acb5ff8216f720c0017ccf63e0e6035698e", size = 806699, upload-time = "2026-06-22T00:46:45.99Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/a6/bfaa00046e58603507dcfc266c4778e3ab7adf68a5dedd73b6274b8d9314/nh3-0.3.6-cp38-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:25c733bee928530556b1db0ea46c52cf5aa686146e38e60a6fc7cb801ef91cec", size = 835165, upload-time = "2026-06-22T00:46:47.617Z" },
+ { url = "https://files.pythonhosted.org/packages/30/a8/fb2c38845efb703a9173bffdfc745fc64d2b0e55cfc73a3647d2f028250c/nh3-0.3.6-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2f90d9a0cfdbee218994fdaaeeb5a0fde62d08f35e4eef0378ec1e2200172fd0", size = 858282, upload-time = "2026-06-22T00:46:49.276Z" },
+ { url = "https://files.pythonhosted.org/packages/68/17/06e72a18ee9b572914447338237ca7eb164c0df901f141bc10d1282247a2/nh3-0.3.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:82ca5bf427ad1b216b65ede1a2e2d87dc49bec417ceba0f297213107d3cd9d78", size = 1014328, upload-time = "2026-06-22T00:46:51.026Z" },
+ { url = "https://files.pythonhosted.org/packages/11/f9/3966c61455668c08853bf5e33b4bed93c421f3194ce4de896dc248d6f6ce/nh3-0.3.6-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f5ed5fe84aee7f39db95c214a7421bf0499fbf500fec6d86a4e29bfc37971438", size = 1098207, upload-time = "2026-06-22T00:46:52.674Z" },
+ { url = "https://files.pythonhosted.org/packages/19/d3/479cb4ae440424825735d60525b53e3c77fd60fd6e6afc0e984f00eb0178/nh3-0.3.6-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:082675ff87b9385ec430ffe6d5847ba7456cc39b73720cd4add472f9f4cffd56", size = 1056961, upload-time = "2026-06-22T00:46:54.335Z" },
+ { url = "https://files.pythonhosted.org/packages/17/0c/6cdb5ee1e127be50dc8391e54bddc1f64e87bf4bfad0c55633320e2e02db/nh3-0.3.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36d06341bd501240d320f5942481ed5e6846136b666e1ba4faf802b78ebc875f", size = 1033829, upload-time = "2026-06-22T00:46:56.258Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/55/9de666ad975d6ccd77d799ea0add55ee2347aa81286ce21b2a97c070746b/nh3-0.3.6-cp38-abi3-win32.whl", hash = "sha256:5276ef17bdba9ad8040575c74072008b13aae429436e9d0429e718bb5f90f4da", size = 609081, upload-time = "2026-06-22T00:46:57.665Z" },
+ { url = "https://files.pythonhosted.org/packages/82/fa/2b5d684e3edf1e81bfd02d298c78c3e3da77ca1d8a2be3183a79544a7548/nh3-0.3.6-cp38-abi3-win_amd64.whl", hash = "sha256:f338ac7d594c067679f1e99b4f5ec3906842979560f9d8f15d6bdfa39a353b10", size = 624461, upload-time = "2026-06-22T00:46:59.163Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/e5/7cafee2f0413ca4cb0ef3bd111e94d408a48810008b283ad8aee00dd1809/nh3-0.3.6-cp38-abi3-win_arm64.whl", hash = "sha256:69f365963f63a1e9bff53bdbb3c542c7c2efed3e163c9d5d83a772a2ac468c21", size = 603060, upload-time = "2026-06-22T00:47:00.596Z" },
+]
+
[[package]]
name = "num2words"
version = "0.5.13"
@@ -1297,6 +1331,7 @@ dependencies = [
{ name = "icalendar" },
{ name = "lxml" },
{ name = "markdown" },
+ { name = "nh3" },
{ name = "num2words" },
{ name = "panflute" },
{ name = "pillow" },
@@ -1378,6 +1413,7 @@ requires-dist = [
{ name = "icalendar", specifier = "==4.0.7" },
{ name = "lxml", specifier = "==6.1.0" },
{ name = "markdown", specifier = "==3.10.2" },
+ { name = "nh3", specifier = "==0.3.6" },
{ name = "num2words", specifier = "==0.5.13" },
{ name = "panflute", specifier = "==2.3.1" },
{ name = "pillow", specifier = "==12.3.0" },