From a1ebbb7004cc9f5b4515a2b8d96e9ee8c13f7603 Mon Sep 17 00:00:00 2001 From: AndyVale Date: Wed, 25 Mar 2026 17:37:08 +0100 Subject: [PATCH 1/7] add sanitization in details.html, configure hook and reafactor filter --- web/settings.py | 4 ++ web/templates/success_stories/detail.html | 3 +- web/templatetags/markdown_filters.py | 47 ++++++++++++++++++++++- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/web/settings.py b/web/settings.py index 4ddcae8bb..f0819f171 100644 --- a/web/settings.py +++ b/web/settings.py @@ -523,6 +523,10 @@ MARKDOWNX_UPLOAD_URLS_PATH = "/markdownx/upload/" MARKDOWNX_MEDIA_PATH = "markdownx/" # Path within MEDIA_ROOT + +MARKDOWNX_MARKDOWNIFY_FUNCTION = "web.templatetags.markdown_filters.markdownify_sanitized" + + USE_X_FORWARDED_HOST = True # GitHub API Token for fetching contributor data diff --git a/web/templates/success_stories/detail.html b/web/templates/success_stories/detail.html index 9b150708d..632cb2efd 100644 --- a/web/templates/success_stories/detail.html +++ b/web/templates/success_stories/detail.html @@ -54,8 +54,9 @@

{{ success_story.title }}

{% endif %} + {% load markdown_filters %} -
{{ success_story.content|safe }}
+
{{ success_story.content|markdown }}

Share this success story

diff --git a/web/templatetags/markdown_filters.py b/web/templatetags/markdown_filters.py index ecda623a4..43352e075 100644 --- a/web/templatetags/markdown_filters.py +++ b/web/templatetags/markdown_filters.py @@ -1,11 +1,54 @@ +import bleach from django import template from django.utils.safestring import mark_safe from markdownx.utils import markdownify register = template.Library() +SAFE_TAGS = { + "h1", "h2", "h3", "h4", "h5", "h6", + "p", "br", "hr", + "strong", "em", "b", "i", "u", "del", + "ul", "ol", "li", + "a", + "pre", "code", + "blockquote", + "table", "thead", "tbody", "tr", "th", "td", + "img", +} + +SAFE_ATTRIBUTES = { + "a": {"href", "title"}, + "img": {"src", "alt", "title"}, + "code": {"class"}, + "td": {"align"}, + "th": {"align"}, +} + +def markdownify_sanitized(content): + """ + Custom markdownify function that sanitizes the output HTML using bleach. + This is used both for template filters and for the markdownx AJAX preview. + """ + if not content: + return "" + + # Convert markdown to HTML + html = markdownify(content) + + # Sanitize HTML + clean_html = bleach.clean( + html, + tags=SAFE_TAGS, + attributes=SAFE_ATTRIBUTES, + strip=True + ) + + return clean_html + @register.filter def markdown(text): - """Convert markdown text to HTML.""" - return mark_safe(markdownify(text)) + """Convert markdown text to sanitized HTML.""" + clean_html = markdownify_sanitized(text) + return mark_safe(clean_html) From e7da5bc006d5f7e584f619aa3d1584daf3c841c2 Mon Sep 17 00:00:00 2001 From: AndyVale Date: Wed, 25 Mar 2026 17:40:04 +0100 Subject: [PATCH 2/7] add templates sanitization --- web/static/js/markdown_preview.js | 39 +++++++++++++++++++++++ web/templates/courses/create.html | 4 ++- web/templates/courses/update.html | 20 ++---------- web/templates/success_stories/create.html | 4 ++- web/templates/teach.html | 4 ++- 5 files changed, 50 insertions(+), 21 deletions(-) create mode 100644 web/static/js/markdown_preview.js diff --git a/web/static/js/markdown_preview.js b/web/static/js/markdown_preview.js new file mode 100644 index 000000000..c19fe0429 --- /dev/null +++ b/web/static/js/markdown_preview.js @@ -0,0 +1,39 @@ +/** + * Helper function to get CSRF token from cookies + */ +function getCookie(name) { + let cookieValue = null; + if (document.cookie && document.cookie !== '') { + const cookies = document.cookie.split(';'); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + if (cookie.substring(0, name.length + 1) === (name + '=')) { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; + } + } + } + return cookieValue; +} + +/** + * Custom preview renderer for EasyMDE that uses the sanitized server-side endpoint. + * @param {string} plainText The raw markdown text from the editor. + * @param {string} previewUrl The URL of the markdownify endpoint. + * @returns {string} The sanitized HTML from the server. + */ +function sanitizedPreviewRender(plainText, previewUrl) { + const formData = new FormData(); + formData.append('content', plainText); + + const xhr = new XMLHttpRequest(); + xhr.open('POST', previewUrl, false); // Synchronous request as required by EasyMDE + xhr.setRequestHeader('X-CSRFToken', getCookie('csrftoken')); + xhr.send(formData); + + if (xhr.status === 200) { + return xhr.responseText; + } + + return '

