flawopen.com/xss/Python
Cross-Site Scripting (XSS) en Python (Flask, Jinja2, Django)
Envelopper une saisie dans Markup, mark_safe ou une f-string passée à render_template_string fait exécuter le texte d'un visiteur comme du JavaScript dans le navigateur des autres utilisateurs, avec leur session (CWE-79). Où Flask, Jinja2 et Django échappent pour vous, les quatre endroits où ils ne le font pas, et comment autoriser du texte riche sans risque.
Imaginez une imprimerie avec un formulaire protégé : le texte saisi dans le champ vide est imprimé comme du texte ordinaire. Mais si un employé colle le message d’un client dans la feuille d’instructions de l’imprimante avant le lancement, ce message peut devenir une commande d’impression. Une f-string Python transmise à render_template_string fait cela : elle insère la saisie dans le code source Jinja2 avant qu’autoescape puisse la traiter comme une variable. Markup et mark_safe ressemblent à un tampon « HTML déjà vérifié » ; ils ne vérifient rien. Gardez la saisie dans les variables du modèle et laissez autoescape agir, ou utilisez format_html pour un petit fragment Django.
Concepts Clés et Termes
Jinja2 autoescape- Flask active l'autoescape de Jinja2 pour les gabarits
.html,.htm,.xmlet.xhtmlet pourrender_template_string:{{ q }}devient<img ...>. Unjinja2.Environment()autonome a l'autoescape désactivé, sauf si vous passezautoescape=select_autoescape(). Markup / |safe / mark_safe- Tous trois marquent une chaîne comme du HTML déjà sûr, et le moteur cesse de l'échapper. Ce sont des promesses du développeur, pas des assainisseurs :
Markup(user_input)oumark_safe(user_input)publie ce que l'utilisateur a tapé. render_template_string(f"...")- Une f-string insère la valeur dans le source du gabarit avant que Jinja2 ne l'analyse : l'autoescape ne la voit jamais comme une variable. La même ligne est aussi une injection de gabarit côté serveur : une valeur comme
{{ 7*7 }}est évaluée par Jinja2. django.utils.html.format_html- Construit du HTML comme
str.format, mais échappe d'abord chaque argument avecconditional_escape, puis marque le résultat comme sûr. C'est la façon approuvée par Django de construire un petit fragment HTML en Python, au lieu demark_safe(f"..."). nh3.clean()- Le texte riche exige un assainisseur, pas un échappement.
nh3.clean()(ammonia, en Rust) applique une liste autorisée de balises et d'attributs et supprime<script>, les gestionnaires d'événements et les URLjavascript:. Bleach est abandonné ; nh3 est son successeur maintenu.
Correspondances de référentiels
- Référence CWE pour la faiblesse décrite dans ce guide : CWE-79
- OWASP Top 10:2025 — A05:2025 Injection
Déroulement de l'Attaque Étape par Étape
La saisie arrive dans une requête
Un champ de recherche, un formulaire de profil ou un paramètre de lien envoie q, bio ou next au serveur. On peut envoyer à la victime un lien piégé, ou la valeur est stockée et affichée à tous les visiteurs du profil.
La valeur contourne l'échappement
Une f-string la place dans le source du gabarit, Markup() ou mark_safe() la déclarent sûre, ou elle atterrit dans un href, où échapper < et > ne suffit pas. La valeur de test inoffensive <img src=x onerror=alert(1)> passe intacte.
Le navigateur l'interprète comme du balisage
La réponse contient <img src=x onerror=alert(1)> comme un vrai élément. Si la ressource de l’image ne se charge pas, onerror se déclenche, et une URL javascript: dans le lien s'exécute au clic.
Le script s'exécute au nom du site
Dans les chemins Flask/Jinja et Django présentés, le balisage émis s’exécute sous l’origine de l’application. Gardez les valeurs non fiables dans des emplacements de données échappés automatiquement et n’autorisez le HTML qu’après une désinfection adaptée au contexte.
Code Source : Vulnérable vs Sécurisé
# views.py : chaque handler ci-dessous laisse du texte HTTP atteindre la page en HTML
from django.utils.safestring import mark_safe
from flask import Flask, render_template_string, request
from markupsafe import Markup
app = Flask(__name__)
# la f-string construit le source du gabarit, Jinja2 n'échappe donc jamais q
@app.get("/search")
def search():
q = request.args.get("q", "")
return render_template_string(f"<h1>Results for {q}</h1>")
# Markup() indique à Jinja2 que la bio est du HTML de confiance
@app.get("/profile")
def profile():
bio = request.args.get("bio", "")
return render_template_string("<div class='bio'>{{ bio }}</div>", bio=Markup(bio))
# mark_safe sur une f-string publie name en HTML
def greeting(name):
return mark_safe(f"<p>Hello, {name}</p>")
# l'autoescape n'empêche pas une URL javascript: dans 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 : les valeurs restent des variables de gabarit ; le texte riche est assaini
import nh3
from django.utils.html import format_html
from flask import Flask, render_template_string, request
app = Flask(__name__)
# q est une variable de gabarit, l'autoescape l'encode donc
@app.get("/search")
def search():
q = request.args.get("q", "")
return render_template_string("<h1>Results for {{ q }}</h1>", q=q)
# nh3 applique une liste autorisée de balises avant qu'on fasse confiance au HTML
@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 échappe chaque argument avant de déclarer le résultat sûr
def greeting(name):
return format_html("<p>Hello, {}</p>", name)
# seuls les chemins du site sont acceptés comme cible du lien
@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)
Liste de Contrôle de Sécurité pour l'Ingénierie
- ✓Faites échouer la CI sur Bandit B703/B308 (
mark_safe) et B704 (Markupsur des non-littéraux), ainsi que sur toutrender_template_stringdont le premier argument est une f-string, un%ou un résultat de.format(). - ✓Passez les valeurs aux gabarits comme variables et laissez l'autoescape faire son travail ; pour Jinja2 autonome, créez
Environment(autoescape=select_autoescape()). - ✓Utilisez
format_html()au lieu demark_safe()pour les petits fragments, etnh3.clean()pour tout HTML fourni par un utilisateur. - ✓Validez toute URL contrôlée par l'utilisateur avant qu'elle n'atteigne
hrefousrc: n'acceptez que des chemins du site commençant par un seul/, ou une liste autorisée explicite http/https. - ✓Envoyez une Content-Security-Policy avec un
script-srcà nonce et sans'unsafe-inline', et marquez les cookies de sessionHttpOnlyetSameSite=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
- nh3 — Python bindings to the ammonia HTML sanitizer