This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
all-in-kingsoft/hzh/DEV/跨域问题调试.md
T
2026-05-15 16:26:14 +08:00

12 KiB
Raw Blame History

tags, create time
tags create time
前端
后端
跨域
调试
网络
Cookie
缓存
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 请求。

诊断流程图

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 不能是 *,必须是具体的源。

// ❌ 错误写法
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。

回顾一下中间件的完整逻辑:

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 确实设上了:

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。

# ❌ 上游的 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:

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 推荐,请求不跨域,无副作用
// 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 才能让前端读到自定义头?

关联笔记