跳至主要內容
Skip to content

同源政策與 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();
});

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,
  })
);
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

進階挑戰

  1. 建立一個 CORS 中介軟體,支援多租戶(每個租戶有自己的允許來源)。
  2. 實作一個 CORS 除錯工具,詳細記錄每個跨域請求的判斷過程。
  3. 研究如何在 Service Worker 中處理跨域請求。

延伸閱讀與資源