Skip to content

CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') - Python

Overview

XSS occurs when untrusted data is included in web output without proper encoding. Python web frameworks like Django and Flask provide built-in protection, but you must use them correctly.

Primary Defence: Use framework auto-escaping (Django templates with {{ }}, Flask/Jinja2 templates for .html files) for automatic HTML encoding, or html.escape() for manual output encoding. For rich HTML content, use nh3.clean() with allowlist-based sanitization.

Common Vulnerable Patterns

Django mark_safe() Misuse

# VULNERABLE - Marking user input as safe

from django.utils.safestring import mark_safe

def profile_view(request):
    user_bio = request.GET.get('bio', '')
    safe_bio = mark_safe(user_bio)  # DANGEROUS!
    return render(request, 'profile.html', {'bio': safe_bio})

Why this is vulnerable: mark_safe() tells Django to skip HTML escaping, so malicious input like <script>alert(document.cookie)</script> renders as executable JavaScript instead of safe text, bypassing Django's automatic XSS protection.

Flask Without Auto-Escaping

# VULNERABLE - Disabling auto-escape

from flask import Flask, request
from markupsafe import Markup

app = Flask(__name__)

@app.route('/comment')
def show_comment():
    comment = request.args.get('text', '')
    return f'<div>{Markup(comment)}</div>'  # DANGEROUS!

Why this is vulnerable: Markup() marks strings as safe HTML, disabling Jinja2's auto-escaping, so user input like <img src=x onerror=alert(1)> executes as JavaScript instead of being encoded to safe text entities.

Manual HTML Construction

# VULNERABLE - String concatenation

from flask import Flask, request

@app.route('/greeting')
def greet():
    name = request.args.get('name', 'Guest')
    html = '<h1>Hello, ' + name + '</h1>'
    return html  # No escaping!

Why this is vulnerable: Returning raw HTML strings bypasses Jinja2's auto-escaping entirely, so malicious input like <script>alert(1)</script> or <img src=x onerror=alert(1)> executes directly when rendered by the browser.

JavaScript Context Without Escaping

# VULNERABLE - Injecting into JavaScript

def search_view(request):
    query = request.GET.get('q', '')
    return render(request, 'search.html', {'query': query})

# Template:

# <script>

#     var searchTerm = '{{ query }}';  // Can break out with '

# </script>

Why this is vulnerable: Even with Django's HTML escaping, JavaScript string contexts require additional escaping: a value such as '; alert(1); // closes the quoted string and lets whatever follows run as code.

Secure Patterns

Django Auto-Escaping (Default)

# SECURE - Django templates auto-escape by default

from django.shortcuts import render

def profile_view(request):
    user_bio = request.GET.get('bio', '')
    # Django automatically HTML-escapes user_bio in template
    return render(request, 'profile.html', {'bio': user_bio})

# Template (profile.html):

# <div class="bio">

#     {{ bio }}  <!-- Automatically escaped -->

# </div>

Why this works: Django's template engine HTML-encodes every {{ }} variable by default, converting <, >, &, " and ' into their entity equivalents (&lt;, &gt;, &amp;, and so on). That happens during template rendering, after your view passes data to the template but before the HTTP response is generated. Escaping is on unless a template explicitly turns it off (autoescape=True in settings), so bypassing it takes a deliberate |safe filter or mark_safe() call - which is what makes the dangerous usages easy to spot in code review. It covers HTML body and attribute content; JavaScript contexts still need the escapejs filter and URL contexts still need urlencode.

Flask/Jinja2 Auto-Escaping

# SECURE - Flask enables auto-escaping for .html templates
from flask import Flask, render_template, request
app = Flask(__name__)

@app.route('/comment')
def show_comment():
    comment = request.args.get('text', '')
    return render_template('comment.html', comment=comment)

# Template (comment.html):
# <div class="comment">
#     {{ comment }}  <!-- Jinja2 auto-escapes -->
# </div>

Why this works: Flask uses Jinja2 as its template engine, which enables HTML auto-escaping by file extension: on for .html, .htm, .xml, and .xhtml, off for anything else, so a .txt template is rendered unescaped. Rendering {{ comment }} converts <, >, &, ", ' to their entity equivalents, after render_template() passes data to the template but before the HTTP response is sent. To emit trusted HTML unescaped, use the |safe filter or wrap the content with Markup() - and only with content you have sanitized first. Flask's Jinja2 configuration can be customized via app.jinja_env.autoescape, but the secure default should rarely be changed. Like Django, this protects HTML contexts but requires additional encoding for JavaScript, CSS, or URL contexts.

Explicit Escaping with MarkupSafe

# SECURE - Explicit HTML escaping

from markupsafe import escape

