flawopen.com/xss/Python
Cross-Site Scripting (XSS) en Python (Flask, Jinja2, Django)
Envolver la entrada del usuario en Markup, mark_safe o una f-string pasada a render_template_string hace que el texto de un visitante se ejecute como JavaScript en el navegador de otros usuarios, con su sesión (CWE-79). Dónde escapan por ti Flask, Jinja2 y Django, los cuatro puntos donde no lo hacen y cómo permitir texto enriquecido de forma segura.
Imagina una imprenta con un formulario protegido: el texto escrito en el campo vacío se imprime como texto normal. Pero si un empleado pega la nota de un cliente en la hoja de instrucciones de la impresora antes de iniciar el trabajo, la nota puede convertirse en una orden de impresión. Una f-string de Python enviada a render_template_string hace eso: inserta la entrada en el código fuente de Jinja2 antes de que autoescape pueda tratarla como variable. Markup y mark_safe son como un sello que dice «HTML ya revisado»; no lo revisan. Mantén la entrada en variables de la plantilla y deja actuar a autoescape, o usa format_html para un fragmento pequeño de Django.
Conceptos Clave y Términos
Jinja2 autoescape- Flask activa el autoescape de Jinja2 para plantillas
.html,.htm,.xmly.xhtmly pararender_template_string, así que{{ q }}se convierte en<img ...>. Unjinja2.Environment()independiente tiene el autoescape desactivado salvo que pasesautoescape=select_autoescape(). Markup / |safe / mark_safe- Los tres marcan una cadena como HTML ya seguro, y la plantilla deja de escaparla. Son promesas del desarrollador, no sanitizadores:
Markup(user_input)omark_safe(user_input)publica lo que el usuario escribió. render_template_string(f"...")- Una f-string mete el valor en el código de la plantilla antes de que Jinja2 lo analice, así que el autoescape nunca lo ve como variable. La misma línea es además una inyección de plantillas en el servidor: un valor como
{{ 7*7 }}lo evalúa Jinja2. django.utils.html.format_html- Construye HTML como
str.format, pero antes escapa cada argumento conconditional_escapey luego marca el resultado como seguro. Es la forma que aprueba Django para construir un pequeño fragmento HTML en Python en lugar demark_safe(f"..."). nh3.clean()- El texto enriquecido necesita un sanitizador, no escape.
nh3.clean()(el ammonia de Rust) mantiene una lista permitida de etiquetas y atributos y elimina<script>, manejadores de eventos y URLsjavascript:. Bleach está obsoleto; nh3 es su sucesor mantenido.
Mapeos de marcos
- Referencia CWE para la debilidad descrita en esta guía: CWE-79
- OWASP Top 10:2025 — A05:2025 Injection
Flujo de Ataque Paso a Paso
La entrada llega en una petición
Una caja de búsqueda, un formulario de perfil o un parámetro de enlace envía q, bio o next al servidor. A la víctima se le puede mandar un enlace preparado, o el valor se guarda y lo ve todo el que visita el perfil.
El valor se salta el escape
Una f-string lo coloca en el código de la plantilla, Markup() o mark_safe() lo marcan como de confianza, o acaba en un href, donde escapar < y > no basta. El valor de prueba inofensivo <img src=x onerror=alert(1)> pasa intacto.
El navegador lo interpreta como marcado
La respuesta contiene <img src=x onerror=alert(1)> como un elemento real. Si el recurso de imagen no se carga, se dispara onerror, y una URL javascript: en el enlace se ejecuta al hacer clic.
El script se ejecuta como si fuera el sitio
En las rutas de Flask/Jinja y Django mostradas, el marcado emitido se ejecuta bajo el origen de la aplicación. Mantén los valores no confiables en posiciones de datos con escape automático y permite HTML solo después de una sanitización adecuada al contexto.
Código Fuente: Vulnerable vs. Seguro
# views.py: cada handler de abajo deja que texto de la petición llegue a la página como 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 construye el código de la plantilla, así que Jinja2 nunca escapa q
@app.get("/search")
def search():
q = request.args.get("q", "")
return render_template_string(f"<h1>Results for {q}</h1>")
# Markup() le dice a Jinja2 que la bio es HTML de confianza
@app.get("/profile")
def profile():
bio = request.args.get("bio", "")
return render_template_string("<div class='bio'>{{ bio }}</div>", bio=Markup(bio))
# mark_safe sobre una f-string publica name como HTML
def greeting(name):
return mark_safe(f"<p>Hello, {name}</p>")
# el autoescape no detiene una URL javascript: en 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: los valores siguen siendo variables de plantilla; el texto enriquecido se sanitiza
import nh3
from django.utils.html import format_html
from flask import Flask, render_template_string, request
app = Flask(__name__)
# q es una variable de plantilla, así que el autoescape la codifica
@app.get("/search")
def search():
q = request.args.get("q", "")
return render_template_string("<h1>Results for {{ q }}</h1>", q=q)
# nh3 aplica una lista permitida de etiquetas antes de confiar en el 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 escapa cada argumento antes de marcar el resultado como seguro
def greeting(name):
return format_html("<p>Hello, {}</p>", name)
# solo se aceptan rutas del propio sitio como destino del enlace
@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)
Lista de Verificación de Seguridad para Ingeniería
- ✓Haz fallar la CI con Bandit B703/B308 (
mark_safe) y B704 (Markupsobre no literales), y con cualquierrender_template_stringcuyo primer argumento sea una f-string, un%o el resultado de.format(). - ✓Pasa los valores a las plantillas como variables y deja que el autoescape se encargue; con Jinja2 independiente, crea
Environment(autoescape=select_autoescape()). - ✓Usa
format_html()en lugar demark_safe()para fragmentos pequeños, ynh3.clean()para cualquier HTML aportado por usuarios. - ✓Valida toda URL controlada por el usuario antes de que llegue a
hrefosrc: acepta solo rutas del propio sitio que empiecen por una única/, o una lista permitida explícita de http/https. - ✓Envía una Content-Security-Policy con
script-srcbasado en nonce y sin'unsafe-inline', y marca las cookies de sesión comoHttpOnlyySameSite=Lax.
Fuentes
- 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