Error rendering markdown preview.

'; +} diff --git a/web/templates/courses/create.html b/web/templates/courses/create.html index faed93502..86114af70 100644 --- a/web/templates/courses/create.html +++ b/web/templates/courses/create.html @@ -10,6 +10,7 @@ + {{ form.media }} {% endblock extra_head %} {% block content %} @@ -102,7 +103,8 @@

Create New Course

minHeight: '100px', maxHeight: '400px', placeholder: textarea.getAttribute('placeholder') || 'Enter your content here...', - autoDownloadFontAwesome: false + autoDownloadFontAwesome: false, + previewRender: (plainText) => sanitizedPreviewRender(plainText, '{% url "markdownx_markdownify" %}') }); // Store the editor instance diff --git a/web/templates/courses/update.html b/web/templates/courses/update.html index ec3300a93..683982b2e 100644 --- a/web/templates/courses/update.html +++ b/web/templates/courses/update.html @@ -10,6 +10,7 @@ + {{ form.media }} {% endblock extra_head %} {% block content %} @@ -72,24 +73,7 @@

Edit Course

'guide' ], theme: document.documentElement.classList.contains('dark') ? 'dark' : 'light', - // Use language-agnostic URL - previewRender: function(plainText) { - // Create a temporary form - const formData = new FormData(); - formData.append('content', plainText); - - // Use a synchronous request to get the rendered HTML - const xhr = new XMLHttpRequest(); - xhr.open('POST', '{% url "markdownx_markdownify" %}', false); // synchronous request - xhr.setRequestHeader('X-CSRFToken', getCookie('csrftoken')); - xhr.send(formData); - - if (xhr.status === 200) { - return xhr.responseText; - } - - return 'Error rendering markdown'; - } + previewRender: (plainText) => sanitizedPreviewRender(plainText, '{% url "markdownx_markdownify" %}') }); // Store the editor instance diff --git a/web/templates/success_stories/create.html b/web/templates/success_stories/create.html index e2fb282d6..cbd6eef2e 100644 --- a/web/templates/success_stories/create.html +++ b/web/templates/success_stories/create.html @@ -15,6 +15,7 @@ + {{ form.media }} {% endblock extra_head %} @@ -145,7 +146,8 @@

'preview', 'side-by-side', 'fullscreen', '|', 'guide' ], - theme: document.documentElement.classList.contains('dark') ? 'dark' : 'light' + theme: document.documentElement.classList.contains('dark') ? 'dark' : 'light', + previewRender: (plainText) => sanitizedPreviewRender(plainText, '{% url "markdownx_markdownify" %}') }); // Store the editor instance diff --git a/web/templates/teach.html b/web/templates/teach.html index 02c111c10..be15e930f 100644 --- a/web/templates/teach.html +++ b/web/templates/teach.html @@ -17,6 +17,7 @@ + {{ form.media }} {% endblock extra_head %} {% block content %} @@ -84,7 +85,8 @@

