
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 , porque o Next.js colapsa os segmentos do roteamento. Mas 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.
404../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:
read .env → JWT_SECRET → sign({ id: victim }) → authenticated as victim, forever
Validei isso contra a lógica de verificação real do projeto com a dependência real jsonwebtoken: um token assinado com o segredo recuperado foi aceito, e o mesmo token assinado com um segredo errado foi rejeitado. O caso de controle importa - sem ele, você tem uma suposição, não uma descoberta.
Passo 4 - o caminho paralelo. DATABASE_URL sozinho é suficiente para acesso direto ao Postgres: leia todas as contas conectadas ou altere diretamente um flag de administrador.
Sem senha. Sem acesso prévio. Sem interação do usuário. Uma única requisição HTTP não autenticada.
CVSS 4.0 9.3 AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N
CVSS 3.1 9.8 AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
PR:N e UI:N são as duas métricas que fazem o trabalho pesado. A rota não exige sessão nem interação da vítima - o atacante age sozinho, pela rede, contra uma configuração padrão.
O único limitador honesto é a barreira de configuração: implantações em S3 ou R2 não estão expostas, porque a rota reescreve para /404. Isso reduz a população afetada, mas não a gravidade para quem está dentro dela - e local é o padrão que acompanha o produto.
O patch dos mantenedores (7936062) tem oito linhas e vale a pena ser lido, porque é correto de uma maneira que essas correções frequentemente não são:
+import { resolve, sep } from 'path';
...
- const filePath =
- process.env.UPLOAD_DIRECTORY + '/' + (path ?? []).join('/');
+ const base = resolve(process.env.UPLOAD_DIRECTORY!);
+ const filePath = resolve(base, (path ?? []).join('/'));
+ // Confine reads to UPLOAD_DIRECTORY. resolve() collapses any `..` segments
+ // (including URL-decoded ones), so this blocks every path-traversal variant.
+ if (filePath !== base && !filePath.startsWith(base + sep)) {
+ return new NextResponse('Not found', { status: 404 });
+ }
Duas coisas que ele acerta:
resolve() colapsa .. após a decodificação estar completa, então não importa como a traversal foi contrabandeada pelo roteamento. A verificação agora fica onde está o perigo.base + sep, não com base. Um filePath.startsWith(base) ingênuo aceitaria /app/uploads-evil/x como estando dentro de /app/uploads - um bypass clássico de correspondência de prefixo. Acrescentar o separador fecha isso, e a cláusula filePath !== base mantém o próprio diretório válido.Essa é a forma correta para uma verificação de contenção: resolver e então comparar com a base usando um separador no final.
Todos os horários em UTC, 2026-07-20, salvo indicação em contrário.
| Horário | Evento |
|---|---|
| 05:55 | Aviso relatado à equipe do Postiz |
| 07:44 | Reconhecido e verificado pelos mantenedores |
| 12:18 | Correção commitada, verificada e publicada |
| 2026-08-07 14:13 | CVE-2026-19264 atribuído pelo Postiz (CNA) |
| 2026-08-07 14:15 | GitHub Security Advisory publicado |
Seis horas e vinte e três minutos do relato ao patch enviado, em um projeto de código aberto sem programa de bug bounty. Já tive relatos parados por meses em organizações com equipes de segurança dedicadas. Crédito a Enno Gelhaus pela coordenação e a Nevo David pela remediação.
Um controle que passa no seu teste não significa que o controle está no lugar certo. O 404 era real. O Next.js genuinamente colapsa ../. A defesa simplesmente foi executada antes que a entrada terminasse de ser decodificada, o que significava que ela protegia o roteador em vez da chamada ao sistema de arquivos. Quando você encontrar uma mitigação, pergunte quando ela é executada em relação ao sink - não apenas se ela existe.
Codificação é uma camada, e camadas são removidas em ritmos diferentes. Sempre que dois componentes em um pipeline de requisição discordam sobre quantas vezes decodificar, a lacuna entre eles é explorável. Correspondentes de rota, middlewares e handlers frequentemente discordam.
Classifique uma primitiva de leitura de arquivo pelo que o processo pode alcançar, não pela primitiva. "Leitura arbitrária de arquivo" parece divulgação de informações. Tornou-se Crítica porque o ambiente era legível, o segredo nele assinava sessões e essas sessões nunca expiravam. Siga a cadeia antes de pontuá-la.
Tokens sem expiração transformam um vazamento em um comprometimento permanente. A divulgação de uma chave de assinatura com tokens de curta duração é um dia ruim. Sem expiresIn, é irrecuperável sem rotacionar o segredo - e a maioria dos operadores nunca saberá que precisava.
Execute o caso de controle. Verificar que um token assinado com o segredo errado é rejeitado é o que separa uma descoberta demonstrada de uma suposta.
Se você auto-hospeda o Postiz:
JWT_SECRET está comprometido se você executou uma versão afetada em um host publicamente acessível com STORAGE_PROVIDER=local. Rotacione-o. Como os tokens não têm expiração, a rotação é a única maneira de invalidar qualquer um que tenha sido forjado.DATABASE_URL e quaisquer segredos OAuth dos provedores conectados mantidos no mesmo ambiente.GET para /uploads/ contendo %2e ou %2f.Pesquisa conduzida de forma independente e divulgada ao fornecedor sob divulgação coordenada. Publicada após o patch ser enviado e o aviso se tornar público. Nenhum sistema de terceiros foi acessado - toda a validação foi realizada contra uma instância local construída a partir do código-fonte do próprio projeto.
Este writeup é licenciado sob CC BY 4.0 - compartilhe e adapte livremente com atribuição. Trechos de código de gitroomhq/postiz-app são citados para análise de segurança e permanecem sob a licença desse projeto.
Krithik Babu P - @DarkLycn1976