flawopen.com/xss/Python
Cross-Site Scripting (XSS) in Python (Flask, Jinja2, Django)
Wrapping user input in Markup, mark_safe or an f-string passed to render_template_string lets a visitor's text run as JavaScript in other users' browsers, with their session (CWE-79). Where Flask, Jinja2 and Django escape for you, the four places they don't, and how to allow rich text safely.
Imagine a print shop with a protected form: text written in the blank field is printed as ordinary text. But if a clerk pastes a customer's note into the printer's instruction sheet before the job starts, the note can become a printing command. A Python f-string passed to render_template_string does that: it inserts input into Jinja2 source before autoescaping can treat it as a variable. Markup and mark_safe are like a stamp saying “already checked HTML”; they do not check it. Keep input in template variables and let autoescape work, or use format_html for a small Django fragment.
Core Concepts & Subsystem Terms
Jinja2 autoescape- Flask enables Jinja2 autoescaping for
.html,.htm,.xmland.xhtmltemplates and forrender_template_string, so{{ q }}becomes<img ...>. A standalonejinja2.Environment()has autoescape off unless you passautoescape=select_autoescape(). Markup / |safe / mark_safe- All three mark a string as already-safe HTML, so the template engine stops escaping it. They are promises made by the developer, not sanitizers:
Markup(user_input)ormark_safe(user_input)publishes whatever the user typed. render_template_string(f"...")- An f-string puts the value into the template source before Jinja2 parses it, so autoescaping never sees it as a variable. The same line is also server-side template injection: a value such as
{{ 7*7 }}is evaluated by Jinja2. django.utils.html.format_html- Builds HTML like
str.format, but escapes every argument withconditional_escapefirst, then marks the result safe. It is the Django-approved way to build a small HTML fragment in Python code instead ofmark_safe(f"..."). nh3.clean()- Rich text needs a sanitizer, not escaping.
nh3.clean()(Rust's ammonia) keeps an allowlist of tags and attributes and drops<script>, event handlers andjavascript:URLs. Bleach is deprecated; nh3 is its maintained successor.
Framework mappings
- CWE reference for the weakness described in this guide: CWE-79
- OWASP Top 10:2025 — A05:2025 Injection
Step-by-Step Attack Flow
Input arrives in a request
A search box, profile form or link parameter sends q, bio or next to the server. A victim can be sent a crafted link, or the value is stored and shown to everyone who views the profile.
The value bypasses escaping
An f-string places it in the template source, Markup() or mark_safe() flags it as trusted, or it lands in an href, where escaping < and > is not enough. The benign test value <img src=x onerror=alert(1)> passes through intact.
The browser parses it as markup
The response contains <img src=x onerror=alert(1)> as a real element. If the image resource fails to load, onerror fires, and a javascript: URL in the link runs when clicked.
The script runs as the site
In the Flask/Jinja and Django paths shown, emitted markup runs under the application origin. Keep untrusted values in autoescaped template data positions; allow HTML only after context-appropriate sanitization.
Source Code: Flaw vs. Secure Implementation
# views.py: each handler below lets request text reach the page as HTML
from django.utils.safestring import mark_safe
from flask import Flask, render_template_string, request
from markupsafe import Markup
app = Flask(__name__)
# the f-string builds template source, so Jinja2 never escapes q
@app.get("/search")
def search():
q = request.args.get("q", "")
return render_template_string(f"<h1>Results for {q}</h1>")
# Markup() tells Jinja2 the bio is trusted HTML
@app.get("/profile")
def profile():
bio = request.args.get("bio", "")
return render_template_string("<div class='bio'>{{ bio }}</div>", bio=Markup(bio))
# mark_safe on an f-string publishes name as HTML
def greeting(name):
return mark_safe(f"<p>Hello, {name}</p>")
# autoescape does not stop a javascript: URL in href
@app.get("/continue")
def continue_link():
url = request.args.get("next", "/")
return render_template_string('<a href="{{ url }}">Continue</a>', url=url)
# views.py: values stay template variables; rich text is sanitized
import nh3
from django.utils.html import format_html
from flask import Flask, render_template_string, request
app = Flask(__name__)
# q is a template variable, so autoescape encodes it
@app.get("/search")
def search():
q = request.args.get("q", "")
return render_template_string("<h1>Results for {{ q }}</h1>", q=q)
# nh3 keeps an allowlist of tags before the HTML is trusted
@app.get("/profile")
def profile():
bio = request.args.get("bio", "")
return render_template_string("<div class='bio'>{{ bio|safe }}</div>", bio=nh3.clean(bio))
# format_html escapes each argument before marking the result safe
def greeting(name):
return format_html("<p>Hello, {}</p>", name)
# only same-site paths are allowed as the link target
@app.get("/continue")
def continue_link():
url = request.args.get("next", "/")
if not url.startswith("/") or url.startswith("//"):
url = "/"
return render_template_string('<a href="{{ url }}">Continue</a>', url=url)
Engineering & System Hardening Checklist
- ✓Fail CI on Bandit B703/B308 (
mark_safe) and B704 (Markupon non-literals), and on anyrender_template_stringwhose first argument is an f-string,%or.format()result. - ✓Pass values to templates as variables and let autoescape handle them; for standalone Jinja2, construct
Environment(autoescape=select_autoescape()). - ✓Use
format_html()instead ofmark_safe()for small fragments, andnh3.clean()for any user-supplied HTML. - ✓Validate every user-controlled URL before it reaches
hreforsrc: accept only same-site paths starting with a single/, or an explicit http/https allowlist. - ✓Send a Content-Security-Policy with a nonce-based
script-srcand no'unsafe-inline', and set session cookiesHttpOnlyandSameSite=Lax.
Sources
- CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
- Flask — Security Considerations: Cross-Site Scripting
- Django — Security: Cross site scripting (XSS) protection
- Django — format_html
- nh3 — Python bindings to the ammonia HTML sanitizer
- OWASP Cross Site Scripting Prevention Cheat Sheet