CORS não é detalhe: por que times ainda erram esse básico
CORS não é detalhe: por que times ainda erram esse básico
CORS 并非小事:为什么团队依然会在这个基础问题上犯错
Introdução
引言
Quando eu vejo um erro de CORS no console do navegador, eu quase sempre vejo o mesmo reflexo no time alguns minutos depois: alguém coloca Access-Control-Allow-Origin: *, desabilita alguma proteção no browser de desenvolvimento e declara a tarefa como resolvida. Isso não resolve nada. Só mascara a regra de segurança que está impedindo um site qualquer de ler respostas sensíveis da sua API.
每当我在浏览器控制台看到 CORS 错误时,几分钟后我几乎总能看到团队做出同样的反应:有人添加了 Access-Control-Allow-Origin: *,禁用了开发浏览器的某些保护机制,然后宣布任务已解决。这根本没有解决问题,只是掩盖了阻止任意网站读取你 API 敏感响应的安全规则。
CORS não é um capricho do Chrome, do Firefox ou do Edge. Ele é o mecanismo que o servidor usa para dizer ao navegador quais origins podem ler uma resposta cross-origin. O browser aplica a regra, mas a decisão vem do backend ou da camada de borda. Se eu configuro isso de forma frouxa, eu não apenas elimino o erro visual do console: eu amplio a superfície de ataque da minha aplicação. CORS 并非 Chrome、Firefox 或 Edge 的心血来潮。它是服务器用来告知浏览器哪些源(origins)可以读取跨域响应的机制。浏览器负责执行规则,但决策权在于后端或边缘层。如果配置过于宽松,我不仅消除了控制台的视觉错误,还扩大了应用程序的攻击面。
Este é o quinto artigo da série Segurança para Devs. No artigo sobre JWT, OAuth2 e OpenID Connect, eu cobri identidade e tokens. No artigo sobre BFF, eu mostrei como tirar token do browser. No artigo sobre API Gateway, eu discuti política centralizada de segurança na borda. Agora eu fecho uma lacuna que aparece em praticamente toda SPA, portal administrativo e integração web: como configurar CORS sem transformar uma proteção importante em enfeite decorativo. 这是“开发者安全”系列的第五篇文章。在关于 JWT、OAuth2 和 OpenID Connect 的文章中,我涵盖了身份和令牌;在关于 BFF 的文章中,我展示了如何将令牌从浏览器中移除;在关于 API 网关的文章中,我讨论了边缘层的集中式安全策略。现在,我将填补几乎所有 SPA、管理后台和 Web 集成中都存在的空白:如何在不将重要保护措施变成摆设的情况下配置 CORS。
Ao final deste artigo, eu vou mostrar o que CORS realmente protege, como a Same-Origin Policy funciona, quando o browser envia preflight OPTIONS, o que cada header faz, quais erros eu mais encontro em pentests e code reviews, e como configurar CORS corretamente no NGINX e no ASP.NET Core. Também vou separar uma confusão comum: CORS não substitui autenticação, autorização nem proteção contra CSRF. 在本文结尾,我将展示 CORS 真正保护的内容、同源策略(Same-Origin Policy)的工作原理、浏览器何时发送预检(preflight)OPTIONS 请求、每个响应头的作用、我在渗透测试和代码审查中最常遇到的错误,以及如何在 NGINX 和 ASP.NET Core 中正确配置 CORS。此外,我还会澄清一个常见的误区:CORS 不能替代身份验证、授权或 CSRF 防护。
⚠️ Atenção: Desligar CORS porque ele atrapalha é como remover o cinto de segurança porque ele aperta. O incômodo é sinal de que a proteção ainda está funcionando. ⚠️ 注意: 因为 CORS 碍事就关掉它,就像因为安全带勒人就把它拆掉一样。这种不适感恰恰说明保护机制正在发挥作用。
Pré-requisitos
前置要求
Para acompanhar os exemplos com contexto real, eu recomendo: 为了结合实际场景理解示例,我建议具备以下条件:
- Conhecimento básico de HTTP, headers e métodos como GET, POST, PUT e OPTIONS.
- 对 HTTP、请求头以及 GET、POST、PUT 和 OPTIONS 等方法有基础了解。
- Familiaridade mínima com navegador, DevTools e requisições fetch.
- 对浏览器、开发者工具(DevTools)和 fetch 请求有基本的熟悉度。
- No backend, alguma experiência com ASP.NET Core e pipeline de middlewares.
- 在后端方面,具备一定的 ASP.NET Core 和中间件管道(middleware pipeline)经验。
- Na camada de borda, leitura básica de configuração NGINX.
- 在边缘层方面,具备基本的 NGINX 配置阅读能力。
Também ajuda ter lido ao menos os artigos anteriores da série, porque eu vou referenciar autorização, cookies HttpOnly, BFF e gateway várias vezes. Mesmo assim, este texto é autocontido e responde diretamente à pergunta principal: por que CORS existe e como eu configuro isso com segurança. 阅读过本系列之前的文章也会有所帮助,因为我将多次引用授权、HttpOnly Cookie、BFF 和网关等概念。即便如此,本文依然是自包含的,并直接回答了核心问题:为什么 CORS 存在,以及如何安全地配置它。
O Problema que o CORS Resolve: Same-Origin Policy
CORS 解决的问题:同源策略 (Same-Origin Policy)
Antes de falar de headers, eu preciso falar de origin. Origin é a combinação de esquema + host + porta. Para o navegador, https://app.com e http://app.com não são a mesma coisa. https://app.com e https://api.app.com também não.
在谈论响应头之前,我必须先谈谈“源”(Origin)。源是协议(scheme)+ 主机(host)+ 端口(port)的组合。对于浏览器来说,https://app.com 和 http://app.com 并非同一个源,https://app.com 和 https://api.app.com 也不是。
A tabela abaixo resume os casos mais comuns: 下表总结了最常见的情况:
| URL da requisição | Origin da página | Same-origin? | Motivo |
|---|---|---|---|
| 请求 URL | 页面源 | 同源? | 原因 |
https://app.com/api | https://app.com | Sim | mesmo esquema, host e porta |
https://app.com/api | https://app.com | 是 | 相同的协议、主机和端口 |
http://app.com/api | https://app.com | Não | esquema diferente |
http://app.com/api | https://app.com | 否 | 协议不同 |
https://api.app.com | https://app.com | Não | host diferente |
https://api.app.com | https://app.com | 否 | 主机不同 |
https://app.com:8443 | https://app.com | Não | porta diferente |
https://app.com:8443 | https://app.com | 否 | 端口不同 |
A partir dessa definição entra a Same-Origin Policy, ou SOP. Essa política do browser impede que um script carregado em uma origin leia livremente respostas vindas de outra. O ponto central aqui é o verbo ler. O navegador pode até permitir que alguns requests cross-origin sejam disparados, mas ele não entrega a resposta ao JavaScript da página se a política não for satisfeita. 基于这个定义,就引入了同源策略(SOP)。该浏览器策略阻止从一个源加载的脚本自由读取来自另一个源的响应。这里的核心在于“读取”这个动作。浏览器可能会允许发送某些跨域请求,但如果策略不满足,它就不会将响应结果交给页面的 JavaScript。
Sem SOP, um site malicioso aberto pela vítima poderia enviar um fetch para o webmail dela, para o internet banking ou para o painel administrativo em que ela já está autenticada, reaproveitando cookies enviados automaticamente pelo navegador. Se a resposta voltasse livremente para o JavaScript do atacante, bastaria parsear o JSON ou o HTML e exfiltrar tudo. A SOP existe justamente para quebrar esse caminho. 如果没有 SOP,受害者打开的恶意网站就可以向她的 Web 邮箱、网上银行或她已登录的管理后台发送 fetch 请求,并利用浏览器自动发送的 Cookie。如果响应能自由返回给攻击者的 JavaScript,只需解析 JSON 或 HTML 即可窃取所有数据。SOP 的存在正是为了切断这一路径。
E onde entra o CORS? CORS é a exceção controlada a essa regra. O servidor de destino informa, por headers HTTP, quais origins podem ler sua resposta. O browser recebe a resposta, verifica esses headers e decide se libera ou não o conteúdo para o JavaScript da página. 那么 CORS 在哪里发挥作用呢?CORS 是该规则的受控例外。目标服务器通过 HTTP 响应头告知哪些源可以读取其响应。浏览器接收到响应后,会检查这些响应头,并决定是否将内容释放给页面的 JavaScript。
Esse detalhe explica uma confusão que eu vejo toda semana: curl, Postman, Insomnia e chamadas backend-para-backend não têm problema de CORS porque não existe navegador aplicando SOP. Se o endpoint responde 200 no Postman e falha no browser, o problema não é “a API está quebrada”. O problema é que o navegador está exigindo uma política explícita para aquela leitura cross-origin. 这一细节解释了我每周都会见到的一个困惑:curl、Postman、Insomnia 以及后端对后端的调用不存在 CORS 问题,因为没有浏览器在执行 SOP。如果接口在 Postman 中返回 200 但在浏览器中失败,问题不在于“API 坏了”,而在于浏览器要求针对该跨域读取行为提供明确的策略。
ℹ️ Informação: O header Origin vem do navegador e não pode ser sobrescrito pelo JavaScript da página. É por isso que o servidor consegue tomar decisão de política com base nele dentro de um contexto browser.
ℹ️ 信息: Origin 请求头由浏览器生成,不能被页面 JavaScript 覆盖。这就是为什么服务器能够在浏览器上下文中基于该请求头做出策略决策。
Anatomia de uma Requisição CORS: Simples vs Preflight
CORS 请求剖析:简单请求 vs 预检请求
Nem toda requisição cross-origin passa pelo mesmo fluxo. O browser separa essas chamadas entre requisições simples e requisições com preflight. 并非所有的跨域请求都遵循相同的流程。浏览器将这些调用分为简单请求和带有预检(preflight)的请求。
Uma requisição simples é aquela que usa GET, HEAD ou POST, com headers limitados e Content-Type restrito a formatos como text/plain, application/x-www-form-urlencoded ou multipart/form-data. Nesses casos, o navegador envia a chamada real com o header Origin e avalia a resposta. Se o servidor devolver Access-Control-Allow-Origin compatível, o JavaScript pode ler o resultado.
简单请求是指使用 GET、HEAD 或 POST 方法,且请求头受限,Content-Type 仅限于 text/plain、application/x-www-form-urlencoded 或 multipart/form-data 等格式的请求。在这种情况下,浏览器会发送带有 Origin 请求头的实际调用并评估响应。如果服务器返回兼容的 Access-Control-Allow-Origin,JavaScript 就可以读取结果。
Quando eu saio desse envelope e uso PUT, DELETE, PATCH, Authorization, Content-Type: application/json ou outros headers customizados, o browser entra em modo mais cauteloso. Antes da requisição real ele envia um OPTIONS chamado preflight perguntando se o servidor aceita aquela combinação de origin, método e headers.
当我超出这个范围,使用 PUT、DELETE、PATCH、Authorization、Content-Type: application/json 或其他自定义请求头时,浏览器会进入更谨慎的模式。在实际请求之前,它会发送一个名为“预检”的 OPTIONS 请求,询问服务器是否接受该源、方法和请求头的组合。