O upload de arquivos é um recurso comum em aplicações web modernas, mas também é uma das superfícies de ataque mais exploradas por invasores. Um upload mal implementado pode permitir a execução de código remoto, o roubo de dados ou a desfiguração do site. Nesta aula, vamos explorar as principais camadas de defesa que todo desenvolvedor deve implementar ao aceitar arquivos de usuários, desde a validação do tipo até o isolamento do armazenamento e a varredura de malware.

O objetivo é que você entenda não apenas as técnicas, mas também os princípios subjacentes de segurança, como a defesa em profundidade e a confiança zero em entradas do usuário. Vamos abordar cada subtópico com exemplos práticos e boas práticas, para que você possa aplicar imediatamente em seus projetos.

Content-Type

O cabeçalho Content-Type é um campo HTTP que indica o tipo de mídia do recurso enviado. No contexto de uploads, o cliente envia esse cabeçalho para informar ao servidor o tipo de arquivo que está sendo enviado. No entanto, confiar cegamente nesse valor é um erro grave, pois ele pode ser facilmente forjado por um atacante usando ferramentas como curl ou scripts personalizados.

Por exemplo, um usuário mal-intencionado pode enviar um arquivo executável com o cabeçalho Content-Type: image/png para tentar burlar validações simples. Portanto, o Content-Type deve ser usado apenas como uma dica inicial, nunca como uma medida de segurança. A validação real deve ser feita com base no conteúdo do arquivo, como veremos no próximo subtópico.

Além disso, ao processar o upload, o servidor deve definir o Content-Type correto ao servir o arquivo, para evitar ataques de content sniffing, onde o navegador tenta adivinhar o tipo de arquivo e pode executar conteúdo malicioso. Isso é feito incluindo o cabeçalho X-Content-Type-Options: nosniff nas respostas.

Exemplo de verificação de Content-Type em Python usando Flask:

from flask import Flask, request, jsonify

app = Flask(__name__)

ALLOWED_MIME_TYPES = ['image/png', 'image/jpeg', 'application/pdf']

@app.route('/upload', methods=['POST'])
def upload():
    file = request.files['file']
    if file.mimetype not in ALLOWED_MIME_TYPES:
        return jsonify({'error': 'Tipo de arquivo não permitido'}), 400
    # Processar o arquivo...
    return jsonify({'message': 'Upload realizado com sucesso'})

Embora esse código filtre por MIME type, lembre-se de que essa é apenas a primeira camada. Um atacante pode alterar o Content-Type facilmente.

File validation

A validação de arquivo vai além do Content-Type e envolve a inspeção do conteúdo real do arquivo. Isso inclui verificar os primeiros bytes (chamados de magic numbers) para confirmar que o arquivo é realmente do tipo esperado, além de validar o tamanho, a extensão e, se possível, o conteúdo estrutural (por exemplo, usar uma biblioteca para abrir a imagem e verificar se é uma imagem válida).

Os magic numbers são sequências de bytes no início do arquivo que identificam seu formato. Por exemplo, arquivos PNG começam com 89 50 4E 47 0D 0A 1A 0A, JPEG com FF D8 FF, e PDF com 25 50 44 46. Verificar esses bytes é uma técnica mais confiável do que confiar no Content-Type.

Além disso, é crucial validar o tamanho do arquivo para evitar ataques de negação de serviço (DoS), onde um atacante envia arquivos gigantes para sobrecarregar o servidor. Defina um limite máximo e rejeite arquivos que excedam esse limite.

Exemplo de validação de arquivo em Python lendo os primeiros bytes:

import os

def validate_image(file_stream):
    """Valida se o arquivo é uma imagem PNG ou JPEG."""
    file_stream.seek(0)
    header = file_stream.read(8)
    if header.startswith(b'\x89PNG\r\n\x1a\n'):
        return 'png'
    elif header.startswith(b'\xff\xd8\xff'):
        return 'jpeg'
    else:
        return None

# Uso no Flask
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route('/upload', methods=['POST'])
def upload():
    file = request.files['file']
    if file.content_length > 10 * 1024 * 1024:  # 10 MB
        return jsonify({'error': 'Arquivo muito grande'}), 413
    file_type = validate_image(file.stream)
    if not file_type:
        return jsonify({'error': 'Arquivo inválido'}), 400
    # Salvar o arquivo com base no tipo detectado
    filename = f'upload.{file_type}'
    file.save(os.path.join('uploads', filename))
    return jsonify({'message': 'Upload realizado com sucesso'})

