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: ~18 min
🧪 Rode você mesmo. Este ataque vem como um laboratório executável no django-security-lab: um endpoint de "baixar um documento" que abre o arquivo nomeado em
?file=. A view comos.path.joindeixa?file=../flag.txtescalar para fora do diretório de documentos e ler uma flag que nunca deveria alcançar; a view comsafe_joinlevantaSuspiciousFileOperatione retorna 404. Reproduza os dois pela linha de comando comcurl— e veja a reviravolta na detecção: os scanners padrão dão um falso verde aqui, então o laboratório traz a regra customizada que de fato pega código realista.
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. É o tipo de bug latente que se esconde em código de produção comum — um nome de arquivo hardcoded alimentando os.path.join hoje, a um refactor de distância do perigo no momento em que esse nome passa a vir de um parâmetro da URL. Manter uma plataforma real de petições é o que torna esse modo de falha concreto em vez de acadêmico.
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.
A partir daí, a exploração é uma questão de mirar no arquivo certo. 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.
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 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.
Em termos de ATT&CK, a travessia em si é T1190 — Exploit Public-Facing Application; uma vez que o appliance estava lendo arquivos arbitrários, o ataque encadeou em T1083 — File and Directory Discovery (enumerar o sistema de arquivos), T1005 — Data from Local System (a leitura) e T1552.001 — Credentials In Files — que é exatamente o que sslvpn_websession era: um arquivo cheio de credenciais em texto plano, a mesma classe de colheita que uma travessia no Django realiza ao ler settings.py ou um .env.
O peso regulatório recai sobre esse arquivo de credenciais. sslvpn_websession guardava os nomes de usuário e senhas de usuários VPN ativos — dados pessoais sob o GDPR europeu e a LGPD (Lei Geral de Proteção de Dados) brasileira. Pelo Artigo 48 da LGPD, um operador que sofre um incidente com risco relevante deve comunicar a autoridade nacional (ANPD) e os titulares afetados; um dump divulgado de credenciais ativas ultrapassa esse limiar com folga. O bug de path traversal é um único ../ não validado, mas a responsabilidade que ele dispara é um incidente notificável de todo usuário cujas credenciais estavam no arquivo que o servidor foi enganado a servir — o defeito técnico e a exposição legal são o mesmo evento visto de duas direções.
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/ImageFieldresolvem arquivos através do backend de storage, não caminhos brutos.django.views.static.serve(a view de servir arquivos apenas para dev) usasafe_join.- Template loaders restringem a resolução a diretórios
DIRSe diretóriostemplates/das apps. SuspiciousFileOperationé uma subclasse deSuspiciousOperation, 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.Pathou concatenação de strings. - Qualquer view que passa um nome fornecido pelo usuário para
open(),FileResponse()ouPath.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 |
A Visão do Analista
Para um analista CySA+, path traversal é um lembrete útil de que um scan limpo não é o mesmo que uma aplicação segura. A CWE-22 é, no fundo, uma falha de canonicalização — o servidor decide qual arquivo servir antes de resolver para onde o caminho realmente aponta — e bugs de canonicalização são notoriamente difíceis para o SAST baseado em padrões enxergar, porque o defeito é a ausência de uma etapa de resolver-e-conter, não uma chamada perigosa que se possa casar. Como a seção de detecção abaixo mostra, as ferramentas padrão não retornam nada na forma realista aqui, e uma delas retorna um falso verde que é pior que o silêncio. Um analista que lê "0 achados" como "sem path traversal" leu a ferramenta errado.
Isso desloca o peso da detecção para o julgamento humano e o teste dinâmico: uma revisão de código dirigida a cada view que serve arquivos (grep por os.path.join, open() e FileResponse alimentados pela requisição), e uma sonda ../ contra o endpoint em execução. Esta é a lição recorrente da série de controle de acesso — autorização e contenção são propriedades que um scanner raramente consegue confirmar, então pertencem ao checklist de modelagem de ameaças, não apenas ao pipeline de CI.
Detectando Automaticamente
Testando Sua Defesa
O controle é pequeno o bastante para testar diretamente: safe_join deve levantar em uma travessia e ficar quieto em um caminho legítimo, e a view construída sobre ele deve retornar 404 em vez de conteúdo do arquivo. As duas metades importam — um teste que só verifica o caminho feliz passaria também contra a view vulnerável com os.path.join.
# 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)
Fazendo a Varredura
Eu esperava que este fosse um scan fácil. Path traversal via os.path.join é um bug de manual, então apontei as ferramentas de sempre para as duas views do laboratório assumindo que ambas acenderiam a vulnerável. Em vez disso recebi um atestado de saúde limpo sobre código que eu sei que vaza a flag — e caçar o porquê virou a coisa mais útil deste post.
Bandit (um lembrete rápido: ele percorre a AST do Python procurando chamadas arriscadas numeradas com B — shell=True, eval, yaml.load) reporta zero achados nas duas views. Isso não é um problema de tuning: o Bandit não traz nenhum plugin de path traversal, e não faz dataflow, então não tem como ligar request.GET → os.path.join → open. O mais próximo que ele tem, B108, só sinaliza um caminho /tmp hardcoded. Nesta classe o Bandit é simplesmente cego.
Semgrep community (casamento estrutural de padrões; seu motor OSS segue taint apenas dentro de uma função) reporta zero também — de p/django, p/python e p/owasp-top-ten juntos. Isso me surpreendeu mais, porque o Semgrep tem uma regra Django de path traversal: python.django.security.injection.path-traversal.path-traversal-join. Duas coisas a impedem de ajudar, e ambas merecem a atenção de um analista:
- Ela não está nos pacotes curados. A regra vive na camada de auditoria do registry, que
p/pythone afins excluem — a mesma razão pela qual as regras de@csrf_exemptemark_safemais cedo nesta série não disparam a partir dos pacotes padrão. Você tem que pedir por ela pelo nome. - Mesmo pedida pelo nome, ela erra código realista. A regra é um padrão sintático, e o motor community segue apenas uma indireção de variável. Ela dispara no one-liner totalmente inline
open(os.path.join(base, request.GET.get('file')))— mas fica em silêncio na forma que este post de fato ensina:
name = request.GET.get('file', '') # salto 1: request -> name
path = os.path.join(MEDIA_ROOT, name) # salto 2: name -> path
open(path, 'rb') # a regra perde o rastro aqui
Atribuir o nome do arquivo a uma variável, construir o caminho em outra, e então abri-lo — o jeito normal como qualquer um escreve uma view de download — são dois saltos, e dois saltos é um a mais do que o motor OSS vai seguir. Ligá-los exige a análise de taint interprocedural do Pro engine do Semgrep, que a edição community não inclui. Então a regra de prateleira dá um falso verde: pega uma forma que quase ninguém escreve e fica quieta na forma que todo mundo escreve. (Reproduzi a fronteira exata — inline dispara, um salto dispara, dois saltos erram — e capturei as execuções no diretório scans/ do laboratório.)
Um falso verde é pior que sinal nenhum, porque convida você a confiar nele. Então o laboratório traz uma pequena regra customizada do Semgrep que pega a forma realista — ela se ancora no salto os.path.join → open que o motor OSS consegue seguir e fica em silêncio na correção com safe_join:
# Dispara na view vulnerável, silenciosa na segura — a asserção que a regra
# de prateleira não consegue dar em código realista.
semgrep scan --config rules/path_traversal.yaml labs/post_09_path_traversal/views_vulnerable.py # 1 achado
semgrep scan --config rules/path_traversal.yaml labs/post_09_path_traversal/views_secure.py # 0 achados
Sua única limitação honesta é o outro lado da moeda do motor OSS: sendo sintática e agnóstica à origem, ela também sinalizará um os.path.join alimentando open() quando todo componente for um literal hardcoded (uma leitura segura) — um falso positivo que ela documenta em vez de esconder. Confirmar que o caminho é genuinamente controlado pelo usuário é exatamente a análise de taint que as ferramentas gratuitas não conseguem fazer aqui.
As lições práticas para uma revisão de código: grep não está abaixo de você. grep -rn "os.path.join" --include="*.py" nas suas views que servem arquivos, e um olhar em cada open() / FileResponse() que recebe um nome da requisição, encontrará esta classe mais rápido que um scan de SAST que não reporta nada. E prove a correção dinamicamente — um payload ../ contra um endpoint em execução é a outra metade:
# Verificação dinâmica contra um endpoint em execução — a metade DAST.
curl -s -o /dev/null -w "%{http_code}\n" ".../download/?file=../../settings.py" # 404, não 200
curl -s ".../download/?file=../../settings.py" | grep -i secret_key # sem output
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-security-lab — Laboratório de Path Traversal (views vulnerável/segura executáveis, scans capturados e a regra customizada do Semgrep)
- 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