
CVE-2026-19264 - Travessia de caminho crítica não autenticada para assumir completamente a instância no Postiz (< 2.22.1). Análise técnica: bypass da ordem de decodificação, escalonamento de JWT_SECRET e análise da correção upstream.

Autor: Krithik Babu P (@DarkLycn1976)
Publicado: 2026-08-10
CVE: CVE-2026-19264
Gravidade: Crítica - CVSS 4.0 9.3 / CVSS 3.1 9.8
CWE: CWE-22 - Limitação inadequada de nome de caminho a um diretório restrito
Afetados: gitroomhq/postiz-app < 2.22.1
Corrigido em: v2.22.1
O Postiz servia mídias armazenadas localmente por meio de uma rota que concatenava segmentos de caminho fornecidos pela URL ao diretório de upload e transmitia o resultado de volta - sem normalização de caminho, sem verificação de contenção e sem autenticação.
O payload de traversal óbvio retorna 404, porque o Next.js colapsa os segmentos ../ antes do roteamento. Mas separadores codificados em URL sobrevivem à correspondência de rota e são decodificados exatamente mais uma vez no caminho até a chamada ao sistema de arquivos, restaurando a traversal do outro lado de todas as verificações.
Um atacante não autenticado podia ler qualquer arquivo legível pelo processo da aplicação - incluindo seu próprio ambiente, que contém o segredo de assinatura JWT. Como o Postiz assina tokens de sessão com esse segredo e os emite sem uma declaração de expiração, recuperá-lo converte uma primitiva de leitura de arquivo em uma sessão permanente e forjável como qualquer usuário, incluindo um administrador.
Uma única requisição GET não autenticada para a assunção total da instância.
Postiz é uma plataforma de agendamento de mídias sociais de código aberto - cerca de 34.000 estrelas no GitHub no momento em que este texto foi escrito - construída como um frontend Next.js com um backend NestJS. É amplamente auto-hospedado por agências e pequenas equipes para gerenciar contas sociais conectadas, conteúdo agendado e cobrança.
Implantações auto-hospedadas podem armazenar mídias enviadas localmente em vez de em armazenamento de objetos. Esse comportamento é controlado por uma única variável de ambiente:
STORAGE_PROVIDER=local
Este é o valor incluído no .env.example, portanto é o que a maioria dos auto-hospedeiros executa, a menos que configurem deliberadamente S3 ou Cloudflare R2.
Quando o armazenamento local está ativo, o next.config.js reescreve o caminho público /uploads/:path* para uma rota de API interna:
apps/frontend/src/app/(app)/api/uploads/[[...path]]/route.ts
Duas propriedades tornam esta rota interessante antes mesmo de qualquer bug estar envolvido:
[[...path]] significa que cada componente de caminho restante chega como um array que o handler é livre para interpretar.Quando STORAGE_PROVIDER é qualquer coisa diferente de local, a reescrita aponta para /404 e o handler fica inacessível. Essa barreira de configuração é a única coisa entre uma implantação e este bug.
O handler, antes da v2.22.1:
export const GET = async (request: NextRequest, context) => {
const { path } = await context.params;
const filePath =
process.env.UPLOAD_DIRECTORY + '/' + (path ?? []).join('/');
const response = createReadStream(filePath);
const fileStats = statSync(filePath);
// ... stream the file back to the caller
};
Três defeitos em quatro linhas:
path.normalize(), path.resolve() - nenhum é chamado. Quaisquer segmentos que chegam são concatenados literalmente.filePath resultante ainda está dentro de UPLOAD_DIRECTORY.+ '/' + trata os componentes como texto, não como um caminho com semântica.O resultado vai direto para createReadStream() e os bytes são transmitidos ao chamador com um tipo MIME inferido do nome do arquivo. Não há lista de permissões de extensões nem filtro de conteúdo.
O ataque clássico é:
GET /uploads/../../../etc/passwd
No Postiz isso retorna 404, e esse 404 é exatamente o motivo pelo qual este bug sobreviveu até ser encontrado.
O Next.js normaliza o caminho da requisição durante o roteamento. Segmentos crus de ../ são colapsados antes que o roteador decida qual handler invocar. Quando a requisição chega ao catch-all, a traversal já foi eliminada - ou o caminho resolve para algum lugar sem rota correspondente, ou resolve de volta dentro de /uploads com os segmentos de ponto removidos.
Para alguém testando rapidamente, esse 404 parece "o framework lida com isso". É uma defesa genuína e funcional. O problema não é que ela esteja ausente - é onde no pipeline ela é executada.
A correspondência de rota e o handler de requisição não executam o mesmo número de passagens de decodificação percentual.
Se os separadores forem codificados em percentual, a sequência não é um separador de caminho durante a correspondência de rota. %2e%2e%2f é apenas uma string opaca - texto inerte que o normalizador não tem motivo para tocar. Ela atravessa o roteamento intacta, é correspondida pelo catch-all e é decodificada no caminho para os params do handler, onde se torna ../ novamente.
Nesse ponto, ela é concatenada a UPLOAD_DIRECTORY e entregue a createReadStream() - depois do roteamento, depois da normalização, depois de todo controle que a teria impedido.
Formas funcionais:
GET /uploads/%2e%2e%2fsecretdir%2fsecret.txt → 200, file outside the upload directory
GET /uploads/..%2f..%2f..%2fetc%2fpasswd → 200
GET /uploads/%2e%2e%2f%2e%2e%2f...%2fetc%2fpasswd → 200, returned the real /etc/passwd
A dupla codificação não funciona - %252e permanece literal através da única passagem de decodificação e nunca se torna um ponto. Exatamente uma camada de codificação é o ponto ideal, o que é um lembrete útil de que "codificar mais forte" não é uma estratégia.
O invariante a reter:
Um controle que é executado antes que a decodificação esteja completa não está protegendo o sink.
Uma primitiva de leitura de arquivo é Alta por si só. O que torna isso Crítico é o que ela alcança.
Passo 1 - ler o ambiente. A configuração do próprio processo Node está em disco na raiz da implantação. O .env fornece, entre outras coisas:
JWT_SECRET - a chave de assinatura dos tokens de sessãoDATABASE_URL - credenciais completas do PostgresPasso 2 - forjar uma sessão. O Postiz assina tokens de sessão com JWT_SECRET usando HS256 via jsonwebtoken. Criticamente, os tokens são emitidos sem expiresIn, então um token forjado é válido indefinidamente.
Passo 3 - tornar-se qualquer um. O middleware de autenticação re-resolve o usuário do banco de dados usando a declaração id. Ele deliberadamente não confia em uma declaração como isSuperAdmin do token - bom design - mas esse endurecimento é irrelevante quando você pode assinar um id arbitrário. Assinar { id: <victim user id> } produz uma sessão indistinguível de um login legítimo: