Programação Por Equipe DevBras

Capa do artigo: Cookie de sessão com HttpOnly, Secure e SameSite: o JavaScript não precisa ler

Cookie de sessão com HttpOnly, Secure e SameSite: o JavaScript não precisa ler

Configure o cookie de sessão com HttpOnly, Secure, SameSite e prefixo __Host-, entenda o que cada flag protege e o que continua sendo trabalho seu contra XSS e CSRF.

Um comentário com script passou pela validação e foi exibido no painel. O script leu document.cookie, mandou o identificador de sessão para um servidor externo, e alguém entrou na conta do administrador sem saber a senha. O cookie de sessão nunca precisou ser lido pelo JavaScript da página. Estava visível porque ninguém marcou o contrário. A resposta curta: cookie de sessão sai do servidor com HttpOnly, Secure, SameSite=Lax e, de preferência, com o prefixo __Host-. Isso não elimina XSS nem CSRF, mas reduz muito o que um ataque consegue levar.

O ajuste é uma linha de configuração na maioria dos frameworks. O que dá trabalho é entender o que cada flag faz, para não achar que ela resolve mais do que resolve.

O que cada atributo do cookie faz

O navegador guarda o cookie e o devolve ao servidor em cada pedido para o mesmo site. Os atributos dizem quando devolver e quem mais pode ver.

HttpOnly tira o cookie do alcance do JavaScript. Ele continua indo em todo pedido, mas document.cookie não o mostra. Um script injetado não consegue copiar o valor e levar para outro lugar.

Secure faz o navegador enviar o cookie só por HTTPS. Sem ele, um link http:// qualquer para o seu domínio, numa rede Wi-Fi aberta, entrega a sessão em texto puro para quem estiver ouvindo.

SameSite controla o envio em pedidos que vêm de outro site. Com Strict, o cookie nunca vai em pedido iniciado por outro site, nem quando a pessoa clica num link para você, e ela chega parecendo deslogada. Com Lax, o cookie vai quando a pessoa navega para o seu site clicando num link (navegação de nível superior com GET), mas não vai em POST vindo de outro site, em iframe ou em fetch cruzado. Com None, vai sempre, e o navegador exige Secure junto. Alguns navegadores tratam a ausência do atributo como Lax, mas não todos. Declare explicitamente.

O prefixo __Host- no nome do cookie é uma trava do navegador: ele só aceita o cookie se tiver Secure, Path=/ e nenhum Domain. Isso impede que um subdomínio comprometido, como um blog antigo em blog.exemplo.com.br, sobrescreva a sessão do domínio principal.

Como configurar o cookie de sessão

  1. Defina o nome com prefixo __Host-, por exemplo __Host-sessao.
  2. Marque HttpOnly e Secure. Não há motivo para o identificador de sessão ser legível por script.
  3. Use SameSite=Lax como padrão. Considere Strict para áreas administrativas onde chegar deslogado por link externo não incomoda.
  4. Defina Path=/ e não defina Domain, a menos que precise compartilhar entre subdomínios, e aí abra mão do prefixo conscientemente.
  5. Defina um prazo com Max-Age coerente com o risco: horas para painel administrativo, dias para loja, se fizer sentido.
  6. Gere um identificador de sessão novo no momento do login, para que um identificador plantado antes não continue valendo.
  7. Se a aplicação roda atrás de proxy que termina o TLS, informe isso ao framework, senão ele acha que a conexão é HTTP e não envia o cookie com Secure.

O cabeçalho que o navegador deve receber:

Set-Cookie: __Host-sessao=k3J9xQ2vT8pL...; Path=/; Max-Age=28800; Secure; HttpOnly; SameSite=Lax

Em Express com express-session:

const express = require('express');
const session = require('express-session');

const app = express();
app.set('trust proxy', 1);

app.use(session({
  name: '__Host-sessao',
  secret: process.env.SESSAO_SEGREDO,
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    path: '/',
    maxAge: 8 * 60 * 60 * 1000
  }
}));

app.post('/login', express.urlencoded({ extended: false }), async (req, res, next) => {
  const usuario = await autenticar(req.body.email, req.body.senha);
  if (!usuario) return res.status(401).send('Não foi possível entrar.');
  req.session.regenerate((erro) => {
    if (erro) return next(erro);
    req.session.usuarioId = usuario.id;
    res.redirect(303, '/painel');
  });
});

O trust proxy faz o Express confiar no cabeçalho X-Forwarded-Proto do proxy. Sem ele, atrás de Nginx com HTTPS, o express-session simplesmente não envia o cookie com secure: true. A função autenticar é a sua, com senha guardada como hash, como em login com senha protegida. O regenerate troca o identificador no login.

O que HttpOnly e SameSite não resolvem

HttpOnly impede que o script leia o cookie. Não impede que o script use o cookie. Um script injetado na sua página pode chamar fetch('/api/conta', { method: 'POST', ... }), e o navegador anexa o cookie normalmente, porque o pedido sai do seu próprio site. O atacante não leva a sessão para casa, mas faz o que quiser enquanto a página estiver aberta. A defesa contra XSS continua sendo escapar saída, validar entrada e ter uma Content-Security-Policy restritiva.

SameSite=Lax bloqueia o POST de formulário vindo de outro site, que é o CSRF clássico. Mas o conceito de site considera o domínio registrável: app.exemplo.com.br e blog.exemplo.com.br são o mesmo site. Um subdomínio vulnerável consegue enviar pedidos com o cookie. E Lax deixa passar GET de navegação, então rota GET que altera estado continua explorável. Para ações sensíveis, mantenha token anti-CSRF no formulário.

Por fim, nada disso faz sentido se o token de sessão também for copiado para localStorage "para facilitar o front". Aí o script lê de lá, e o HttpOnly vira enfeite. O mesmo raciocínio vale para tokens JWT, discutido em JWT que expira.

Erros comuns

  • Cookie de sessão sem HttpOnly. Qualquer XSS leva a sessão para fora e o ataque continua mesmo depois que a página fecha.
  • Sem Secure em site com HTTPS. Um único acesso por HTTP em rede aberta expõe a sessão.
  • SameSite=None sem necessidade. O cookie volta a ir em todo pedido cruzado, e o CSRF clássico volta a funcionar.
  • Rota GET que altera estado. Lax permite a navegação, então um link ou imagem dispara a ação com a sessão da vítima.
  • Não trocar o identificador no login. Quem plantou um identificador antes do login herda a sessão autenticada.
  • Esquecer o proxy. O framework não manda o cookie com Secure, alguém desliga o Secure para funcionar e a proteção some.

Checklist

  • No console do navegador, document.cookie não mostra o cookie de sessão.
  • Na aba Application do DevTools, o cookie aparece com HttpOnly e Secure marcados.
  • O atributo SameSite está declarado explicitamente.
  • O nome usa o prefixo __Host-, ou há um motivo documentado para não usar.
  • O identificador muda depois do login.
  • Nenhuma rota GET altera dados.
  • Formulários sensíveis têm token anti-CSRF.
  • O token de sessão não é copiado para localStorage.

Abra o seu sistema logado, aperte F12 e digite document.cookie no console. Se o identificador de sessão aparecer na resposta, qualquer script que entrar na página também o vê. E a pergunta seguinte: qual rota GET do seu sistema muda alguma coisa no banco?

Avalie este artigo

Referência: https://developer.mozilla.org/docs/Web/HTTP/Cookies