Files
cs-note/hhs/DEV/跨域问题/跨域问题.md
T
2026-05-24 12:43:51 +08:00

15 KiB
Raw Blame History

tags, create time
tags create time
CORS
跨域
安全
HTTP
Web开发
前后端分离
2026-05-18 14:30

企业项目跨域问题解决方案

概述

跨域(Cross-Origin Resource Sharing, CORS)是前后端分离架构中最常见的网络限制问题。本文从浏览器同源策略出发,系统讲解跨域产生的原理、常见场景及完整解决方案,涵盖服务端配置、Nginx 反向代理和客户端降级方案,并附带 Go / Spring Boot / Node.js / Nginx 的实际代码示例。

[!question] 思考:为什么浏览器要阻止跨域请求?

根源在于浏览器的「同源策略」(Same-Origin Policy)——它是一种安全机制,防止恶意网站读取你当前页面的 Cookie、LocalStorage 和 API 响应。但这一保护也带来了开发的困扰:我们需要在保持安全的前提下,让不同域的服务之间正常通信。

什么是跨域

同源的判定

两个 URL 只要 协议(protocol)、域名(host)、端口(port) 三者完全一致,就称为同源。任意一项不同,即为跨域。

当前 URL 目标 URL 是否跨域 原因
https://api.example.com https://app.example.com ✅ 跨域 域名不同
http://localhost:3000 http://localhost:8080 ✅ 跨域 端口不同
https://example.com http://example.com ✅ 跨域 协议不同
https://example.com/api https://example.com/data ❌ 同域 仅路径不同(不算跨域)

跨域的本质

跨域不是请求发不出去,而是 浏览器拒绝接收响应。实际过程如下:

flowchart TD
    A["前端发起 AJAX / Fetch 请求"] --> B{"是否为跨域请求?"}
    B -- '否' --> C["正常发送, 浏览器接收响应"]
    B -- '是' --> D{"是否预检请求?"}
    D -- '简单请求' --> E["直接发送请求"]
    D -- '复杂请求' --> F["先发 OPTIONS 预检"]
    F --> G{"服务端返回 200 + 正确 CORS 头?"}
    G -- '是' --> E
    G -- '否' --> H["浏览器拦截预检, 不发送真实请求"]
    E --> I{"服务端是否携带 CORS 响应头?"}
    I -- '是' --> C
    I -- '否' --> J["浏览器拒绝响应, 控制台报错"]

关键结论:服务器必须设置正确的 CORS 响应头,浏览器才会放行响应给 JavaScript。

常见跨域场景

开发环境(本地 vs 后端)

flowchart LR
    DevBrowser["浏览器<br/>localhost:5173"] --> Backend["Go API :8080<br/>(请求 /api/users)"]

这是开发阶段最常见的跨域场景——前端 dev server 跑在 localhost:5173,后端服务在 :8080,端口不同即触发跨域。

生产环境(多子域名)

flowchart LR
    App["app.company.com"] --> Api["api.company.com<br/>(需配 CORS)"]

主站和 API 属于不同子域名,同样需要处理跨域。

嵌入场景(iframe / CDN)

  • 页面加载第三方 CDN 上的静态资源(字体、JS、CSS)→ 通常没问题(GET 请求天然不受限)
  • 页面内 iframe 向自身域名发 XHR 请求 → 受跨域限制
  • 调用第三方 API(如支付回调、地图 SDK)→ 取决于对方是否支持 CORS

解决方案

方案一:服务端配置 CORS(推荐首选)

适用场景:自有服务端,可直接修改响应头。

Go (Gin 框架)

使用官方 gin-contrib/cors 中间件:

import "github.com/gin-contrib/cors"

router := gin.Default()

config := cors.DefaultConfig()
config.AllowOrigins = []string{"https://app.example.com"}   // 明确允许的源
config.AllowMethods = []string{"GET", "POST", "PUT", "DELETE"}
config.AllowHeaders = []string{"Origin", "Content-Type", "Authorization"}
config.AllowCredentials = true  // 携带 Cookie 时必须设为 true
config.MaxAge = 12 * time.Hour  // 预检请求缓存时长

router.Use(cors.New(config))

AllowOrigins 白名单是安全的第一道防线——它告诉浏览器"只有这些域名可以读取我的接口数据"。AllowCredentials 用于允许携带 Cookie(如登录态),但配合白名单使用,绝不可与通配符 * 同时出现。MaxAge 设置预检请求在浏览器的缓存时长,减少不必要的 OPTIONS 请求。

[!tip] 生产环境安全要点

  • 不要用 AllowOrigins = ["*"] 配合 AllowCredentials = true,这会引发 panic 或无效配置。
  • 应显式列出允许的前端域名白名单,避免恶意站点劫持接口。
  • 如需支持多个前端域名,可从环境变量或配置中心动态读取。

