为什么浏览器会进行OPTIONS预检请求

81 阅读6分钟

一、什么是跨域请求

1.1 同源策略(Same-Origin Policy)

同源策略是浏览器的一种安全机制,用于限制一个源(Origin)的文档或脚本如何与另一个源的资源进行交互。

什么是"源"(Origin)

源由三部分组成:协议(Protocol)+ 域名(Domain)+ 端口(Port)

https://www.example.com:443/api/users
└─┬─┘   └──────┬──────┘ └┬┘
协议        域名        端口

同源判断规则

只有当两个 URL 的协议、域名、端口完全相同时,才被认为是同源。

当前页面 URL目标 URL是否同源原因
https://www.example.com/page1https://www.example.com/page2✅ 同源协议、域名、端口都相同
https://www.example.com/page1http://www.example.com/page2❌ 跨域协议不同(https vs http)
https://www.example.com/page1https://api.example.com/page2❌ 跨域域名不同(www vs api)
https://www.example.com/page1https://www.example.com:8080/page2❌ 跨域端口不同(443 vs 8080)
https://www.example.com/page1https://www.example.org/page2❌ 跨域域名不同(.com vs .org)

1.2 什么是跨域请求

跨域请求是指从一个源向另一个不同源的服务器发起的 HTTP 请求。

典型的跨域场景

场景 1:前后端分离架构

前端页面:https://www.example.com
后端 API:https://api.example.com
→ 跨域(域名不同)

场景 2:本地开发环境

前端开发服务器:http://localhost:3000
后端开发服务器:http://localhost:8080
→ 跨域(端口不同)

场景 3:CDN 资源加载

网站页面:https://www.example.com
CDN 资源:https://cdn.example.com
→ 跨域(域名不同)

场景 4:第三方 API 调用

网站页面:https://www.mysite.com
第三方 API:https://api.thirdparty.com
→ 跨域(域名完全不同)

1.3 同源策略限制的内容

浏览器的同源策略会限制以下跨域行为:

1. Ajax/Fetch 请求

// 从 https://www.example.com 发起请求
fetch('https://api.example.com/users')  // ❌ 被同源策略阻止
  .then(response => response.json())
  .catch(error => console.error('跨域错误:', error));

2. Cookie、LocalStorage、IndexedDB 访问

// 无法读取其他域的 Cookie
document.cookie  // 只能访问当前域的 Cookie

3. DOM 访问

// 无法访问 iframe 中不同源页面的 DOM
const iframe = document.getElementById('myIframe');
iframe.contentWindow.document  // ❌ 跨域访问被阻止

1.4 不受同源策略限制的内容

以下资源加载不受同源策略限制:

1. 图片、CSS、JavaScript 文件

<!-- ✅ 允许跨域加载 -->
<img src="https://cdn.example.com/image.jpg">
<link rel="stylesheet" href="https://cdn.example.com/style.css">
<script src="https://cdn.example.com/script.js"></script>

2. 表单提交

<!-- ✅ 允许跨域提交 -->
<form action="https://api.example.com/submit" method="POST">
  <input type="text" name="username">
  <button type="submit">提交</button>
</form>

3. 页面跳转

// ✅ 允许跨域跳转
window.location.href = 'https://other-site.com';

1.5 跨域解决方案

方案 1:CORS(跨域资源共享)- 推荐

服务器通过设置响应头允许跨域请求:

Access-Control-Allow-Origin: https://www.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization

方案 2:JSONP(仅支持 GET 请求)

利用 <script> 标签不受同源策略限制的特性:

// 前端
function handleResponse(data) {
  console.log(data);
}

const script = document.createElement('script');
script.src = 'https://api.example.com/data?callback=handleResponse';
document.body.appendChild(script);

方案 3:代理服务器

通过同源的代理服务器转发请求:

浏览器 → 同源代理服务器 → 目标服务器

