Path Traversal: Como ../../settings.py Escapa do Seu Diretório de Mídia — e Como o safe_join do Django Impede
Django Security Series — Post 9 | Série II: Broken Access Control
OWASP A01:2021 — Broken Access Control | Tempo de leitura: ~14 min
Os Posts 6 a 8 trataram de falhas de controle de acesso sobre objetos no banco de dados. O atacante trocou um ID para ler a petição de outra pessoa (Post 6), injetou is_staff para se promover (Post 7), ou enganou o navegador da vítima para submeter uma requisição forjada (Post 8). Em cada caso o "recurso" sob ataque era uma instância de modelo, uma linha no PostgreSQL. O Post 9 move a mesma falha para o sistema de arquivos.
Enquanto pesquisava este post, rodei os.path.join('/app/media', '/etc/passwd') num shell Python e recebi /etc/passwd. O diretório base simplesmente sumiu. Eu sempre assumi que o primeiro argumento ancorava o resultado. Não ancora — se qualquer componente subsequente for um caminho absoluto, os.path.join descarta silenciosamente tudo antes dele. Essa sessão no shell mudou como eu penso sobre toda view de servir arquivos que já escrevi. Também fiz um grep no codebase do Petição Brasil e encontrei uma tcc_view que usa os.path.join com um nome de arquivo hardcoded — não é vulnerável hoje, mas fica a um refactor de distância se aquele nome um dia vier de um parâmetro da URL.
Path traversal é classificado sob A01 (Broken Access Control) no OWASP 2021 porque o servidor entrega um arquivo que o usuário nunca foi autorizado a ler. O "objeto" é um arquivo em disco em vez de uma linha no banco, mas a falha é idêntica: a aplicação aceita um identificador da requisição e recupera o recurso sem verificar se ele está dentro do limite permitido. O Django fornece uma utilidade, safe_join, que levanta SuspiciousFileOperation quando o caminho resolvido escapa do diretório base. A vulnerabilidade existe onde um desenvolvedor usa os.path.join em vez dele.
O Ataque: O Que É e Como Funciona
Path traversal explora a forma como sistemas operacionais resolvem segmentos relativos de caminho. Quando um caminho contém ../, o sistema de arquivos se move um diretório acima. Se uma aplicação web constrói um caminho de arquivo concatenando um diretório base com um nome fornecido pelo usuário, e o usuário fornece ../../../etc/passwd em vez de relatorio.pdf, o caminho resultante resolve fora do diretório pretendido. A aplicação abre um arquivo que nunca deveria servir.
A causa raiz é a mesma que percorre cada ataque de injeção nesta série: a aplicação trata input do usuário como dados confiáveis quando deveria tratá-lo como instruções não confiáveis. Na SQL injection (Post 1), o input vira sintaxe SQL. Na injeção de comandos (Post 4), vira sintaxe de shell. Aqui, vira navegação no sistema de arquivos. A sequência ../ não é conteúdo de dados; é uma instrução do sistema de arquivos que muda o contexto do diretório. E os.path.join não se importa. É um concatenador de strings: os.path.join('/app/media', '../../etc/passwd') retorna '/app/media/../../etc/passwd' com os pontos-pontos ainda na string. A função nunca normaliza, nunca resolve, nunca verifica. A resolução acontece depois, no kernel, quando open() é chamado e o SO percorre o caminho. É isso que torna os.path.join perigoso: ele nem olha o que está juntando.
O ataque tem duas variantes principais. Travessia relativa usa sequências ../ para escalar para fora do diretório pretendido. os.path.join as passa adiante intactas, e open() as resolve no nível do SO. Injeção de caminho absoluto é pior: se qualquer componente começa com / ou uma letra de drive (C:\), os.path.join descarta silenciosamente todos os componentes anteriores. os.path.join('/app/media', '/etc/passwd') retorna '/etc/passwd', não '/app/media/etc/passwd'. A documentação do Python diz isso explicitamente, mas surpreende desenvolvedores que assumiram que o diretório base sempre seria prefixado.
Como Atacantes Exploram
O alvo típico é um endpoint de download ou de servir arquivos que pega um nome de arquivo da URL ou de um parâmetro de query: /download/?file=relatorio.pdf ou /media/serve/<filename>. O atacante substitui o nome do arquivo por um payload de travessia: ?file=../../../manage.py, ?file=../../../../etc/passwd, ou ?file=../settings.py. Se a aplicação concatena isso num caminho e abre o resultado sem validação, o atacante lê o que o processo do servidor web tiver permissão para acessar.
Os alvos de alto valor num servidor Django são previsíveis. settings.py contém SECRET_KEY, credenciais de banco de dados, chaves AWS e tokens de API. Ler SECRET_KEY permite ao atacante forjar qualquer artefato produzido por django.core.signing (cookies assinados, tokens com timestamp, links de reset de senha se também tiver o hash da senha do usuário), sequestrar sessões se o projeto usa o backend de sessão signed_cookies, e adulterar o framework de messages baseado em cookies. /etc/passwd confirma nomes de usuário. Arquivos .env guardam credenciais que python-decouple ou django-environ leem na inicialização. No Windows, os alvos equivalentes são web.config, exports de registro, ou qualquer arquivo .env na raiz do projeto.
Em servidores Linux, o atacante também pode mirar /proc/self/environ (as variáveis de ambiente do processo, que frequentemente contêm segredos passados via ambiente) e /proc/self/cmdline (o comando de inicialização). O sistema de arquivos se torna uma superfície de coleta de informações.
MITRE ATT&CK mapeia a travessia em si como T1190 — Exploit Public-Facing Application. Uma vez lendo arquivos, T1083 — File and Directory Discovery cobre a enumeração, T1005 — Data from Local System cobre a leitura, e T1552.001 — Credentials In Files captura a etapa de coleta de credenciais quando a travessia lê settings.py ou .env.
Incidentes Reais
Fortinet FortiOS SSL VPN — CVE-2018-13379 (2018–2020)
Em maio de 2019, a Fortinet publicou o advisory FG-IR-18-384 para a CVE-2018-13379, uma vulnerabilidade de path traversal no portal web do SSL VPN do FortiOS (versões afetadas: FortiOS 6.0.0–6.0.4, 5.6.3–5.6.7, 5.4.6–5.4.12; corrigida em 6.0.5, 5.6.8, 5.4.13). Os pesquisadores da Devcore, Meh Chang e Orange Tsai, publicaram detalhes técnicos e apresentaram o exploit no Black Hat USA 2019 em agosto. A falha estava no handler de arquivo de idioma do portal SSL VPN (/remote/fgt_lang?lang=…): uma requisição HTTP especialmente elaborada permitia que um atacante não autenticado lesse arquivos arbitrários do appliance, incluindo sslvpn_websession, que continha nomes de usuário e senhas em texto plano de usuários VPN ativos.
Apesar da Fortinet liberar patches, a exploração foi ampla. Em novembro de 2020, dois atores separados publicaram dados de exploração em fóruns de hacking: um (“pumpedkicks”) postou 49.577 endereços IP de FortiGate vulneráveis com one-liners de exploit em 19 de novembro, e outro (“arendee2018”) postou dumps de credenciais extraídas de sslvpn_websession por volta de 25 de novembro. A CISA adicionou a CVE-2018-13379 ao seu catálogo de Vulnerabilidades Exploradas Conhecidas. Segundo o advisory conjunto AA21-321A (FBI, CISA, ACSC, NCSC, novembro de 2021), atores patrocinados pelo estado iraniano escanearam e exploraram a vulnerabilidade diretamente (MITRE ATT&CK T1190) para obter acesso inicial a redes governamentais e de infraestrutura crítica. A vulnerabilidade recebeu pontuação 9.8 CRITICAL no CVSSv3 (NVD) — não autenticada, explorável pela rede, sem interação do usuário necessária. Uma única sequência ../ em uma requisição HTTP, não validada pelo servidor, deu aos atacantes as chaves de dezenas de milhares de redes. No Reino Unido, o NCSC orientou organizações com dispositivos Fortinet VPN sem patches a assumir comprometimento, remover os dispositivos de serviço e iniciar resposta a incidentes.
Fonte: NVD — CVE-2018-13379: Fortinet FortiOS Path Traversal (CVSS 9.8)
Proteções Padrão do Django
Pergunte "o Django me protege de path traversal?" e a resposta honesta é: depende de como você serve arquivos.
Se você usa FileField, ImageField e a API de Storage do Django (que é o que deveria fazer), o nome do arquivo nunca é um caminho bruto do usuário. O upload_to do FileField controla o diretório, o backend de storage controla onde o arquivo fica, e a recuperação passa por FieldFile.open() ou default_storage.open(). O usuário nunca fornece um caminho no sistema de arquivos. Ele interage com uma instância de modelo, e o modelo guarda a chave de storage. Este é o padrão IDOR do Post 6 aplicado a arquivos: o "identificador" é uma chave primária ou UUID do banco de dados, não um nome de arquivo, e o queryset é escopado para objetos autorizados. Path traversal na recuperação não pode acontecer porque nenhum caminho fornecido pelo usuário chega ao sistema de arquivos. (Nomes de arquivo enviados são fornecidos pelo usuário e chegam ao storage — o Django adicionou validate_file_name e corrigiu a CVE-2021-31542 exatamente para essa superfície — mas o caminho de recuperação via FileField é seguro.)
O perigo surge quando um desenvolvedor sai da abstração de storage: um endpoint de download customizado que pega um nome de arquivo da URL, uma view que constrói um caminho com os.path.join(MEDIA_ROOT, request.GET['file']), ou uma view de servir arquivos que abre um caminho controlado pelo usuário. É aí que a utilidade dedicada do Django importa.
django.utils._os.safe_join faz o que os.path.join não faz: depois de juntar a base e o componente fornecido pelo usuário, ele resolve o resultado para um caminho absoluto e verifica que ainda começa com a base. Se não começa, se sequências ../ ou um caminho absoluto escaparam do sandbox, ele levanta SuspiciousFileOperation. A verificação é simples e eficaz, mas tem duas ressalvas importantes.
Primeiro, safe_join vive em django.utils._os, um módulo privado (underscore inicial). Não há entrada de referência de API pública, nenhum ciclo de depreciação e nenhuma garantia de compatibilidade retroativa. A primitiva de segurança de caminho mais útil do Django é uma que ele não promete manter. O equivalente público é django.core.files.utils.validate_file_name, que Storage.save() chama internamente para rejeitar .., caminhos absolutos e caracteres separadores de caminho em nomes de arquivo enviados.
Segundo, safe_join usa abspath (normalização léxica), não realpath (resolução de symlinks). Se um symlink dentro de MEDIA_ROOT aponta para um arquivo fora dele, safe_join vê um caminho que lexicamente começa com a base e não levanta nada. A verificação consciente de symlinks é:
from pathlib import Path
base = Path(settings.MEDIA_ROOT).resolve()
candidate = (base / user_filename).resolve()
if not candidate.is_relative_to(base):
raise Http404()
Path.resolve() segue symlinks e normaliza o caminho para sua localização real. is_relative_to() (Python 3.9+) então verifica contenção. Use isso quando MEDIA_ROOT pode conter conteúdo influenciado pelo atacante (arquivos enviados, diretórios criados pelo usuário). Para a maioria dos projetos Django onde o diretório de mídia contém apenas arquivos enviados via FileField, safe_join é suficiente porque o caminho de upload em si não cria symlinks.
from django.utils._os import safe_join
# Isso funciona — o resultado está dentro de MEDIA_ROOT
path = safe_join(settings.MEDIA_ROOT, 'reports/q1.pdf')
# Isso levanta SuspiciousFileOperation — o resultado escapa de MEDIA_ROOT
path = safe_join(settings.MEDIA_ROOT, '../../settings.py')
O tratamento de arquivos estáticos do Django (collectstatic, StaticFilesStorage) e o servidor de desenvolvimento django.views.static.serve ambos usam safe_join internamente. O template loader também restringe buscas de templates a diretórios configurados e levanta TemplateDoesNotExist para caminhos que escapam deles. Mas se você escreve uma view customizada que abre arquivos, nada disso se aplica automaticamente. Você está por conta própria.
O que o Django protege automaticamente:
- FileField / ImageField resolvem arquivos através do backend de storage, não caminhos brutos.
- django.views.static.serve (a view de servir arquivos apenas para dev) usa safe_join.
- Template loaders restringem a resolução a diretórios DIRS e diretórios templates/ das apps.
- SuspiciousFileOperation é uma subclasse de SuspiciousOperation, que o tratamento de erros padrão do Django captura e converte em resposta 400 (prevenindo que a exceção vaze informações de caminho).
O que o Django NÃO protege automaticamente:
- Qualquer view customizada que constrói um caminho de arquivo a partir de input do usuário usando os.path.join, pathlib.Path ou concatenação de strings.
- Qualquer view que passa um nome fornecido pelo usuário para open(), FileResponse() ou Path.read_bytes() sem validação de caminho.
- Comandos de management ou tasks Celery que processam nomes de arquivo fornecidos pelo usuário do banco de dados.
Padrão Vulnerável: O Que NÃO Fazer
Padrão 1 — os.path.join com input do usuário numa view de download
# INSEGURO — path traversal via os.path.join
import os
from django.conf import settings
from django.http import FileResponse, Http404
from django.contrib.auth.decorators import login_required
@login_required
def download_document(request):
filename = request.GET.get('file', '')
filepath = os.path.join(settings.MEDIA_ROOT, 'documents', filename)
if os.path.exists(filepath):
return FileResponse(open(filepath, 'rb'))
raise Http404()
Uma requisição para ?file=../../nomedoprojeto/settings.py produz o caminho MEDIA_ROOT/documents/../../nomedoprojeto/settings.py. Com um MEDIA_ROOT = BASE_DIR / 'media' típico, os dois segmentos ../ sobem de documents/ para media/ para BASE_DIR/, e então descem no pacote do projeto. O SO resolve isso no momento do open(). A verificação os.path.exists passa porque o arquivo existe. O atacante lê SECRET_KEY, credenciais do banco de dados e cada segredo no módulo de settings.
Padrão 2 — Um parâmetro de URL que substitui a base inteiramente
Esse foi o comportamento que me pegou de surpresa:
# INSEGURO — injeção de caminho absoluto
import os
from django.conf import settings
from django.http import FileResponse
def serve_report(request, filename):
# Desenvolvedor assume que MEDIA_ROOT é sempre prefixado
path = os.path.join(settings.MEDIA_ROOT, filename)
return FileResponse(open(path, 'rb'))
Se filename for /etc/passwd, os.path.join('/app/media', '/etc/passwd') retorna /etc/passwd. O diretório base é descartado inteiramente. Testei isso num shell e o resultado genuinamente me surpreendeu:
>>> import os
>>> os.path.join('/app/media', '/etc/passwd')
'/etc/passwd'
O modelo mental do desenvolvedor é "os.path.join sempre constrói dentro do primeiro argumento." A documentação do Python diz o contrário. A função descarta todos os componentes anteriores quando encontra um componente de caminho absoluto.
Padrão 3 — Carregamento de template a partir de input do usuário (Django bloqueia isso)
# PARECE perigoso — mas os template loaders do Django bloqueiam a travessia
from django.template.loader import get_template
from django.http import HttpResponse
def render_email(request):
template_name = request.GET.get('template', 'default.html')
template = get_template(f'emails/{template_name}')
return HttpResponse(template.render({'user': request.user}))
Isso parece exatamente como o Padrão 1: input do usuário controla qual arquivo a aplicação lê. Mas get_template com os loaders padrão de sistema de arquivos e diretórios de apps não resolve para caminhos arbitrários. Três frames abaixo, get_template_sources chama safe_join contra cada entrada DIRS configurada e captura SuspiciousFileOperation. Se o caminho resolvido escapa do diretório de templates, o loader pula. Fornecer template=../../settings.py levanta TemplateDoesNotExist, não uma leitura de arquivo.
Testei isso contra o Django 5.2 para confirmar: get_template('emails/../../../settings.py') levanta TemplateDoesNotExist toda vez, independente de quantas sequências ../ você adicione. A guarda não é uma verificação frágil; é safe_join, a mesma utilidade que este post recomenda para suas próprias views.
A lição vale ser dita mesmo assim: nunca aceite nomes de template do input do usuário. A defesa aqui é um detalhe de implementação dos loaders padrão do Django, não uma garantia que o framework faz para você. Um template loader customizado que pula safe_join, ou um DIRS mal configurado que inclui /, reabriria a superfície. Use um mapeamento de chaves de template permitidas em vez de passar input do usuário para get_template diretamente.
Implementação Segura: O Jeito Django
Regra 1 — Use safe_join para qualquer caminho construído a partir de input do usuário
Substitua os.path.join por django.utils._os.safe_join em todo lugar que input do usuário toca um caminho no sistema de arquivos:
# SEGURO — safe_join levanta SuspiciousFileOperation em travessia
import os
from django.conf import settings
from django.core.exceptions import SuspiciousFileOperation
from django.http import FileResponse, Http404
from django.utils._os import safe_join
from django.contrib.auth.decorators import login_required
@login_required
def download_document(request):
filename = request.GET.get('file', '')
try:
filepath = safe_join(settings.MEDIA_ROOT, 'documents', filename)
except SuspiciousFileOperation:
raise Http404()
if os.path.exists(filepath):
return FileResponse(open(filepath, 'rb'))
raise Http404()
safe_join resolve o caminho completo e verifica que ele permanece dentro do diretório base. Se sequências ../ ou um caminho absoluto escapam do sandbox, ele levanta SuspiciousFileOperation. Capture a exceção e retorne 404 (não 400 com uma mensagem, o que poderia confirmar a tentativa de travessia ao atacante).
Regra 2 — Prefira consultas ao banco de dados em vez de caminhos no sistema de arquivos
A defesa mais forte é remover o nome do arquivo da URL inteiramente. Em vez de ?file=relatorio.pdf, busque o arquivo através de uma instância de modelo:
# SEGURO — o usuário fornece um PK ou UUID, não um nome de arquivo
import os
from django.shortcuts import get_object_or_404
from django.http import FileResponse
from django.contrib.auth.decorators import login_required
@login_required
def download_document(request, document_id):
doc = get_object_or_404(
Document, pk=document_id, owner=request.user # verificação de propriedade do Post 6
)
return FileResponse(doc.file.open('rb'), as_attachment=True, filename=os.path.basename(doc.file.name))
O usuário nunca fornece um caminho. Ele fornece um identificador que mapeia para uma instância de modelo, e o FileField do modelo resolve o caminho de storage internamente. Isso elimina path traversal inteiramente porque não há caminho para atravessar. Também herda a proteção IDOR do Post 6: o queryset é escopado para owner=request.user, então a verificação de autorização e a garantia de segurança de caminho vêm da mesma linha de código.
Este é o padrão que o Petição Brasil usa para PDFs de assinatura. O usuário fornece um UUID, a view busca o modelo Signature, verifica autorização (criador da petição, signatário ou staff), e gera uma URL assinada do S3 a partir de signature.signed_pdf.name. Nenhuma string fornecida pelo usuário jamais toca um caminho no sistema de arquivos.
Regra 3 — Valide e restrinja nomes de arquivo quando precisar aceitá-los
Às vezes o endpoint genuinamente precisa aceitar um nome de arquivo (baixar de um conjunto conhecido de relatórios estáticos, por exemplo). Restrinja o input a uma allowlist estrita ou padrão:
# SEGURO — abordagem de allowlist (mais forte)
from django.conf import settings
from django.http import FileResponse, Http404
from django.utils._os import safe_join
from django.contrib.auth.decorators import login_required
ALLOWED_REPORTS = {
'q1-2026': 'reports/q1_2026_summary.pdf',
'q2-2026': 'reports/q2_2026_summary.pdf',
}
@login_required
def download_report(request, report_key):
relative_path = ALLOWED_REPORTS.get(report_key)
if relative_path is None:
raise Http404()
filepath = safe_join(settings.MEDIA_ROOT, relative_path)
return FileResponse(open(filepath, 'rb'), as_attachment=True)
O usuário fornece uma chave (q1-2026), não um caminho. O mapeamento é definido no servidor. Mesmo se o usuário enviar ../../settings.py como chave, simplesmente não corresponde a nenhuma entrada e retorna 404.
Quando uma allowlist é impraticável, valide o nome do arquivo contra um padrão estrito e combine com safe_join:
import re
from django.conf import settings
from django.http import Http404
from django.utils._os import safe_join
def download_user_file(request, filename):
# Apenas permitir nomes alfanuméricos com um único ponto para a extensão
if not re.match(r'^[a-zA-Z0-9_-]+\.[a-zA-Z0-9]+\Z', filename):
raise Http404()
filepath = safe_join(settings.MEDIA_ROOT, 'uploads', filename)
# ... servir o arquivo
A regex rejeita qualquer input contendo /, \, .. ou bytes nulos. Combinada com safe_join, esta é uma defesa de duas camadas: a regex captura os payloads óbvios, e safe_join captura qualquer coisa que a regex tenha perdido.
Regra 4 — Nunca registre ou retorne o caminho resolvido em caso de falha
Quando uma tentativa de travessia é detectada, retorne um 404 genérico. Não inclua o caminho resolvido na resposta ou em mensagens de erro visíveis ao usuário:
# INSEGURO — vaza informação de caminho
except SuspiciousFileOperation as e:
return HttpResponseBadRequest(f"Caminho inválido: {e}")
# SEGURO — 404 genérico, sem informação de caminho
except SuspiciousFileOperation:
raise Http404()
Registre a tentativa no lado do servidor para monitoramento (Post 30 cobre isso), mas a resposta ao atacante deve ser indistinguível de "o arquivo não existe."
Checklist de Prevenção de Path Traversal
| Controle | O que cobre |
|---|---|
FileField / API de Storage |
Elimina caminhos brutos inteiramente; arquivos são acessados através de instâncias de modelo |
safe_join em vez de os.path.join |
Levanta SuspiciousFileOperation quando o caminho resolvido escapa do diretório base |
| Consulta ao banco em vez de nome de arquivo | O usuário fornece um PK/UUID, não um caminho; autorização é imposta pelo queryset |
| Allowlist de nomes ou regex estrita | Rejeita caracteres de travessia antes que cheguem ao sistema de arquivos |
| 404 genérico em caso de falha | Previne vazamento de informação de caminho ao atacante |
| Queryset escopado por proprietário (Post 6) | Garante que o usuário está autorizado a acessar o arquivo específico, não qualquer arquivo no diretório |
Testando Sua Defesa
Testes Unitários
# tests/test_path_traversal.py
from django.conf import settings
from django.core.exceptions import SuspiciousFileOperation
from django.test import TestCase, Client
from django.utils._os import safe_join
from django.contrib.auth.models import User
class SafeJoinTests(TestCase):
"""Testes unitários diretos para safe_join — sem camada HTTP, sem dependência de fixture."""
def test_relative_traversal_raises(self):
with self.assertRaises(SuspiciousFileOperation):
safe_join(settings.MEDIA_ROOT, '../../settings.py')
def test_deep_traversal_raises(self):
with self.assertRaises(SuspiciousFileOperation):
safe_join(settings.MEDIA_ROOT, '../../../etc/passwd')
def test_absolute_path_raises(self):
with self.assertRaises(SuspiciousFileOperation):
safe_join(settings.MEDIA_ROOT, '/etc/passwd')
def test_legitimate_path_does_not_raise(self):
"""safe_join deve permitir um caminho que fica dentro da base."""
# Nenhuma asserção além de "não levanta" — essa é a propriedade.
safe_join(settings.MEDIA_ROOT, 'documents', 'report.pdf')
class PathTraversalViewTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
'alice', email='alice@example.com', password='testpass123'
)
self.client = Client()
self.client.login(username='alice', password='testpass123')
def _get_response_body(self, response):
"""Lê o corpo completo de uma resposta regular ou streaming."""
if response.streaming:
return b''.join(response.streaming_content)
return response.content
def test_relative_traversal_returns_404(self):
"""Um payload ../ deve retornar 404, não conteúdo do arquivo."""
response = self.client.get(
'/documents/download/', {'file': '../../nomedoprojeto/settings.py'}
)
self.assertEqual(response.status_code, 404)
def test_url_encoded_traversal_returns_404(self):
"""../ codificado em porcentagem não deve contornar a verificação.
Nota: use uma query string CRUA. Passar {'file': '..%2f..'} pelo
dict de dados recodifica o '%' para '%25', então a view nunca vê
a travessia. A string crua abaixo chega decodificada como '../../'.
"""
response = self.client.get(
'/documents/download/?file=..%2f..%2fnomedoprojeto%2fsettings.py'
)
self.assertEqual(response.status_code, 404)
def test_absolute_path_returns_404(self):
"""Um caminho absoluto não deve contornar o diretório base."""
response = self.client.get(
'/documents/download/', {'file': '/etc/passwd'}
)
self.assertEqual(response.status_code, 404)
def test_backslash_traversal_returns_404(self):
"""Travessia com barra invertida estilo Windows.
Só faz sentido em CI Windows: no POSIX, '\\' é um caractere legal
de nome de arquivo, então isso é um nome literal que retorna 404
por outro motivo. Mantido para documentar a superfície no Windows.
"""
response = self.client.get(
'/documents/download/', {'file': '..\\..\\settings.py'}
)
self.assertIn(response.status_code, [400, 404])
def test_response_never_leaks_secrets(self):
"""Mesmo em resposta não-404, SECRET_KEY nunca deve aparecer."""
for payload in ['../../nomedoprojeto/settings.py', '/etc/passwd', '../../.env']:
response = self.client.get('/documents/download/', {'file': payload})
if response.status_code != 404:
body = self._get_response_body(response)
self.assertNotIn(b'SECRET_KEY', body)
Análise Estática
# Grep para chamadas os.path.join que podem receber input do usuário
grep -rn "os.path.join" --include="*.py" apps/ | grep -v migrations | grep -v test
# Procurar chamadas open() com caminhos dinâmicos
grep -rn "open(" --include="*.py" apps/ | grep -v migrations | grep -v test | grep -v ".pyc"
# Nota: Bandit não tem verificação para path traversal em views.
# Os comandos grep acima são a principal ferramenta de análise estática para este padrão.
# Verificar se safe_join é usado onde necessário
grep -rn "safe_join\|SuspiciousFileOperation" --include="*.py" apps/
Verificação Manual
# Testar travessia contra um endpoint de servir arquivos
curl -v "https://staging.example.com/download/?file=../../../manage.py"
# Esperado: 404 Not Found
curl -v "https://staging.example.com/download/?file=/etc/passwd"
# Esperado: 404 Not Found
# Verificar que a resposta não contém conteúdo do arquivo ou informação de caminho
curl -s "https://staging.example.com/download/?file=../../settings.py" | grep -i "secret_key"
# Esperado: sem output (o arquivo não foi servido)
O Que Encontrei nos Meus Projetos
A primeira coisa que pesquisei foi os.path.join em código de servir arquivos. No apps/core/views.py do Petição Brasil, a tcc_view constrói um caminho assim:
def tcc_view(request):
pdf_filename = 'TCC - Abaixo-assinados Digitais...'
pdf_path = os.path.join(settings.BASE_DIR, 'static', 'documents', pdf_filename)
response = FileResponse(open(pdf_path, 'rb'), content_type='application/pdf')
O nome do arquivo é hardcoded. Não há input do usuário no caminho. Então não é vulnerável. Mas o padrão, os.path.join alimentando open() alimentando FileResponse, é exatamente a forma do Padrão Vulnerável 1. Se um desenvolvedor futuro tornar pdf_filename dinâmico (digamos, para servir múltiplos documentos TCC de um dropdown), a superfície de travessia se abre instantaneamente. Estou sinalizando isso no codebase com um comentário e considerando substituir a chamada os.path.join por safe_join como medida preventiva, mesmo que não seja estritamente necessário hoje.
O download de PDF de assinatura é o padrão no qual tenho mais confiança. DownloadSignaturePDFView em apps/signatures/views.py recebe um UUID da URL, busca o modelo Signature, verifica autorização (criador da petição, signatário ou staff), e gera uma URL assinada do S3 a partir de signature.signed_pdf.name. O usuário nunca fornece um nome de arquivo ou caminho. Não há superfície de travessia porque toda a resolução de arquivo passa pelo FileField do Django e o backend de storage S3:
class DownloadSignaturePDFView(View):
def get(self, request, uuid):
user = request.user
signature = get_object_or_404(
Signature.objects.select_related('petition'),
uuid=uuid,
verification_status=Signature.STATUS_APPROVED
)
# Verificação de autorização — não apenas login_required
is_authorized = (
user.is_authenticated and (
user == signature.petition.creator or
user.is_staff or
(hasattr(user, 'email') and user.email == signature.email)
)
)
if not is_authorized:
raise PermissionDenied(...)
# Arquivo resolvido através do FileField — sem caminho fornecido pelo usuário
signed_url = s3_manager.generate_signed_url(
file_path=signature.signed_pdf.name, expiration=3600
)
Esta é a Regra 2 em ação: consulta ao banco, escopo por proprietário, abstração de storage. A questão do safe_join nunca surge porque não há join a fazer.
Um grep -rn "safe_join" apps/ por todo o codebase retornou zero resultados. O Petição Brasil não usa safe_join em lugar nenhum, porque não tem nenhum endpoint que construa caminhos no sistema de arquivos a partir de input do usuário. Esse é o resultado certo pela razão errada: o projeto é seguro não porque usa safe_join, mas porque acontece de evitar o padrão que precisaria dele. Se eu algum dia adicionar um endpoint direto de servir arquivos, safe_join precisa ser o primeiro import.
A lição do path traversal se encaixa no padrão desta série inteira: o Django protege você quando usa suas abstrações (FileField, Storage, safe_join), e a vulnerabilidade aparece no momento em que você sai delas. Sirva arquivos através de instâncias de modelo, não nomes de arquivo. Se precisar aceitar um nome de arquivo, passe por safe_join e teste com ../../settings.py da mesma forma que testa SQL injection com ' OR 1=1 --.
O Post 10 encerra a Série II com Mass Assignment, onde o atacante escreve campos que o desenvolvedor nunca expôs, transformando um endpoint de edição de perfil numa superfície de escrita em todo o banco de dados.
Leitura Complementar
- Django Docs — File Storage API
- Django Docs — FileField Reference
- Django Source (GitHub) — django.utils._os.safe_join
- OWASP A01:2021 — Broken Access Control
- OWASP Cheat Sheet — Input Validation
- PortSwigger Web Security Academy — Path Traversal
- MITRE ATT&CK — T1190: Exploit Public-Facing Application
- MITRE ATT&CK — T1083: File and Directory Discovery
- NVD — CVE-2018-13379: Fortinet FortiOS Path Traversal (CVSS 9.8)
- Web Security for Developers: Real Threats, Practical Defense (Malcolm McDonald) — Chapter 11: Access Control and Privilege Escalation