Programação Por Equipe DevBras

Capa do artigo: Listar arquivos por data em PHP sem expor backup, .env e pasta do sistema

Listar arquivos por data em PHP sem expor backup, .env e pasta do sistema

Monte em PHP uma listagem do mais novo para o mais antigo que só mostra o que deve aparecer e um download que confere de novo cada pedido.

Segunda de manhã, o cliente manda um print da tela "Relatórios": o PDF mais novo está no topo, como pedido. Logo abaixo aparece backup-2026-10-12.sql.gz, com a base inteira, e um .env com a senha do banco. O código só fez scandir na pasta pública e ordenou por data. A resposta curta: a pasta listada é fixa no código e fica fora da raiz do site, a lista aceita só a extensão que você espera, e o download nunca recebe caminho, só um nome que é conferido contra essa mesma lista. Ordenar por data é a parte fácil.

O exemplo que atravessa o texto é uma área logada de um escritório de contabilidade. Os relatórios mensais em PDF são gerados por um job e gravados em /var/app/storage/relatorios. A página mostra nome, data e tamanho, do mais recente para o mais antigo, e cada linha tem um botão de baixar.

Por que a listagem vaza mais do que devia

Funções como scandir e glob('*') devolvem tudo o que o sistema de arquivos tem ali: arquivos ocultos, subpastas, links simbólicos, o dump que alguém deixou "só por hoje". O código de ordenação não tem como saber o que é relatório e o que é lixo operacional. Se você não filtra, publica.

O segundo vazamento é o link. Quando o href carrega o caminho, por exemplo baixar.php?f=relatorios/outubro.pdf, basta trocar o parâmetro por ../.env para tentar sair da pasta. É o mesmo ataque de travessia de diretório que aparece em upload de arquivo em PHP, só que na saída.

O terceiro é o próprio nome. Listar já é divulgar. Se o arquivo se chama folha-joao-silva-cpf-12345678900.pdf, a lista é dado pessoal, e quem vê a lista vê o dado, mesmo sem baixar nada.

Qual data o PHP devolve

Para ordenar por "data", use a de modificação, filemtime ou getMTime(). No Linux, filectime não é data de criação: é a última mudança no inode, e muda com um simples chmod. Outro detalhe: copiar com cp sem -p grava a data da cópia, enquanto rsync -a preserva a original. Se a ordem ficar estranha depois de uma migração de servidor, é quase sempre isso.

Como montar a listagem segura

  1. Defina a pasta numa constante, fora da raiz pública. Nada de pasta vinda de parâmetro.
  2. Resolva o caminho real da base com realpath uma vez e trate o caso de ela não existir.
  3. Percorra com DirectoryIterator, pulando ., .., subpastas e nomes que começam com ponto.
  4. Aceite só a extensão esperada, aqui pdf, comparada em minúsculas.
  5. Confirme que o caminho real de cada arquivo continua dentro da base. Isso derruba link simbólico apontando para fora.
  6. Ordene do mais novo para o mais antigo, com desempate pelo nome para a ordem não mudar a cada carregamento.
<?php
// relatorios.php
const PASTA_RELATORIOS = '/var/app/storage/relatorios';

function listarRelatorios(): array
{
    $base = realpath(PASTA_RELATORIOS);
    if ($base === false) {
        throw new RuntimeException('Pasta de relatórios não encontrada');
    }

    $itens = [];
    foreach (new DirectoryIterator($base) as $arquivo) {
        if ($arquivo->isDot() || !$arquivo->isFile()) {
            continue;
        }
        $nome = $arquivo->getFilename();
        if (str_starts_with($nome, '.')) {
            continue;
        }
        if (strtolower($arquivo->getExtension()) !== 'pdf') {
            continue;
        }
        $real = $arquivo->getRealPath();
        if ($real === false || !str_starts_with($real, $base . DIRECTORY_SEPARATOR)) {
            continue;
        }
        $itens[] = [
            'nome'    => $nome,
            'mtime'   => $arquivo->getMTime(),
            'tamanho' => $arquivo->getSize(),
        ];
    }

    usort($itens, fn($a, $b) => [$b['mtime'], $a['nome']] <=> [$a['mtime'], $b['nome']]);
    return $itens;
}

