彻底搞懂Access-Control-Allow-Origin跨域请求头的常见误区与正确配置

东方依甜 安全 阅读 2,163
赞 32 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

上个月上线一个数据看板,前端调用内部多个微服务 API,其中一半接口走的是 https://jztheme.com/api/(纯静态资源托管),另一半是 https://backend.internal/api/(内网服务,反向代理到外网的 /api/v2/ 路径)。上线后用户反馈“点开页面要等 4–5 秒才出数据”,我本地复现也一样——首屏白屏时间稳定在 4.8s 左右,Network 面板里一堆 pending 状态,尤其几个跨域请求,光预检 OPTIONS 就占了 1.2s。

彻底搞懂Access-Control-Allow-Origin跨域请求头的常见误区与正确配置

不是接口慢。后端查了日志,所有接口真实响应都在 80–120ms。问题明显出在浏览器和服务器之间的握手环节。我一开始以为是 Nginx 缓存没配好,折腾半小时改了一堆 proxy_buffer、gzip_vary,没用。最后抓包一看:每个带自定义 header 的请求(比如 X-Client-ID: abc123)都触发了 OPTIONS 预检,而且每次预检都走完整 TLS 握手 + HTTP/1.1 连接重建——因为服务器没返回 Access-Control-Max-Age,也没复用连接。

找到病根了!

用 Chrome DevTools 的 Network → Timing 标签页挨个点开 pending 请求,发现三件事特别扎眼:

  • 所有带 AuthorizationX-Request-ID 的请求,Timing 里 “Initial connection” 和 “SSL” 时间加起来平均 680ms;
  • OPTIONS 响应头里压根没有 Access-Control-Max-Age
  • Access-Control-Allow-Origin 每次都是动态拼的(后端代码里写的是 res.header('Access-Control-Allow-Origin', req.headers.origin || '*')),导致浏览器无法缓存预检结果。

再翻 MDN,确认了:只要 Access-Control-Allow-Origin 是通配符 *,就不能带 credentials;但如果用了具体域名,又必须跟请求 origin 完全匹配,否则预检失败。我们前端是部署在 https://dashboard.jztheme.com,但测试环境还有 localhost:3000192.168.x.x:3000……所以后端之前搞了个“白名单数组 + 字符串匹配”,结果每次响应头都不一样,浏览器直接放弃缓存。

试了几种方案

第一种:把所有 origin 全写死进 Nginx 的 add_header?不行,测试环境 IP 经常变,运维拒绝加。

第二种:后端改用正则匹配,固定返回同一个 origin(比如只认 https://dashboard.jztheme.com,其他全拒)?那开发联调直接瘫痪,pass。

第三种:前端砍掉所有自定义 header?也不现实,X-Client-ID 是埋点必需,Authorization 是登录态凭证,动不了。

最后盯上了两个可动手的地方:让 OPTIONS 响应可缓存 + 复用连接。前者靠 Access-Control-Max-Age 和稳定的 Access-Control-Allow-Origin;后者靠 HTTP/2 + keep-alive + 合理的预检缓存时长。

核心优化:预检缓存 + HTTP/2 连接复用

关键就两步:

  1. 后端对所有 CORS 响应,统一设置 Access-Control-Allow-Origin: https://dashboard.jztheme.com(生产环境),开发环境单独判断 host,但保证每次响应头完全一致;
  2. 强制返回 Access-Control-Max-Age: 86400(24 小时),且确保 OPTIONS 响应体为空、状态码为 204;
  3. Nginx 开启 HTTP/2,并显式设置 keepalive_timeout 75s,同时加一行 add_header 'Access-Control-Allow-Credentials' 'true';(注意:这个 header 必须跟 Allow-Origin 配套,不能是 *)。

后端 Node.js(Express)实际改法:

// cors.js
const ALLOWED_ORIGINS = {
  production: 'https://dashboard.jztheme.com',
  development: 'http://localhost:3000',
  staging: 'https://staging.jztheme.com'
};

function getCorsOrigin(req) {
  const env = process.env.NODE_ENV || 'development';
  const origin = req.headers.origin;
  if (!origin) return false;
  // 注意:这里不再做模糊匹配,只认白名单里的完整字符串
  return origin === ALLOWED_ORIGINS[env] ? origin : false;
}

app.use((req, res, next) => {
  const origin = getCorsOrigin(req);
  if (origin) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Access-Control-Allow-Credentials', 'true');
    res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Client-ID, X-Request-ID');
    res.setHeader('Access-Control-Max-Age', '86400'); // 关键!必须大于 0 才能缓存
  }
  if (req.method === 'OPTIONS') {
    res.status(204).end(); // 必须 204,不能 200 + 空 body
    return;
  }
  next();
});

