FlawOpen

flawopen.com/xss/Python

● CWE-79 · Alta
Pesquisa · FlawOpen

Cross-Site Scripting (XSS) em Python (Flask, Jinja2, Django)

Envolver a entrada do usuário em Markup, mark_safe ou numa f-string passada a render_template_string faz o texto de um visitante rodar como JavaScript no navegador de outros usuários, com a sessão deles (CWE-79). Onde Flask, Jinja2 e Django escapam por você, os quatro pontos em que não escapam e como permitir texto rico com segurança.

💡 Explicação em Linguagem Simples (ELI5)

Imagine uma gráfica com um formulário protegido: o texto escrito no campo em branco é impresso como texto comum. Mas, se um funcionário colar o recado de um cliente na folha de instruções da impressora antes de iniciar o trabalho, o recado pode virar um comando de impressão. Uma f-string Python enviada a render_template_string faz isso: insere a entrada no código-fonte Jinja2 antes que o autoescape possa tratá-la como variável. Markup e mark_safe são como um carimbo de “HTML já conferido”; eles não conferem nada. Mantenha a entrada em variáveis do template e deixe o autoescape agir, ou use format_html para um pequeno fragmento Django.

Conceitos Centrais e Termos

Jinja2 autoescape
O Flask ativa o autoescape do Jinja2 para templates .html, .htm, .xml e .xhtml e para render_template_string, então {{ q }} vira <img ...>. Um jinja2.Environment() avulso vem com autoescape desligado, a menos que você passe autoescape=select_autoescape().
Markup / |safe / mark_safe
Os três marcam uma string como HTML já seguro, e o template deixa de escapá-la. São promessas do desenvolvedor, não sanitizadores: Markup(user_input) ou mark_safe(user_input) publica o que o usuário digitou.
render_template_string(f"...")
Uma f-string coloca o valor no código do template antes de o Jinja2 analisá-lo, então o autoescape nunca o vê como variável. A mesma linha também é injeção de template no servidor: um valor como {{ 7*7 }} é avaliado pelo Jinja2.
django.utils.html.format_html
Monta HTML como str.format, mas antes escapa cada argumento com conditional_escape e só então marca o resultado como seguro. É a forma aprovada pelo Django de montar um pequeno fragmento HTML em Python, no lugar de mark_safe(f"...").
nh3.clean()
Texto rico precisa de sanitizador, não de escape. nh3.clean() (o ammonia do Rust) mantém uma allowlist de tags e atributos e remove <script>, manipuladores de eventos e URLs javascript:. O Bleach está descontinuado; o nh3 é seu sucessor mantido.

Mapeamentos de frameworks

Fluxo de Ataque Passo a Paso

Passo 1

A entrada chega numa requisição

Uma caixa de busca, um formulário de perfil ou um parâmetro de link envia q, bio ou next ao servidor. A vítima pode receber um link preparado, ou o valor fica salvo e aparece para todos que veem o perfil.

Passo 2

O valor escapa do escape

Uma f-string o coloca no código do template, Markup() ou mark_safe() o marcam como confiável, ou ele cai num href, onde escapar < e > não basta. O valor de teste inofensivo <img src=x onerror=alert(1)> passa intacto.

Passo 3

O navegador o interpreta como marcação

A resposta contém <img src=x onerror=alert(1)> como um elemento real. Se o recurso da imagem não carregar, onerror será disparado, e uma URL javascript: no link roda quando clicada.

Passo 4

O script roda como se fosse o site

Nos caminhos Flask/Jinja e Django mostrados, a marcação emitida é executada na origem da aplicação. Mantenha valores não confiáveis em posições de dados com escape automático e permita HTML apenas após sanitização adequada ao contexto.

Código-Fonte: Vulnerável vs. Seguro

IMPLEMENTAÇÃO VULNERÁVEL
# views.py: cada handler abaixo deixa texto da requisição chegar à 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__)


# a f-string monta o código do template, então o 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() diz ao Jinja2 que a bio é HTML confiável
@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 uma f-string publica name como HTML
def greeting(name):
    return mark_safe(f"<p>Hello, {name}</p>")


# o autoescape não impede uma URL javascript: no href
@app.get("/continue")
def continue_link():
    url = request.args.get("next", "/")
    return render_template_string('<a href="{{ url }}">Continue</a>', url=url)
PATCH SEGURO E ROBUSTO
# views.py: valores continuam variáveis de template; texto rico é sanitizado
import nh3
from django.utils.html import format_html
from flask import Flask, render_template_string, request

app = Flask(__name__)


# q é variável de template, então o autoescape a codifica
@app.get("/search")
def search():
    q = request.args.get("q", "")
    return render_template_string("<h1>Results for {{ q }}</h1>", q=q)


# o nh3 aplica uma allowlist de tags antes de o HTML ser confiado
@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 o resultado como seguro
def greeting(name):
    return format_html("<p>Hello, {}</p>", name)


# só caminhos do próprio site são aceitos como destino do link
@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 Verificação de Segurança para Engenharia

Fontes