Java (Spring Boot)

Spring Boot 提供了多种 CORS 配置方式:

全局配置(实现 WebMvcConfigurer):

@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")                  // 生效的路径
                .allowedOrigins("https://app.example.com")
                .allowedMethods("GET", "POST", "PUT", "DELETE")
                .allowedHeaders("*")
                .allowCredentials(true)
                .maxAge(3600);                         // 预检缓存 1 小时
    }
}

注解级别(细粒度控制单个 Controller):

@CrossOrigin(origins = "https://app.example.com", maxAge = 3600)
@RestController
@RequestMapping("/api/admin")
public class AdminController { ... }

Node.js (Express)

const cors = require('cors');

app.use(cors({
  origin: ['https://app.example.com', 'https://admin.example.com'],
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
}));

此配置传入 Express 的中间件链中,会对所有路由生效。如需限定路径范围,可改为 app.use('/api', cors(config)) 。与 Go / Java 方案相比,Node.js 方案最轻量——无需额外注解或配置类,只需在启动时注册一次即可。

方案二:Nginx 反向代理(零侵入)

适用场景:无法修改后端代码、多语言混合后端统一治理、隐藏后端地址提升安全。

核心思路:让前端访问 Nginx 的 /api 路径,Nginx 把请求转发到后端,同时注入 CORS 响应头。

server {
    listen 443 ssl;
    server_name app.example.com;

    # --- 前端静态资源 ---
    location / {
        root /usr/share/nginx/html;
        try_files $uri $uri/ /index.html;
    }

    # --- Location 1: OPTIONS 预检请求(直接返回,不通后端)---
    location ~ ^/api/.*$ {
        if ($request_method = 'OPTIONS') {
            add_header 'Access-Control-Allow-Origin' '$http_origin';
            add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
            add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization';
            add_header 'Access-Control-Max-Age' 1728000;
            add_header 'Content-Type' 'text/plain; charset=utf-8';
            add_header 'Content-Length' 0;
            return 204;
        }
    }

    # --- Location 2: 真实请求(反向代理 + CORS 头)---
    location /api/ {
        add_header 'Access-Control-Allow-Origin' '$http_origin' always;
        add_header 'Access-Control-Allow-Credentials' 'true' always;
        add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';

        proxy_pass http://backend_server:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

[!warning] Nginx 注意事项

  • if ($request_method = 'OPTIONS') 中的 if 在 Nginx 中有陷阱,但它在这里是安全的用法(只操作 header)。
  • always 参数确保即使错误响应(4xx / 5xx)也带上 CORS 头。
  • return 204 直接返回空体,避免 OPTIONS 请求打到后端增加负载。

Nginx 方案的架构图

flowchart TD
    Browser["浏览器 app.example.com"] --> Nginx["Nginx 443<br/>(GET /api/users)"]
    Nginx --> Backend["后端 :8080<br/>(proxy_pass)"]
    Backend --> JSONResponse["JSON 响应"]
    Nginx --> BrowserReply["返回含 CORS 头的<br/>JSON 响应"]
    BrowserReply --> Browser

对比之下,不加 Nginx 时的直连模式:

flowchart TD
    Browser["浏览器<br/>app.example.com<br/><b>❌ 跨域 + 无 CORS 头</b>"] --> Backend["后端 api.example.com:8080"]

此时浏览器直接访问后端接口,因域名和端口均不同触发跨域限制,且服务端未配置 CORS 头,浏览器拦截响应。

方案三:开发期 Vite/Webpack Proxy(开发专用)

适用场景:本地开发时绕过跨域,上线后由 Nginx 处理。

Vite 配置 (vite.config.ts)

import { defineConfig } from 'vite';

export default defineConfig({
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8080',  // 后端地址
        changeOrigin: true,               // 改写 Host 头
        secure: false,                    // 自签证书时可关闭校验
      },
    },
  },
});

这样前端发 fetch('/api/users') 时,Vite dev server 会转发到 http://localhost:8080/api/users,因为是同源请求,不存在跨域。

Webpack (vue.config.js / webpack.config.js)

module.exports = {
  devServer: {
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
      },
    },
  },
};

changeOrigin: true 会将被代理请求的 Host 头改为后端地址,这对于某些依赖 Host 做鉴权的后端服务是必要的。Webpack proxy 同样只在本地开发生效,生产环境仍需 Nginx 或 CORS 中间件配合。

[!info] Vite/Webpack Proxy 与正式方案的区别

开发 Proxy Nginx / 服务端 CORS
用途 仅限本地开发 生产环境
需要后端改吗 不需要 不需要(Nginx)/ 需要(服务端)
HTTPS 支持 需额外配置 原生支持
部署复杂度 无 低

