12

[TabNews – Postmortem] XSS no feed RSS e CSRF na API

Na terça-feira (29/09), fomos informados pelo Renan sobre duas vulnerabilidades no TabNews: um XSS armazenado no feed RSS e um problema de CSRF em requisições autenticadas por cookie. Os dois reports chegaram pelo GitHub (Security advisories) e também por e-mail, e as correções foram feitas na quarta-feira (30/09). 🙌

O Renan também nos ajudou no postmortem anterior sobre Open Redirect, e seguimos muito gratos por essa colaboração. 💪

Embora as falhas pudessem ser exploradas em determinados cenários, não encontramos nenhum indício de que isso tenha acontecido. Mais detalhes na seção sobre a verificação.

O que aconteceu

1. XSS armazenado no feed RSS

O XSS (Cross-Site Scripting) acontece quando um conteúdo escrito por alguém é interpretado como código pelo navegador de outra pessoa.

O feed RSS do TabNews coloca o título, a descrição e o conteúdo das publicações dentro de blocos CDATA, que servem para o XML tratar o texto como texto. Só que uma publicação com uma sequência específica de caracteres no título conseguia fechar esse bloco antes da hora, e o que vinha depois passava a ser interpretado como parte do próprio feed.

Como o feed é servido pelo próprio domínio tabnews.com.br, qualquer JavaScript poderia ser executado quando alguém abrisse o feed no navegador, como um alert ou o disparo de requisições HTTP usando a sessão da pessoa logada. Na prática, isso permitiria agir em nome dela no TabNews, realizando ações pela API.

O CSRF (Cross-Site Request Forgery) acontece quando um site de terceiros faz o navegador enviar uma requisição para outro site em que a pessoa está logada, e o navegador inclui o cookie de sessão automaticamente.

No TabNews, uma pessoa logada que acessasse uma página maliciosa em outro site poderia ter requisições feitas em seu nome, como publicar um conteúdo. Em contas com permissões elevadas, o risco também incluía ações administrativas.

Como corrigimos

Para cada problema, adicionamos mais de uma camada de proteção. Assim, se uma falhar ou for contornada, as outras continuam protegendo.

RSS

  1. Escape de todas as ocorrências da sequência que fecha o CDATA, no título, na descrição e no conteúdo. Essa é a correção principal e foi sugerida no report.
  2. Content-Security-Policy restritivo na resposta do RSS, que também foi sugerido no report. Ele impede que o navegador execute scripts ou carregue recursos a partir do feed, mesmo que algum dia surja uma nova forma de injetar conteúdo nele.

Ambas as camadas têm testes: o escape é coberto por testes unitários do gerador do feed e por um teste de integração que publica um conteúdo malicioso e verifica o XML retornado; o header de CSP tem um teste de integração próprio.

Correção feita no PR #2085, com mais casos de teste criados no PR #2087.

CSRF

  1. Recusa de requisições vindas de outra origem (403). Quando uma requisição com cookie de sessão altera dados (qualquer método diferente de GET, HEAD e OPTIONS), verificamos os headers Sec-Fetch-Site e Origin que o navegador envia. Se a origem for diferente da nossa, a requisição é recusada. Aplicamos essa regra a todos os métodos que alteram dados, e não só aos que foram citados no report.
  2. SameSite=Lax no cookie de sessão. Esse atributo pede ao navegador para não enviar o cookie em requisições originadas por outros sites (com exceção de navegações simples, como clicar em um link).
  3. Content-Type: application/json obrigatório (415) no login e nas requisições com cookie que alteram dados e têm corpo. Um formulário HTML não consegue enviar JSON, e uma requisição feita via JavaScript por outro site com esse header passa pelas regras de CORS do navegador.

Os três pontos têm testes de integração. Vale a ressalva de que esses testes simulam os headers da requisição e conferem o atributo SameSite do cookie, mas não reproduzem o comportamento de um navegador real. Como a maioria dos testes do TabNews, eles exercitam a API diretamente.

Correção feita no PR #2086.

Cuidado extra com a API

Queremos que a API continue acessível para casos de uso legítimos, como aplicativos e integrações, então não podemos restringir demais. Por isso:

  • Requisições sem cookie de sessão não são afetadas, com exceção do login, que agora exige JSON.
  • Requisições que não vêm de navegadores (e portanto não enviam Origin nem Sec-Fetch-Site) continuam funcionando normalmente.

Impacto para quem usa a API: requisições com cookie vindas de outra origem agora recebem 403, e o login e as requisições com cookie que alteram dados recebem 415 se o corpo não for JSON.

Outros problemas que encontramos

Durante as correções, encontramos e resolvemos mais alguns pontos que não estavam nos reports:

  • Caracteres inválidos em XML no feed RSS. Alguns caracteres de controle não são permitidos em XML 1.0, e mesmo dentro de um CDATA eles tornam o feed ilegível para os leitores de RSS. Agora esses caracteres são removidos antes de gerar o feed.
  • Limpeza da configuração de CORS. Removemos o header Access-Control-Allow-Credentials, que não tinha efeito junto com o Access-Control-Allow-Origin: *, e deixamos de permitir o header X-CSRF-Token, que nunca foi usado e podia passar a impressão de que já existia uma proteção contra CSRF.

Verificação de abuso

Para o RSS, consultamos o banco de dados em busca de publicações que explorassem a falha. Não encontramos nenhuma exploração, apenas uma tentativa antiga, sem impacto, de alguém testando se o site renderizava tags HTML (como script), e que já havia sido removida.

Para o CSRF, não há como ter 100% de certeza, mas nada fora do normal foi identificado na moderação.

O que aprendemos

  • Dependências, inclusive indiretas, também podem causar problemas. Parte do comportamento que levou à falha no feed RSS vinha de bibliotecas que usamos indiretamente, e isso reforça a importância de validar o resultado final, e não só a nossa parte do código.
  • Uma única camada de proteção é pouco. Para cada falha, adicionamos pelo menos duas camadas independentes, reduzindo o risco de um problema futuro.
  • Não definir o SameSite do cookie deixa o comportamento a cargo do navegador. Sem o atributo, o Firefox e o Safari enviavam o cookie em requisições de outros sites, enquanto o Chrome e o Edge aplicavam Lax por padrão, mas com uma exceção de cerca de 2 minutos após a criação do cookie. Definir SameSite=Lax explicitamente deixa o comportamento igual em todos eles.
  • O SameSite=Lax ajuda, mas não deve ser a única proteção. Ele ainda envia o cookie em navegações com GET e não distingue subdomínios do mesmo site, então a verificação de origem no servidor continua sendo importante.
  • A proteção contra CSRF precisa equilibrar segurança e uso legítimo da API. Restringir demais poderia quebrar aplicativos e integrações.

O que ainda podemos melhorar

  • Ainda não temos um Content-Security-Policy para o site inteiro. Hoje ele só existe na resposta do RSS.

Como reportar vulnerabilidades

Se você encontrar qualquer problema de segurança no TabNews, pedimos que utilize uma das opções abaixo para fazer o reporte:

Todos os relatos são analisados com prioridade, e agradecemos desde já qualquer colaboração nesse sentido.

Agradecimentos

Nosso agradecimento ao Renan, mais uma vez, que identificou e compartilhou as duas vulnerabilidades de forma ética, com detalhes que facilitaram a reprodução das falhas e a implementação das correções. 🤝

Carregando publicação patrocinada...
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! 🤝