def build_html(user_input):
    escaped = escape(user_input)
    return f'<div>{escaped}</div>'

# Example:
# user_input = '<script>alert("xss")</script>'
# result = '<div>&lt;script&gt;alert(&#34;xss&#34;)&lt;/script&gt;</div>'

Why this works: MarkupSafe is the library that powers Jinja2's auto-escaping, and its escape() function gives you the same HTML encoding outside of templates. escape(user_input) converts HTML-significant characters (<, >, &, ", ') into their entity equivalents and returns a Markup object, which Jinja2 recognizes as already-safe - so the Markup class tracks what has been escaped and the result is not escaped a second time when it reaches a template. Use escape() when building HTML in Python code (outside templates), especially when combining user input with HTML fragments; it handles the edge cases and character encodings that hand-written string replacement tends to miss.

Context-Specific Encoding

HTML Context

# SECURE - HTML body content

from django.utils.html import escape

def display_message(request):
    msg = request.GET.get('msg', '')
    safe_msg = escape(msg)
    return HttpResponse(f'<p>{safe_msg}</p>')

Why this works: Django's escape() function (from django.utils.html) applies the same HTML entity encoding as template auto-escaping, for code that builds a response without going through a template. It handles Unicode correctly and returns a string that's safe to embed in HTML. Use it when constructing HttpResponse objects directly, building error messages, or anywhere else you bypass the template layer. Template-based rendering is still preferred where you have the choice, because manual HTML construction leaves every value one forgotten call away from being unescaped.

JavaScript Context

# SECURE - JavaScript string context

from django.shortcuts import render

def search_view(request):
    query = request.GET.get('q', '')
    return render(request, 'search.html', {'query': query})

# Template:
# <script>
#     var searchTerm = '{{ query|escapejs }}';
#     console.log(searchTerm);
# </script>

URL Context

# SECURE - URL encoding

from urllib.parse import quote

def build_search_url(query):
    encoded_query = quote(query)
    return f'/search?q={encoded_query}'

# Django template filter:
# <a href="/search?q={{ query|urlencode }}">Search</a>

JSON Responses

# SECURE - JSON is automatically escaped

from django.http import JsonResponse
from flask import jsonify

# The application's own user lookup, used by the Flask half below
from accounts import get_user

# Django:
def api_user(request, user_id):
    user = User.objects.get(id=user_id)
    return JsonResponse({
        'name': user.name,  # Automatically JSON-escaped
        'bio': user.bio
    })

# Flask:
@app.route('/api/user/<int:user_id>')
def api_user(user_id):
    user = get_user(user_id)
    return jsonify({
        'name': user.name,
        'bio': user.bio
    })

Why this works: Both JsonResponse() in Django and jsonify() in Flask serialize Python objects to JSON and set the Content-Type: application/json header. JSON encoding handles JSON syntax characters such as quotes, backslashes, and control characters. The content type is the critical browser boundary: it tells browsers to treat the response as data rather than renderable HTML. This makes JSON a safe transport format for API responses, but it does not make the values safe for every later sink. If client code inserts a returned string into HTML with innerHTML, the client still needs HTML encoding, sanitization, or a safe DOM API at that point.

Framework-Specific Guidance

Django

# SECURE - Default behavior is safe
from django.shortcuts import render
from django.utils.html import escape, format_html

def comment_view(request):
    author = request.GET.get('author', '')
    text = request.GET.get('text', '')

    # Template auto-escapes these
    return render(request, 'comment.html', {
        'author': author,
        'text': text
    })

# Template (comment.html):
# <div class="comment">
#     <strong>{{ author }}</strong>: {{ text }}
# </div>

# For building HTML in Python code:

from django.utils.html import format_html

def build_message(username, msg):
    return format_html(
        '<div class="msg"><b>{}</b>: {}</div>',
        username,
        msg
    )  # format_html auto-escapes arguments

Django Settings:

# settings.py - Ensure templates auto-escape

TEMPLATES = [{
    'BACKEND': 'django.template.backends.django.DjangoTemplates',
    'OPTIONS': {
        'autoescape': True,  # Default, don't disable!
    },
}]

Flask / Jinja2

# SECURE - Jinja2 auto-escapes .html templates

from flask import Flask, render_template, request
from markupsafe import escape

app = Flask(__name__)

@app.route('/profile/<username>')
def profile(username):
    bio = request.args.get('bio', '')
    # Auto-escaped in template
    return render_template('profile.html', 
                          username=username, 
                          bio=bio)

# profile.html:
# <h1>{{ username }}'s Profile</h1>
# <p>{{ bio }}</p>
# For manual HTML building:

@app.route('/message')
def message():
    text = request.args.get('text', '')
    escaped_text = escape(text)
    return f'<div>{escaped_text}</div>'

FastAPI

