Skip to content
Closed
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
102 changes: 102 additions & 0 deletions tests/test_markdown_filters.py
Original file line number Diff line number Diff line change
@@ -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 <script>, <iframe>, <embed>, <object> tags and dangerous attributes are stripped."""
# Test script tag removal (note: bleach keeps text content by default)
sanitized = markdownify_sanitized('<script>alert("XSS")</script>')
self.assertNotIn("<script>", sanitized)
self.assertIn('alert("XSS")', sanitized)

# Test attribute removal (onerror, onload, onclick)
sanitized_img = markdownify_sanitized('<img src="x" onerror="alert(1)" onload="alert(2)">')
self.assertIn('src="x"', sanitized_img)
self.assertNotIn("onerror", sanitized_img)
self.assertNotIn("onload", sanitized_img)

sanitized_p = markdownify_sanitized('<p onclick="alert(1)">Click me</p>')
self.assertIn("Click me", sanitized_p)
self.assertNotIn("onclick", sanitized_p)

# Test dangerous tags that are not in SAFE_TAGS
# Note: markdownify might wrap the result in <p> tags
dangerous_payloads = [
('<iframe src="javascript:alert(1)"></iframe>', ""),
('<embed src="evil.swf">', ""),
('<object data="evil.swf"></object>', ""),
]
for payload, expected in dangerous_payloads:
sanitized = markdownify_sanitized(payload)
self.assertNotIn("<iframe", sanitized)
self.assertNotIn("<embed", sanitized)
self.assertNotIn("<object", sanitized)

def test_markdownify_sanitized_enforces_protocols(self):
"""Ensure only allowed protocols (http, https, mailto) are permitted."""
test_cases = [
("[JS](javascript:alert(1))", "<a>JS</a>"),
("[Data](data:text/html,xss)", "<a>Data</a>"),
("[HTTP](http://example.com)", '<a href="http://example.com">HTTP</a>'),
("[HTTPS](https://example.com)", '<a href="https://example.com">HTTPS</a>'),
("[Mail](mailto:test@example.com)", '<a href="mailto:test@example.com">Mail</a>'),
]
for markdown_input, expected_substring in test_cases:
self.assertIn(expected_substring, markdownify_sanitized(markdown_input))

def test_markdownify_sanitized_preserves_legitimate_markdown(self):
"""Ensure standard Markdown features are preserved and correctly rendered."""
# Links and Images
self.assertIn('<a href="http://example.com">Link</a>', markdownify_sanitized("[Link](http://example.com)"))

# Images (attributes might be reordered)
sanitized_img = markdownify_sanitized("![Alt](http://example.com/img.png)")
self.assertIn('src="http://example.com/img.png"', sanitized_img)
self.assertIn('alt="Alt"', sanitized_img)

# Tables
table_md = "| Head |\n| --- |\n| Cell |"
rendered = markdownify_sanitized(table_md)
self.assertIn("<table>", rendered)
self.assertIn("<thead>", rendered)
self.assertIn("<tbody>", rendered)
self.assertIn("<td>Cell</td>", rendered)

# Fenced Code Blocks
code_md = "```python\nprint(1)\n```"
rendered = markdownify_sanitized(code_md)
self.assertIn('<pre><code class="language-python">', rendered)
self.assertIn("print(1)", rendered)

# Blockquotes and Headings
self.assertIn("<blockquote>", markdownify_sanitized("> Quote"))
self.assertIn("<h1>Title</h1>", markdownify_sanitized("# Title"))

def test_markdownify_sanitized_handles_empty_input(self):
"""Ensure empty or None input returns an empty string."""
self.assertEqual(markdownify_sanitized(""), "")
self.assertEqual(markdownify_sanitized(None), "")

def test_markdownify_sanitized_preview_flow(self):
"""
Simulate the markdownify sanitized preview flow (AJAX-like).
Ensures that even complex payloads passed directly to the function are sanitized.
"""
complex_payload = "Check this [link](javascript:alert(1)) and <script>console.log('x')</script>."
sanitized = markdownify_sanitized(complex_payload)
self.assertIn("<a>link</a>", sanitized)
self.assertNotIn("<script>", sanitized)
self.assertNotIn("javascript:", sanitized)
self.assertIn("console.log('x')", sanitized)


if __name__ == "__main__":
import unittest

unittest.main()
4 changes: 4 additions & 0 deletions web/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions web/static/js/markdown_preview.js
Original file line number Diff line number Diff line change
@@ -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 '<p style="color: red;">Error rendering markdown preview.</p>';
}
4 changes: 3 additions & 1 deletion web/templates/courses/create.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.css" />
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
<script src="{% static 'js/markdown_preview.js' %}"></script>
{{ form.media }}
{% endblock extra_head %}
{% block content %}
Expand Down Expand Up @@ -102,7 +103,8 @@ <h1 class="text-3xl font-bold mb-4">Create New Course</h1>
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
Expand Down
36 changes: 2 additions & 34 deletions web/templates/courses/update.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.css" />
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
<script src="{% static 'js/markdown_preview.js' %}"></script>
{{ form.media }}
{% endblock extra_head %}
{% block content %}
Expand Down Expand Up @@ -72,24 +73,7 @@ <h1 class="text-3xl font-bold">Edit Course</h1>
'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
Expand All @@ -101,22 +85,6 @@ <h1 class="text-3xl font-bold">Edit Course</h1>
});
});

// 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) {
Expand Down
4 changes: 3 additions & 1 deletion web/templates/success_stories/create.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.css" />
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
<script src="{% static 'js/markdown_preview.js' %}"></script>
{{ form.media }}
<link rel="stylesheet" href="{% static 'css/markdown.css' %}" />
{% endblock extra_head %}
Expand Down Expand Up @@ -145,7 +146,8 @@ <h1 class="text-2xl font-bold mb-6">
'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
Expand Down
3 changes: 2 additions & 1 deletion web/templates/success_stories/detail.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{% extends "base.html" %}

{% load markdown_filters %}
{% load static %}

{% block title %}{{ success_story.title }}{% endblock %}
Expand Down Expand Up @@ -55,7 +56,7 @@ <h1 class="text-2xl md:text-3xl font-bold mb-3">{{ success_story.title }}</h1>
{% endif %}
</header>
<!-- Main Content -->
<div class="prose prose-orange dark:prose-invert max-w-none">{{ success_story.content|safe }}</div>
<div class="prose prose-orange dark:prose-invert max-w-none">{{ success_story.content|markdown }}</div>
<!-- Share Links -->
<div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold mb-3">Share this success story</h3>
Expand Down
4 changes: 3 additions & 1 deletion web/templates/teach.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
<!-- Flatpickr JS -->
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<script src="{% static 'js/markdown_preview.js' %}"></script>
{{ form.media }}
{% endblock extra_head %}
{% block content %}
Expand Down Expand Up @@ -84,7 +85,8 @@ <h3 class="text-2xl font-semibold mb-6">Create Your Course</h3>
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', () => {
Expand Down
64 changes: 61 additions & 3 deletions web/templatetags/markdown_filters.py
Original file line number Diff line number Diff line change
@@ -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))
Loading