""" 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
] 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('

Heading

') >>> markdown_filter("- Item 1\n- Item 2") Markup('') >>> markdown_filter("[Link](http://example.com)") Markup('Link') 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
 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"
{escape(value)}
") 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: ... or (self-closing) pattern = f'<{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