Files
cs-note/hzh/DEV/跨域问题调试.md
T
2026-05-24 11:42:38 +08:00

272 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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]] — 中间件三级作用域机制