CORS 到底在保护谁?90% 的前端理解错了跨域的本质

0 阅读1分钟

CORS 到底在保护谁?90% 的前端理解错了跨域的本质

每次新人入职,接手项目,联调接口,控制台里弹出那个红色的报错:

bc7c0fe4755e158b63a09301165b0279930f40b9.png

然后他做的第一件事是什么?

给后端发消息:哥,接口跨域了,帮忙加个 CORS 头。

后端回:好。

五分钟后,接口通了。两个人都没有深入想过这件事到底意味着什么。

在绝大多数前端的认知里,CORS 就是一个拦路虎——一个让开发变得麻烦的安全限制,只要后端配一下就能绕过去,配完就不用管了。

这个理解是错的,而且错得很危险!

CORS 不是在保护服务端。服务端不需要 CORS 来保护自己——任何人用 curl、Postman 或者一段后端代码就能随意调你的接口,CORS 根本拦不住。

CORS 保护的是浏览器里的用户。 它防止的是:你打开了一个恶意网页之后,这个网页偷偷用你的身份去调别的网站的接口。

这个区别搞不清楚,你就永远不会理解为什么 后端加个 Access-Control-Allow-Origin: * 就行了 这句话在某些场景下有多危险。


如果没有 CORS,会发生什么?

假设浏览器没有同源策略,也没有 CORS 机制。你打开了一个看起来很正常的网页 baidu.com。

你不知道的是,baidu.com 的页面里藏着一段 JavaScript:

// baidu.com 页面里的恶意代码
// 你刚刚登录过 bank.com,浏览器里存着 bank.com 的登录 Cookie

fetch('https://bank.com/api/transfer', {
  method: 'POST',
  credentials: 'include',  // 带上 bank.com 的 Cookie
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    to: 'attacker-account',
    amount: 50000,
  }),
});

如果没有同源策略,这段代码会带着你的 bank.com 的登录 Cookie,向银行的转账接口发一个真实的请求。银行服务端收到的请求里有合法的 Cookie,它会认为这是你本人在操作——然后钱就转走了。

Gemini_Generated_Image_8ilqjv8ilqjv8ilq.jpg

你什么都没做,只是打开了一个网页。

这就是浏览器的同源策略和 CORS 要防止的事情。它的安全模型很简单:

你在 A 网站的页面里,不能随便用用户的身份去调 B 网站的接口。除非 B 网站明确说我允许 A 来调。

所以限制的是浏览器里的行为,不是服务端。用 curl 直接调接口完全不受影响,因为 curl 不是浏览器,它不存在用户身份被偷用的风险。


为什么后端加个 * 就行了?

Access-Control-Allow-Origin: * 的意思是:任何网站都可以调这个接口。

对于公开的、不需要用户身份的接口,比如天气 API、汇率查询、公开的 CDN 资源,* 完全没问题。因为这些接口谁调都一样,不涉及用户隐私。

但对于需要用户身份的接口——登录、支付、个人信息、后台管理——* 会制造一个巨大的安全缺口。

浏览器有一条硬规则:Access-Control-Allow-Origin: * 和 credentials: include 不能同时使用。 如果接口返回了 *,浏览器会拒绝在请求里带上 Cookie。

这是浏览器帮你兜的底。

但很多后端为了方便,不用 *,而是把请求头里的 Origin 值原样反射回去:

# 危险的后端写法:把请求的 Origin 原样返回
@app.after_request
def add_cors(response):
    origin = request.headers.get('Origin')
    if origin:
        response.headers['Access-Control-Allow-Origin'] = origin
        response.headers['Access-Control-Allow-Credentials'] = 'true'
    return response

这段代码的效果是:任何网站发来的请求,都被允许带着用户的 Cookie 访问接口。 它绕过了浏览器对 * 的限制,同时打开了和 * 一样大的安全漏洞——实际上比 * 更危险,因为它允许携带凭据。

上面那个银行转账的攻击场景,在这种配置下就完全可行了。


CORS 是浏览器在替你做安全决策

搞清楚了 保护谁 的问题之后,再来看 CORS 的机制就清晰了。

整个流程分两种情况👇

Gemini_Generated_Image_phtb6jphtb6jphtb.jpg

简单请求 - 浏览器先发后问

如果你的请求满足以下条件(方法是 GET / HEAD / POST,且没有自定义 Header,Content-Type 只能是 form-urlencoded / multipart/form-data / text/plain),浏览器会直接发出请求,但会检查响应头里有没有 Access-Control-Allow-Origin。

如果没有,或者值不匹配当前页面的域名,浏览器会把响应拦住,不让你的 JavaScript 读到。

注意:请求已经发出去了,服务端已经收到并处理了。 浏览器只是不让前端代码拿到响应。这就是为什么说 CORS 不保护服务端——请求已经到了服务端,该执行的都执行了。

预检请求 - 浏览器先问后发

如果你的请求不满足简单请求的条件——比如用了 PUT / DELETE,或者加了 Authorization 头,或者 Content-Type 是 application/json——浏览器会先发一个 OPTIONS 请求,问服务端我能不能这么干?

OPTIONS /api/users HTTP/1.1
Host: api.example.com
Origin: https://my-app.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: Authorization, Content-Type

服务端回答:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://my-app.com
Access-Control-Allow-Methods: GET, POST, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 86400

