SQL Injection: Como Atacantes Quebram Seu Banco de Dados e Como o ORM do Django os Impede
Django Security Series — Post 1 | Série I: Ataques de Injeção
OWASP A03:2021 — Injection | Tempo de leitura: ~12 min
A Django Security Series é o meu projeto contínuo de documentar o que aprendo sobre segurança de aplicações — mapeando os ataques web mais perigosos para sua prevenção concreta em Python e Django. Cada post é fundamentado em incidentes reais, código real e no OWASP Top 10: escrevo para entender e publico para que você possa percorrer o mesmo caminho. As técnicas de ataque são mapeadas para táticas e técnicas do MITRE ATT&CK, as orientações de prevenção são ancoradas na documentação oficial de segurança do Django e cada post se apoia em dois livros de referência para profissionais: Web Security for Developers (Malcolm McDonald) e Secure Web Application Development with Python and Django (Matthew Baker, Apress).
SQL Injection é por onde começamos. Está presente em todas as edições do OWASP Top 10 desde que a lista foi criada em 2003 e no relatório de 2021, 94% das aplicações foram testadas para alguma forma de injeção — tornando-a a categoria mais amplamente avaliada do conjunto de dados. A taxa de incidência média entre essas aplicações foi de 3,37%, com picos chegando a 19%. Duas décadas de vazamentos públicos, frameworks e educação não eliminaram o problema — porque o mal-entendido não é sobre ignorância do ataque, mas sobre uma falsa sensação de segurança por usar um ORM.
O ORM do Django protege por padrão. Mas desenvolvedores que contornam esse padrão — via raw(), extra(), formatação de string ou integrações de terceiros — abrem uma brecha que atacantes encontram de forma confiável há tanto tempo quanto aplicações web existem. Entender onde a proteção do ORM termina é tão importante quanto saber que ela existe.
Neste post, vamos ver como o SQL Injection realmente acontece no nível da query, por que a parametrização nativa do Django bate o escaping manual de longe, e os hábitos de código que podem silenciosamente desativar essa proteção.
O Ataque: O Que É e Como Funciona
SQL Injection ocorre quando um atacante insere sintaxe SQL em um campo de entrada que a aplicação web embute diretamente em uma consulta ao banco de dados — sem a devida separação entre código e dados.
A causa raiz quase sempre se resume a um erro básico: tratar a entrada do usuário como código executável em vez de dado bruto. O banco de dados não consegue distinguir entre o SQL pretendido pelo desenvolvedor e o payload injetado pelo atacante.
Como Funciona — Passo a Passo
Considere uma view de login que verifica credenciais assim:
SELECT * FROM users
WHERE username = '[ENTRADA DO USUÁRIO]'
AND password = '[ENTRADA DO USUÁRIO]'
Um atacante digita admin' -- como nome de usuário. A aspas simples fecha o literal de string, e -- comenta o restante da consulta. O banco executa:
SELECT * FROM users
WHERE username = 'admin' --' AND password = ''
A verificação de senha está comentada. Se existir um usuário chamado admin, o atacante entra — sem saber a senha.
Técnicas mais avançadas:
| Técnica | O que faz |
|---|---|
| Tautologia | ' OR '1'='1 — torna a cláusula WHERE sempre verdadeira |
| Injeção UNION | Acrescenta um segundo SELECT para extrair dados de outras tabelas |
| Blind SQLi (booleana) | Infere dados com perguntas verdadeiro/falso: AND 1=1 -- vs AND 1=2 -- |
| Blind SQLi (baseada em tempo) | Usa pg_sleep() / SLEEP() para extrair dados pela demora na resposta |
| Consultas empilhadas | Acrescenta um segundo comando: '; DROP TABLE users; -- |
Incidentes Reais
Heartland Payment Systems (2008)
Em 2008, Albert Gonzalez e dois cúmplices invadiram a Heartland Payment Systems — uma empresa que processava mais de 100 milhões de transações de cartão por mês para 250.000 estabelecimentos nos Estados Unidos. Os invasores usaram SQL injection para comprometer a aplicação web interna da Heartland e instalaram malware de captura de rede para interceptar dados de cartão em texto puro. Quando investigadores forenses identificaram a violação, aproximadamente 130 milhões de números de cartão de crédito e débito já haviam sido roubados — a maior violação de cartão de pagamento já registrada na época. A parte absurda da violação da Heartland não foi algum zero-day sofisticado de estado-nação. Foi um SQL injection básico em um formulário web — a vulnerabilidade que ensinamos os desenvolvedores a evitar nos primeiros tutoriais de desenvolvimento web.
A Heartland reportou prejuízos de quase US$ 130 milhões e chegou a acordos separados com Visa, American Express e instituições financeiras emissoras. Gonzalez foi condenado a 20 anos de prisão federal com base no Computer Fraud and Abuse Act (CFAA). Em termos do MITRE ATT&CK, o acesso inicial se enquadra em T1190 (Explorar Aplicação Voltada ao Público); a pós-exploração incluiu coleta de credenciais e movimentação lateral para alcançar a infraestrutura de processamento de cartões. A empresa foi removida da lista de Prestadores de Serviços Validados PCI DSS da Visa e a violação impulsionou os requisitos mais rigorosos de testes de penetração do PCI DSS v2.0. Para desenvolvedores Django, a lição é direta: uma vulnerabilidade de SQL injection pode desencadear processos criminais com base no CFAA, litígios civis com múltiplas partes e perda de certificação de processamento de pagamentos. Uma consulta parametrizada não é uma otimização de desempenho — é uma obrigação legal.
Fonte: Heartland Payment Systems: Lessons Learned from a Data Breach
Proteções Padrão do Django
O ORM do Django parametriza todas as consultas que gera. Essa é a proteção central.
Em Web Security for Developers (Capítulo 6: Injection Attacks), Malcolm McDonald define uma consulta parametrizada como aquela em que o código SQL e os valores dos dados são enviados ao banco de dados por canais completamente separados. O template da query é compilado primeiro — o banco analisa sua estrutura e monta um plano de execução — e os valores dos dados são vinculados depois. Como o banco já decidiu o que é código e o que é dado antes de os valores chegarem, é estruturalmente impossível que a entrada do usuário altere a lógica da query, independentemente dos caracteres que ela contenha. McDonald contrasta essa abordagem com as técnicas de construção de strings (concatenação, f-strings, formatação com %), nas quais código e dado compartilham um único canal e o banco não consegue distinguir um do outro. Ele descreve a parametrização não como uma técnica de sanitização, mas como uma separação arquitetural — a causa raiz do SQL Injection é removida, não remendada.
Essa distinção é importante. Escapar caracteres especiais é uma mitigação; a parametrização elimina a classe de vulnerabilidade por completo ao tornar a superfície de ataque estruturalmente inalcançável.
Quando você escreve:
Post.objects.filter(title=user_input)
Django gera:
SELECT * FROM blog_post WHERE title = %s
…e passa user_input como um parâmetro separado para o driver do banco de dados (psycopg3 para PostgreSQL, sqlite3 para SQLite). O driver transmite a estrutura da consulta e os dados de forma independente. O banco de dados processa user_input como um valor literal de string, nunca como sintaxe SQL.
Um payload como ' OR '1'='1 é tratado como a string de busca ' OR '1'='1 — não como lógica SQL. O ataque falha estruturalmente, independentemente dos caracteres que a entrada contenha.
Essa proteção não é obtida por escapamento ou filtragem. Ela funciona porque o SQL e os dados nunca compartilham o mesmo canal. A parametrização não apenas filtra caracteres maliciosos; ela separa completamente os dados da estrutura da query. É por isso que é muito superior a qualquer abordagem de escaping manual.
Padrão Vulnerável: O Que NÃO Fazer
# INSEGURO — Nunca construa SQL com formatação de string
from django.db import connection
def search_posts(request):
query = request.GET.get('q', '')
# PERIGO: f-string embute a entrada do usuário diretamente no SQL
# Ainda vejo isso em code reviews com frequência alta demais.
sql = f"SELECT id, title, slug FROM blog_post WHERE title LIKE '%{query}%'"
with connection.cursor() as cursor:
cursor.execute(sql) # o atacante controla a estrutura do SQL
rows = cursor.fetchall()
return render(request, 'search.html', {'results': rows})
Payload de ataque: q=%' UNION SELECT id, password, email FROM auth_user LIMIT 10 --
A consulta resultante despeja até 10 linhas da tabela de usuários. O atacante agora tem senhas com hash e endereços de e-mail.
A mesma vulnerabilidade aparece em todos esses padrões:
# Concatenação de string — igualmente perigosa
sql = "SELECT * FROM blog_post WHERE slug = '" + slug + "'"
# Formatação com % — NÃO é parametrização, ainda vulnerável
sql = "SELECT * FROM blog_post WHERE slug = '%s'" % slug
# .format() — também vulnerável
sql = "SELECT * FROM blog_post WHERE slug = '{}'".format(slug)
# ORM.extra() com entrada não sanitizada — obsoleto E perigoso
Post.objects.extra(where=[f"title = '{user_input}'"])
# ORM.raw() com formatação de string — perigoso
Post.objects.raw(f"SELECT * FROM blog_post WHERE slug = '{slug}'")
O que todos esses padrões têm em comum — e por que diferem da parametrização
Todo padrão acima tem o mesmo problema de raiz: todos constroem uma única string que mistura sintaxe SQL com dados fornecidos pelo usuário antes de enviá-la ao banco de dados. Concatenação de string, f-strings, formatação com % e .format() são apenas sintaxes Python diferentes para o mesmo erro — produzem uma string SQL final em que código e dado já estão fundidos. Quando essa string chega ao banco, o parser não consegue distinguir quais caracteres foram escritos pelo desenvolvedor e quais foram fornecidos pelo usuário. Uma aspas, um marcador de comentário ou a palavra-chave UNION na entrada é idêntica aos mesmos caracteres no seu próprio SQL.
A parametrização funciona de forma diferente em cada etapa:
- O template SQL é enviado primeiro, sem nenhum dado do usuário. O driver do banco transmite uma query como
WHERE slug = %s— uma estrutura com placeholders tipados, não valores preenchidos. O banco faz o parse dessa estrutura e monta o plano de execução antes de ter visto qualquer entrada do usuário. - Os valores são enviados separadamente, por um segundo canal. O driver transmite então os valores dos parâmetros como dados tipados. O banco os vincula aos placeholders que já processou — mas nesse ponto, o parse já terminou. Não existe etapa em que a entrada do usuário possa influenciar a estrutura da query, pois ela foi finalizada antes de os dados chegarem.
- O mecanismo do banco impõe a fronteira. Mesmo que um valor contenha
' OR '1'='1,--ou um comandoUNION SELECTcompleto, o mecanismo trata o valor inteiro como um escalar — uma string a ser comparada, não sintaxe a ser executada.
A distinção é mais importante para a confusão com %s: o operador % do Python ("WHERE slug = '%s'" % slug) e cursor.execute("WHERE slug = %s", [slug]) se parecem superficialmente, mas são estruturalmente opostos. O operador % produz uma string finalizada em Python — o banco recebe um único canal. cursor.execute() com lista de parâmetros transmite template e dados por canais separados no nível do protocolo — o banco recebe dois. Usar formatação com % em uma string SQL bruta antes de passá-la para execute() é exatamente tão perigoso quanto um f-string.
Implementação Segura: O Jeito Django
Preferido: Querysets do ORM
Para a grande maioria dos casos de uso, o ORM cuida de tudo com segurança:
# SEGURO — o ORM parametriza automaticamente
def search_posts(request):
query = request.GET.get('q', '').strip()
results = Post.objects.filter(
title__icontains=query,
publish=True,
language='pt',
).order_by('-created')[:20]
return render(request, 'search.html', {'results': results})
icontains se traduz em ILIKE %s (sem distinção de maiúsculas/minúsculas; LIKE %s em bancos sem suporte a ILIKE). Vale ser preciso sobre o que acontece com os curingas %, porque esse é um ponto fácil de confundir com o padrão vulnerável.
O Django não gera ILIKE '%{query}%' — isso seria idêntico à vulnerabilidade de f-string que acabamos de ver. Em vez disso, ele gera ILIKE %s como template SQL e passa %query% como o valor do parâmetro. Os curingas são adicionados ao dado, não ao SQL. O banco recebe uma estrutura de query contendo um único placeholder tipado, e separadamente recebe a string %query% para vincular a ele. Mesmo que query contenha uma aspas, um comentário ou UNION SELECT, esse conteúdo faz parte do valor vinculado — a estrutura da query já foi finalizada antes de o valor chegar.
Quando SQL Bruto é Necessário: Placeholders Parametrizados
Ocasionalmente você precisa de SQL bruto — para consultas complexas, recursos específicos do banco ou busca full-text. Quando isso ocorrer, sempre use placeholders %s e passe os valores como uma lista separada — nunca os interpole na string SQL.
O %s em uma chamada cursor.execute() não é formatação de string do Python. É um sinal ao driver do banco de dados de que um parâmetro chegará pelo segundo argumento. O driver transmite o template SQL e a lista de parâmetros de forma independente pelo protocolo de comunicação com o banco. O mecanismo do banco os recebe em campos separados do protocolo, faz o parse da estrutura da query primeiro e vincula os valores depois — a mesma garantia de dois canais que o ORM fornece automaticamente. A única diferença é que aqui você está escrevendo o template SQL manualmente, então a responsabilidade de manter código e dados separados recai sobre você.
# SEGURO — SQL bruto com placeholders parametrizados
from django.db import connection
def full_text_search(query):
with connection.cursor() as cursor:
cursor.execute(
"""
SELECT id, title, slug,
ts_rank(to_tsvector('portuguese', body), plainto_tsquery(%s)) AS rank
FROM blog_post
WHERE to_tsvector('portuguese', body) @@ plainto_tsquery(%s)
AND publish = TRUE
ORDER BY rank DESC
LIMIT 20
""",
[query, query] # ← parâmetros passados separadamente, nunca interpolados
)
return cursor.fetchall()
RawSQL() para Anotações do ORM
# SEGURO — entrada do usuário passada via lista de parâmetros
from django.db.models.expressions import RawSQL
Post.objects.annotate(
rank=RawSQL(
"ts_rank(to_tsvector('portuguese', body), plainto_tsquery(%s))",
[user_search_term] # ← parametrizado
)
).filter(publish=True)
Quando Usar raw() — e Como Usá-lo com Segurança
raw() deve ser um último recurso. O ORM deve lidar com a grande maioria das queries, e cursor.execute() cobre qualquer coisa que precise de resultados fora de um model. raw() ocupa uma zona intermediária estreita: você precisa escrever SQL diretamente, mas também quer de volta um queryset de instâncias do model em vez de tuplas brutas. Motivos legítimos comuns:
- Recursos específicos do banco que o ORM não consegue expressar — window functions do PostgreSQL, CTEs, lateral joins, operadores JSONB
- Busca full-text com ranking —
ts_rankeplainto_tsquerycomo no exemplo acima - Queries em que o ORM gera um plano ineficiente e você precisa controlar o SQL exato após profiling
As formas segura e insegura seguem exatamente a mesma lógica de cursor.execute():
# INSEGURO — formatação de string produz uma string SQL finalizada (canal único)
Post.objects.raw(f"SELECT ... WHERE slug = '{slug}'")
# SEGURO — template SQL + lista de parâmetros (dois canais)
Post.objects.raw(
"SELECT id, title, slug FROM blog_post WHERE slug = %s AND publish = TRUE",
[slug] # ← passado separadamente, nunca interpolado na string SQL
)
Na forma insegura, slug é incorporado na string antes de raw() vê-la — o banco recebe um único canal, e o mesmo risco de injeção de um f-string se aplica. Na forma segura, o driver transmite o template SQL e a lista de parâmetros de forma independente; o banco já fez o parse da estrutura da query antes de o valor chegar.
Use cada abordagem raw nas seguintes situações:
| Use | Quando |
|---|---|
Post.objects.raw(sql, [params]) |
Precisa de um queryset de instâncias do model mas a query é complexa demais para o ORM |
cursor.execute(sql, [params]) |
Precisa de dados fora de um model: agregações, resultados entre tabelas ou DDL durante migrations |
RawSQL(sql, [params]) em .annotate() |
Precisa de uma expressão SQL customizada dentro de uma query ORM |
| Querysets do ORM | Todo o resto — sempre o padrão |
extra() — Quando Você o Encontra e Por Que Substituí-lo
extra() era a válvula de escape original do Django para queries que o ORM não conseguia expressar — injetando fragmentos SQL brutos na cláusula WHERE, na lista SELECT ou no ORDER BY de um queryset ORM. Você o encontrará em codebases mais antigas filtrando por colunas computadas pelo banco, adicionando predicados específicos do banco, ou contornando limitações de versões antigas do Django.
Obsoleto. O time do Django declara explicitamente que não está mais melhorando nem corrigindo bugs em
extra()e que ele está direcionado para remoção futura. Construir sobre ele significa quebras sem aviso em uma versão futura do Django.
Todo caso de uso que extra() cobre tem uma alternativa suportada:
# Antes — obsoleto
Post.objects.extra(where=["slug = %s"], params=[slug])
# Depois
Post.objects.filter(slug=slug) # ORM (preferido)
Post.objects.raw("SELECT ... WHERE slug = %s", [slug]) # raw() para queries complexas
Post.objects.annotate(val=RawSQL("...", [param])) # RawSQL() para colunas computadas
Substitua qualquer chamada extra() que encontrar. Se precisar tocar em uma antes de migrá-la, nunca embuta valores diretamente na string where — sempre use placeholders %s com uma lista params separada.
Resumo das Regras
| Faça | Não Faça |
|---|---|
Post.objects.filter(title=value) |
f"SELECT ... WHERE title = '{value}'" |
cursor.execute("... WHERE id = %s", [value]) |
cursor.execute("... WHERE id = " + value) |
RawSQL("... %s ...", [value]) |
Post.objects.extra(where=[f"... '{value}'"]) |
Princípio do Menor Privilégio
A parametrização é sua defesa primária, mas Malcolm McDonald faz um ponto importante em Web Security for Developers (Capítulo 6): defesa em profundidade significa proteger cada camada da pilha, não apenas a construção da query.
Para SQL injection especificamente, isso significa que a conta de banco de dados usada pela sua aplicação Django em tempo de execução deve ter apenas as permissões de que realmente precisa — tipicamente SELECT, INSERT, UPDATE e DELETE (o subconjunto DML do SQL). Ela não deve ter privilégios de CREATE, DROP ou ALTER (DDL). Se um atacante conseguir contornar a parametrização, uma conta com menor privilégio limita o raio de impacto: ele não consegue apagar tabelas ou modificar o schema, apenas operar dentro dos dados que já pode acessar.
Na prática para um projeto Django:
-- PostgreSQL: criar um usuário restrito para execução
CREATE USER blogtech_app WITH PASSWORD 'senhaforte';
GRANT CONNECT ON DATABASE blogtech TO blogtech_app;
GRANT USAGE ON SCHEMA public TO blogtech_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO blogtech_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO blogtech_app;
-- Sem CREATE, DROP ou ALTER
Execute as migrations com uma conta privilegiada separada; deixe a aplicação usar a conta restrita em tempo de execução.
Validação de Entrada: A Terceira Camada
A parametrização é a defesa estrutural contra SQL injection. O menor privilégio limita o raio de impacto. A validação de entrada adiciona uma terceira camada: rejeitar dados malformados antes mesmo de chegarem ao banco de dados.
Essa não é uma substituta da parametrização — payloads de SQLi bem elaborados são especificamente projetados para contornar filtros de validação. É defesa em profundidade.
Coerção de tipo via conversores de URL
A coerção de tipo é a forma mais forte de validação de entrada contra SQL injection porque elimina completamente a superfície de ataque para uma determinada entrada. SQL injection exige que o atacante forneça caracteres como ', --, espaços e palavras-chave como UNION ou SELECT. Um valor restrito a conter apenas dígitos decimais — pelo sistema de tipos, não por um filtro — não pode conter nenhum desses caracteres. Não há nada para injetar.
Os conversores de URL do Django aplicam essa restrição na camada de roteamento, antes mesmo de a view ser chamada:
# urls.py — <int:pk> rejeita qualquer coisa que não seja um inteiro válido
path('blog/<int:pk>/', views.post_detail),
Se o segmento da URL não for uma sequência de dígitos, o Django retorna 404 e a view nunca executa. O valor pk que chega em post_detail já é um int Python — não uma string, não dados brutos da requisição. Não existe vetor de SQL injection aqui porque não há string para injetar.
Para IDs recebidos como parâmetros de query (?id=42), o conversor de URL não se aplica, então imponha o tipo explicitamente na view:
def post_by_id(request):
try:
pk = int(request.GET.get('id', ''))
except (ValueError, TypeError):
raise Http404
post = get_object_or_404(Post, pk=pk, publish=True)
return render(request, 'blog/post_detail.html', {'post': post})
int() lança ValueError para qualquer entrada que não seja um inteiro válido — ' OR '1'='1, 1; DROP TABLE ou mesmo 1.5 falham antes de tocar o banco. Se o parâmetro id estiver ausente da query string, request.GET.get('id', '') retorna '', e int('') também lança ValueError — a view retorna 404, que é o comportamento correto. O ORM então recebe um inteiro Python puro e o parametriza com segurança. Duas camadas independentes precisam falhar ao mesmo tempo para que qualquer exploit chegue ao banco de dados.
Django forms para entradas de texto livre
Para campos de busca e outras entradas de texto livre, não é possível eliminar a superfície de ataque da forma que a coerção de tipo faz para inteiros — uma query de busca é uma string e precisa aceitar qualquer caractere. Aqui a defesa está em garantir que a view nunca opere diretamente sobre dados brutos da requisição.
O framework de forms do Django fornece um pipeline de limpeza que já está embutido no framework — você não o implementa, apenas declara o que espera e o Django aplica. Ao chamar form.is_valid(), o Django passa cada campo pelos seus validadores e coerções de tipo embutidos em sequência. Somente se todos os campos passarem é que cleaned_data fica disponível. cleaned_data sempre contém valores Python normalizados, nunca strings brutas de request.GET ou request.POST.
Para um campo de busca:
from django import forms
class SearchForm(forms.Form):
q = forms.CharField(max_length=200, required=False, empty_value='')
def search_posts(request):
form = SearchForm(request.GET)
if not form.is_valid():
return render(request, 'search.html', {'results': []})
query = form.cleaned_data['q'] # ← str Python normalizado, não dado bruto da requisição
results = Post.objects.filter(title__icontains=query, publish=True)
return render(request, 'search.html', {'results': results})
forms.CharField faz o seguinte automaticamente: remove espaços no início e no fim, converte o valor para str Python, aplica max_length, e substitui um valor vazio ou ausente pelo empty_value declarado ('' aqui). O ORM recebe então uma string Python limpa e a parametriza — nenhum dado bruto da requisição toca a query. O form não substitui a parametrização; é uma barreira que garante que você sempre trabalha com um valor normalizado ao chegar na chamada ao ORM.
Defesa em Profundidade: O Quadro Completo
Essas três camadas são independentes — cada uma se sustenta mesmo que as outras sejam contornadas. Juntas, cobrem todo o caminho do ataque, da entrada do usuário ao banco de dados:
| Camada de defesa | O que protege |
|---|---|
| Parametrização | SQL injection — SQL e dados nunca compartilham o mesmo canal |
| Validação de entrada (coerção de tipo + limpeza de form) | Rejeita valores malformados ou ausentes antes de chegarem ao ORM — entradas numéricas eliminam completamente a superfície de injeção |
| Menor privilégio | Previne danos catastróficos ao schema se algum bypass for encontrado |
Testando Sua Defesa
Teste Unitário
# blog/tests.py
from django.test import TestCase, Client
from blog.models import Post
class SQLInjectionTests(TestCase):
def setUp(self):
Post.objects.create(
title="Post Normal",
slug="post-normal",
body="Conteúdo",
publish=True,
language='pt',
)
def test_payload_tautologia_nao_retorna_resultado(self):
"""A string de injeção é tratada como literal — não como lógica SQL."""
payload = "' OR '1'='1"
self.assertEqual(Post.objects.filter(title=payload).count(), 0)
def test_payload_union_nao_vaza_dados_via_view(self):
"""Um payload UNION não deve fazer linhas de blog_post aparecerem nos resultados de busca.
O payload injeta um UNION contra a própria blog_post, então se a injeção
fosse interpretada como SQL retornaria todas as linhas — incluindo o post do setUp.
O ORM trata a string inteira como termo de busca literal, então nenhuma linha corresponde."""
client = Client()
payload = "x' UNION SELECT id, title, slug FROM blog_post--"
response = client.get('/pt/blog/', {'q': payload})
self.assertEqual(response.status_code, 200)
# O payload aponta para blog_post diretamente via UNION, então se fosse interpretado
# como SQL, 'post-normal' apareceria nos resultados. Sua ausência prova que a
# parametrização tratou o payload inteiro como string literal, não como sintaxe SQL.
result_slugs = [p.slug for p in response.context['results']]
self.assertNotIn('post-normal', result_slugs)
def test_view_trata_injecao_com_seguranca(self):
"""HTTP 200 sozinho não prova nada — uma view vulnerável também retorna 200.
Uma tautologia como ' OR '1'='1 corresponderia a todas as linhas se interpretada como SQL.
Confirme que foi tratada como literal: o post do setUp não deve aparecer nos resultados."""
client = Client()
response = client.get('/pt/blog/', {'q': "' OR '1'='1"})
self.assertEqual(response.status_code, 200)
# Se a tautologia tivesse sido interpretada como SQL, todos os posts corresponderiam —
# incluindo o criado no setUp. Sua ausência prova que a entrada foi tratada
# como string literal, não como lógica SQL.
result_slugs = [p.slug for p in response.context['results']]
self.assertNotIn('post-normal', result_slugs)
Verificação Manual
python manage.py shell
>>> from blog.models import Post
>>> Post.objects.filter(title="' OR '1'='1").count()
0 # correto: tratado como string literal, não como SQL
Varredura Automatizada
SQLMap é a ferramenta padrão da indústria para detectar SQL injection. Execute-a apenas em ambiente de homologação:
# Varredura básica de um endpoint de busca
sqlmap -u "https://homolog.exemplo.com.br/blog/?q=teste" --level=2 --risk=1
# Uma aplicação Django corretamente parametrizada produz:
# [INFO] GET parameter 'q' does not appear to be injectable
No fundo, SQL Injection não é magia negra — é só o que acontece quando ficamos desleixados e deixamos os dados se misturarem às nossas strings de query. Use o ORM, parametrize suas queries raw, e você fecha a porta para toda essa classe de ataques. O ORM do Django elimina esse risco por design, mas apenas enquanto você permanece dentro da sua interface parametrizada. Cada vez que você usa um f-string, formatação com % ou concatenação de string bruta em uma consulta ao banco de dados, você está saindo dessa garantia. A regra é simples: SQL e entrada do usuário nunca devem compartilhar o mesmo canal — e defesa em profundidade significa aplicar as três camadas abordadas aqui: parametrização, validação de entrada e menor privilégio. O Post 2 desta série passa para o lado do navegador: Cross-Site Scripting (XSS), por que mark_safe é tão perigoso quanto uma query não parametrizada, e como renderizar Markdown enviado pelo usuário sem abrir um vetor de XSS.
Leitura Complementar
- Documentação de Segurança do Django — Proteção contra SQL Injection
- Django Docs — Consultas SQL brutas (
cursor.execute,raw()) - Django Docs — Expressão
RawSQL() - OWASP A03:2021 — Injection
- OWASP SQL Injection Prevention Cheat Sheet
- PortSwigger Web Security Academy — SQL Injection
- SQLMap — Ferramenta de SQL Injection Automático
- MITRE ATT&CK — T1190 Exploit Public-Facing Application
- Web Security for Developers: Real Threats, Practical Defense (Malcolm McDonald) — Capítulo 6: Injection Attacks
- Secure Web Application Development: A Hands-On Guide with Python and Django (Matthew Baker, Apress)
Próximo nesta série → Post 2: Cross-Site Scripting (XSS): Ataques Armazenados, Refletidos e Baseados em DOM — e Por Que mark_safe e Markdown Inseguro São Igualmente Perigosos