Path Traversal: Como ../../settings.py Escapa do Seu Diretório de Mídia — e Como o safe_join do Django Impede

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 com os.path.join deixa ?file=../flag.txt escalar para fora do diretório de documentos e ler uma flag que nunca deveria alcançar; a view com safe_join levanta SuspiciousFileOperation e retorna 404. Reproduza os dois pela linha de comando com curl — 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 / 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

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 Bshell=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.GETos.path.joinopen. 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/python e afins excluem — a mesma razão pela qual as regras de @csrf_exempt e mark_safe mais 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.joinopen 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

← Voltar para todos os posts