只有服务端明确回答允许,浏览器才会发出真正的请求。否则直接拦截。

Access-Control-Max-Age: 86400 是一个很容易被忽略但很重要的字段——它告诉浏览器这个预检结果你可以缓存 24 小时。如果不设这个值,每一次非简单请求都会先发一个 OPTIONS,接口的请求量直接翻倍。


生产环境的正确配置是怎么样的?

说完原理,给一个能直接用的生产级 CORS 配置。以 Cloudflare Workers 为例:

// CORS 中间件:生产级白名单配置
const ALLOWED_ORIGINS = new Set([
  'https://my-app.com',
  'https://admin.my-app.com',
  'https://staging.my-app.com',
]);

// 本地开发环境单独处理
function isAllowedOrigin(origin: string | null): boolean {
  if (!origin) return false;
  if (ALLOWED_ORIGINS.has(origin)) return true;
  // 开发环境放行 localhost
  if (origin.startsWith('http://localhost:')) return true;
  return false;
}

function handleCORS(request: Request, response: Response): Response {
  const origin = request.headers.get('Origin');
  
  // 预检请求
  if (request.method === 'OPTIONS') {
    return new Response(null, {
      status: 204,
      headers: {
        'Access-Control-Allow-Origin': isAllowedOrigin(origin) ? origin! : '',
        'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
        'Access-Control-Allow-Headers': 'Content-Type, Authorization',
        'Access-Control-Allow-Credentials': 'true',
        'Access-Control-Max-Age': '86400', // 缓存预检结果 24 小时
      },
    });
  }

  // 正常请求:只在白名单内的 Origin 才设置 CORS 头
  const headers = new Headers(response.headers);
  if (isAllowedOrigin(origin)) {
    headers.set('Access-Control-Allow-Origin', origin!);
    headers.set('Access-Control-Allow-Credentials', 'true');
  }
  // 响应因 Origin 头而不同,必须分别缓存
  headers.append('Vary', 'Origin');

  return new Response(response.body, {
    status: response.status,
    headers,
  });
}

几个容易犯的错误:

Gemini_Generated_Image_unyib2unyib2unyi.jpg


CORS 拦不住哪些攻击?

理解了 CORS 的保护机制之后,你还需要知道它的边界在哪里——CORS 不是万能的安全方案,它只防御跨域读取,不防御跨域写入。

<form> 提交不受 CORS 限制

CORS 只限制 XMLHttpRequest 和 fetch。原生的 <form> 表单提交完全不受同源策略限制:

<!-- 这段代码放在 baidu.com 上,用户打开后会自动提交 -->
<form action="https://bank.com/api/transfer" method="POST" id="attack-form">
  <input type="hidden" name="to" value="attacker-account" />
  <input type="hidden" name="amount" value="50000" />
</form>
<script>document.getElementById('attack-form').submit();</script>

这就是经典的 CSRF(跨站请求伪造)攻击。浏览器会带着 bank.com 的 Cookie 提交这个表单,服务端无法区分这是用户主动操作还是被恶意网页触发的。

CORS 不能防 CSRF。 防 CSRF 需要额外的机制👇:

Gemini_Generated_Image_r0c562r0c562r0c5.jpg

// 服务端 CSRF 防御示例
function validateRequest(request: Request): boolean {
  const origin = request.headers.get('Origin');
  const referer = request.headers.get('Referer');
  
  // 对于状态变更的请求(POST/PUT/DELETE),必须检查来源
  if (['POST', 'PUT', 'DELETE'].includes(request.method)) {
    const source = origin || (referer ? new URL(referer).origin : null);
    if (!source || !ALLOWED_ORIGINS.has(source)) {
      return false; // 拒绝来源不明的请求
    }
  }
  return true;
}

<img> / <script> / <link> 也不受 CORS 限制

浏览器加载图片、脚本、样式表时,默认是跨域的,而且不受同源策略限制。这就是为什么你可以在自己的页面里引用别人 CDN 上的 jQuery。

但这也意味着:恶意网页可以通过图片标签(<img>)请求 https://your-api.com/api/user?callback=steal 的方式触发你的 GET 接口,带上用户的 Cookie。如果你的 GET 接口有副作用(比如已读标记、或者返回的数据被编码在 URL 的重定向里),就可能被利用。

所以 GET 接口永远不要做状态变更操作。 这不是约定,是安全红线。


一句话总结

CORS 不是在保护你的服务器——任何人用 curl 或后端代码都能无视它。

CORS 保护的是坐在浏览器前的那个用户。它防止的是:用户在不知情的情况下,被恶意网页利用身份去操作其他网站。

下次后端同事说 我加个 * 就行了 的时候,你应该追问一句:这个接口需要登录态吗?如果需要,* 不行,必须上白名单

这一句话的技术含量不高,但它背后的安全意识,值得每一个前端工程师记住🫡

好了,今天就分享到这里吧,喜欢请点赞收藏😃

谢谢大家 (2).gif


喜欢我的文章,也欢迎关注我的微信公众号:【前端技术官】。

主要分享:前端架构 · AI 编程 · 职场认知 · 开发者成长

前端技术官-ErpanOmer

微信扫码关注 👆

不定期更新,不刷屏,聊点真正有用的干货。