O usort compara dois arrays: primeiro a data invertida (mais novo antes), depois o nome em ordem alfabética. Com isso, dois relatórios gerados no mesmo segundo sempre aparecem na mesma posição. str_starts_with exige PHP 8, que é o mínimo razoável em 2026.

A página que mostra a lista

Na hora de imprimir, escape o nome com htmlspecialchars e codifique o parâmetro com rawurlencode. Um nome com aspas ou < não pode quebrar o HTML. A data aparece no fuso do escritório, e não no do servidor; se isso já te mordeu, veja fuso horário no PHP.

<?php
require __DIR__ . '/relatorios.php';
date_default_timezone_set('America/Sao_Paulo');
?>
<ul>
<?php foreach (listarRelatorios() as $r): ?>
  <li>
    <a href="baixar.php?arquivo=<?= rawurlencode($r['nome']) ?>">
      <?= htmlspecialchars($r['nome']) ?>
    </a>
    , <?= date('d/m/Y H:i', $r['mtime']) ?>
    , <?= number_format($r['tamanho'] / 1024, 0, ',', '.') ?> KB
  </li>
<?php endforeach; ?>
</ul>

O download confere tudo de novo

O link é um pedido, não uma autorização. O baixar.php verifica a sessão, pega o nome pedido e só aceita se ele estiver, exatamente, na lista que a mesma função gera. Não há basename "limpando" o que chegou nem concatenação de caminho vindo do usuário: ou o nome existe na lista, ou a resposta é 404.

<?php
// baixar.php
require __DIR__ . '/relatorios.php';
session_start();

if (empty($_SESSION['usuario_id'])) {
    http_response_code(403);
    exit;
}

$pedido = (string) ($_GET['arquivo'] ?? '');
$permitidos = array_column(listarRelatorios(), 'nome');

if (!in_array($pedido, $permitidos, true)) {
    http_response_code(404);
    exit;
}

$caminho = PASTA_RELATORIOS . '/' . $pedido;
header('Content-Type: application/pdf');
header("Content-Disposition: attachment; filename*=UTF-8''" . rawurlencode($pedido));
header('Content-Length: ' . filesize($caminho));
header('X-Content-Type-Options: nosniff');
readfile($caminho);

Se cada cliente só pode ver os próprios relatórios, a lista precisa receber o identificador do cliente e montar a pasta a partir dele, do lado do servidor. Quando a regra de acesso fica mais complexa que isso, o caminho natural é guardar os arquivos numa tabela com id, dono e nome de exibição, e o link passa a levar só o id.

Erros comuns

  • Listar a pasta pública inteira. O .env, o dump e o zip de deploy aparecem junto com os PDFs, e qualquer pessoa com a URL baixa direto pelo servidor web, sem passar pelo PHP.
  • Receber o caminho no parâmetro. Um ../ no lugar certo faz o script entregar arquivos de configuração ou de outros clientes.
  • Usar filectime achando que é data de criação. Um chmod em massa reordena a lista inteira e o relatório antigo vira "o mais novo".
  • Ordenar sem desempate. Arquivos gerados no mesmo segundo trocam de lugar a cada carregamento, e o usuário acha que a lista está bugada.
  • Esquecer o link simbólico. Um symlink criado para "facilitar" aponta para /etc ou para outra aplicação, e passa por um filtro que só olha o nome.
  • Colocar CPF ou nome de cliente no nome do arquivo. A listagem vira exposição de dado pessoal, assunto que entra direto na adequação à LGPD.

Checklist

  • A pasta listada está numa constante e fora da raiz que o servidor web publica.
  • Arquivos ocultos, subpastas e extensões diferentes da esperada não aparecem.
  • O caminho real de cada item começa com o caminho real da base.
  • A ordem usa data de modificação com desempate pelo nome.
  • O nome é escapado no HTML e codificado na URL.
  • O download exige sessão e só aceita nome presente na lista gerada no servidor.
  • Um arquivo de teste fora do padrão, como teste.sql, não aparece na página.

Coloque agora um nao-deveria.txt e um link simbólico para /etc na pasta de teste e recarregue a página. Algum dos dois apareceu? E, olhando os nomes que estão lá hoje, quem além do dono do arquivo consegue abrir essa URL?

Avalie este artigo

Referência: http://www.mauricioprogramador.com.br/posts/listar-arquivos-ordenado-por-data-com-php