5

Mais uma vez, muito feliz em poder contribuir com o TabNews e com a comunidade!

Como fiz no postmortem anterior, vou tentar detalhar o que acontece por debaixo dos panos nas duas falhas. São vulnerabilidades de naturezas bem diferentes, mas os dois têm em comum o fato de dependerem de comportamentos "escondidos" em camadas que a gente nem sempre olha: uma biblioteca indireta em um caso, e os padrões do navegador no outro.

1. XSS armazenado no feed RSS (quebra de CDATA)

Detalhamento

O feed coloca title, description e content dentro de blocos CDATA. A ideia do CDATA é justamente dizer ao parser de XML "trate isso aqui como texto puro, não interprete". Então, em teoria, colocar o título dentro de um CDATA já resolveria qualquer tentativa de injeção.

O problema não está na decisão de usar CDATA, e sim em como o bloco é escrito. A biblioteca feed@5.2.1 serializa o título como um nó { _cdata: '...' }, e quem de fato monta a string final é a xml-js@1.6.11, usada indiretamente. O trecho relevante dela (lib/js2xml.js, na função writeCdata) é este:

return options.ignoreCdata ? '' : '<![CDATA[' + (...
  : cdata.replace(']]>', ']]]]><![CDATA[>')) + ']]>';

O detalhe fatal está no cdata.replace(']]>', ...). Quando o primeiro argumento de replace() é uma string (e não uma regex global), o JavaScript substitui apenas a primeira ocorrência. Ou seja, a biblioteca "sabe" que ]]> precisa ser escapado, mas só escapa o primeiro que encontra.

Então, se o título tiver duas sequências ]]>, a segunda passa intacta e fecha o bloco CDATA antes da hora. A partir daquele ponto, tudo que vem depois deixa de ser texto e volta a ser XML vivo dentro do documento do feed.

Título de PoC:

A]]>B]]><script xmlns="http://www.w3.org/1999/xhtml">alert(document.domain)</script><![CDATA[C

Reproduzindo a saída do serializador, sai assim:

<title><![CDATA[A]]]]><![CDATA[>B]]><script xmlns="http://www.w3.org/1999/xhtml">alert(document.domain)</script><![CDATA[C]]></title>

Repare na sequência:

  • O primeiro ]]> vira ]]]]><![CDATA[> - escapado corretinho.
  • O segundo ]]> sai literal e fecha o CDATA.
  • O <script> que vem na sequência deixa de ser texto e passa a ser markup de verdade.

O xmlns="http://www.w3.org/1999/xhtml" no <script> é o toque que faz o navegador tratar aquilo como um elemento HTML dentro do documento XML, em vez de uma tag genérica sem significado.

Por que isso vira tomada de conta

O feed é servido pela própria origem (pages/api/v1/contents/rss/index.js responde com Content-Type: text/xml; charset=utf-8) e dá pra acessar em /recentes/rss, /rss e /rss.xml. Como não havia CSP, qualquer <script> injetado rodava no contexto de tabnews.com.br.

E rodar na origem principal é o que transforma um alert() inofensivo em algo sério: o script passa a poder disparar requisições autenticadas pela API usando a sessão de quem abriu o feed. No pior caminho, dá pra trocar o e-mail da vítima (que não pedia reconfirmação de senha) e, na sequência, acionar a recuperação de senha para o e-mail do atacante, fechando a tomada de conta.

Detalhamento

Aqui a falha nasce da soma de três comportamentos que, isolados, parecem inofensivos.

O cookie de sessão não definia SameSite: Em models/session.js, o serialize configurava só httpOnly, secure, path e maxAge:

serialize('session_id', sessionToken, {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  path: '/',
  maxAge: SESSION_EXPIRATION_IN_SECONDS,
})

Sem o atributo, quem decide o comportamento é o navegador, e eles não concordam entre si. Foi o ponto que mais me surpreendeu:

  • Firefox e Safari: sem SameSite, não aplicam Lax por padrão. Mandam o cookie num POST de navegação top-level de outro site.
  • Chrome e Edge: aplicam Lax por padrão, mas com uma janela de exceção de ~2 minutos logo após a criação do cookie, durante a qual um POST cross-site ainda carrega o cookie.

Ou seja, mesmo no Chrome havia uma brecha curta logo após o login, bem no momento em que a pessoa está ativa no site.

O body parser do Next.js aceita formulário HTML: O parser padrão do next decodifica application/x-www-form-urlencoded, montando um objeto que os validadores Joi aceitam igual a um body JSON. E application/x-www-form-urlencoded é exatamente o que um <form> HTML puro envia.

Nenhuma checagem de origem: Não havia verificação de Origin, Sec-Fetch-Site nem exigência de Content-Type: application/json no controller nem nas rotas, e nenhum token CSRF.

CORS não protege aqui

É comum pensar "mas o CORS não barra isso?". Não barra, e esse é o ponto que mais passa despercebido.

Um POST de formulário com Content-Type: application/x-www-form-urlencoded é uma "simple request" na definição de CORS. Simple request não dispara preflight. E sem preflight, os headers Access-Control-* do servidor nem entram na decisão do navegador de enviar (ou não) a requisição e o cookie. O CORS controla se o JavaScript do atacante consegue ler a resposta, mas o ataque de CSRF não precisa ler nada, só precisa que a requisição chegue e seja processada.

Por isso as rotas PATCH e DELETE (troca de e-mail, edição, nuke) já estavam, sem querer, protegidas: elas não são simple requests, então o navegador faz preflight, e Access-Control-Allow-Origin: * combinado com credenciais é recusado. Os POST que alteram estado é que ficavam expostos:

<form action="https://tabnews.com.br/api/v1/contents" method="POST">
  <input name="title" value="CSRF PoC" />
  <input name="body" value="Publicado via requisicao cross-site." />
  <input name="status" value="published" />
</form>
<script>document.forms[0].submit();</script>

Bastava a vítima estar logada e abrir a página do atacante. Sem clique, sem token roubado.

Como eu encontrei

Como no open redirect, não foi nada de um dia pro outro. O RSS eu já tinha olhado antes pensando em injection. A virada foi quando fui olhar o CDATA e como ele estava sendo gerado na prática, aí apareceu o replace com string, que só pega a primeira ocorrência.

O CSRF veio de um caminho parecido: testei mandar um POST com corpo de formulário em vez de JSON, só pra ver se a API aceitava, e aceitou. A partir daí foi juntar os pontos sobre SameSite ausente e o fato de simple requests não dispararem preflight.

Reforçando o que falei da última vez: dá pra contribuir de forma ética sem ser especialista em segurança. Basta curiosidade e vontade de ir até a última camada do que está acontecendo.

Obrigado mais uma vez pelas menções e por toda a transparência no postmortem! 🤝

Carregando publicação patrocinada...