É importante também rejeitar arquivos com extensões duplas ou perigosas, como .php.jpg, e usar uma lista de extensões permitidas, mas sempre combinada com a validação de conteúdo.

Storage isolation

O isolamento do armazenamento refere-se à prática de salvar arquivos enviados pelos usuários em um local separado do código da aplicação, preferencialmente fora do diretório público do servidor web. Isso impede que arquivos maliciosos sejam executados diretamente pelo servidor web.

Se os arquivos forem armazenados no diretório público, como public/uploads, um atacante pode enviar um arquivo PHP ou outro script e, em seguida, acessá-lo diretamente pelo navegador, fazendo com que o servidor execute o código. Para evitar isso, armazene os arquivos em um diretório privado, sem acesso direto pela web, e sirva-os por meio de um script que faça a leitura e envie com os cabeçalhos corretos (por exemplo, como download).

Além disso, é recomendável usar um nome de arquivo aleatório, gerado pelo servidor, e não o nome original do usuário, para evitar ataques de path traversal e outros problemas. O nome original pode ser armazenado em um banco de dados para referência, mas o nome no disco deve ser único e imprevisível.

Exemplo de isolamento de armazenamento em Node.js com Express:

const express = require('express');
const multer = require('multer');
const path = require('path');
const crypto = require('crypto');

const app = express();

const storage = multer.diskStorage({
    destination: (req, file, cb) => {
        // Diretório privado fora do diretório público
        cb(null, '/var/www/private/uploads');
    },
    filename: (req, file, cb) => {
        // Gerar nome aleatório com extensão da lista permitida
        crypto.randomBytes(16, (err, buf) => {
            if (err) return cb(err);
            const ext = path.extname(file.originalname);
            cb(null, buf.toString('hex') + ext);
        });
    }
});

const upload = multer({ storage });

app.post('/upload', upload.single('file'), (req, res) => {
    // Aqui você pode salvar o nome original e o novo nome no banco
    res.json({ message: 'Upload realizado', filename: req.file.filename });
});

app.get('/download/:filename', (req, res) => {
    const filePath = path.join('/var/www/private/uploads', req.params.filename);
    res.download(filePath);
});

app.listen(3000);

Além de isolar o armazenamento, considere usar um serviço de armazenamento em nuvem (como S3) com permissões restritas, ou um diretório em um servidor diferente, para reduzir o impacto de um comprometimento.

Malware scanning

A varredura de malware é uma camada adicional de segurança que verifica o conteúdo do arquivo em busca de códigos maliciosos, como vírus, trojans ou scripts maliciosos. Essa prática é especialmente importante se a aplicação aceitar arquivos de fontes não confiáveis, como em sistemas de compartilhamento ou fóruns.

Existem várias abordagens: usar bibliotecas de detecção de malware, como o ClamAV, que pode ser integrado via API ou linha de comando; ou usar serviços de terceiros, como o VirusTotal, que agregam múltiplos mecanismos de detecção. No entanto, a varredura pode ser cara e demorada, então é comum fazer uma varredura assíncrona após o upload, enquanto o arquivo permanece em quarentena até que seja considerado seguro.

Exemplo de integração com ClamAV em Python usando a biblioteca pyclamd:

import pyclamd

def scan_file(file_path):
    try:
        cd = pyclamd.ClamdAgnostic()
        if not cd.ping():
            raise Exception('ClamAV não está rodando')
        result = cd.scan_file(file_path)
        if result:
            # result é um dicionário com o nome do arquivo e a detecção
            return {'infected': True, 'virus': result[file_path]}
        else:
            return {'infected': False}
    except Exception as e:
        return {'error': str(e)}

# Uso no upload
from flask import Flask, request, jsonify
import os

app = Flask(__name__)

@app.route('/upload', methods=['POST'])
def upload():
    file = request.files['file']
    temp_path = f'/tmp/{file.filename}'
    file.save(temp_path)
    scan_result = scan_file(temp_path)
    if scan_result.get('infected'):
        os.remove(temp_path)
        return jsonify({'error': 'Arquivo infectado', 'virus': scan_result['virus']}), 400
    # Mover para armazenamento final
    os.rename(temp_path, os.path.join('uploads', file.filename))
    return jsonify({'message': 'Upload realizado com sucesso'})

Lembre-se de que a varredura de malware não é infalível e pode gerar falsos positivos. Por isso, é importante ter um processo de revisão manual para casos ambíguos. Além disso, a varredura deve ser feita em um ambiente isolado, como um contêiner, para evitar que um arquivo malicioso infecte o servidor principal.