Create Your Course

minHeight: '100px', maxHeight: '400px', placeholder: 'Describe your course...', - autoDownloadFontAwesome: false + autoDownloadFontAwesome: false, + previewRender: (plainText) => sanitizedPreviewRender(plainText, '{% url "markdownx_markdownify" %}') }); editors.set(textarea.name, editor); editor.codemirror.on('change', () => { From 87f0397685f78d934a82b7b8313c8a2251363237 Mon Sep 17 00:00:00 2001 From: AndyVale Date: Wed, 25 Mar 2026 18:09:13 +0100 Subject: [PATCH 3/7] fix coderabbit issues --- web/templates/success_stories/detail.html | 2 +- web/templatetags/markdown_filters.py | 16 +++++++--------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/web/templates/success_stories/detail.html b/web/templates/success_stories/detail.html index 632cb2efd..74757cb06 100644 --- a/web/templates/success_stories/detail.html +++ b/web/templates/success_stories/detail.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% load markdown_filters %} {% load static %} @@ -54,7 +55,6 @@

{{ success_story.title }}

{% endif %} - {% load markdown_filters %}
{{ success_story.content|markdown }}
diff --git a/web/templatetags/markdown_filters.py b/web/templatetags/markdown_filters.py index 43352e075..c32c648bf 100644 --- a/web/templatetags/markdown_filters.py +++ b/web/templatetags/markdown_filters.py @@ -1,4 +1,5 @@ import bleach +from typing import Optional from django import template from django.utils.safestring import mark_safe from markdownx.utils import markdownify @@ -25,7 +26,9 @@ "th": {"align"}, } -def markdownify_sanitized(content): +ALLOWED_PROTOCOLS = {"http", "https", "mailto"} + +def markdownify_sanitized(content: Optional[str]) -> str: """ Custom markdownify function that sanitizes the output HTML using bleach. This is used both for template filters and for the markdownx AJAX preview. @@ -33,19 +36,14 @@ def markdownify_sanitized(content): if not content: return "" - # Convert markdown to HTML - html = markdownify(content) - - # Sanitize HTML - clean_html = bleach.clean( - html, + return bleach.clean( + markdownify(content), tags=SAFE_TAGS, attributes=SAFE_ATTRIBUTES, + protocols=ALLOWED_PROTOCOLS, strip=True ) - return clean_html - @register.filter def markdown(text): From 286f9142fb614710eb6445d89952922feeb6cbe1 Mon Sep 17 00:00:00 2001 From: AndyVale Date: Wed, 25 Mar 2026 20:10:42 +0100 Subject: [PATCH 4/7] fix remove getCookie from courses/update.html --- web/templates/courses/update.html | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/web/templates/courses/update.html b/web/templates/courses/update.html index 683982b2e..cd2f7d80e 100644 --- a/web/templates/courses/update.html +++ b/web/templates/courses/update.html @@ -85,22 +85,6 @@

Edit Course

}); }); - // Helper function to get CSRF token from cookies - function getCookie(name) { - let cookieValue = null; - if (document.cookie && document.cookie !== '') { - const cookies = document.cookie.split(';'); - for (let i = 0; i < cookies.length; i++) { - const cookie = cookies[i].trim(); - if (cookie.substring(0, name.length + 1) === (name + '=')) { - cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); - break; - } - } - } - return cookieValue; - } - // Fix form submission for hidden required textareas const form = document.getElementById('updateCourseForm'); form.addEventListener('submit', function(event) { From 3872ac0cd6dae7dbdd176ae81b025373c1e8023f Mon Sep 17 00:00:00 2001 From: AndyVale Date: Wed, 25 Mar 2026 20:21:05 +0100 Subject: [PATCH 5/7] add type hints to the markdown filter for consistency --- web/templatetags/markdown_filters.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/web/templatetags/markdown_filters.py b/web/templatetags/markdown_filters.py index c32c648bf..97246db76 100644 --- a/web/templatetags/markdown_filters.py +++ b/web/templatetags/markdown_filters.py @@ -1,5 +1,5 @@ import bleach -from typing import Optional +from typing import Any, Optional from django import template from django.utils.safestring import mark_safe from markdownx.utils import markdownify @@ -46,7 +46,6 @@ def markdownify_sanitized(content: Optional[str]) -> str: @register.filter -def markdown(text): +def markdown(text: str) -> Any: """Convert markdown text to sanitized HTML.""" - clean_html = markdownify_sanitized(text) - return mark_safe(clean_html) + return mark_safe(markdownify_sanitized(text)) From ef871ad4573f320db20197cd66e3499b79a26d13 Mon Sep 17 00:00:00 2001 From: AndyVale Date: Wed, 25 Mar 2026 20:28:10 +0100 Subject: [PATCH 6/7] fix black formatting --- web/templatetags/markdown_filters.py | 39 ++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/web/templatetags/markdown_filters.py b/web/templatetags/markdown_filters.py index 97246db76..c5cb33a43 100644 --- a/web/templatetags/markdown_filters.py +++ b/web/templatetags/markdown_filters.py @@ -7,14 +7,34 @@ register = template.Library() SAFE_TAGS = { - "h1", "h2", "h3", "h4", "h5", "h6", - "p", "br", "hr", - "strong", "em", "b", "i", "u", "del", - "ul", "ol", "li", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "p", + "br", + "hr", + "strong", + "em", + "b", + "i", + "u", + "del", + "ul", + "ol", + "li", "a", - "pre", "code", + "pre", + "code", "blockquote", - "table", "thead", "tbody", "tr", "th", "td", + "table", + "thead", + "tbody", + "tr", + "th", + "td", "img", } @@ -28,6 +48,7 @@ ALLOWED_PROTOCOLS = {"http", "https", "mailto"} + def markdownify_sanitized(content: Optional[str]) -> str: """ Custom markdownify function that sanitizes the output HTML using bleach. @@ -37,11 +58,7 @@ def markdownify_sanitized(content: Optional[str]) -> str: return "" return bleach.clean( - markdownify(content), - tags=SAFE_TAGS, - attributes=SAFE_ATTRIBUTES, - protocols=ALLOWED_PROTOCOLS, - strip=True + markdownify(content), tags=SAFE_TAGS, attributes=SAFE_ATTRIBUTES, protocols=ALLOWED_PROTOCOLS, strip=True ) From 924f21e8b3a9ae8345f7baef4aaec8b1985c8101 Mon Sep 17 00:00:00 2001 From: AndyVale Date: Thu, 26 Mar 2026 10:29:11 +0100 Subject: [PATCH 7/7] add tests and coderabbitai changes --- tests/test_markdown_filters.py | 102 ++++++++++++++++++++++ web/templates/success_stories/detail.html | 2 +- web/templatetags/markdown_filters.py | 3 +- 3 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 tests/test_markdown_filters.py diff --git a/tests/test_markdown_filters.py b/tests/test_markdown_filters.py new file mode 100644 index 000000000..94c7b2c6a --- /dev/null +++ b/tests/test_markdown_filters.py @@ -0,0 +1,102 @@ +from django.test import TestCase + +from web.templatetags.markdown_filters import markdownify_sanitized + + +class MarkdownFiltersSanitizationTests(TestCase): + """ + Regression tests covering the sanitizer boundary around the markdownify_sanitized function. + """ + + def test_markdownify_sanitized_strips_scripts_and_dangerous_tags(self): + """Ensure ') + self.assertNotIn("." + sanitized = markdownify_sanitized(complex_payload) + self.assertIn("link", sanitized) + self.assertNotIn("