跳至主要內容
Skip to content

同源政策與 CORS(上):原理篇

CORS 問題是前端工程師的「必經之痛」。要真正解決它,必須先理解同源政策為何存在。


一、 什麼是同源政策?

1.1 同源的定義

「同源」指的是兩個 URL 的以下三項完全相同:

組成說明
協定(Protocol)http 或 https
主機(Host)網域名稱
埠號(Port)80, 443, 3000 等
javascript
// 假設當前頁面是 https://example.com:443

https://example.com/api      // ✅ 同源
https://example.com:443/api  // ✅ 同源(443 是 https 預設埠)
http://example.com/api       // ❌ 協定不同
https://api.example.com/api  // ❌ 主機不同
https://example.com:8080/api // ❌ 埠號不同

1.2 為什麼需要同源政策?

同源政策阻止了這種攻擊:

1.3 同源政策限制什麼?

操作是否受限說明
JavaScript 讀取回應✅ 受限核心保護
Cookie 存取✅ 受限需要同源
DOM 存取✅ 受限iframe 無法跨域操作
AJAX/Fetch✅ 受限需要 CORS
<img> 載入❌ 不受限可以嵌入跨域圖片
<script> 載入❌ 不受限JSONP 利用這點
<link> 載入 CSS❌ 不受限嵌入跨域樣式
表單提交❌ 不受限可以跨域提交(CSRF!)

二、 瀏覽器如何判斷?

2.1 簡單請求 vs 預檢請求

瀏覽器將跨域請求分為兩類:

簡單請求(Simple Request)

同時滿足以下條件:

  1. 方法:GET、HEAD、POST
  2. 標頭:只有「安全標頭」
    • Accept
    • Accept-Language
    • Content-Language
    • Content-Type(有限制)
  3. Content-Type 只能是:
    • text/plain
    • multipart/form-data
    • application/x-www-form-urlencoded
javascript
// ✅ 簡單請求
fetch("https://api.example.com/data", {
  method: "GET",
});

fetch("https://api.example.com/data", {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body: "name=John",
});

預檢請求(Preflight Request)

不符合簡單請求條件的:

javascript
// ❌ 需要預檢
fetch("https://api.example.com/data", {
  method: "PUT", // 非簡單方法
});

fetch("https://api.example.com/data", {
  method: "POST",
  headers: { "Content-Type": "application/json" }, // 非簡單 Content-Type
  body: JSON.stringify({ name: "John" }),
});

fetch("https://api.example.com/data", {
  headers: { Authorization: "Bearer token" }, // 自定義標頭
});

2.2 預檢請求流程

2.3 預檢請求詳解

http
# 預檢請求
OPTIONS /api/users HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Content-Type, Authorization

# 預檢回應
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400

三、 CORS 標頭詳解

3.1 請求標頭(瀏覽器自動添加)

標頭說明
Origin請求來源
Access-Control-Request-Method預檢時,實際請求的方法
Access-Control-Request-Headers預檢時,實際請求的標頭

3.2 回應標頭(伺服器設置)

標頭說明範例
Access-Control-Allow-Origin允許的來源https://app.com*
Access-Control-Allow-Methods允許的方法GET, POST, PUT
Access-Control-Allow-Headers允許的標頭Content-Type, Authorization
Access-Control-Expose-Headers可被 JS 讀取的標頭X-Custom-Header
Access-Control-Max-Age預檢快取時間(秒)86400
Access-Control-Allow-Credentials是否允許攜帶 Cookietrue

3.3 Allow-Origin 的值

javascript
// ✅ 指定特定來源
'Access-Control-Allow-Origin': 'https://app.example.com'

// ✅ 允許所有來源(公開 API)
'Access-Control-Allow-Origin': '*'

// ❌ 不能使用 * 同時攜帶 credentials
'Access-Control-Allow-Origin': '*'
'Access-Control-Allow-Credentials': 'true'
// 這會報錯!

// ✅ 動態設置(根據請求的 Origin)
const origin = req.headers.origin
if (allowedOrigins.includes(origin)) {
  res.setHeader('Access-Control-Allow-Origin', origin)
}

四、 常見錯誤訊息

4.1 錯誤類型

# Origin 不被允許
Access to fetch at 'https://api.com' from origin 'https://app.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin'
header is present on the requested resource.

# 預檢失敗
Access to fetch at 'https://api.com' from origin 'https://app.com'
has been blocked by CORS policy: Response to preflight request
doesn't pass access control check.

# Credentials 錯誤
Access to fetch at 'https://api.com' from origin 'https://app.com'
has been blocked by CORS policy: The value of the
'Access-Control-Allow-Origin' header must not be the wildcard '*'
when the request's credentials mode is 'include'.

4.2 除錯步驟


五、 不受同源限制的情況

5.1 嵌入資源

html
<!-- 這些不受同源限制 -->
<img src="https://other.com/image.jpg" />
<script src="https://cdn.com/lib.js"></script>
<link href="https://cdn.com/style.css" rel="stylesheet" />
<video src="https://media.com/video.mp4"></video>
<iframe src="https://embed.com/widget"></iframe>

5.2 JSONP(傳統技巧)

利用 <script> 不受限的特性:

javascript
// 前端
function handleData(data) {
  console.log(data);
}

const script = document.createElement("script");
script.src = "https://api.com/data?callback=handleData";
document.body.appendChild(script);

// 伺服器回應
// handleData({"name": "John"})

WARNING

JSONP 只支援 GET,且有安全風險。現代開發請使用 CORS。

5.3 postMessage

iframe 間的跨域通訊:

javascript
// 父頁面
const iframe = document.getElementById("child");
iframe.contentWindow.postMessage({ type: "HELLO" }, "https://child.com");

window.addEventListener("message", (event) => {
  if (event.origin !== "https://child.com") return;
  console.log(event.data);
});

// 子頁面
window.addEventListener("message", (event) => {
  if (event.origin !== "https://parent.com") return;
  event.source.postMessage({ type: "HI" }, event.origin);
});

六、 安全考量

6.1 為什麼不能用 * 加 Credentials?

javascript
// 假設這被允許...
fetch("https://bank.com/api/account", {
  credentials: "include", // 攜帶bank.com的cookie
});

// 如果 bank.com 回應:
// Access-Control-Allow-Origin: *
// Access-Control-Allow-Credentials: true

// evil.com 就能讀取用戶的銀行資料!

所以瀏覽器強制:使用 credentials 時,必須指定具體的 Origin

6.2 Origin 驗證

javascript
// ❌ 危險:反射 Origin
app.use((req, res, next) => {
  res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
  next();
});

// ✅ 安全:白名單驗證
const allowedOrigins = ["https://app.com", "https://admin.app.com"];

app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (allowedOrigins.includes(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
  }
  next();
});

總結

概念說明
同源協定 + 主機 + 埠 相同
同源政策阻止跨域讀取回應
簡單請求GET/POST + 安全標頭
預檢請求OPTIONS 先詢問
CORS 標頭伺服器授權跨域

> **記住**:同源政策是**瀏覽器**的保護機制。伺服器端呼叫 API 不受此限制(沒有 Origin 概念)。


進階挑戰

  1. 使用瀏覽器 DevTools 觀察預檢請求和實際請求的差異。
  2. 研究:為什麼 WebSocket 連線不需要 CORS?
  3. 思考:如果 CDN 設置了 Access-Control-Allow-Origin: *,可能有什麼安全風險?

延伸閱讀與資源