FlawOpen

flawopen.com/xss/Python

● CWE-79 · Alta
Investigación · FlawOpen

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.

💡 Explicación en Lenguaje Sencillo (ELI5)

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, .xml y .xhtml y para render_template_string, así que {{ q }} se convierte en <img ...>. Un jinja2.Environment() independiente tiene el autoescape desactivado salvo que pases autoescape=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) o mark_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 con conditional_escape y luego marca el resultado como seguro. Es la forma que aprueba Django para construir un pequeño fragmento HTML en Python en lugar de mark_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 URLs javascript:. Bleach está obsoleto; nh3 es su sucesor mantenido.

Mapeos de marcos

Flujo de Ataque Paso a Paso

Paso 1

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.

Paso 2

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.

Paso 3

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.

Paso 4

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

IMPLEMENTACIÓN VULNERABLE
# 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)
PARCHE SEGURO Y ROBUSTO
# 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

Fuentes