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.'
);
}
});
}
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();
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)
Sou fã do projeto waifuvault, e vc fez um ótimo estudo, parabéns :D
As the creator of Waifuvault, i am glad you like my project enough to use it as reference material!