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 (<, >, &, 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><script>alert("xss")</script></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:
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()orMarkup()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, andmark_safe()disables Django's only protection for it. - Using
|safeor{% 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'sjson_scriptfilter or Jinja'stojson, which escape for the HTML/script context. - Configuring
nh3.clean()with an allowlist wider than the feature needs, such as permittingstyleattributes or a broadALLOWED_ATTRIBUTESset. 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 itDevelopment Status :: 7 - Inactive- so newly discovered parser-confusion techniques will not be fixed there. Its own documentation points tonh3as the replacement.