# SECURE - Jinja2 templates with FastAPI

from fastapi import FastAPI, Request
from fastapi.templating import Jinja2Templates

app = FastAPI()
templates = Jinja2Templates(directory="templates")

@app.get("/profile/{user_id}")
async def profile(request: Request, user_id: int, bio: str = ""):
    # Request first: the TemplateResponse(name, {"request": request, ...})
    # form was removed in Starlette 1.0
    return templates.TemplateResponse(request, "profile.html", {
        "user_id": user_id,
        "bio": bio  # Auto-escaped
    })

# JSON responses are automatically safe:

@app.get("/api/user/{user_id}")
async def get_user(user_id: int):
    return {"name": "John", "bio": "<script>alert('xss')</script>"}
    # FastAPI serializes to JSON, which escapes special chars

Rich HTML Sanitization

When you need to allow safe HTML (e.g., WYSIWYG editor):

# Use the nh3 library for HTML sanitization

import nh3

ALLOWED_TAGS = {'p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'a'}
ALLOWED_ATTRIBUTES = {'a': {'href', 'title'}}

def sanitize_html(dirty_html):
    # tags/attributes take sets; disallowed tags are removed outright
    clean = nh3.clean(
        dirty_html,
        tags=ALLOWED_TAGS,
        attributes=ALLOWED_ATTRIBUTES,
    )
    return clean

# Django view:

from django.utils.safestring import mark_safe

def save_article(request):
    content = request.POST.get('content', '')
    sanitized = sanitize_html(content)

    article = Article.objects.create(
        title=request.POST.get('title'),
        content=sanitized
    )
    return redirect('article_detail', pk=article.pk)

# Template (only mark_safe AFTER sanitization):
# <div class="article-content">
#     {{ article.content|safe }}
# </div>

Installation:

pip install nh3

Input Validation (Defense in Depth)

# Django forms with validation

from django import forms

class CommentForm(forms.Form):
    author = forms.CharField(
        max_length=100,
        required=True,
        validators=[
            RegexValidator(
                regex=r'^[a-zA-Z0-9\s]+$',
                message='Only alphanumeric characters allowed'
            )
        ]
    )

    text = forms.CharField(
        max_length=1000,
        widget=forms.Textarea
    )

# View:

def post_comment(request):
    form = CommentForm(request.POST)
    if form.is_valid():
        # Even with validation, template still auto-escapes
        comment = form.cleaned_data['text']
        Comment.objects.create(text=comment)
    return redirect('comments')

Content Security Policy

# Django middleware for CSP

from django.utils.deprecation import MiddlewareMixin

class SecurityHeadersMiddleware(MiddlewareMixin):
    def process_response(self, request, response):
        response['Content-Security-Policy'] = (
            "default-src 'self'; "
            "script-src 'self' https://trusted-cdn.com; "
            "style-src 'self' 'unsafe-inline'; "
            "img-src 'self' data: https:; "
            "frame-ancestors 'none';"
        )
        response['X-Content-Type-Options'] = 'nosniff'
        response['X-Frame-Options'] = 'DENY'
        return response

# settings.py

MIDDLEWARE = [
    'myapp.middleware.SecurityHeadersMiddleware',
    # ... other middleware
]

# Flask:

from flask import Flask

app = Flask(__name__)

@app.after_request
def set_security_headers(response):
    response.headers['Content-Security-Policy'] = (
        "default-src 'self'; "
        "script-src 'self'"
    )
    response.headers['X-Content-Type-Options'] = 'nosniff'
    return response

Common Pitfalls

  • Wrapping a value in mark_safe() or Markup() because it has already been truncated, run through .format(), or otherwise processed by business logic. None of those operations remove HTML - the value is still attacker-controlled, and mark_safe() disables Django's only protection for it.
  • Using |safe or {% autoescape false %} to fix a display problem for one variable. Turning off escaping for a template block silences it for every variable in that block, including any that carry untrusted data, not just the one causing the formatting issue.
  • Pasting json.dumps() output directly into an inline <script> block. json.dumps() produces valid JSON but does not escape </script>, so a value containing </script><script>alert(1)</script> can still terminate the script element early. Use Django's json_script filter or Jinja's tojson, which escape for the HTML/script context.
  • Configuring nh3.clean() with an allowlist wider than the feature needs, such as permitting style attributes or a broad ALLOWED_ATTRIBUTES set. An overly permissive allowlist can still let CSS-based injection or event-handler-bearing attributes through even though the obvious <script> tag is stripped.
  • Continuing to use bleach. It is no longer maintained - PyPI classifies it Development Status :: 7 - Inactive - so newly discovered parser-confusion techniques will not be fixed there. Its own documentation points to nh3 as the replacement.

Additional Resources