272 lines
12 KiB
Markdown
272 lines
12 KiB
Markdown
---
|
||
tags: [前端, 后端, 跨域, 调试, 网络, Cookie, 缓存]
|
||
create time: 2026-04-29 15:30
|
||
---
|
||
|
||
# 跨域问题调试
|
||
|
||
## 概述
|
||
|
||
本文档是面向实际开发中的跨域排查指南,从"控制台一条红色报错"开始,逐步教你定位问题根因。不同于原理型笔记,这里关注的是**你真正会踩的坑**。
|
||
|
||
## 正文
|
||
|
||
### 第一步:读懂浏览器的错误信息
|
||
|
||
浏览器控制台只会报一句话,但这句话已经包含了所有关键信息:
|
||
|
||
```
|
||
Access to fetch at 'https://api.example.com/user' from origin 'https://app.example.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
|
||
```
|
||
|
||
拆解这条消息,核心就三个字:**缺 Header**。
|
||
|
||
| 片段 | 含义 |
|
||
|------|------|
|
||
| `from origin 'https://app.example.com'` | 页面的源(协议 + 域名 + 端口) |
|
||
| `to 'https://api.example.com/user'` | 请求的目标地址 |
|
||
| `No 'Access-Control-Allow-Origin' header` | 服务端响应里没有这个 Header |
|
||
|
||
> **思考**:如果消息变成 `Actual response does not match allowed origin`,区别在哪里?答案在 Credential 模式里。
|
||
|
||
### 第二步:判断是不是真的跨域
|
||
|
||
很多人一看到请求失败就认定是跨域,其实不然。先确认三个要素是否确实不同:
|
||
|
||
```
|
||
同源 = 协议相同 && 域名相同 && 端口相同
|
||
```
|
||
|
||
任何一项不同就是跨域。举几个反直觉的例子:
|
||
|
||
| 页面 | 请求地址 | 跨域? | 原因 |
|
||
|------|---------|--------|------|
|
||
| `http://localhost:3000` | `http://localhost:8080/api` | ✅ | 端口不同 |
|
||
| `http://localhost:3000` | `http://127.0.0.1:8080/api` | ✅ | 域名不同(localhost ≠ 127.0.0.1) |
|
||
| `https://app.example.com` | `https://app.example.com:443/api` | ❌ | 443 是 HTTPS 默认端口,等价 |
|
||
| `http://example.com` | `http://www.example.com/api` | ✅ | 域名不同(有 www 和没 www) |
|
||
|
||
> **常见坑**:本地开发时前端走 `localhost`,后端绑在 `127.0.0.1` —— 这确实是跨域!详见 [[跨域问题调试/localhost-vs-127.0.0.1]]。
|
||
|
||
#### 快速验证
|
||
|
||
用浏览器直接打开目标 API 地址。如果能正常返回 JSON,说明服务端可达,问题大概率在跨域配置;如果直接 404 或连不上,是网络或服务问题,不是跨域。
|
||
|
||
### 第三步:看 Network 面板的关键指标
|
||
|
||
> 参考示例 -> [[跨域 HTTP 请求示例]]
|
||
|
||
打开 DevTools → Network → 找到失败的请求,关注以下字段:
|
||
|
||
**Request Headers 区:**
|
||
|
||
| 关键字段 | 关注点 |
|
||
|---------|--------|
|
||
| `Origin` | 值是什么?是否和你的预期页面一致? |
|
||
| `Referer` / `Sec-Fetch-Dest` | 帮助判断请求来源是否被代理或重写 |
|
||
|
||
**Response Headers 区(最关键):**
|
||
|
||
| 检查项 | 期望值 | 出问题的表现 |
|
||
|--------|--------|-------------|
|
||
| `Access-Control-Allow-Origin` | 具体域名或 `*` | 缺失、写错、写了 `*` 但前端带了凭证 |
|
||
| `Access-Control-Allow-Credentials` | `true` | 缺失或为 `false`(当前端 `credentials: "include"` 时必须为 `true`) |
|
||
| `Access-Control-Allow-Methods` | 包含你的 HTTP 方法 | 预检失败,缺少 `PUT` / `DELETE` 等 |
|
||
| `Access-Control-Allow-Headers` | 包含你的自定义 Header | 预检失败,缺少 `X-Token` / `Authorization` 等 |
|
||
| 状态码 | `204`(OPTIONS 预检)或 `2xx` | 预检返回 `403`/`405`/`500` 意味着中间件未生效或拦截了 |
|
||
|
||
> **教学提示**:把 Network 面板顶部的 **Preserve log** 勾上——否则页面跳转后历史请求会被清掉,看不到失败的 OPTIONS 请求。
|
||
|
||
#### 诊断流程图
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
A[控制台报 CORS 错误] --> B{Network 里有 OPTIONS 请求?}
|
||
B -- 否 --> C["简单请求,查响应头<br/>是否有 Access-Control-Allow-Origin"]
|
||
B -- 是 --> D{OPTIONS 状态码?}
|
||
D -- 204/200 --> E["预检通过,查实际请求响应头<br/>是否仍缺少 CORS header"]
|
||
D -- 405/403/404 --> F["预检被服务端拒绝<br/>→ 中间件未注册或路由拦截"]
|
||
D -- 500 --> G["服务端异常<br/>→ 检查中间件代码 panic"]
|
||
C -- Header 存在 --> H["检查 Origin 匹配逻辑<br/>动态 Origin 回显是否正确"]
|
||
C -- Header 缺失 --> I["服务端未设置 CORS header<br/>→ 检查中间件顺序和执行路径"]
|
||
E -- Header 正确 --> J["浏览器缓存的老预检结果<br/>→ 清除缓存或改 Max-Age"]
|
||
E -- Header 缺失或不匹配 --> K["实际响应链路断了<br/>→ handler 中覆盖或重定向丢失 header"]
|
||
```
|
||
|
||
### 第四步:高频场景逐一排查
|
||
|
||
#### 场景 1:Credential 模式下 `Allow-Origin: *` 报错
|
||
|
||
这是最常见的生产环境跨域问题之一。
|
||
|
||
**错误信息:**
|
||
```
|
||
Access to fetch at '...' from origin '...' has been blocked by CORS policy:
|
||
The value of the 'Access-Control-Allow-Origin' header (*) in the response must
|
||
not be the wildcard '*' when the request's credentials mode is 'include'.
|
||
```
|
||
|
||
**原因**:当你在 JavaScript 中设置了 `credentials: "include"`(即允许携带 Cookie),CORS 响应头里的 `Access-Control-Allow-Origin` **不能**是 `*`,必须是具体的源。
|
||
|
||
```go
|
||
// ❌ 错误写法
|
||
c.Header("Access-Control-Allow-Origin", "*")
|
||
c.Header("Access-Control-Allow-Credentials", "true")
|
||
|
||
// ✅ 正确写法 — 动态回显请求方 Origin
|
||
origin := c.Request.Header.Get("Origin")
|
||
if origin != "" {
|
||
c.Header("Access-Control-Allow-Origin", origin)
|
||
}
|
||
c.Header("Access-Control-Allow-Credentials", "true")
|
||
```
|
||
|
||
> **为什么这样设计?** `*` + 凭证 = 任何网站都能带上它的 Cookie 向你的 API 发请求,等同于完全没有 CSRF 保护。浏览器强制阻断。
|
||
|
||
这也是为什么 Gin 的 `cors` 中间件文档专门提到动态 Origin 的问题 — 详见 [[GIN/3-middleware/cors-registration-scope]]。
|
||
|
||
#### 场景 2:预检成功但实际请求仍然报错
|
||
|
||
这种情况通常是中间件只处理了 OPTIONS 而没有在实际请求的响应中注入 CORS header。
|
||
|
||
回顾一下中间件的完整逻辑:
|
||
|
||
```go
|
||
func CORSMiddleware() gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
if c.Request.Method == http.MethodOptions {
|
||
// ← 很多新手只做了这一步
|
||
c.Header("Access-Control-Allow-Origin", "*")
|
||
c.AbortWithStatus(http.StatusNoContent)
|
||
return
|
||
}
|
||
// ← 漏掉这行!实际响应的响应头里没有 CORS header
|
||
c.Header("Access-Control-Allow-Origin", "*")
|
||
c.Next()
|
||
}
|
||
}
|
||
```
|
||
|
||
预检通过了只是第一步。浏览器收到实际响应时还会**二次校验**响应头里的 CORS header 是否与当前页面 Origin 匹配。缺了这一层,前面做的全部白费。
|
||
|
||
见 [[GIN/3-middleware/cors-registration-scope/cors-preflight]] 的四轮对话机制图解。
|
||
|
||
#### 场景 3:handler 内部逻辑出错导致 CORS header 丢失
|
||
|
||
Gin 中如果在 middleware 之后、handler 返回值之前发生重定向、JSON 渲染异常或者 panic recovery 中断了响应链,CORS header 可能不会出现在最终响应中。
|
||
|
||
**排查手法**:在 middleware 最后一行加日志输出 header,确认 header 确实设上了:
|
||
|
||
```go
|
||
c.Next()
|
||
log.Printf("Response headers: Allow-Origin=%s", c.Writer.Header().Get("Access-Control-Allow-Origin"))
|
||
```
|
||
|
||
如果日志显示 header 存在但浏览器仍然收不到,问题可能在**反向代理层**(Nginx / Cloudflare / AWS ALB)过滤了自定义 header。
|
||
|
||
#### 场景 4:Nginx / 反向代理挡住了 CORS header
|
||
|
||
> 详细的 Nginx 反向代理处理跨域的三种方案,详见 [[hzh/DEV/跨域问题调试/nginx-reverse-proxy-cors]]。
|
||
|
||
当你使用 Nginx 做反向代理时,它默认会过滤掉上游没有明确声明的 header。
|
||
|
||
```nginx
|
||
# ❌ 上游的 CORS header 被 Nginx 丢弃
|
||
location /api/ {
|
||
proxy_pass http://backend:8080;
|
||
}
|
||
|
||
# ✅ 透传必要的 CORS header
|
||
location /api/ {
|
||
proxy_pass http://backend:8080;
|
||
add_header Access-Control-Allow-Origin $http_origin always;
|
||
add_header Access-Control-Allow-Credentials "true" always;
|
||
add_header Access-Control-Allow-Headers "Content-Type, Authorization, X-Token" always;
|
||
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
|
||
|
||
if ($request_method = OPTIONS) {
|
||
return 204;
|
||
}
|
||
}
|
||
```
|
||
|
||
> **特征信号**:用 Postman 或 curl 直接调 API 能拿到正确的 CORS header,但在浏览器中不行。这就是中间层拦截的典型表现。
|
||
|
||
#### 场景 5:`Access-Control-Expose-Headers` — 你能读到响应头吗?
|
||
|
||
默认情况下,跨域请求中浏览器 JavaScript **只能访问**以下 7 个响应头:
|
||
|
||
```
|
||
Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma
|
||
```
|
||
|
||
如果你想在前端用 `response.headers.get('X-Total-Count')` 读取自定义头,必须在服务端设置 `Access-Control-Expose-Headers`:
|
||
|
||
```go
|
||
c.Header("Access-Control-Expose-Headers", "X-Total-Count, X-Request-Id")
|
||
```
|
||
|
||
**排查手法**:在 Console 中输入 `console.log(fetch(...).then(r => r.headers))` 观察哪些头对 JS 可见。
|
||
|
||
#### 场景 6:Max-Age 缓存导致的"改了没生效"
|
||
|
||
浏览器会缓存成功的 OPTIONS 预检结果,缓存时长由 `Access-Control-Max-Age` 控制:
|
||
|
||
```
|
||
Access-Control-Max-Age: 86400 ← 缓存 24 小时
|
||
```
|
||
|
||
你在服务端修改了允许的 Header 列表或方法列表,但浏览器用的是缓存的旧预检结果,所以看起来"配置不生效"。
|
||
|
||
**解决方法**:
|
||
1. 开发阶段:将 `Max-Age` 设为 `0` 或直接关浏览器缓存(DevTools → Network → Disable cache)
|
||
2. 生产环境:修改 CORS 策略后等待缓存过期,或在部署时临时设置为 `0` 再恢复
|
||
|
||
> **生活化类比**:Max-Age 就像电梯的门禁卡——有效期内不用每次刷卡,但你换岗位后门禁权限变了,还得等新卡激活。
|
||
|
||
### 第五步:开发阶段快速绕过(仅限本地开发)
|
||
|
||
> **警告**:以下方法仅适用于本地开发调试,绝不要用于生产环境。
|
||
|
||
| 方式 | 实现 | 注意事项 |
|
||
|------|------|---------|
|
||
| Chrome 启动参数 | `chrome.exe --disable-web-security --user-data-dir=C:\tmp\chrome` | 需要全新 profile 隔离,避免污染正常浏览器数据 |
|
||
| 浏览器插件 | CORS Unblock 等扩展 | 一键开关,最方便但容易忘记关闭 |
|
||
| 前端代理 | Vite `server.proxy` / Webpack `devServer.proxy` | **推荐**,请求不跨域,无副作用 |
|
||
|
||
```js
|
||
// Vite 示例
|
||
export default {
|
||
server: {
|
||
proxy: {
|
||
'/api': {
|
||
target: 'http://localhost:8080',
|
||
changeOrigin: true,
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
请求变成 `localhost:3000/api/xxx` → Vite 转发到 `localhost:8080/api/xxx`,同源无需 CORS。
|
||
|
||
### 调试清单(收藏用)
|
||
|
||
遇到问题时按顺序过一遍:
|
||
|
||
- [ ] Origin 是否真的跨域?(协议 / 域名 / 端口逐项核对)
|
||
- [ ] 响应头中是否存在 `Access-Control-Allow-Origin`?
|
||
- [ ] 若带凭证,`Allow-Origin` 是否写死了具体域名而非 `*`?
|
||
- [ ] 若为非简单请求,OPTIONS 预检是否返回 2xx?
|
||
- [ ] 预检通过的 Methods/Headers 是否覆盖了实际请求?
|
||
- [ ] 实际请求的响应中是否也注入了 CORS header?
|
||
- [ ] 如果有反向代理,header 是否被透传?
|
||
- [ ] 是否命中了 Max-Age 缓存?试着禁用缓存重试
|
||
- [ ] 是否需要 `Access-Control-Expose-Headers` 才能让前端读到自定义头?
|
||
|
||
## 关联笔记
|
||
|
||
- [[hzh/DEV/跨域问题调试/nginx-reverse-proxy-cors]] — Nginx 反向代理处理跨域的三种方案
|
||
- [[GIN/3-middleware/cors-registration-scope]] — 全局 vs 分组注册 CORS 中间件的取舍
|
||
- [[GIN/3-middleware/cors-preflight]] — OPTIONS 预检请求的触发条件与两轮对话流程
|
||
- [[GIN/3-middleware]] — 中间件三级作用域机制 |