彻底搞懂浏览器Preflight请求的触发条件与解决方案

夏侯佳杰 安全 阅读 2,162
赞 14 收藏
二维码
手机扫码查看
反馈

先看效果,再看代码

上周上线一个新功能,前端调用后端接口,本地跑得好好的,一上测试环境就 404 —— 不是接口不存在,而是 OPTIONS 请求直接被 Nginx 拦了,连后端的门都没摸到。抓包一看:浏览器发了个空 body 的 OPTIONS 请求,然后卡住不动了。那一刻我盯着 DevTools Network 面板,心里默念:又来了,Preflight 又来搞我。

彻底搞懂浏览器Preflight请求的触发条件与解决方案

今天不讲 CORS 是啥、RFC 是哪年写的——这些你搜一下比我看还快。我就说我在 jztheme.com 做实际项目时,怎么写、怎么配、怎么 debug,哪些方式亲测有效,哪些坑我反复踩了三次才记牢。

核心代码就这几行(但必须全)

假设你要往 https://jztheme.com/api/submit 发个带 token 和 JSON body 的 POST 请求:

fetch('https://jztheme.com/api/submit', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer abc123',
    'X-Request-ID': 'req-' + Date.now()
  },
  body: JSON.stringify({ name: '张三', email: 'zhang@example.com' })
})

这段代码一发,浏览器立刻悄悄发一个 OPTIONS 请求去“问路”。如果你后端没正确响应这个 OPTIONS,后续 POST 就不会发出去,fetch 会直接 reject,报错信息还特别含糊:Failed to fetch 或者 CORS error —— 但根本没告诉你到底是 preflight 挂了,还是 POST 挂了。

后端怎么接住这个 OPTIONS?别光靠 Express 中间件

我一开始图省事,在 Express 里加了个万能中间件:

app.options('*', (req, res) => {
  res.set('Access-Control-Allow-Origin', '*');
  res.set('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
  res.set('Access-Control-Allow-Headers', 'Content-Type,Authorization,X-Request-ID');
  res.status(204).end();
});

❌ 亲测无效。为什么?因为 它在所有路由之前执行,但没处理预检请求的路径匹配逻辑。比如你的 API 是 /api/submit,而 Express 的 * 匹配会走通,但某些反向代理(Nginx、Cloudflare)或网关层可能在到达 Node 之前就把 OPTIONS 给吞了或拒绝了。

✅ 我现在固定写法:在每个需要跨域的路由前,显式加一个同路径的 OPTIONS 处理:

// ✅ 正确姿势:和业务路由一一对应
app.options('/api/submit', (req, res) => {
  res.set('Access-Control-Allow-Origin', 'https://your-app.com');
  res.set('Access-Control-Allow-Methods', 'POST');
  res.set('Access-Control-Allow-Headers', 'Content-Type,Authorization,X-Request-ID');
  res.set('Access-Control-Allow-Credentials', 'true');
  res.status(204).end();
});

app.post('/api/submit', (req, res) => {
  // 实际业务逻辑
  res.json({ ok: true });
});

注意三点:

  • Origin 不要用 *:如果你用了 credentials(比如带 cookie 或 Authorization),Access-Control-Allow-Origin 就不能是 *,必须写死域名(如 https://your-app.com),否则浏览器直接拒收响应;
  • Headers 要列全:前端 header 里写了啥,这里 Access-Control-Allow-Headers 就得包含啥,少一个(比如漏了 X-Request-ID),preflight 就失败;
  • 状态码必须是 204:不是 200,不是 201,就是 204 No Content。有些老教程写 200,实测 Chrome 会认为响应非法,直接中断流程。

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

1. Nginx 默认拦 OPTIONS —— 我们线上环境 Nginx 配置里有一行:if ($request_method !~ ^(GET|HEAD|POST|PUT|DELETE|PATCH)$) { return 405; }。结果 OPTIONS 直接 405。改法很简单,在 location 块里加:

location /api/ {
  if ($request_method = 'OPTIONS') {
    add_header Access-Control-Allow-Origin "https://your-app.com";
    add_header Access-Control-Allow-Methods "POST,OPTIONS";
    add_header Access-Control-Allow-Headers "Content-Type,Authorization,X-Request-ID";
    add_header Access-Control-Allow-Credentials "true";
    add_header Access-Control-Max-Age 86400;
    return 204;
  }
}

2. Spring Boot 的 WebMvcConfigurer 默认不放行 OPTIONS —— 如果你用的是 Spring,别只配 @CrossOrigin 注解。它只对实际请求生效,对 preflight 无效。必须手动配置:

@Configuration
public class CorsConfig implements WebMvcConfigurer {
  @Override
  public void addCorsMappings(CorsRegistry registry) {
    registry.addMapping("/api/**")
        .allowedOrigins("https://your-app.com")
        .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
        .allowedHeaders("Content-Type", "Authorization", "X-Request-ID")
        .allowCredentials(true);
  }
}

3. Chrome DevTools 有时不显示 OPTIONS 请求 —— 特别是在“Disable cache”没勾上、或者页面刷新太快的时候。建议打开 Network → Filter 输入 “OPTIONS”,并勾选 “Preserve log”。不然你以为没发,其实是发了但被刷掉了。

高级技巧:如何让 preflight 缓存更久?

默认情况下,浏览器每次遇到新的请求头组合(比如多加一个 X-Trace-ID),就会重新发一次 OPTIONS。你可以通过 Access-Control-Max-Age 告诉它缓存多久(单位秒):

res.set('Access-Control-Max-Age', '86400'); // 缓存 24 小时

但注意:这个值不是越大越好。如果后端 CORS 策略变了(比如新增了一个 header),浏览器还在用旧缓存,就会出问题。我们目前统一设为 3600(1 小时),够用又不至于太僵硬。

这个场景最好用:微前端子应用跨域调主应用 API

我们有个 qiankun 微前端架构,子应用部署在 sub.jztheme.com,要调主应用的 https://jztheme.com/api/user。这时候 preflight 就特别敏感 —— 因为 Origin 是子域名,而主应用的 CORS 配置如果只写了 jztheme.com,不带 sub.,就会失败。

解决方案不是放宽 Origin,而是用动态匹配:

app.options('/api/user', (req, res) => {
  const origin = req.headers.origin;
  // 只允许 jztheme.com 及其子域名
  if (/^https?://[^/]*.?jztheme.com(:d+)?$/.test(origin)) {
    res.set('Access-Control-Allow-Origin', origin);
    res.set('Access-Control-Allow-Credentials', 'true');
    // 其他 header...
    res.status(204).end();
  } else {
    res.status(403).end();
  }
});

⚠️ 注意:正则里用了 ?.jztheme.com,是为了同时支持 jztheme.comsub.jztheme.com,但不要用 .*.jztheme.com,容易被绕过。

结语

以上是我过去半年在多个项目里折腾 preflight 的全部实战经验。没有银弹,只有一个个 case 挨个解决。现在我已经把上面那套 Express + Nginx + Chrome 调试组合拳写进团队 Wiki 了,新人拉完代码跑一遍就能过。

这个技术的拓展用法还有很多,比如:如何用 Service Worker 拦截并伪造 preflight 响应(仅限调试)、如何在 Electron 中彻底关闭 CORS(有风险)、以及 WebAssembly 加载时的 preflight 特殊行为……后续会继续分享这类博客。

以上是我踩坑后的总结,希望对你有帮助。有更优的实现方式欢迎评论区交流。

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

暂无评论