DEV Community

Cover image for 5 lições de segurança que aprendi estudando um host temporário de arquivos
Matheus Silva
Matheus Silva

Posted on

5 lições de segurança que aprendi estudando um host temporário de arquivos

Estudei como funciona um projeto open-source de host temporário de arquivos, o Waifuvault, e criei um utilizando Laravel. Neste artigo, vou compartilhar 5 lições de segurança que são essenciais nesse tipo de aplicação e um pouco da experiência de criar esse projeto utilizando Laravel.

Não confie no que é enviado pelo usuário

Provavelmente você já deve ter ouvido essa frase e nesse tipo de aplicação, é importante fazer a validação dos dados enviados pelo usuário, como nome do arquivo, headers como Content-Type e Content-Length que são variáveis importantes nesse contexto.

Dado um cenário em que é enviado um arquivo de 10mb, o servidor precisa verificar o tamanho real recebido para garantir que não ultrapasse o limite permitido pela aplicação.

Uma forma de se prevenir isso é durante o recebimento de arquivos por streaming verificar os bytes recebidos conforme for armazenando. Se o limite for atingido, ocorrer uma falha ou divergência, o upload deve ser rejeitado e o arquivo parcial que estava sendo salvo deve ser removido, evitando registros órfãos no sistema.

Também é uma boa prática alterar o nome do arquivo para algo gerado pela aplicação, como um UUID. Isso reduz riscos de sobrescrita de arquivos e uso de nomes maliciosos para sua aplicação.

flowchart TD
    A[Usuário envia o arquivo] --> B[Servidor grava os bytes no storage]
    B --> C{Tamanho gravado é<br/>igual ao informado?}
    C -- Sim --> D[Upload aceito]
    C -- Não --> E[Upload rejeitado]
    E --> F[Apaga o arquivo parcial<br/>do storage]

Validar o conteúdo real do arquivo, não o que ele diz ser

Complementando um pouco o tópico anterior, essa é uma parte interessante, por exemplo, o que pode acontecer é o usuário enviar um arquivo informando uma extensão e Content-Type de .pdf mas o MIME type do conteúdo indica que se trata de um arquivo .png, mais uma divergência das informações e uma possível vulnerabilidade para o sistema.
Para identificar o real conteúdo de um arquivo, se é uma foto .png, .jpeg, vídeo .mp4, áudio .mp3, podemos verificar os primeiros bytes do conteúdo. Eles indicam o verdadeiro tipo de arquivo quase como uma assinatura mesmo, então mesmo que você renomeie o arquivo e mude a extensão o conteúdo vai ser o mesmo.
Um teste rápido: pegue um arquivo .png qualquer real e use a ferramenta xxd no Linux. Note que os primeiros bytes retornados são 8950 4e47, renomeie esse arquivo para "sexta.pdf" e verifique novamente, os bytes serão os mesmos, é uma curiosidade bem legal.

flowchart TD
    A["Usuário envia shell.png"] --> B[Servidor lê os primeiros bytes<br/>do arquivo]
    B --> C[Detecta o tipo real<br/>pela assinatura]
    C --> D{Tipo real bate com<br/>o informado?}
    D -- Sim --> E[Upload segue o fluxo]
    D -- Não --> F[Upload rejeitado]

Mostrando um pouquinho de código, como o Laravel é um Framework bem completo, é até simples fazer essa validação dentro um FormRequest, segue o exemplo abaixo:

use Illuminate\Validation\Validator;

public function withValidator(Validator $validator): void
{
    $validator->after(function (Validator $validator) {
        $file = $this->file('file');

        if (! $file || ! $file->isValid()) {
            return;
        }

        $clientMime = $file->getClientMimeType();
        $contentMime = $file->getMimeType();

        if ($clientMime !== $contentMime) {
            $validator->errors()->add(
                'file',
                'O MIME type informado não corresponde ao conteúdo do arquivo.'
            );
        }
    });
}
Enter fullscreen mode Exit fullscreen mode