方案四:JSONP / 服务端重定向(历史方案,了解即可)

JSONP

利用 <script> 标签不受同源策略限制的特性,通过回调函数接收数据。仅支持 GET,且存在 XSS 风险,已属历史方案。

原理:后端返回 callbackName(data) 这样的 JavaScript 代码,前端动态插入 <script> 标签触发执行。

<!-- 前端 -->
<script>
  function jsonpCallback(data) {
    console.log(data);  // 拿到后端返回的数据
  }
</script>
<script src="https://api.example.com/data?callback=jsonpCallback"></script>
// Go 后端示例
func JSONPHandler(w http.ResponseWriter, r *http.Request) {
    callback := r.URL.Query().Get("callback")
    data := `{"name": "John", "age": 30}`
    w.Header().Set("Content-Type", "application/javascript")
    fmt.Fprintf(w, "%s(%s)", callback, data)
}

[!note] JSONP 的局限性

  • 只支持 GET——POST/PUT/DELETE 等无法实现
  • 无错误处理——脚本加载失败时只能通过 onerror 粗略捕获
  • 安全风险——任何提供 JSONP 的接口都可能被恶意站点调用,需额外做 Referer 校验
  • 已被 CORS 全面取代——现代浏览器全部支持 CORS,新项目不应考虑此方案

服务端重定向

后端自己调后端,前端只请求同源接口。适合微服务间调用,但不解决浏览器侧跨域。

进阶话题

带凭据的请求(Cookie / Authorization Header)

当 withCredentials: true(fetch)或 xhr.withCredentials = true(XMLHttpRequest)时:

  1. 服务端 Access-Control-Allow-Origin 不能为 *,必须是具体域名
  2. 服务端需设置 Access-Control-Allow-Credentials: true
  3. 浏览器会在请求中带上 Cookie
// 前端 fetch 带 Cookie
fetch('https://api.example.com/user/profile', {
  method: 'GET',
  credentials: 'include',     // 关键:带上 Cookie
  headers: { 'Authorization': 'Bearer xxx' },
});

credentials: 'include' 是让浏览器发送 Cookie 的关键。如果只设 credentials 而不配服务端 Access-Control-Allow-Credentials: true,或者后端 Allow-Origin 仍为 *,请求同样会被拦截——这是一个"双方都满足才能通过"的条件。

预检请求的优化

复杂请求(自定义头、PUT/DELETE 等)会先发送 OPTIONS 预检。可通过以下方式减少开销:

Max-Age 缓存的影响

flowchart LR
    A["Max-Age = 0<br/><b>每次请求都发 OPTIONS</b>"] --> B["⚠️ 高延迟,<br/>服务器压力大"]
    C["Max-Age = 86400<br/><b>24小时内复用预检结果</b>"] --> D["✅ 首慢后续快,<br/>负载低"]

设置方式

服务端或 Nginx 中设置响应头即可:

  • Access-Control-Max-Age: 86400 — 缓存 24 小时,超过需重新预检
  • 一般建议设为 1 天到 7 天,具体取决于接口变更频率

RESTful 动词与预检的关系

方法 是否可能预检 原因
GET ❌ 不会 简单请求
POST (application/x-www-form-urlencoded / multipart/form-data / text/plain) ❌ 可能不会 Content-Type 在白名单内
POST (application/json) ✅ 会预检 Content-Type 不在简单请求白名单
PUT / DELETE / PATCH ✅ 会预检 不在简单请求方法的白名单

常见问题排查清单

[!check] 跨域问题排查流程

  1. 确认跨域类型 — 打开浏览器 DevTools → Network,看请求状态码是 200 还是 cors error
  2. 检查请求头 — Request Headers 里是否有 Origin,值是否正确
  3. 检查响应头 — Response Headers 中是否有 Access-Control-Allow-Origin,值和前端域名匹配吗
  4. 区分简单请求和预检 — 有无单独的 OPTIONS 请求发出?OPTIONS 返回什么?
  5. credentials 场景 — 是否同时满足了 具体 Allow-Origin + Allow-Credentials: true
  6. 多级代理链路 — 经过网关 / WAF / CDN 时,确认每一层都不删除 CORS 头

方案选型速查表

场景 推荐方案 理由
前后端同公司,后端可改 服务端 CORS 中间件 最直接,语义清晰
多语言后端 / 不便改代码 Nginx 反代注入 CORS 头 统一治理,零侵入
本地开发 Vite/Webpack Proxy 开发体验最佳
生产环境隐藏后端 IP Nginx 反代 安全 + 跨域双收益
纯静态页调用第三方公开 API 让第三方配好 CORS 自身无能为力

关联笔记