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(" + {{ 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..cd2f7d80e 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 @@ -101,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) { 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/success_stories/detail.html b/web/templates/success_stories/detail.html index 9b150708d..7acf699ed 100644 --- a/web/templates/success_stories/detail.html +++ b/web/templates/success_stories/detail.html @@ -1,5 +1,6 @@ {% extends "base.html" %} +{% load markdown_filters %} {% load static %} {% block title %}{{ success_story.title }}{% endblock %} @@ -55,7 +56,7 @@

{{ success_story.title }}

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

Share this success story

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', () => { diff --git a/web/templatetags/markdown_filters.py b/web/templatetags/markdown_filters.py index ecda623a4..f05621bce 100644 --- a/web/templatetags/markdown_filters.py +++ b/web/templatetags/markdown_filters.py @@ -1,11 +1,69 @@ +from typing import Any, Optional + +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"}, +} + +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. + """ + if not content: + return "" + + return bleach.clean( + markdownify(content), tags=SAFE_TAGS, attributes=SAFE_ATTRIBUTES, protocols=ALLOWED_PROTOCOLS, strip=True + ) + @register.filter -def markdown(text): - """Convert markdown text to HTML.""" - return mark_safe(markdownify(text)) +def markdown(text: str) -> Any: + """Convert markdown text to sanitized HTML.""" + return mark_safe(markdownify_sanitized(text))