Boas práticas e observações finais

Além dos subtópicos acima, existem outras boas práticas que reforçam a segurança de uploads:

  • Limite de tamanho: Defina limites de tamanho tanto no cliente quanto no servidor, para evitar sobrecarga.
  • Política de extensões: Use uma lista branca de extensões permitidas, mas nunca confie apenas na extensão.
  • Tratamento de erros: Não exponha detalhes internos do sistema em mensagens de erro.
  • Logs: Registre todas as tentativas de upload, incluindo IP, data e resultado da validação.
  • Atualização constante: Mantenha bibliotecas e ferramentas de segurança atualizadas.

Em resumo, a segurança de uploads exige uma abordagem em camadas: validação de tipo, validação de conteúdo, isolamento de armazenamento e varredura de malware. Nenhuma camada é suficiente sozinha; a combinação delas reduz drasticamente o risco de exploração.

Referências

Exercícios

  1. Explique por que confiar apenas no cabeçalho Content-Type é uma má prática de segurança. Dê um exemplo de ataque que pode ser realizado se essa validação for a única.
  2. ✓ Resposta: O cabeçalho Content-Type é enviado pelo cliente e pode ser forjado por um atacante. Por exemplo, um invasor pode enviar um arquivo PHP executável com o cabeçalho Content-Type: image/png. Se o servidor apenas verificar esse cabeçalho, o arquivo será aceito como imagem e salvo no diretório público. O atacante pode então acessar o arquivo diretamente pelo navegador, fazendo com que o servidor execute o código PHP, resultando em execução remota de código.
  3. Descreva como você validaria o conteúdo de um arquivo para garantir que ele é uma imagem PNG ou JPEG, sem confiar na extensão nem no Content-Type.
  4. ✓ Resposta: Para validar o conteúdo, podemos ler os primeiros bytes do arquivo e comparar com os magic numbers conhecidos. Para PNG, os primeiros 8 bytes devem ser \x89PNG\r\n\x1a\n. Para JPEG, os primeiros 3 bytes devem ser \xff\xd8\xff. Em Python, podemos abrir o arquivo em modo binário, ler os primeiros bytes e fazer a comparação. Alternativamente, podemos usar bibliotecas como Pillow (Python) ou sharp (Node.js) para tentar abrir o arquivo como imagem; se falhar, o arquivo é inválido.
  5. Qual é a importância de armazenar arquivos em um diretório fora do diretório público? Dê um exemplo de ataque que é evitado com essa prática.
  6. ✓ Resposta: Armazenar arquivos fora do diretório público impede que o servidor web os sirva diretamente e execute scripts maliciosos. Por exemplo, se um atacante envia um arquivo PHP com nome shell.php e ele é salvo em public/uploads, um acesso a http://site.com/uploads/shell.php fará o servidor executar o código PHP. Se o arquivo for salvo em um diretório privado, o servidor não o servirá diretamente; o aplicativo pode servir o arquivo via um script que define o tipo de conteúdo corretamente, mas não executa o código.
  7. Como você implementaria uma varredura de malware em um fluxo de upload? Descreva os passos e as ferramentas que usaria.
  8. ✓ Resposta: Para implementar varredura de malware, eu seguiria estes passos: 1) Receber o upload e salvar temporariamente em um diretório de quarentena. 2) Chamar um serviço de varredura, como ClamAV (via biblioteca pyclamd ou clamd daemon) ou uma API como VirusTotal. 3) Se a varredura detectar malware, rejeitar o arquivo e removê-lo da quarentena. 4) Se estiver limpo, mover o arquivo para o armazenamento final. 5) Em caso de erro na varredura, decidir se o arquivo é aceito ou rejeitado, dependendo da política de segurança. Ferramentas: ClamAV é open-source e pode ser executado localmente; VirusTotal é um serviço online que agrega múltiplos scanners, mas tem limites de uso.
  9. Cite pelo menos três boas práticas adicionais para segurança de uploads além das mencionadas na aula.
  10. ✓ Resposta: Três boas práticas adicionais são: 1) Usar um nome de arquivo aleatório no servidor para evitar path traversal e conflitos. 2) Definir limites de tamanho no servidor (e também no cliente) para evitar DoS. 3) Registrar logs detalhados de todas as tentativas de upload, incluindo IP, data, nome original e resultado da validação, para fins de auditoria.