184 lines
6.1 KiB
Python
184 lines
6.1 KiB
Python
"""
|
|||
|
|
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
|