Files
2026-05-24 12:43:51 +08:00

390 lines
15 KiB
Markdown
Raw Permalink 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: [CORS, 跨域, 安全, HTTP, Web开发, 前后端分离]
create time: 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` | ❌ 同域 | 仅路径不同(不算跨域) |
### 跨域的本质
跨域不是请求发不出去,而是 **浏览器拒绝接收响应**。实际过程如下:
```mermaid
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 后端)
```mermaid
flowchart LR
DevBrowser["浏览器<br/>localhost:5173"] --> Backend["Go API :8080<br/>(请求 /api/users)"]
```
这是开发阶段最常见的跨域场景——前端 dev server 跑在 `localhost:5173`,后端服务在 `:8080`,端口不同即触发跨域。
### 生产环境(多子域名)
```mermaid
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` 中间件:
```go
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):**
```java
@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):**
```java
@CrossOrigin(origins = "https://app.example.com", maxAge = 3600)
@RestController
@RequestMapping("/api/admin")
public class AdminController { ... }
```
#### Node.js (Express)
```js
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 响应头。
```nginx
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 方案的架构图
```mermaid
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 时的直连模式:
```mermaid
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`)
```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`)
```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>` 标签触发执行。
```html
<!-- 前端 -->
<script>
function jsonpCallback(data) {
console.log(data); // 拿到后端返回的数据
}
</script>
<script src="https://api.example.com/data?callback=jsonpCallback"></script>
```
```go
// 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
```ts
// 前端 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 缓存的影响
```mermaid
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 | 自身无能为力 |
## 关联笔记
- [[hhs/GIN/3-middleware/cors-registration-scope/cors-preflight]] — Gin 框架下预检请求的详细机制
- [[hhs/DEV/Nginx/Nginx]] — Nginx 配置速查,含反向代理、负载均衡等
- [[hhs/DEV/XSS与CSRF攻击/XSS与CSRF攻击]] — 同源策略是防御 XSS/CSRF 的基础
- [[03-服务端鉴权认证方案]] — Cookie / Session 鉴权与 CORS 凭据的配合
- [[hhs/MS/02-服务治理/01-API网关]] — 微服务架构中通过 API 网关统一处理跨域