同源政策與 CORS(下):實戰篇
了解原理後,讓我們進入實戰。本篇將介紹如何正確配置 CORS,解決各種跨域場景。
一、 Express CORS 配置
1.1 使用 cors 套件
javascript
const express = require("express");
const cors = require("cors");
const app = express();
// 最簡設置:允許所有來源
app.use(cors());
// 自定義配置
app.use(
cors({
origin: "https://app.example.com",
methods: ["GET", "POST", "PUT", "DELETE"],
allowedHeaders: ["Content-Type", "Authorization"],
exposedHeaders: ["X-Total-Count"],
credentials: true,
maxAge: 86400,
})
);1.2 動態 Origin
javascript
const allowedOrigins = [
"https://app.example.com",
"https://admin.example.com",
"http://localhost:3000", // 開發環境
];
app.use(
cors({
origin: (origin, callback) => {
// 允許沒有 origin 的請求(如 Postman)
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error("Not allowed by CORS"));
}
},
credentials: true,
})
);1.3 針對特定路由
javascript
// API 路由:嚴格限制
app.use(
"/api",
cors({
origin: "https://app.example.com",
credentials: true,
})
);
// 公開資源:允許所有
app.use("/public", cors());
// 單一路由
app.get("/special", cors({ origin: "https://special.com" }), handler);1.4 手動實作
javascript
app.use((req, res, next) => {
const origin = req.headers.origin;
if (allowedOrigins.includes(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
}
res.setHeader(
"Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, OPTIONS"
);
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Access-Control-Max-Age", "86400");
// 處理預檢請求
if (req.method === "OPTIONS") {
return res.status(204).end();
}
next();
});二、 預檢請求處理
2.1 什麼觸發預檢?
javascript
// ❌ 這些請求需要預檢
fetch(url, { method: "PUT" });
fetch(url, { method: "DELETE" });
fetch(url, {
headers: { "Content-Type": "application/json" },
});
fetch(url, {
headers: { Authorization: "Bearer token" },
});
// ✅ 這些是簡單請求,不需要預檢
fetch(url); // GET
fetch(url, { method: "POST", body: formData });2.2 預檢快取
javascript
app.use(
cors({
maxAge: 86400, // 24 小時
})
);
// 瀏覽器會快取預檢結果
// 在這段時間內,相同的跨域請求不會再發送預檢2.3 確保 OPTIONS 回應正確
javascript
// Express 預設不處理 OPTIONS
// cors 套件會自動處理
// 如果手動處理:
app.options("*", (req, res) => {
res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.status(204).end();
});三、 Cookie 跨域
3.1 問題
javascript
// 預設,跨域請求不會攜帶 Cookie
fetch("https://api.example.com/user");
// 請求中沒有 Cookie!3.2 前端設置
javascript
// Fetch API
fetch("https://api.example.com/user", {
credentials: "include", // 重要!
});
// Axios
axios.get("https://api.example.com/user", {
withCredentials: true,
});
// 全域設置
axios.defaults.withCredentials = true;3.3 後端設置
javascript
app.use(
cors({
origin: "https://app.example.com", // 必須指定,不能用 *
credentials: true,
})
);3.4 Cookie 屬性
javascript
res.cookie("session", "abc123", {
httpOnly: true,
secure: true, // HTTPS only
sameSite: "none", // 跨站必須
domain: ".example.com", // 可選:共享 Cookie
});四、 常見問題與解決
4.1 Cannot use wildcard with credentials
錯誤:The value of the 'Access-Control-Allow-Origin' header
must not be '*' when the request's credentials mode is 'include'.原因:使用 credentials 時不能用 *
解決:
javascript
// ❌ 錯誤
app.use(
cors({
origin: "*",
credentials: true,
})
);
// ✅ 正確
app.use(
cors({
origin: "https://app.example.com",
credentials: true,
})
);
// ✅ 動態 origin
app.use(
cors({
origin: (origin, callback) => {
if (allowedOrigins.includes(origin)) {
callback(null, origin);
} else {
callback(null, false);
}
},
credentials: true,
})
);4.2 Missing Access-Control-Allow-Headers
錯誤:Request header field authorization is not allowed by
Access-Control-Allow-Headers in preflight response.解決:
javascript
app.use(
cors({
allowedHeaders: ["Content-Type", "Authorization", "X-Custom-Header"],
})
);4.3 Preflight 返回非 2xx
錯誤:Response to preflight request doesn't pass access control check:
It does not have HTTP ok status.原因:OPTIONS 請求被擋住了
解決:
javascript
// 確保 OPTIONS 返回 204 或 200
app.options("*", cors());
// 或者確保認證中介軟體不擋 OPTIONS
app.use((req, res, next) => {
if (req.method === "OPTIONS") {
return next(); // 跳過認證
}
authenticateToken(req, res, next);
});4.4 Nginx 反向代理
nginx
# 在 Nginx 層處理 CORS
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' 'Content-Type, Authorization';
add_header 'Access-Control-Allow-Credentials' 'true';
add_header 'Access-Control-Max-Age' 86400;
return 204;
}
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
proxy_pass http://backend;
}五、 代理方案
5.1 開發環境代理
如果後端沒有設置 CORS,可以用代理繞過:
javascript
// vite.config.js
export default {
server: {
proxy: {
"/api": {
target: "https://api.example.com",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ""),
},
},
},
};javascript
// 前端請求本地代理
fetch("/api/users");
// Vite 會轉發到 https://api.example.com/users
// 對瀏覽器來說,這是同源請求!5.2 生產環境代理
nginx
# Nginx 同域代理
server {
listen 443 ssl;
server_name app.example.com;
# 前端靜態檔案
location / {
root /var/www/app;
try_files $uri $uri/ /index.html;
}
# API 代理
location /api {
proxy_pass https://api.internal.example.com;
}
}5.3 Serverless 代理
javascript
// Vercel API Routes (pages/api/proxy/[...path].js)
export default async function handler(req, res) {
const { path } = req.query;
const url = `https://api.example.com/${path.join("/")}`;
const response = await fetch(url, {
method: req.method,
headers: {
"Content-Type": "application/json",
Authorization: req.headers.authorization,
},
body: req.method !== "GET" ? JSON.stringify(req.body) : undefined,
});
const data = await response.json();
res.status(response.status).json(data);
}六、 除錯技巧
6.1 瀏覽器 DevTools
Network 面板:
1. 找到失敗的請求
2. 檢查是否有 OPTIONS 預檢
3. 查看 Response Headers 中的 CORS 標頭
4. 查看 Console 中的具體錯誤訊息6.2 curl 測試
bash
# 測試簡單請求
curl -v https://api.example.com/users \
-H "Origin: https://app.example.com"
# 測試預檢請求
curl -v -X OPTIONS https://api.example.com/users \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: PUT" \
-H "Access-Control-Request-Headers: Content-Type"6.3 檢查清單
markdown
□ 伺服器有設置 Access-Control-Allow-Origin?
□ Origin 值正確匹配?
□ 使用 credentials 時,Origin 不是 \*?
□ 需要的 Headers 都在 Allow-Headers 中?
□ OPTIONS 請求返回 2xx?
□ Cookie 有設置 SameSite=None; Secure?
□ 前端有設置 credentials: 'include'?七、 最佳實踐
7.1 安全配置
javascript
// 生產環境推薦配置
const corsOptions = {
origin: (origin, callback) => {
// 允許的來源清單
const whitelist = process.env.ALLOWED_ORIGINS?.split(",") || [];
// 開發環境或伺服器間呼叫
if (!origin || whitelist.includes(origin)) {
callback(null, true);
} else {
callback(new Error("CORS not allowed"));
}
},
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
allowedHeaders: ["Content-Type", "Authorization"],
exposedHeaders: ["X-Total-Count", "X-Request-Id"],
credentials: true,
maxAge: 86400,
optionsSuccessStatus: 204,
};
app.use(cors(corsOptions));7.2 環境區分
javascript
const isDev = process.env.NODE_ENV === "development";
app.use(
cors({
origin: isDev
? true // 開發環境:允許所有
: ["https://app.example.com", "https://admin.example.com"],
credentials: true,
})
);總結
| 問題 | 解決方案 |
|---|---|
| 基本跨域 | 設置 Access-Control-Allow-Origin |
| Cookie 跨域 | credentials: true + 具體 origin |
| 自定義標頭 | 添加到 Allow-Headers |
| 預檢失敗 | 正確處理 OPTIONS |
| 開發環境 | 使用代理 |
> **黃金法則**:
- 生產環境不要用
origin: '*' - 使用白名單驗證 Origin
- Cookie 需要
SameSite=None; Secure
進階挑戰
- 建立一個 CORS 中介軟體,支援多租戶(每個租戶有自己的允許來源)。
- 實作一個 CORS 除錯工具,詳細記錄每個跨域請求的判斷過程。
- 研究如何在 Service Worker 中處理跨域請求。