同源政策與 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)
同時滿足以下條件:
- 方法:GET、HEAD、POST
- 標頭:只有「安全標頭」
- Accept
- Accept-Language
- Content-Language
- Content-Type(有限制)
- 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 | 是否允許攜帶 Cookie | true |
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 概念)。
進階挑戰
- 使用瀏覽器 DevTools 觀察預檢請求和實際請求的差異。
- 研究:為什麼 WebSocket 連線不需要 CORS?
- 思考:如果 CDN 設置了
Access-Control-Allow-Origin: *,可能有什麼安全風險?