Convergência entre dois sistemas(Storage e Banco)

No primeiro tópico, falei sobre evitar registros órfãos no sistema algo que pode acontecer caso o arquivo seja salvo no storage mas por algum motivo o insert no banco falhar, ou também o contrário disso.
Para isso é importante manter o Storage e o Banco de dados em sincronia quanto ao estado de um registro.
Acredito que a ordem de salvar primeiro o arquivo no storage e, depois, registrar os metadados no banco de dados seja a mais apropriada, para caso o insert falhe faça a limpeza do arquivo no Storage.

flowchart TD
    A[Salva o arquivo no storage] --> B[Insere o registro no banco]
    B --> C{Insert funcionou?}
    C -- Sim --> D[Arquivo e registro em sincronia]
    C -- Não --> E[Apaga o arquivo do storage]
    E --> F[Nenhum órfão fica no sistema]

Storage privado exige entrega mediada

Quando você vai entregar um arquivo ao usuário por um link, isso não é simplesmente entregar a URL do storage para acessar o arquivo diretamente. Isso impossibilitaria a aplicação de regras de acesso, senha e expiração, além de poder expor arquivos.
Por isso, o acesso por link deve passar pela a aplicação, para sejam aplicadas essas validações de regras de negócio antes de disponibilizar o arquivo.

flowchart LR
    A[Usuário acessa o link] --> B[Aplicação]
    B --> C{Regras válidas?<br/>expiração, senha, downloads}
    C -- Não --> D[Responde com erro<br/>ex.: 404]
    C -- Sim --> E[(Storage privado)]
    E --> F[Aplicação devolve<br/>o arquivo ao usuário]

Expiração não é o mesmo que limpeza

Expiração é um estado do arquivo no sistema, o arquivo não é mais disponibilizado pelo servidor, então a tentativa de acesso por link é inválida.
Limpeza é a ação de apagar o arquivo do armazenamento e seus metadados do banco de dados.
A limpeza não precisa ser feita no momento em que o arquivo expira, ela pode ser feita por um cron job periódico, como a entrega é mediada pela aplicação a tentativa de acesso por link retornaria um erro 404.
Logo, um arquivo pode estar expirado, mas ainda existir no armazenamento até que uma ação de limpeza o exclua.
Para a limpeza no Laravel, utilizei um Schedule simples diário e a trait Prunable que facilita a exclusão de registros.

Vou deixar trechos de código da implementação e seguem os links da documentação para ver mais sobre: pruning-models e schedule.

/**
 * Define a query que seleciona arquivos expirados para remoção.
 *
 * @return Builder<static>
 */
public function prunable(): Builder
{
    return $this->newQuery()->where('expires_at', '<=', now());
}

/**
 * Exclui o arquivo armazenado antes de remover seus dados do banco.
 */
protected function pruning(): void
{
    if (! Storage::disk($this->disk)->delete($this->path)) {
        throw new RuntimeException("The expired file [{$this->id}] could not be deleted.");
    }
}

// E o schedule diário para a limpeza
Schedule::command('model:prune')->daily();
Enter fullscreen mode Exit fullscreen mode

Considerações finais

Foi muito legal estudar um sistema open-source que uso no dia a dia, além de criar um sistema com essa finalidade e aprendendo mais funcionalidades do Laravel, espero que esse artigo tenha lhe ajudado de alguma forma.

Referências

Waifuvault, o projeto que estudei: https://waifuvault.moe/
Resumo dos meus estudos: https://github.com/matheusdsilva01/learning/tree/main/about-waifuvault
Guia de segurança da OWASP sobre envio de arquivos:
https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html
Projeto que desenvolvi: https://github.com/matheusdsilva01/temp67

Top comments (2)

Collapse
 
clintonrocha98 profile image
Clinton Rocha •

Sou fã do projeto waifuvault, e vc fez um ótimo estudo, parabéns :D

Collapse
 
victorique profile image
Victoria •

As the creator of Waifuvault, i am glad you like my project enough to use it as reference material!