Files
Reklamator/app/utils/markdown_utils.py
T

184 lines
6.1 KiB
Python
Raw Normal View History

"""
Markdown to HTML conversion utilities with security sanitization.
This module provides Jinja2 template filters for converting markdown-formatted
text to HTML with proper sanitization to prevent XSS attacks.
"""
import logging
from typing import Optional
import bleach
import markdown2
from markupsafe import Markup, escape
# Configure logging
logger = logging.getLogger(__name__)
# Allowed HTML tags after markdown conversion
ALLOWED_TAGS = [
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', # Headings
'p', 'br', # Paragraphs and line breaks
'strong', 'em', # Bold and italic
'code', 'pre', # Code blocks
'ul', 'ol', 'li', # Lists
'table', 'thead', 'tbody', 'tr', 'th', 'td', # Tables
'a' # Links
]
# Allowed HTML attributes per tag
ALLOWED_ATTRIBUTES = {
'a': ['href', 'title', 'target', 'rel'],
'code': ['class'], # For syntax highlighting hints
'*': [] # No attributes on other tags
}
# Markdown conversion extras
MARKDOWN_EXTRAS = [
'tables', # Support for tables
'fenced-code-blocks', # Support for ```code blocks```
'code-friendly', # Better code handling
'break-on-newline', # Convert newlines to <br>
]
def markdown_filter(value: Optional[str], feedback_id: str = "unknown") -> Markup:
"""
Convert markdown-formatted text to sanitized HTML.
This filter converts markdown to HTML using markdown2, then sanitizes
the output with bleach to prevent XSS attacks. Links are automatically
configured to open in new tabs with security attributes.
Args:
value: Markdown-formatted string (or None)
feedback_id: Optional feedback ID for logging (default: "unknown")
Returns:
Flask Markup object (HTML-safe string)
Examples:
>>> markdown_filter("## Heading")
Markup('<h2>Heading</h2>')
>>> markdown_filter("- Item 1\n- Item 2")
Markup('<ul><li>Item 1</li><li>Item 2</li></ul>')
>>> markdown_filter("[Link](http://example.com)")
Markup('<a href="http://example.com" target="_blank" rel="noopener noreferrer nofollow">Link</a>')
Security:
- XSS prevention: All potentially dangerous HTML is stripped
- Link security: All links get target="_blank" and rel="noopener noreferrer nofollow"
- Image exclusion: Images are removed from output
- Script/iframe blocking: All script and iframe tags are stripped
Error Handling:
- None/empty input: Returns empty string
- Conversion exception: Returns original text in <pre> tag and logs warning
"""
# Handle None or empty input
if not value:
return Markup("")
try:
# Convert markdown to HTML
html = markdown2.markdown(
value,
extras=MARKDOWN_EXTRAS
)
# Pre-sanitization: Remove dangerous tags and their content entirely
# This prevents script/iframe content from being left behind
html = _remove_dangerous_elements(html)
# Create bleach Cleaner for sanitization
cleaner = bleach.Cleaner(
tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES,
strip=True # Strip disallowed tags instead of escaping
)
# Sanitize HTML
sanitized_html = cleaner.clean(html)
# Add security attributes to all links
sanitized_html = _add_link_security_attributes(sanitized_html)
# Check if any content was stripped (potential security issue)
if len(sanitized_html) < len(html) * 0.8: # More than 20% content removed
logger.warning(
f"Significant content stripped during sanitization for feedback_id={feedback_id}. "
f"Original length: {len(html)}, Sanitized length: {len(sanitized_html)}"
)
return Markup(sanitized_html)
except Exception as e:
# Log the error with feedback_id for debugging
logger.warning(
f"Markdown conversion failed for feedback_id={feedback_id}: {str(e)}. "
f"Falling back to preformatted text."
)
# Return original markdown in a preformatted block as fallback
return Markup(f"<pre>{escape(value)}</pre>")
def _remove_dangerous_elements(html: str) -> str:
"""
Remove dangerous HTML elements and their content entirely.
This function removes script, iframe, and other dangerous tags along with
their content to prevent XSS attacks. Unlike bleach's strip=True which
leaves content behind, this removes both tags and content.
Args:
html: HTML string to sanitize
Returns:
HTML string with dangerous elements removed
"""
import re
# List of dangerous tags to remove entirely (tag + content)
dangerous_tags = ['script', 'iframe', 'object', 'embed', 'style', 'form', 'input', 'button']
for tag in dangerous_tags:
# Remove opening tag, content, and closing tag (case-insensitive, handles attributes)
# Pattern matches: <tag...>...</tag> or <tag.../> (self-closing)
pattern = f'<{tag}[^>]*>.*?</{tag}>|<{tag}[^>]*/>'
html = re.sub(pattern, '', html, flags=re.IGNORECASE | re.DOTALL)
return html
def _add_link_security_attributes(html: str) -> str:
"""
Add security attributes to all links in HTML.
This ensures all links open in new tabs and have proper security attributes
to prevent tabnabbing and other security issues.
Args:
html: HTML string with links
Returns:
HTML string with security attributes added to all links
"""
# Use bleach's linkify to add attributes to existing links
def add_rel_nofollow(attrs, new=False):
"""Add security attributes to links."""
attrs[(None, 'target')] = '_blank'
attrs[(None, 'rel')] = 'noopener noreferrer nofollow'
return attrs
# Apply the callback to all existing links
result = bleach.linkify(
html,
callbacks=[add_rel_nofollow],
skip_tags=['pre', 'code'] # Don't linkify URLs in code blocks
)
return result