1. 浏览器(https://www.example.com)
   ↓ 发起请求到同源代理
2. 代理服务器(https://www.example.com/proxy)
   ↓ 转发请求到目标服务器
3. 目标服务器(https://api.thirdparty.com)
   ↓ 返回数据给代理
4. 代理服务器
   ↓ 返回数据给浏览器
5. 浏览器接收数据(同源,没有跨域问题)

实际例子(Node.js Express):

// 前端代码(运行在 http://localhost:3000)
fetch('/proxy/api/users')  // 请求同源的代理接口
  .then(response => response.json())
  .then(data => console.log(data));

// 后端代理服务器代码(也运行在 http://localhost:3000)
const express = require('express');
const axios = require('axios');
const app = express();

app.get('/proxy/api/users', async (req, res) => {
  // 代理服务器去请求真正的目标服务器
  const response = await axios.get('https://api.thirdparty.com/users');
  res.json(response.data);  // 返回给前端
});

app.listen(3000);

方案 4:Nginx 反向代理

浏览器访问:https://www.example.com/api/users
              ↓
Nginx 接收请求(https://www.example.com)
              ↓
Nginx 根据 location 规则匹配 /api/
              ↓
Nginx 转发到:https://api.example.com/users
              ↓
后端服务器返回数据
              ↓
Nginx 返回给浏览

实际配置例子

server {
    listen 80;
    server_name www.example.com;

    # 前端静态资源
    location / {
        root /var/www/html;
        index index.html;
    }

    # API 请求代理到后端服务器
    location /api/ {
        # 将 /api/users 转发到 https://api.example.com/users
        proxy_pass https://api.example.com/;
        
        # 传递原始请求信息
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

1.6 为什么需要同源策略

安全保护

  1. 防止 CSRF 攻击:恶意网站无法读取其他网站的 Cookie
  2. 保护用户隐私:防止恶意脚本窃取用户数据
  3. 隔离不同站点:确保网站之间的数据隔离

示例:没有同源策略的危险场景

// 假设没有同源策略
// 用户访问恶意网站 evil.com
fetch('https://bank.com/api/account')  // 能读取银行账户信息
  .then(response => response.json())
  .then(data => {
    // 恶意网站窃取用户银行数据
    sendToHacker(data);
  });

二、什么是 OPTIONS 预检请求

OPTIONS 请求是浏览器在发送跨域请求前自动发起的一种"预检请求"(Preflight Request),用于检查服务器是否允许实际的跨域请求。

1.1 触发条件

浏览器会在以下情况下自动发起 OPTIONS 预检请求:

简单请求 vs 非简单请求

简单请求(不会触发 OPTIONS)需要同时满足:

  • 请求方法为:GETHEADPOST
  • HTTP 头部仅包含:
    • Accept
    • Accept-Language
    • Content-Language
    • Content-Type(仅限 application/x-www-form-urlencodedmultipart/form-datatext/plain

20260209-100645.gif

非简单请求(会触发 OPTIONS)包括:

  • 使用 PUTDELETEPATCH 等方法
  • 发送 application/json 类型的数据
  • 自定义 HTTP 头部(如 AuthorizationX-Custom-Header
  • 携带 Cookie 或认证信息

20260209-100910.gif

跨域请求 vs 同源请求

  • 情况 1:同源 + 简单请求 → ❌ 不发送 OPTIONS 示例:
// 页面:http://localhost:9087/index.html
// API: http://localhost:9087/api/users

fetch('http://localhost:9087/api/users', {
    method: 'GET'
});

结果: 直接发送 GET 请求,没有 OPTIONS 原因: 同源请求不需要 CORS 检查

  • 情况 2:同源 + 非简单请求 → ❌ 不发送 OPTIONS 示例:
// 页面:http://localhost:9087/index.html
// API: http://localhost:9087/api/users/123

fetch('http://localhost:9087/api/users/123', {
    method: 'PUT',  // 非简单方法
    headers: {
        'Content-Type': 'application/json',  // 非简单 Content-Type
        'Authorization': 'Bearer token123'   // 自定义头部
    },
    body: JSON.stringify({ name: '张三' })
});

结果: 直接发送 PUT 请求,没有 OPTIONS 原因: 同源请求不需要 CORS 检查(即使是非简单请求)

  • 情况 3:跨域 + 简单请求 → ❌ 不发送 OPTIONS 示例:
// 页面:http://localhost:63342/index.html
// API: http://localhost:9087/api/users

fetch('http://localhost:9087/api/users', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/x-www-form-urlencoded'
    },
    body: 'name=张三'
});

结果: 直接发送 POST 请求,没有 OPTIONS 原因: 虽然跨域,但是简单请求,不需要预检

  • 情况 4:跨域 + 非简单请求 → ✅ 发送 OPTIONS 示例:
// 页面:http://localhost:63342/index.html
// API: http://localhost:9087/api/users/123

fetch('http://localhost:9087/api/users/123', {
    method: 'PUT',  // 非简单方法
    headers: {
        'Content-Type': 'application/json'  // 非简单 Content-Type
    },
    body: JSON.stringify({ name: '张三' })
});

结果: 先发送 OPTIONS 预检,预检通过后再发送 PUT 请求顺序: OPTIONS /api/users/123 ← 预检请求 PUT /api/users/123 ← 实际请求 原因: 跨域 + 非简单请求,需要预检确认服务器是否允许


二、为什么浏览器要发起 OPTIONS 请求

2.1 安全机制:同源策略(Same-Origin Policy)

浏览器的同源策略是一种安全机制,限制了不同源之间的资源访问。OPTIONS 预检请求是 CORS(跨域资源共享)机制的核心组成部分。

2.2 OPTIONS 请求的作用

  1. 安全验证:在发送实际请求前,先询问服务器是否允许跨域访问
  2. 权限确认:检查服务器是否允许特定的 HTTP 方法和头部
  3. 避免副作用:防止非简单请求直接修改服务器数据
  4. 性能优化:通过缓存预检结果,减少后续请求的预检次数

2.3 工作流程

客户端                                服务器
  |                                    |
  |  1. OPTIONS 预检请求                 |
  |  (询问是否允许跨域)                   |
  | ---------------------------------> |
  |                                    |
  |  2. 返回 CORS 响应头                 |
  |  (告知允许的方法、头部等)              |
  | <--------------------------------- |
  |                                    |
  |  3. 发送实际请求                     |
  |  (POST/PUT/DELETE 等)              |
  | ---------------------------------> |
  |                                    |
  |  4. 返回业务数据                     |
  | <--------------------------------- |

三、OPTIONS 请求示例

3.1 预检请求示例

OPTIONS /api/users/123 HTTP/1.1                     # OPTIONS 方法:预检请求;/api/users/123:请求路径(包含用户ID 123);HTTP/1.1:使用的 HTTP 协议版本
Accept: */*                                         # 客户端接受任何类型的响应内容
Accept-Encoding: gzip, deflate, br, zstd            # 客户端支持的压缩算法
Accept-Language: zh-CN,zh;q=0.9                     # 客户端首选中文语言
Access-Control-Request-Headers: content-type        #【预检关键】实际请求将携带的自定义请求头(Content-Type)
Access-Control-Request-Method: PUT                  #【预检关键】实际请求将使用的 HTTP 方法(PUT)
Connection: keep-alive                              # 保持 TCP 连接以便复用
Host: localhost:9087                                # 目标服务器地址和端口
Origin: http://localhost:63342                      #【预检关键】发起请求的源地址(前端页面地址)
Referer: http://localhost:63342/                    # 请求的来源页面 URL
Sec-Fetch-Dest: empty                               # 请求的目标类型(empty 表示 CORS 预检)
Sec-Fetch-Mode: cors                                #【预检关键】请求模式为 CORS 跨域请求
Sec-Fetch-Site: same-site                           # 请求的站点关系(same-site 表示同站但不同端口)
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36  # 浏览器标识信息

3.2 服务器响应示例

HTTP/1.1 200                                         # HTTP 
Vary: Origin                                         # 告诉缓存服务器:响应内容会根据请求的 Origin 头而变化
Vary: Access-Control-Request-Method                  # 告诉缓存服务器:响应内容会根据请求的方法而变化
Vary: Access-Control-Request-Headers                 # 告诉缓存服务器:响应内容会根据请求的头部而变化
Access-Control-Allow-Origin: http://localhost:63342  # 【核心】允许的源地址,必须与请求的 Origin 完全匹配
Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS # 【核心】允许的 HTTP 方法列表,告诉浏览器这些方法可以跨域使用
Access-Control-Allow-Headers: content-type           # 【核心】允许的请求头,响应预检请求中的 Access-Control-Request-Headers
Access-Control-Expose-Headers: Content-Disposition, X-File-Size, X-Download-Token, X-Total-Count, X-Page-Size, X-Current-Page, X-Total-Pages, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset
                                                    # 暴露给前端 JavaScript 的自定义响应头列表
                                                    # 前端可通过 response.headers.get('X-Total-Count') 等方式读取这些头
Access-Control-Max-Age: 0                           # 预检结果的缓存时间(秒),0 表示不缓存,每次都需要发送预检请求
Content-Length: 0                                   # 响应体长度为 0(预检请求无响应体)
Date: Mon, 09 Feb 2026 02:09:02 GMT                 # 服务器响应时间
Keep-Alive: timeout=60                              # TCP 连接保持时间为 60 秒
Connection: keep-alive                              # 保持 TCP 连接,避免频繁建立连接

四、Gateway 层面的处理方案

4.1 核心配置要点

1. 允许 OPTIONS 方法

确保 Gateway 不会拦截或拒绝 OPTIONS 请求。

2. 返回正确的 CORS 响应头

必须包含以下关键响应头:

响应头说明示例值
Access-Control-Allow-Origin允许的源https://www.example.com*
Access-Control-Allow-Methods允许的 HTTP 方法GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers允许的请求头Content-Type, Authorization,或*
Access-Control-Max-Age预检结果缓存时间(秒)86400(24小时)
Access-Control-Allow-Credentials是否允许携带凭证true

Access-Control-Allow-Credentials 是 CORS 中用于控制是否允许跨域请求携带凭证(Cookie、HTTP 认证信息等)的响应头,一般情况下会设置成*。

3. 快速响应 OPTIONS 请求

OPTIONS 请求不需要转发到后端服务,应在 Gateway 层直接返回 200 响应。


4.2 Spring Cloud Gateway 配置方案

方案一:全局 CORS 配置(推荐)

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.reactive.CorsWebFilter;
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;

/**
 * 全局 CORS 跨域配置
 * 用于处理浏览器的 OPTIONS 预检请求
 */
@Configuration
public class CorsConfig {

    @Bean
    public CorsWebFilter corsWebFilter() {
        CorsConfiguration config = new CorsConfiguration();
        
        // 允许的源(生产环境应指定具体域名)
        config.addAllowedOriginPattern("*");
        
        // 允许的 HTTP 方法
        config.addAllowedMethod("GET");
        config.addAllowedMethod("POST");
        config.addAllowedMethod("PUT");
        config.addAllowedMethod("DELETE");
        config.addAllowedMethod("OPTIONS");
        
        // 允许的请求头
        config.addAllowedHeader("*");
        
        // 是否允许携带凭证(Cookie)
        config.setAllowCredentials(true);
        
        // 预检请求的缓存时间(秒)
        config.setMaxAge(86400L);
        
        // 暴露给前端的响应头
        config.addExposedHeader("*");
        
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        
        return new CorsWebFilter(source);
    }
}

方案二:application.yml 配置

spring:
  cloud:
    gateway:
      globalcors:
        cors-configurations:
          '[/**]':
            # 允许的源
            allowed-origin-patterns: "*"
            # 允许的方法
            allowed-methods:
              - GET
              - POST
              - PUT
              - DELETE
              - OPTIONS
            # 允许的请求头
            allowed-headers: "*"
            # 是否允许携带凭证
            allow-credentials: true
            # 预检请求缓存时间(秒)
            max-age: 86400

方案三:自定义 GlobalFilter(高级场景)

import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

/**
 * 全局 CORS 过滤器
 * 优先级最高,确保 OPTIONS 请求被正确处理
 */
@Component
public class CorsGlobalFilter implements GlobalFilter, Ordered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        ServerHttpResponse response = exchange.getResponse();
        HttpHeaders headers = response.getHeaders();
        
        // 获取请求源
        String origin = request.getHeaders().getOrigin();
        
        // 设置 CORS 响应头
        if (origin != null) {
            headers.add(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, origin);
        }
        headers.add(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, "GET, POST, PUT, DELETE, OPTIONS");
        headers.add(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, "*");
        headers.add(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
        headers.add(HttpHeaders.ACCESS_CONTROL_MAX_AGE, "86400");
        
        // 如果是 OPTIONS 请求,直接返回 200
        if (request.getMethod() == HttpMethod.OPTIONS) {
            response.setStatusCode(HttpStatus.OK);
            return Mono.empty();
        }
        
        // 继续执行后续过滤器
        return chain.filter(exchange);
    }

    @Override
    public int getOrder() {
        // 设置最高优先级
        return Ordered.HIGHEST_PRECEDENCE;
    }
}

4.3 Nginx Gateway 配置方案

server {
    listen 80;
    server_name api.example.com;

    # 处理 OPTIONS 预检请求
    if ($request_method = 'OPTIONS') {
        add_header 'Access-Control-Allow-Origin' '$http_origin' always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
        add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-Requested-With' always;
        add_header 'Access-Control-Allow-Credentials' 'true' always;
        add_header 'Access-Control-Max-Age' 86400 always;
        add_header 'Content-Length' 0;
        add_header 'Content-Type' 'text/plain charset=UTF-8';
        return 204;
    }

    # 为所有响应添加 CORS 头
    add_header 'Access-Control-Allow-Origin' '$http_origin' always;
    add_header 'Access-Control-Allow-Credentials' 'true' always;

    location /api/ {
        proxy_pass http://backend-service;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

4.4 配置建议

生产环境安全配置

// ❌ 不推荐:允许所有源
config.addAllowedOrigin("*");

// ✅ 推荐:指定具体域名
config.addAllowedOrigin("https://www.example.com");
config.addAllowedOrigin("https://admin.example.com");

// ✅ 推荐:使用 Pattern 支持多个子域名
config.addAllowedOriginPattern("https://*.example.com");

性能优化配置

// 设置较长的缓存时间,减少预检请求频率
config.setMaxAge(86400L); // 24 小时

// 仅暴露必要的响应头
config.addExposedHeader("Content-Disposition");
config.addExposedHeader("X-Total-Count");

五、浏览器这么做的意义

5.1 安全保障

  1. 防止 CSRF 攻击:通过预检机制,确保只有授权的源才能发起跨域请求
  2. 保护用户数据:避免恶意网站未经授权访问用户的敏感数据
  3. 服务器主动控制:让服务器决定哪些跨域请求是安全的

5.2 兼容性保障

  1. 向后兼容:保护不支持 CORS 的旧服务器不被意外访问
  2. 渐进增强:允许现代 Web 应用安全地进行跨域通信

5.3 性能优化

  1. 缓存机制:通过 Access-Control-Max-Age 缓存预检结果
  2. 减少请求:缓存期内的相同请求不再发起预检
  3. 快速失败:在预检阶段就能发现跨域问题,避免浪费资源

六、常见问题与解决方案

6.1 问题:每次请求都发起 OPTIONS

原因

  • 未设置 Access-Control-Max-Age 响应头
  • 缓存时间设置过短
  • 请求头或方法发生变化

解决方案

// 设置较长的缓存时间
config.setMaxAge(86400L); // 24 小时

6.2 问题:OPTIONS 请求返回 403 或 404

原因

  • Gateway 未正确处理 OPTIONS 请求
  • 路由配置不匹配 OPTIONS 方法

解决方案

  • 确保 CORS 过滤器优先级最高
  • 在 Gateway 层直接返回 200,不转发到后端

6.3 问题:携带 Cookie 的请求失败

原因

  • 未设置 Access-Control-Allow-Credentials: true
  • 使用了 Access-Control-Allow-Origin: *(与 Credentials 冲突)

解决方案

config.setAllowCredentials(true);
config.addAllowedOriginPattern("https://www.example.com"); // 不能用 *

6.4 问题:设置 Cookie 的接口跨域失败

现象

  • 前端调用登录接口,使用了 withCredentials: true
  • 后端设置了 config.setAllowCredentials(false) 或未设置
  • 浏览器报错:CORS policy blocked

总结

  • withCredentials: trueAccess-Control-Allow-Credentials: true 必须同时存在
  • 这个配置不仅影响携带 Cookie,也影响接收 Cookie(Set-Cookie)
  • 设置 Cookie 的登录接口也需要这个配置

6.5 问题:自定义请求头被拒绝

原因

  • 未在 Access-Control-Allow-Headers 中声明

解决方案

config.addAllowedHeader("Authorization");
config.addAllowedHeader("X-Custom-Header");
// 或者允许所有头部(开发环境)
config.addAllowedHeader("*");

七、最佳实践总结

7.1 开发环境配置

// 宽松配置,方便开发调试
config.addAllowedOriginPattern("*");
config.addAllowedMethod("*");
config.addAllowedHeader("*");
config.setAllowCredentials(true);
config.setMaxAge(3600L);

7.2 生产环境配置

// 严格配置,确保安全
config.addAllowedOriginPattern("https://*.example.com");
config.addAllowedMethod("GET");
config.addAllowedMethod("POST");
config.addAllowedMethod("PUT");
config.addAllowedMethod("DELETE");
config.addAllowedHeader("Content-Type");
config.addAllowedHeader("Authorization");
config.setAllowCredentials(true);
config.setMaxAge(86400L);

7.3 监控与日志

@Slf4j
@Component
public class CorsLoggingFilter implements GlobalFilter, Ordered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        
        if (request.getMethod() == HttpMethod.OPTIONS) {
            log.info("OPTIONS 预检请求: {} from {}", 
                request.getURI(), 
                request.getHeaders().getOrigin());
        }
        
        return chain.filter(exchange);
    }

    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE + 1;
    }
}

八、总结

OPTIONS 预检请求是浏览器的安全机制,虽然会增加一次额外的请求,但通过合理配置:

  1. 在 Gateway 层统一处理 CORS,避免每个微服务重复配置
  2. 设置合理的缓存时间,减少预检请求频率
  3. 生产环境严格控制允许的源,确保安全性
  4. 开发环境宽松配置,提高开发效率

这样既能保证安全性,又能优化性能,是现代 Web 应用的标准做法。


九、参考资料