Resumo teórico
Imagine a sua casa. Ela tem porta da frente, janelas, uma fechadura, uma chave debaixo do tapete e um vizinho que às vezes pede açúcar emprestado. Segurança é o trabalho de pensar em tudo isso: quem pode entrar, o que pode levar e o que fazemos para que a pessoa errada não consiga. Software também tem portas e chaves. Um site tem um login (a porta da frente), cookies (bilhetinhos que dizem "essa pessoa já entrou") e um banco de dados (o armário onde fica tudo que vale a pena guardar).
A maioria das invasões na internet não é coisa de filme, com hacker digitando rápido. Elas acontecem porque um programador confiou em algo que deveria ter verificado: um nome digitado numa caixinha, um número no endereço, um arquivo que alguém enviou. Esta matéria, chamada segurança de aplicações, ensina como os programas falham quando alguém os usa de um jeito que ninguém esperava, para que a gente os construa de modo que não falhem.
Tudo aqui é defensivo. Para cada falha explicamos por que ela acontece e, sempre, como evitá-la. O código que você vê é o menor exemplo necessário para reconhecer o problema, e vem sempre ao lado da correção. As ideias seguem os guias do OWASPOpen Worldwide Application Security Project, um grupo sem fins lucrativos que escreve guias gratuitos de segurança, a referência padrão da área.
No final você vai conhecer as principais famílias de falhas da web (injeção, scripts plantados em páginas, requisições forjadas, senhas fracas, sessões roubadas, tokens quebrados, falta de checagem de permissão, servidores enganados, uploads inseguros), os hábitos que barram cada uma e o básico de criptografia e segredos que sustenta todo o resto.
Como a web funciona: as peças com que um atacante brinca
Antes de estudar como as coisas quebram, precisamos saber como um site normalmente funciona. Pense num restaurante. Você (o navegador) senta à mesa e escreve um pedido num papel. O garçom leva até a cozinha (o servidor). A cozinha cozinha e o garçom traz o prato. O papel do pedido se chama requisição HTTPuma mensagem pequena que o navegador manda ao servidor pedindo algo, como uma página ou uma ação, e o prato é a resposta.
Navegador Servidor
| |
| GET /invoices/1001 + cookie |
| -------------------------------> | busca os dados,
| | monta uma página
| 200 OK + página (HTML) |
| <------------------------------- |
| |Algumas palavras vão voltar o tempo todo. Um cookie é um bilhetinho que o servidor pede ao navegador para guardar e mostrar de novo em toda visita seguinte, como a pulseira de um parque aquático: os funcionários não lembram do seu rosto, só conferem a pulseira. Uma origem é o endereço exato de um site: o protocolo (https), o nome (shop.test) e a porta. Duas páginas de origens diferentes são estranhas uma para a outra, e o navegador faz valer isso com a política de mesma origemuma regra do navegador: um script de uma origem não pode ler dados que pertencem a outra origem.
| Palavra | Imagem do dia a dia | O que é |
|---|---|---|
| Cliente | O freguês na mesa | O navegador ou app do usuário. O usuário o controla por completo |
| Servidor | A cozinha | O programa que escrevemos e protegemos |
| Cookie | Uma pulseira | Um bilhete que o navegador devolve automaticamente a cada requisição |
| Origem | Um endereço de rua completo | Protocolo + nome + porta, por exemplo https://shop.test:443 |
| Entrada não confiável | O bilhete de um estranho | Tudo que vem de fora: formulários, URLs, cabeçalhos, arquivos e até dados lidos de volta do banco |
OWASP Top 10, ameaças e bons hábitos
Por onde começar? Médicos têm uma lista das doenças mais comuns. Quem trabalha com segurança também. O OWASP Top 10 é um documento de conscientização, não é lei nem certificado. Ele agrupa falhas em dez categorias de risco, ordenadas pela frequência com que aparecem em dados reais e por uma pesquisa com profissionais. É um bom ponto de partida para estudar e revisar, mas não é uma lista completa de todas as falhas, e passar numa revisão baseada nele não prova que uma aplicação é segura.
Neste resumo usamos a edição de 2021. A categoria número um é Broken Access Control (controle de acesso quebrado): uma pessoa logada alcança coisas que não são dela. A injeção, que liderava a edição de 2017, foi para o terceiro lugar.
| Posição | Categoria | Em uma frase |
|---|---|---|
| A01 | Broken Access Control | Um usuário lê ou altera o que não deveria |
| A02 | Cryptographic Failures | Dados sensíveis não são protegidos, ou são protegidos mal |
| A03 | Injection | Dados se misturam a código (SQL, shell) e rodam como código |
| A04 | Insecure Design | O próprio plano não tem um controle necessário |
| A05 | Security Misconfiguration | Padrões, recursos abertos e erros barulhentos esquecidos em produção |
| A06 | Vulnerable and Outdated Components | Bibliotecas antigas com falhas conhecidas |
| A07 | Identification and Authentication Failures | Senhas fracas, sessões fracas, login fraco |
| A08 | Software and Data Integrity Failures | Atualizações e dados aceitos sem checar de onde vieram |
| A09 | Security Logging and Monitoring Failures | Ninguém percebe a invasão |
| A10 | Server-Side Request Forgery (SSRF) | O servidor é enganado e chama lugares que não deveria |
Pensando como defensor: modelagem de ameaças
Modelagem de ameaçasdesenhar o sistema e perguntar o que pode dar errado, antes de construí-lo é responder quatro perguntas cedo: O que estamos construindo? O que pode dar errado? O que vamos fazer a respeito? Fizemos um bom trabalho? Ela rende mais na fase de projeto, antes de existir código, porque mudar um desenho é barato e mudar um sistema pronto é caro. Quando a arquitetura muda, você faz de novo.
Uma lista prática para "o que pode dar errado" é o STRIDE. Cada letra é um tipo de ameaça, e cada uma quebra uma propriedade que queremos manter. Saber qual propriedade foi quebrada indica o tipo de controle a usar.
| Letra | Ameaça | Propriedade quebrada | Controle típico |
|---|---|---|---|
| S | Spoofing: fingir ser outra pessoa | Autenticidade | Autenticação forte |
| T | Tampering: alterar dados | Integridade | Assinaturas, checagens no servidor |
| R | Repudiation: negar que fez algo | Não repúdio | Logs e trilhas de auditoria |
| I | Information disclosure: vazar segredos | Confidencialidade | Criptografia, controle de acesso |
| D | Denial of service: fazer parar de funcionar | Disponibilidade | Limites, capacidade, filtragem |
| E | Elevation of privilege: ganhar mais direitos | Autorização | Checagem de permissões, privilégio mínimo |
Princípios que aparecem em todo lugar
- Privilégio mínimo: cada pessoa, programa ou conta de banco recebe só as permissões de que o trabalho precisa. Quem cuida do bichinho da turma precisa da chave da sala, não da escola inteira. Se o app só lê produtos, a conta dele no banco não pode ter permissão de apagar tabelas.
- Defesa em profundidade: empilhe várias proteções independentes, porque qualquer uma pode falhar. Um castelo tem fosso, muralha e guardas.
- Seguro por padrão e negar por padrão: a escolha segura é a que você recebe quando não faz nada.
- Design inseguro versus implementação insegura: um bug é código que não faz o que o plano dizia. Design inseguro é código que faz exatamente o que o plano dizia, quando o plano esqueceu um controle. Exemplo: uma loja deixa qualquer pessoa reservar estoque ilimitado por 24 horas sem pagar. Robôs reservam tudo. O código não tem bug; falta um limite na regra. A correção é redesenhar a regra, e a prevenção é escrever casos de abuso ao lado dos casos de uso.
- Configuração insegura: o código está bom, mas a implantação não. Contas padrão
admin/admin, listagem de diretórios ligada e páginas de erro mostrando o stack trace completo são exemplos clássicos. A cura é um processo de endurecimento (hardening) repetível, igual em todos os ambientes.
Criptografia e TLS: códigos secretos que protegem a estrada
Criptografiaa ciência de transformar informação numa forma que só as pessoas certas conseguem usar ou verificar é a caixa de ferramentas por trás das senhas, dos tokens e do cadeado do navegador. Você não precisa da matemática. Precisa saber qual ferramenta responde a qual pergunta.
Duas maneiras de trancar uma mensagem
A criptografia simétrica usa a mesma chave para trancar e destrancar, como um diário com uma chave que os dois amigos têm. É muito rápida, mas há um problema: como os dois combinam a chave sem que ninguém mais escute? A criptografia assimétrica usa um par de chaves. A chave pública pode ser mostrada ao mundo todo, como a abertura da caixa de correio onde qualquer um joga cartas. A chave privada fica em segredo, como a chave que abre a caixa. Ela resolve o problema de compartilhar, mas é bem mais lenta.
| Aspecto | Simétrica | Assimétrica |
|---|---|---|
| Chaves | Uma chave secreta compartilhada | Uma chave pública e uma chave privada |
| Velocidade | Muito rápida | Bem mais lenta |
| Problema principal | Como compartilhar a chave com segurança | O custo, por isso é usada só por um instante |
| No TLS serve para | Cifrar todos os dados da conexão | O handshake: provar quem é o servidor e combinar as chaves |
Sistemas reais usam as duas, o que se chama criptografia híbrida. O TLS usa a ferramenta assimétrica, lenta, uma vez no início, para combinar um segredo sem nenhum segredo anterior. Depois disso, tudo é protegido com a ferramenta simétrica, rápida.
Hash não é criptografia
Uma função hash é como um moedor de carne: qualquer entrada entra, uma "impressão digital" de tamanho fixo sai, e não dá para transformar a carne de volta. Ela é de mão única. Uma cifra é uma tranca reversível: com a chave você recupera o original. Elas atendem a necessidades diferentes, e usar uma no lugar da outra é um erro clássico.
| Ferramenta | Reversível? | Tamanho da saída | Use para |
|---|---|---|---|
| Hash (por exemplo SHA-256) | Não | Sempre igual | Verificar que um arquivo não mudou |
| Cifra (por exemplo AES) | Sim, com a chave | Cerca do tamanho da entrada | Manter dados em segredo |
| MAC / HMAC | Não | Fixo | Verificar que uma mensagem não foi alterada por quem não tem a chave |
| Assinatura digital | Não | Fixo | Provar quem escreveu uma mensagem quando ninguém compartilha chave |
Por que um hash simples não basta para proteger uma mensagem no caminho? Porque se alguém no meio alterar a mensagem, basta calcular um novo hash para a nova mensagem. O hash não tem segredo nenhum. Um MACcódigo de autenticação de mensagem: uma impressão digital misturada com uma chave secreta, de modo que só quem conhece a chave consegue produzir uma válida como o HMAC mistura uma chave secreta ao hash. Sem a chave, ninguém produz um valor válido para uma mensagem alterada. Quando os dois lados não compartilham chave, uma assinatura digital faz o mesmo papel.
TLS: o que o cadeado realmente significa
O TLS é o protocolo por trás do https. Trancar a conversa não basta, porque você pode estar trancando para a pessoa errada. Por isso o servidor mostra um certificado, um documento assinado por uma autoridade confiável dizendo "esta chave pública pertence a este domínio". O navegador o valida. O que isso prova? Que a chave pública realmente pertence ao domínio que você visita, e que o servidor tem a chave privada correspondente. Não prova que o site é honesto. Um site golpista pode ter um cadeado perfeitamente válido.
1. Navegador: "oi, quero shop.test"
2. Servidor : certificado (chave pública + assinatura)
navegador confere: é de shop.test?
3. Ambos : combinam chaves de sessão novas
(assimétrica, Diffie-Hellman efêmero)
4. Daí em diante: criptografia simétrica rápida
+ checagem de integridade em cada mensagemHá mais uma propriedade legal. Suponha que alguém grave todo o seu tráfego cifrado hoje e, daqui a cinco anos, roube a chave privada do servidor. Dá para ler as gravações antigas? Com o TLS 1.3, não. Isso se chama sigilo perfeito de encaminhamento (forward secrecy). Cada conexão cria suas chaves com uma troca Diffie-Hellman efêmerade vida curta: usada uma vez e depois jogada fora, com números aleatórios descartados no fim. A chave do certificado serve só para provar identidade, não para proteger o tráfego, então roubá-la depois não abre nada do passado.
↑ Voltar ao sumárioInjeção: quando dados viram comandos
Imagine que você diz a um garçom robô: "Traga a sopa". Agora uma criança travessa escreve na comanda: "sopa, e também abra o caixa". Se o robô lê a comanda como uma lista longa de instruções, ele obedece às duas. Isso é injeção: o programa deveria tratar o que o usuário escreveu como simples dados, mas deixa esses dados serem lidos como código. Os dois se misturaram num mesmo pedaço de texto.
Na injeção de SQL o "robô" é o banco de dados. O programa monta a consulta colando textos, então um valor com uma aspa pode mudar a forma do comando. O problema é a cola, não o usuário.
const email = body.email; // vem do usuário
const sql = "SELECT id, name FROM users WHERE email = '" + email + "'";
const result = await db.query(sql);
// um email como x' OR '1'='1 muda o significado da consultaconst sql = "SELECT id, name FROM users WHERE email = $1";
const result = await db.query(sql, [email]);
// o banco fixa a forma do comando primeiro,
// e só depois recebe email como valorPor que a segunda versão funciona? O banco primeiro lê o texto com os marcadores ($1, ou ? em outros bancos) e decide o que é o comando. Só depois recebe os valores, e um valor só pode preencher uma posição de dado. Então x' OR '1'='1 é só um email estranho para comparar, não uma condição. Isso se chama consulta parametrizadatambém chamada de prepared statement: o texto SQL e os valores viajam separados, e é a defesa principal porque remove a causa.
Outras defesas e coisas que não funcionam
- Validação (o valor tem o tamanho, o tipo e o formato certos?) é uma boa camada extra. Ela decide se um valor faz sentido. Não substitui parâmetros. Um campo de sobrenome ainda deve aceitar
O'Brien, então você aceita o apóstrofo e envia o valor como parâmetro. - Privilégio mínimo: uma conta de banco que só lê o que precisa limita o estrago se algo escapar.
- Listas de bloqueio e escape manual (remover aspas, banir palavras) tratam sintomas e são fáceis de contornar. Não conte com elas.
- ORMs (bibliotecas que escrevem o SQL por você) parametrizam seus métodos normais, então reduzem muito o risco. Mas SQL cru montado com interpolação de strings dentro de um ORM é tão injetável quanto qualquer outro.
- Dados do seu próprio banco também não são confiáveis. Se um nome foi guardado com segurança e depois colado em outra consulta, essa segunda consulta é vulnerável. Parametrizar é regra para toda consulta, não só para o que acabou de chegar.
- Stored procedures não são mágica. Se uma procedure monta SQL dinâmico colando o argumento numa string, ela é injetável. Dentro do banco, o SQL dinâmico precisa dos próprios parâmetros (no PostgreSQL,
EXECUTE ... USING).
Quando a estrutura precisa mudar
Marcadores representam valores, não nomes de colunas, nomes de tabelas ou ASC/DESC. Suponha que uma lista possa ser ordenada por price ou name. Não cole a palavra recebida na consulta. Use a palavra só como chave de uma lista fixa e pegue o nome real da coluna dessa lista.
const columns: Record<string, string> = { price: "price", name: "name" };
const column = columns[query.sort];
if (!column) return status(400);
const sql = `SELECT id, name FROM products ORDER BY ${column}`;
// o texto vem da nossa lista, nunca do usuárioParâmetros também não mudam o que um valor significa dentro de uma mini-linguagem própria. Em LIKE '%' || $1 || '%', se o usuário enviar %, não há injeção, mas % ainda funciona como o curinga "qualquer coisa" e casa com todas as linhas. Se a busca precisa ser literal, escape % e _ no valor.
A mesma ideia com o sistema operacional
A injeção de comandos é o mesmo erro com outro interpretador: o shell. Um servidor que monta o texto ping -c 1 mais um host digitado pelo usuário e entrega a um shell está em apuros, porque caracteres como ;, | e && significam "rode outro comando" para um shell. A cura: não use o shell. Inicie o programa diretamente, informando o executável e uma lista de argumentos, para que cada argumento chegue como dado, e valide o host contra um formato estrito.
// Vulnerável: um shell lê a string inteira
exec("ping -c 1 " + host);
// Corrigido: sem shell, argumentos em lista, host validado antes
if (!/^[a-zA-Z0-9.-]{1,253}$/.test(host)) return status(400);
spawn("ping", ["-c", "1", host]);Cross-site scripting (XSS): o script de um estranho na sua página
A injeção tem uma prima em que o "robô" é o navegador. Um navegador lê texto HTML e constrói a página a partir dele. Alguns pedaços de HTML são código, como um bloco <script> ou um atributo como onerror="...". No cross-site scripting (XSS), um script escrito por outra pessoa vai parar dentro da sua página, e o navegador não consegue saber que não foi você quem o escreveu. Ele roda com a origem do site vulnerável, então enxerga a página, lê o que a página pode ler e age em nome de quem está com a página aberta.
A causa raiz é sempre a mesma: dados foram escritos num lugar que é interpretado como código sem serem codificados para aquele lugar. Há três tipos clássicos, e a diferença é só como os dados chegam à página.
| Tipo | Como os dados chegam | Exemplo |
|---|---|---|
| Armazenado (stored) | O servidor guarda e serve a todos que abrem a página | Um comentário com uma tag de script, mostrado a todo leitor do artigo |
| Refletido (reflected) | O servidor devolve na hora algo que veio na requisição | Uma página de busca que imprime o texto buscado sem alterar |
| Baseado em DOM | O JavaScript da própria página lê os dados e os insere na página. O servidor pode nunca vê-los | Um script que coloca na página a parte depois do # do endereço |
Defesa 1: codificar na saída
Ao escrever dados do usuário em HTML, transforme os caracteres de significado especial em equivalentes inofensivos: < vira <, > vira >, & vira &, e as aspas também são trocadas. O navegador então mostra os caracteres como texto em vez de lê-los como tags. Nada é apagado; o visitante vê exatamente o que foi digitado.
Sem codificação:
servidor escreve : <span> <script>bad()</script> </span>
navegador vê : uma tag script -> EXECUTA
Com codificação:
servidor escreve : <span> <script>bad()</script> </span>
navegador vê : texto puro -> mostrado, nunca executaCodifique na hora da saída, não ao salvar no banco. A codificação certa depende de onde o dado cai (texto HTML, atributo, URL, JavaScript, CSS), e isso você só sabe no momento de escrever a página. Um dado guardado já codificado para HTML ficaria errado numa API JSON e continuaria inseguro dentro de JavaScript. O contexto importa: algo inofensivo num lugar pode ser código em outro. Num href ou src, por exemplo, o esquema da URL também precisa ser checado, porque um link javascript: executa código ao ser clicado. Aceite só http: e https:, de uma lista de permissão.
Defesa 2: APIs seguras do navegador
No XSS baseado em DOM o servidor não tem nada para codificar, porque a parte perigosa nunca viaja até ele. A correção fica dentro do script: use APIs que tratam o valor como texto.
const name = decodeURIComponent(location.hash.slice(1));
// Vulnerável: innerHTML interpreta o texto como HTML
// document.querySelector('#greeting').innerHTML = 'Olá, ' + name;
// Corrigido: textContent sempre insere texto puro
document.querySelector('#greeting').textContent = 'Olá, ' + name;| Seguro: trata o valor como texto | Perigoso: interpreta o valor como marcação |
|---|---|
textContent, createTextNode | innerHTML, outerHTML |
setAttribute para atributos que não executam código | document.write, insertAdjacentHTML |
Frameworks, sanitizadores e o que sobra
- Frameworks modernos de interface codificam automaticamente os valores que você interpola nos templates. O XSS volta quando você usa a saída de "HTML cru" com dados do usuário, coloca uma URL do usuário num
hrefou mexe direto no DOM. - Se os usuários precisam poder enviar formatação (negrito, listas, links), a codificação a estragaria. Passe o HTML por um sanitizador mantido que preserve só uma lista de permissão de tags, atributos e esquemas de URL. Listas de permissão falham de forma segura: tudo que é desconhecido é removido. Não escreva um sanitizador você mesmo; são muito difíceis de acertar.
- Cookies
HttpOnlysão uma mitigação, não uma correção. O script não consegue ler aquele cookie, mas ainda pode enviar requisições como a vítima, porque o navegador anexa o cookie sozinho. A correção continua sendo codificação e destinos seguros, com a Content Security Policy como camada extra (próxima seção).
Content Security Policy e cabeçalhos de segurança
A codificação é a defesa principal contra o XSS, mas programadores esquecem coisas. Então acrescentamos uma rede de segurança: dizemos ao navegador, de antemão, o que a página pode fazer. Pense numa excursão da escola: a professora entrega ao motorista do ônibus uma lista escrita dos lugares que a turma pode visitar. Se um estranho tentar incluir uma parada, o motorista recusa. Essa lista é uma Content Security Policy (CSP).
A CSP é um cabeçalho HTTP enviado pelo servidor. É uma lista de permissão aplicada pelo navegador: diz de quais origens a página pode carregar e executar conteúdo. Ela reduz o estrago quando a codificação de saída falha em algum ponto, mas não a substitui.
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'| Diretiva | Significado |
|---|---|
script-src 'self' | Só arquivos de script da mesma origem rodam. Blocos <script> inline e handlers inline como onclick são recusados |
default-src 'self' | Tudo que não foi listado (imagens, estilos, conexões) só da mesma origem |
object-src 'none' | Sem plugins como <object> ou <embed> |
base-uri 'none' | Sem elemento <base> que poderia mudar para onde apontam os endereços relativos |
form-action 'self' | Formulários só podem ser enviados para a mesma origem |
frame-ancestors 'none' | Ninguém pode colocar esta página dentro de um <iframe> |
Com script-src 'self', se um comentário mal codificado deixar passar um script inline para a página, o navegador roda /app.js (um arquivo da mesma origem) e bloqueia o script inline. Esse é o ganho principal: a maioria das injeções parece marcação dentro da página. Para se beneficiar, o seu JavaScript precisa ficar em arquivos, não em atributos de evento.
Não quebre a rede
Quando código antigo precisa de scripts inline, alguém pode ser tentado a escrever script-src 'self' 'unsafe-inline'. A palavra-chave tem esse nome por um motivo: ela devolve ao atacante o caminho mais fácil, e a política passa a valer pouco contra XSS. As formas certas de manter um script inline necessário são movê-lo para um arquivo ou autorizá-lo individualmente com um nonce ou um hash.
Um nonce"number used once", número usado uma vez: um valor imprevisível gerado novo a cada resposta é colocado tanto no cabeçalho (script-src 'nonce-R4nd0m') quanto no atributo nonce das suas próprias tags de script. Um script injetado não conhece o valor desta resposta e é bloqueado. Se o valor for fixo num arquivo de configuração, ele é igual em toda página, qualquer um pode lê-lo uma vez e colocá-lo na própria tag, e a proteção acaba.
Implantando com segurança
Num site grande e antigo, ligar uma política estrita pode quebrar páginas que usam algo que você esqueceu. O caminho seguro é o Content-Security-Policy-Report-Only. O navegador avalia a política sem aplicá-la e envia relatórios de violação. Corrija o que os relatórios mostrarem e, quando estiverem limpos, mova a mesma política para o cabeçalho Content-Security-Policy de verdade.
Outros cabeçalhos de segurança
| Cabeçalho | O que faz | Por que importa |
|---|---|---|
Strict-Transport-Security (HSTS) | Diz ao navegador para usar só HTTPS neste domínio durante o max-age | Sem ele, a primeira requisição pode ir por HTTP e alguém no caminho pode impedir a troca para HTTPS. Só vale quando recebido por HTTPS |
X-Content-Type-Options: nosniff | O navegador deve respeitar o Content-Type declarado e não adivinhar | Impede que um arquivo enviado cheio de HTML seja executado porque o navegador achou que era uma página |
frame-ancestors (na CSP) | Declara quem pode colocar a página num frame | Defende contra clickjacking: sua página carregada invisível no frame de outro site para roubar cliques. 'none' não permite ninguém, 'self' só a sua própria origem |
CSRF e cookies SameSite: requisições forjadas
Imagine uma pizzaria que entrega para quem tiver o seu cartão fidelidade. Um brincalhão não precisa roubar o cartão. Ele só grita "Ei, pizzaria, entregue 50 pizzas neste endereço!" de um jeito que faz o seu próprio celular fazer o pedido, e a pizzaria vê o número do seu cartão e entrega. Isso é cross-site request forgery (CSRF): outro site faz o navegador da vítima enviar uma requisição que o site alvo aceita como se fosse da vítima.
Repare no que o atacante não faz. Ele não lê a resposta nem rouba a senha. Só precisa que uma ação aconteça com os direitos da vítima, como trocar um e-mail ou transferir dinheiro. Por isso o CSRF mira requisições que mudam estado.
Ele funciona por causa de como os cookies se comportam. Um cookie de sessão é uma credencial ambiente: o navegador o anexa sozinho a toda requisição para aquele site, mesmo quando a requisição foi iniciada por uma página de outro site. O servidor recebe uma requisição perfeitamente válida e não tem como saber que o usuário não quis aquilo.
página evil.test Navegador bank.test
| form oculto p/ bank.test | |
| -------------------------> | |
| | POST /transfer |
| | + cookie bank.test |
| | -----------------> |
| | servidor: sessão |
| | válida, executa! |Defesa 1: cookies SameSite
O atributo SameSite diz ao navegador quando o cookie pode ser enviado em requisições entre sites. Um site é o domínio registrável (por exemplo example.com), uma ideia maior que a de origem.
| Valor | O cookie é enviado | Contrapartida |
|---|---|---|
Strict | Só em requisições feitas a partir do mesmo site | O mais seguro. Quem chega por um link externo parece "deslogado" na primeira página |
Lax | Também em navegações de nível superior com método seguro, como clicar num link (GET) | Um bom padrão, mas supõe que GET não tem efeitos colaterais |
None | Sempre, inclusive entre sites; exige Secure | Nenhuma proteção contra CSRF |
O Lax não é mágico. Se o seu site tem GET /account/delete, um link em outro site apontando para lá será clicado pela vítima e o navegador envia o cookie, então a conta é apagada. Nunca altere dados em GET. Use POST, PUT, PATCH ou DELETE, protegidos por um token.
Lembre também que o SameSite protege contra outros sites, não contra outras origens do mesmo site. Se app.example.com e pages.example.com compartilham o domínio registrável example.com, e clientes publicam o próprio HTML em pages, então uma página publicada lá é do mesmo site que a sua aplicação. O SameSite=Strict não segura o cookie. Coloque conteúdo não confiável num domínio registrável separado e mantenha o token como defesa principal.
Defesa 2: o token anti-CSRF
No padrão do token sincronizador, o servidor gera um valor aleatório ligado à sessão, coloca-o num campo oculto dos seus formulários e confere em todo POST. O cookie vai sozinho, mas o token não é enviado automaticamente. A página forjada não consegue ler a sua página real para copiar o token, porque a política de mesma origema regra do navegador que impede uma página de uma origem de ler páginas de outra proíbe.
<form method="post" action="/transfer">
<input type="hidden" name="csrf" value="9f3a...aleatorio-por-sessao">
<input name="amount">
</form>
<!-- servidor: recusa o POST se csrf faltar ou não bater -->Isso também explica por que o XSS derrota o token: um script injetado está na mesma origem, então tem permissão para ler o token.
Dois mal-entendidos comuns
- "Não configuramos CORS, então outros sites estão bloqueados." Errado. A política de mesma origem e o CORS protegem a leitura de respostas entre origens. Eles não impedem que um POST simples de formulário seja enviado e processado. O CSRF não precisa ler nada. A defesa precisa viver no servidor, com token e
SameSite. - "Nossa API usa um token Bearer, estamos expostos?" Pouco. Se o token fica na memória e é enviado explicitamente no cabeçalho
Authorization, o navegador não o anexa a requisições forjadas vindas de outro site. O CSRF é um problema de credenciais ambiente: cookies, autenticação Basic, certificados de cliente. O preço é que scripts da página alcançam o token, então a defesa contra XSS pesa ainda mais.
Autenticação: senhas, hashing e mais de um fator
Duas palavras são fáceis de confundir. Autenticação responde "quem é você?". Autorização responde "você pode fazer isso?". Num cinema, mostrar o ingresso na porta é autenticação; poder entrar só na sala 3 e não na sala 5 é autorização. Um sistema pode fazer a primeira com perfeição e a segunda mal, e vice-versa. São controles diferentes, com falhas diferentes.
Como guardar uma senha
O servidor nunca precisa saber a sua senha, só reconhecê-la. Então guarda a saída de uma função de mão única e, no login, repete o cálculo com o que você digitou e compara. Senhas não são criptografadas (quem pegasse a chave levaria todas as senhas); elas passam por hash.
Dois ingredientes tornam um hash adequado para senhas. Primeiro, um salt: um valor aleatório, diferente para cada senha e guardado junto do hash. Sem ele, dois usuários com a mesma senha têm o mesmo hash, e um atacante pode usar uma tabela pré-calculada de senhas comuns para quebrar muitas contas de uma vez. Com salt, cada linha é um problema separado. O salt não é secreto. Segundo, o hash precisa ser lento de propósito.
Por que lento? Hashes rápidos como MD5 ou SHA-256 foram feitos para conferir arquivos depressa, o que é ótimo para arquivos e péssimo para senhas, porque quem tem uma tabela roubada pode tentar milhões de palpites por segundo. Argon2id, scrypt e bcrypt adicionam um custo que você pode aumentar com o tempo; Argon2id e scrypt também exigem muita memória, o que torna as placas de vídeo (GPUs) caras de usar. Um SHA-256 com salt continua inadequado: o salt impede o ataque em massa, mas só o custo atrasa cada conta individual.
- MD5, sem salt
- 843,636 hashes por segundo
- SHA-256 + salt de 16 bytes
- 548,724 hashes por segundo
- Argon2id 19 MiB, t=2
- 22 hashes por segundo
- Argon2id 64 MiB, t=3
- 3.5 hashes por segundo
Leia o gráfico como o número de palpites por segundo que um atacante pode tentar numa tabela roubada, por núcleo. O Argon2id faz cada palpite custar cerca de 45 milissegundos e dezenas de megabytes de memória, aproximadamente 38.000 vezes mais que o MD5 naquela execução. Um usuário paga esse custo uma vez por login e não percebe. Máquina, versões e comando estão registrados nos resultados do lab.
| Esquema | Mão única | Salt | Lento e exigente em memória |
|---|---|---|---|
| Texto puro | não | não | não |
| MD5 | sim | não | não |
| SHA-256 com salt | sim | sim | não |
| Argon2id | sim | sim | sim |
Um pepper é diferente de um salt. O pepper é um segredo compartilhado por toda a aplicação e guardado fora do banco de dados (num cofre ou no ambiente). Ele ajuda quando só o banco vaza, por exemplo por uma injeção de SQL ou um backup roubado. Se o servidor inteiro for tomado, o pepper vaza junto, então é uma camada extra, não uma substituição.
E se um sistema antigo guarda só MD5(senha) e queremos todos protegidos por Argon2id agora, sem saber as senhas? Guarde Argon2id(MD5 antigo) para todas as contas de uma vez e registre qual esquema cada linha usa. No próximo login bem-sucedido de cada usuário, você tem a senha de verdade em mãos, então guarde o Argon2id simples e descarte o invólucro.
Telas de login e políticas
- Diga a mesma coisa em toda falha. Se a tela responde "usuário não encontrado" para e-mails desconhecidos e "senha errada" para os conhecidos, você entrega uma lista de quem tem conta. Responda "credenciais inválidas" sempre, com o mesmo código de status e tempo parecido. O mesmo vale para as telas de cadastro e de recuperação de senha.
- Limite tentativas, mas pense em quem controla o contador. Bloquear uma conta para sempre após 3 senhas erradas deixa qualquer um bloquear qualquer um. Use bloqueios curtos que crescem, limites por conta e por origem, e um segundo fator.
- Siga a orientação atual (NIST SP 800-63B). Exija um tamanho mínimo, aceite frases longas e qualquer caractere, permita colar, recuse senhas já encontradas em vazamentos e não force trocas periódicas sem motivo. Tamanho e unicidade valem mais que regras estranhas de complexidade.
Fatores
Um fator é uma categoria de prova. Há três: algo que você sabe (senha, PIN), algo que você tem (um celular, uma chave de segurança) e algo que você é (impressão digital, rosto). A autenticação multifator de verdade combina categorias diferentes, para que roubar uma senha não baste. Uma senha mais um código temporário de um app autenticador são dois fatores. Uma senha mais uma pergunta secreta não são: ambas são "algo que você sabe".
↑ Voltar ao sumárioJWT, OAuth 2.0 e OpenID Connect: tokens e login delegado
Alguns sistemas não guardam sessão no servidor. Em vez disso, entregam a você um bilhete assinado, um JSON Web Token (JWT), e confiam no que está escrito nele, desde que a assinatura confira. É como uma pulseira de show com holograma: o segurança não liga para a bilheteria, só confere o holograma. Um JWT são três textos unidos por pontos:
base64url(header) . base64url(payload) . assinatura
| | |
como foi assinado as claims prova de que o emissor
{"alg":"HS256"} {"sub":"bob", escreveu as duas
"exp":1800000900} primeiras partesDois fatos moldam tudo. Primeiro, o payload é apenas codificado em base64url, não cifrado. Qualquer pessoa com o token o lê com qualquer ferramenta, então nunca coloque segredos ou dados pessoais desnecessários nele. A assinatura garante que nada foi alterado e quem emitiu, não o sigilo. Segundo, o header é escrito por quem envia. Tudo que o verificador lê dele antes de checar a assinatura é uma sugestão de um estranho.
As checagens que um verificador deve fazer
| Checagem | Por quê |
|---|---|
O alg do header bate com o algoritmo que o servidor espera | Senão quem envia decide se haverá alguma verificação. O algoritmo none significa "sem assinatura"; um verificador que obedece ao token aceita um bilhete forjado |
| A assinatura é válida | Prova que foi emitido por quem tem a chave e não foi alterado |
exp é posterior ao momento atual (uma pequena tolerância é aceitável) | A expiração limita o estrago de um token vazado. Um verificador que nunca compara exp aceita tokens antigos para sempre |
iss é o emissor em quem você confia | O token vem do lugar certo |
aud inclui este serviço | Um token feito para o serviço de relatórios não deve funcionar no serviço de pagamentos, mesmo com assinatura correta |
Uma assinatura válida sozinha não basta: o token pode ser autêntico mas expirado, ou autêntico mas destinado a outro serviço. Bibliotecas maduras checam tudo isso por padrão e você passa a elas uma lista explícita de algoritmos permitidos (normalmente um). Verificadores escritos à mão costumam esquecer etapas.
Outra armadilha é o segredo. Com HS256, a mesma chave assina e verifica, então toda a força é a aleatoriedade da chave. Se a chave é uma palavra humana como secret, um atacante que tenha um token capturado pode testar palpites no próprio computador, offline, sem tocar no servidor e sem nenhum bloqueio, até um reproduzir a assinatura. Aí ele pode assinar qualquer payload. Uma chave HS256 precisa de pelo menos 256 bits gerados aleatoriamente, nunca uma palavra memorável.
Além disso, um token sem estado não pode ser recolhido: depois de um logout ou roubo ele funciona até o exp. O desenho usual é um token de acesso de vida curta (minutos) mais um refresh token guardado no servidor, rotacionado e revogável.
OAuth 2.0 e OpenID Connect
Imagine que você dá ao manobrista a chave do carro, mas uma chave especial que só abre as portas e liga o motor, não o porta-luvas nem o porta-malas. O OAuth 2.0 é essa ideia: permite que um app aja em seu nome com direitos limitados (os scopes), sem nunca ver a sua senha. Ele resolve a autorização delegada: "este app pode acessar aquele recurso em nome do usuário?" O resultado é um access token destinado a uma API.
O OAuth não diz quem é o usuário. O OpenID Connect (OIDC) é uma camada fina sobre o OAuth que adiciona autenticação. Ele entrega um ID token, um JWT assinado com a identidade do usuário, e um endpoint de informações do usuário.
| Aspecto | Access token | ID token |
|---|---|---|
| Responde | Este app pode chamar aquela API, com quais scopes? | Quem fez login? |
| Destinado a | A API (servidor de recursos) | O próprio app cliente (o aud é o id do cliente) |
| Enviado à API? | Sim, em Authorization: Bearer | Não. Uma API que aceita ID tokens deixa de checar se a permissão foi concedida para ela |
Apps como aplicativos móveis e single-page applications não conseguem guardar um segredo de cliente. O fluxo recomendado hoje para todo cliente é o fluxo de código de autorização com PKCE, não o antigo fluxo implícito. Com o PKCEProof Key for Code Exchange: um segredo gerado novo a cada tentativa de login, o app cria um code_verifier aleatório, envia o hash dele (code_challenge) ao pedir o código e envia o valor original ao trocar o código por tokens. Quem intercepta o código de autorização mas não iniciou o fluxo não tem o verifier, então o código é inútil para essa pessoa.
Controle de acesso: IDOR, papéis e mass assignment
Numa escola, a carteirinha de estudante deixa você entrar no prédio (autenticação). Mas ela não abre a sala dos professores nem os armários dos outros alunos (autorização). O controle de acesso quebrado acontece quando um programa esquece de checar a segunda parte. Foi a categoria número um do OWASP Top 10 (2021).
A forma mais famosa é o IDOR (insecure direct object reference, referência direta insegura a objeto). Um usuário logado troca /invoices/1001 por /invoices/1002 na barra de endereço e vê a fatura de outra pessoa. O número na URL, no corpo ou num campo oculto é só um pedido. O servidor precisa decidir, a cada requisição, se este usuário pode acessar aquele objeto. Estar logado não basta.
// Vulnerável: sabe quem você é, nunca pergunta se é seu
const invoice = await db.invoices.findById(params.id);
// Corrigido: o dono faz parte da própria consulta
const invoice = await db.invoices.findOne({
id: params.id,
ownerId: session.user.id,
});
if (!invoice) return status(404);Colocar o dono na consulta (WHERE id = $1 AND owner_id = $2), ou passar por uma única função de política central, faz com que nenhuma rota possa esquecer a regra.
Duas direções da mesma falha
| Direção | O que o usuário alcança | Exemplo |
|---|---|---|
| Horizontal | Dados de outro usuário do mesmo nível | IDOR: ler a fatura de outro cliente |
| Vertical | Uma função de um papel mais alto | Um usuário comum digita /admin/users e a página responde |
Esconder o link "Gerenciar usuários" dos usuários comuns é só navegação. A rota precisa checar o papel por conta própria. Tudo no cliente (páginas, apps, campos ocultos) está sob o controle do usuário. A interface pode esconder coisas por gentileza, mas a decisão de segurança acontece no servidor, a cada requisição, de preferência num único mecanismo reutilizado.
Boas regras
- Negar por padrão. Recuse tudo, exceto o que uma regra permite explicitamente. Com uma lista de permissão, uma regra esquecida bloqueia um uso legítimo, e alguém reclama logo. Com uma lista de bloqueio, uma regra esquecida expõe dados em silêncio. Você está escolhendo o jeito como o sistema falha.
- Ids imprevisíveis não são controle de acesso. Trocar números sequenciais por UUIDs aleatórios é um extra útil contra adivinhação, mas um identificador foi feito para circular. Se outra pessoa obtiver o id por qualquer meio, uma checagem ausente ainda a deixa entrar.
- Cuidado com mass assignment. Se uma rota deixa os usuários editarem o próprio perfil e faz
update(session.user.id, body), um usuário pode acrescentar"role": "admin"ao corpo e se promover, mesmo com a posse checada. A autorização vale para os campos também. Valide o corpo com um esquema que liste os campos permitidos e recuse o resto. - Escolha o código de recusa de propósito. Um serviço de repositórios privados pode responder 404 em vez de 403 quando o repositório existe mas não é seu, porque um 403 confirma que ele existe. Ambos são recusas válidas; o 404 esconde a existência do recurso. De qualquer forma, a decisão está no servidor e a resposta é consistente, tempo inclusive.
SSRF, travessia de diretórios e upload de arquivos
Esta seção trata de três jeitos de enganar um servidor para que ele use algo que não deveria: um endereço web, um caminho de arquivo ou um nome de arquivo que veio de fora.
SSRF: o servidor prestativo
Muitos recursos fazem o servidor agir como um navegador: prévia de links, "importar de uma URL", webhooks, buscadores de imagens. Imagine pedir a um funcionário simpático do escritório: "por favor, ligue para este número para mim". Se você escolhe o número, pode pedir que ele ligue para a linha privada do chefe, que só quem está dentro do escritório alcança. No server-side request forgery (SSRF) o usuário fornece uma URL e o servidor a busca de dentro da rede da empresa, então consegue alcançar localhost, serviços internos e o endereço de metadados da nuvem que não têm senha porque "só máquinas internas conseguem conectar". Ele tem categoria própria no OWASP (A10, 2021).
A correção segue a causa: a aplicação precisa decidir para onde aceita se conectar, e fazer a checagem sobre o que a conexão realmente usa, o endereço numérico, em cada salto.
- Uma lista de bloqueio de texto não funciona. Rejeitar URLs que contêm
localhostou127.0.0.1falha, porque o mesmo endereço tem muitas grafias e qualquer nome DNS pode apontar para dentro. Faça o parse da URL, aceite sóhttpehttps, resolva o nome e recuse endereços de loopback, privados e link-local. Quando o conjunto de destinos é conhecido, uma lista de permissão de hosts é mais forte que qualquer lista de bloqueio. - Redirecionamentos são uma armadilha. Um site permitido pode responder
302com umLocationapontando para um serviço interno, e clientes HTTP seguem redirecionamentos por padrão. Desligue os redirecionamentos automáticos, siga-os você mesmo até um limite pequeno e confira de novo cada novo destino. - Checar e usar têm de ser a mesma consulta. Se você resolve o nome, confere, e depois entrega o nome ao cliente e ele resolve de novo, um servidor DNS malicioso pode responder com um endereço público na primeira vez e um interno na segunda (DNS rebinding). Resolva uma vez, valide, conecte-se a esse mesmo endereço e mantenha o nome só para o cabeçalho
Hoste para o TLS. - Adicione regras de rede também. Rode o componente que busca URLs de usuários num segmento isolado ou atrás de um proxy de saída, e autentique entre serviços internos. As checagens da aplicação são uma camada.
- Esconder a resposta não é correção. A requisição continua sendo feita (SSRF cego).
URL do usuário
-> parse: esquema é http/https? host na lista de permissão?
-> resolver o host uma vez -> todos os endereços públicos?
-> conectar a ESSE endereço (cabeçalho Host = nome)
-> redirecionou? recomeçar do topo (máx. 3 saltos)Travessia de diretórios: saindo da pasta
Uma rota de download junta a pasta uploads com o nome do arquivo da requisição. Um nome como ../../etc/something usa a sequência .., que significa "suba uma pasta". O sistema operacional a resolve normalmente, e o arquivo lido está fora da pasta de uploads. Isso quebra a suposição de que um nome fica dentro de uma pasta.
// Vulnerável: join confia no nome
const target = path.join(UPLOADS, name);
// Corrigido: valide o RESULTADO, não a entrada
const target = path.resolve(UPLOADS, name);
if (!target.startsWith(UPLOADS + path.sep)) return status(400);Valide o caminho absoluto final (resolvendo também links simbólicos) e confira que está dentro da raiz permitida. Melhor ainda, nunca aceite caminhos do cliente: receba um identificador e procure-o num índice. Rode o processo com poucas permissões, porque o alcance da falha é tudo o que o processo pode ler.
Upload de arquivos: nome, tipo e conteúdo são alegações
Quando um usuário envia um arquivo, o nome, a extensão e o Content-Type declarado são todos só texto que o cliente escolheu. Um cabeçalho dizendo image/png não prova nada. Um upload seguro combina várias medidas.
| Medida | Por quê |
|---|---|
| Gerar um nome aleatório no servidor, guardando o nome original como metadado | O nome do cliente nunca é usado no sistema de arquivos, então não consegue atravessar nem sobrescrever |
| Lista de permissão de tipos, e conferir os primeiros bytes (a assinatura do arquivo) | O tipo declarado é só uma alegação |
| Limite de tamanho | Evita encher o disco ou a memória |
Guardar fora de pastas executáveis ou servidas diretamente; enviar X-Content-Type-Options: nosniff | Um arquivo cheio de HTML não pode ser executado como página ou programa |
Segredos, dependências e a cadeia de suprimentos
Um segredo é qualquer coisa que prova quem você é para outro sistema: uma chave de API, uma senha de banco, uma chave privada. Pense nas chaves da escola. Você não as cola na porta da frente, e não faz uma cópia para cada visitante. Segredos são tratados do mesmo jeito.
Onde os segredos moram
Um segredo é configuração, não código. Ele entra no processo em tempo de execução, vindo de variáveis de ambiente ou de um gerenciador de segredos (um cofre) que controla quem pode lê-lo, registra cada uso e facilita a rotação. O repositório guarda apenas os nomes das variáveis esperadas, nunca os valores.
E se uma pessoa desenvolvedora commita uma chave por engano e depois apaga a linha num novo commit? A chave continua no histórico, e cópias do repositório se espalham rápido. Então a primeira ação eficaz é revogar a chave e emitir uma nova (rotação). Só depois limpe o histórico, verifique se a chave foi usada e evite que aconteça de novo com varredura de segredos antes dos commits.
- Revogue a chave vazada e crie uma nova.
- Confira nos logs qualquer uso da chave antiga.
- Limpe o histórico e as cópias, se possível.
- Adicione varredura de segredos antes dos commits para que não aconteça de novo.
Dependências: o código dos outros
A maior parte da sua aplicação é código escrito por outras pessoas: bibliotecas e as bibliotecas dessas bibliotecas. Essa é a cadeia de suprimentos. Duas ideias a protegem.
- Lockfile. Arquivos como
bun.lockoupackage-lock.jsonregistram a versão exata e o hash de cada dependência. Sem um, uma faixa como^1.2.0instala o que for mais novo naquele dia, e máquinas diferentes podem rodar código diferente. Commite o lockfile e use-o em modo estrito nos builds. - Continue verificando, mesmo sem mudar nada. Uma aplicação publicada há um ano com todas as dependências auditadas pode estar vulnerável hoje, porque novas falhas são divulgadas em versões que você já instalou. Saiba o que está instalado (dependências indiretas incluídas), acompanhe avisos de segurança, atualize com frequência e remova o que não usa. Essa é a categoria do OWASP Vulnerable and Outdated Components.
Juntando tudo: uma falha, um hábito
Todas essas falhas parecem diferentes, mas a maioria compartilha uma de duas raízes. Ou o programa misturou dados com código (injeção, XSS), ou o programa confiou numa alegação de fora (um id, um papel, um nome de arquivo, uma URL, um header de token) sem checar no servidor. A tabela abaixo liga cada falha ao hábito que a barra.
| Falha | Causa raiz | Defesa principal | Camadas extras |
|---|---|---|---|
| Injeção de SQL / comandos | Dados colados no texto do comando | Parâmetros, listas de argumentos | Validação, privilégio mínimo, lista de permissão para a estrutura |
| XSS | Dados interpretados como marcação ou script | Codificação na saída, textContent | Sanitizador, CSP, checagem do esquema da URL |
| CSRF | O navegador envia cookies sozinho | Token anti-CSRF | SameSite, sem mudar estado em GET |
| Senhas fracas | Hash rápido ou sem salt | Argon2id / bcrypt com salt | Pepper, MFA, limites de tentativas |
| Roubo / fixação de sessão | Id previsível ou reutilizado, servidor mantém a sessão após o logout | Id aleatório, id novo no login, logout no servidor | HttpOnly, Secure, __Host-, tempos limite |
| Erros de JWT | Confiar no header e pular as claims | Algoritmo fixado, chave forte | Checagens de exp, iss, aud |
| IDOR / mass assignment | Sem checagem de dono ou de campo | Autorizar no servidor, a cada requisição | Negar por padrão, lista de campos permitidos |
| SSRF | O servidor busca um endereço escolhido pelo usuário | Lista de permissão + checagem do endereço resolvido | Redirecionamentos manuais, conexão fixada, regras de saída da rede |
| Travessia / upload | Nome ou tipo do cliente aceito | Validar o caminho resolvido, nome de arquivo aleatório | Checagem da assinatura do conteúdo, nosniff |
| Segredo vazado | Segredo no repositório | Ambiente ou cofre, rotacionar | Varredura de segredos |
| Componente desatualizado | Falhas conhecidas em bibliotecas antigas | Lockfile + avisos + atualizações | Remover pacotes não usados |
Erros comuns a evitar
- Corrigir o sintoma (banir alguns caracteres) em vez da causa (manter código e dados separados).
- Esconder coisas na interface e chamar isso de controle de acesso.
- Usar uma defesa e parar. As camadas falham uma de cada vez; empilhe-as.
- Tratar o OWASP Top 10 como uma lista completa ou um certificado.
- Acreditar que cifrar ou aplicar hash a algo é o mesmo que autenticá-lo.