Nginx 配置补丁(只贴关键段):

server {
  listen 443 ssl http2; # 必须加 http2
  server_name backend.jztheme.com;

  location /api/ {
    proxy_pass https://internal-api/;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;

    # 复用连接
    proxy_set_header Connection '';
    proxy_http_version 1.1;

    # 强制加 CORS 头(兜底,避免后端漏发)
    add_header 'Access-Control-Allow-Origin' 'https://dashboard.jztheme.com' always;
    add_header 'Access-Control-Allow-Credentials' 'true' always;
    add_header 'Access-Control-Allow-Methods' 'GET,POST,PUT,DELETE,OPTIONS' always;
    add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-Client-ID, X-Request-ID' always;
    add_header 'Access-Control-Max-Age' '86400' always;

    # 关键:让浏览器知道这个 OPTIONS 可缓存
    if ($request_method = 'OPTIONS') {
      add_header 'Access-Control-Max-Age' '86400' always;
      add_header 'Access-Control-Allow-Origin' 'https://dashboard.jztheme.com' always;
      add_header 'Access-Control-Allow-Credentials' 'true' always;
      add_header 'Access-Control-Allow-Methods' 'GET,POST,PUT,DELETE,OPTIONS' always;
      add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-Client-ID, X-Request-ID' always;
      add_header 'Content-Length' '0' always;
      add_header 'Content-Type' 'text/plain charset=UTF-8' always;
      return 204;
    }
  }
}

优化后:流畅多了

改完发版,本地测了三次,Network 面板里 OPTIONS 请求从“每个接口都来一遍”变成“整个页面只触发一次”,且 Timing 里 Initial connection 直接干到 30ms 以内(复用连接的效果立竿见影)。

真实用户监控数据(Sentry + 自研性能打点):

  • 首屏数据加载耗时:从 4820ms → 790ms(↓83%);
  • 跨域请求平均延迟:从 1140ms → 180ms(↓84%);
  • 页面初始化阶段的请求数:从 22 个 → 13 个(OPTIONS 全部合并);
  • 更直观的:用户反馈“现在点开秒出”,客服那边关于“页面卡死”的工单一周内归零。

当然也有代价:预检缓存设成 24 小时,万一哪天要切域名,得手动清浏览器缓存或等一天。但我们内部发布流程有灰度机制,真要切,先停服务 5 分钟再切,影响可控。另外,HTTP/2 在某些老旧 Android WebView 里支持不稳,不过我们最低支持 Android 8+,没问题。

性能数据对比

这是同一条用户路径(打开看板 → 加载 5 个图表数据)的典型对比(单位:ms):

指标 优化前 优化后 变化
TTFB(首字节时间) 1120 142 ↓87%
预检 OPTIONS 平均耗时 680 × 5 = 3400 680(仅首次) ↓96%
JS 数据解析+渲染 320 310 基本不变
总耗时(TTFB + OPTIONS + 渲染) 4820 790 ↓83.6%

最爽的是:不用改前端一行业务代码,纯后端和基础设施调优,见效快、风险低。

踩坑提醒:这三点一定注意

1. Access-Control-Max-Age 不是设了就生效:必须配合稳定的 Access-Control-Allow-Origin(不能是 *,也不能每次动态算)。我第一次设了 86400,但 origin 还是 req.headers.origin,结果浏览器照样不缓存——Chrome 控制台 Network 里 OPTIONS 响应头会标个“Provisional headers are shown”,意思就是“我根本没信你”。

2. OPTIONS 必须返回 204:有些老项目用 200 + 空 body,浏览器会认为“这不是标准预检响应”,拒绝缓存。必须 status 204,且 body 为空。

3. HTTP/2 必须全链路开启:Nginx 开了,但 upstream(比如后端 Node)如果还用 HTTP/1.1,连接还是断的。我们后端是 Express,加了 http2.createSecureServer() 并换掉 http.Server,才真正复用成功。

以上是我踩坑后的总结,希望对你有帮助。这个方案不是理论最优(比如用 CDN 缓存 OPTIONS 更狠),但对我们团队来说,改得少、上线快、效果实打实。有更好的实现方式欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论