FlawOpen

flawopen.com/xss/Python

● CWE-79 · Haute
Recherche · FlawOpen

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.

💡 Explication en Langage Simple (ELI5)

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, .xml et .xhtml et pour render_template_string : {{ q }} devient <img ...>. Un jinja2.Environment() autonome a l'autoescape désactivé, sauf si vous passez autoescape=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) ou mark_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 avec conditional_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 de mark_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 URL javascript:. Bleach est abandonné ; nh3 est son successeur maintenu.

Correspondances de référentiels

Déroulement de l'Attaque Étape par Étape

Étape 1

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.

Étape 2

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.

Étape 3

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.

Étape 4

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é

IMPLÉMENTATION VULNÉRABLE
# 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)
PATCH SÉCURISÉ ET ROBUSTE
# 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

Sources