什麼是跨源、跨域
所謂同源(即指在同一個域)就是兩個頁面具有相同的協議(protocol
),主機(host
)埠號(port
)。
同源策略是瀏覽器的一個安全功能,不同源的客戶端指令碼在未明確授權的情況下,不能讀寫對方的資源。
同源策略 是瀏覽器安全的基石。
同源策略會阻止一個域的 javascript
指令碼和另外一個域的內容進行互動,例如辦公內外網環境,當我們訪問外網一個惡意網站的時候,惡意網站就會利用我們的主機向內網的 url 傳送 ajax 請求,破壞或盜取資料。
瀏覽器的非同源限制、解決思路
非同源限制
- 無法讀取非同源網頁的
Cookie
、LocalStorage
和IndexedDB
; - 無法操作非同源網頁的
DOM
; - 無法向非同源地址傳送
AJAX
請求,即XHR
請求。
跨域的解決思路-3種
避免非同源限制—“躲著走”
- 讓瀏覽器不做限制,指定引數,讓瀏覽器不做校驗,但該方法不太合理,因為它需要每個使用者都去對瀏覽器設定做改動;
- 不要發出 XHR 請求,這樣就算是跨域,瀏覽器也不會有非同源限制,解決方案是
JSONP
,通過動態建立一個script
,通過script
發出請求;
跨域資源共享方案
根據 W3C 的跨源資源共享方案,在被呼叫方修改程式碼,加上欄位,告訴瀏覽器該網站支援跨域。
通過伺服器反向代理,隱藏跨域
使用 Nginx
反向代理,在 a
域名中的請求地址使用反向代理指向 b
域名,讓瀏覽器一直以為在訪問 a
網站,不觸發跨域限制。
JSONP
簡單示例(JavaScript + PHP)
JavaScript
<script>
var url="http://b.cn/do.php?callback=foo";
//注意 foo函式的定義要先於 引入 url裡的內容
function foo(res){
console.log(res);
console.log(res.a);
}
function loadData(url){
var elem=document.createElement('script');
elem.src=url;
document.getElementsByTagName('head')[0].appendChild(elem);
}
loadData(url);
</script>
PHP
$arr=['a'=>'ajax','b'=>'bbc'];
$callback=$_GET['callback'];
$json=$callback."(".json_encode($arr).");";
echo $json; //foo({"a":"ajax","b":"bbc"});
上述程式碼中,相當於使用 PHP
結合前端的 JavaScript
拼接成一個 foo
方法的呼叫,也就是 loadData
方法最後返回的是 foo
函式的一個呼叫,也就是 foo({"a":"ajax","b":"bbc"})
;
Jquery中 JSONP 示例
這裡使用到 Jquery
中兩個引數:
Jsonp
:回撥函式的引數名,預設為callback
,服務端憑藉這個引數獲得回撥函式名稱;JsonpCallback
:jsonp
請求成功後,在js
中呼叫的回撥函式的名字,預設是Jquery
自動生成。指定jsonpCallback
時可以將回撥函式寫在Ajax
外面做其他操作,不指定時不能這樣做,只能在SUCCESS
裡做操作未指定 Jsonp、JsonpCallback 的名稱
JavaScript
$.ajax({
url: 'http://lifeloopdev.info/get_events',
dataType: "jsonp",
data: "offset=0&num_items=10",
success: function (data) {
$.each(data.success, function (i, item) {
$("body").append('<h1>' + item.title + '</h1>');
});
}
});
PHP
header('content-type:text-html;charset=utf-8');
$callback = trim($_GET['callback']);
echo $callback + "{ \"success\": [{ \"id\": 1, \"title\": \"title 1\" }, { \"id\": 2, \"title\": \"title 2\" }, { \"id\": 3, \"title\": \"title 3\"}] }";
指定 Jsonp、JsonpCallback 的名稱
JavaScript
$.ajax({
url: 'http://lifeloopdev.info/get_events',
dataType: "jsonp",
data: "offset=0&num_items=10",
jsonp: "selfCallback",
jsonpCallback: 'successCallback'
});
function successCallback(data) {
$.each(data.success, function (i, item) {
$("body").append('<h1>' + item.title + '</h1>');
});
}
PHP
header('content-type:text-html;charset=utf-8');
$callback = trim($_GET['selfCallback']);
echo $callback + "{ \"success\": [{ \"id\": 1, \"title\": \"title 1\" }, { \"id\": 2, \"title\": \"title 2\" }, { \"id\": 3, \"title\": \"title 3\"}] }";
JSONP 的弊端
- 需要伺服器改動程式碼;
- 只支援 GET 請求;
- 傳送的不是 XHR請求;
- 相對不安全。
後端解決跨域
跟使用者資料有關的就是動態請求,沒有資料的是靜態請求,比如 css js,So,HTTP 伺服器(Apache、Nginx 等)至少做了兩個作用
- HTTP 伺服器,處理靜態請求;
- 反向代理,負載均衡。
在伺服器端解決跨域有2種解決思路:
- 在被呼叫後端應用解決:在響應頭增加指定欄位,告訴瀏覽器,允許呼叫,這種解決方案的請求是直接從瀏覽器傳送給後端伺服器,在瀏覽器上會看到 b.com 的 url。
- 在前端伺服器解決:這是隱藏跨域的解決方案。這種跨域請求不是直接從瀏覽器傳送的,而是從中間的 http 伺服器(前端應用所在伺服器)轉發過去的,在瀏覽器中看到的還是 a.com 的 url,所以不會認為是跨域,但是該到 b.com 的請求還是會到 b.com。
CORS解決跨域相關原理
為了解決瀏覽器跨域問題,W3C 提出了跨源資源共享方案,即 CORS(Cross-Origin Resource Sharing)。
CORS 可以在不破壞即有規則的情況下,通過後端伺服器實現 CORS 介面,就可以實現跨域通訊。
CORS 將請求分為兩類:簡單請求和非簡單請求,分別對跨域通訊提供了支援。
簡單請求
簡單請求就是普通 HTML Form 在不依賴指令碼的情況下可以發出的請求,比如表單的 method 如果指定為 POST ,可以用 enctype 屬性指定用什麼方式對錶單內容進行編碼,合法的值就是前述這三種。
- 在 CORS 出現前,傳送 HTTP 請求時在頭資訊中不能包含任何自定義欄位,且 HTTP 頭資訊不超過以下幾個欄位:
- Accept
- Accept-Language
- Content-Language
- Last-Event-ID
- Content-Type 僅為這3種
- application/x-www-form-urlencoded
- multipart/form-data
- text/plain
- 請求方法是 GET HEAD POST 且滿足條件1
一個簡單請求的?
GET /test HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate, sdch, br
Origin: http://www.test.com
Host: www.test.com
對於簡單請求,CORS 的策略是請求時在請求頭中增加一個 Origin 欄位,表示請求發出的域。
伺服器收到請求後,根據該欄位判斷是否允許該請求訪問。
- 如果允許,則在 HTTP 頭資訊中新增 Access-Control-Allow-Origin 欄位,並返回正確的結果
- 如果不允許,則不新增 Access-Control-Allow-Origin 欄位
除了上面提到的 Access-Control-Allow-Origin,還有幾個欄位用於描述 CORS 返回結果
- Access-Control-Allow-Credentials:可選,使用者是否可以傳送、處理cookie
- Access-Control-Expose-Headers:可選,可以讓使用者拿到的欄位。有幾個欄位無論是否允許跨域都可以拿到的:Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma
非簡單請求
一般是傳送 JSON 格式的 ajax 請求,或帶有自定義頭的請求。
非簡單請求就是普通 HTML Form 無法實現的請求。比如 PUT 方法、需要其他的內容編碼方式、自定義頭之類的。
對於非簡單請求的跨源請求,瀏覽器會在真實請求發出前,增加一次 OPTION 請求,稱為預檢請求(preflightrequest)。
預檢請求將真實請求的資訊,包括請求方法、自定義頭欄位、源資訊新增到 HTTP 頭資訊欄位中,詢問伺服器是否允許這樣的操作。
例如一個 GET 請求的預檢請求,包含一個自定義引數X-Custom-Header。
OPTIONS /test HTTP/1.1
Origin: http://www.test.com
Access-Control-Request-Method: GET // 請求使用的 HTTP 方法
Access-Control-Request-Headers: X-Custom-Header // 請求中包含的自定義頭欄位
Host: www.test.com
伺服器收到請求時,需要分別對 Origin、Access-Control-Request-Method、Access-Control-Request-Headers 進行驗證,驗證通過後,會在返回 HTTP 頭資訊中新增:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://www.test.com // 允許的域
Access-Control-Allow-Methods: GET, POST, PUT, DELETE // 允許的方法
Access-Control-Allow-Headers: X-Custom-Header // 允許的自定義欄位
Access-Control-Allow-Credentials: true // 是否允許使用者傳送、處理 cookie
Access-Control-Max-Age: 172800 // 預檢請求的有效期,單位為秒。有效期內,不需要傳送預檢請求,ps 48小時
當預檢請求通過後,瀏覽器才會傳送真實請求到伺服器。這樣就實現了跨域資源的請求訪問。
所以後端處理其實處理的就是這次預檢後請求。
<?php
header('Content-Type: text/html;charset=utf-8');
header('Access-Control-Allow-Origin:*'); // *代表允許任何網址請求
header('Access-Control-Allow-Methods:POST,GET,OPTIONS,DELETE'); // 允許請求的型別
header('Access-Control-Allow-Credentials: true'); // 設定是否允許傳送 cookies
header('Access-Control-Allow-Headers: Content-Type,Content-Length,Accept-Encoding,X-Requested-with, Origin'); // 設定允許自定義請求頭的欄位
if($_GET['name']) {
$name = $_GET['name'];
echo $name;
} else {
echo "請求成功但。。。。";
} ?>
預檢請求—CORS-preflight
對於伺服器來說:
第一,許多伺服器壓根沒打算給跨源用。當然你不給 CORS 響應頭,瀏覽器也不會使用響應結果,但是請求本身可能已經造成了後果。所以最好是預設禁止跨源請求。
第二,要回答某個請求是否接受跨源,可能涉及額外的計算邏輯。這個邏輯可能很簡單,比如一律放行。也可能比較複雜,結果可能取決於哪個資源哪種操作來自哪個 origin。對瀏覽器來說,就是某個資源是否允許跨源這麼簡單;對伺服器來說,計算成本卻可大可小。所以我們希望最好不用每次請求都讓伺服器勞神計算。
CORS-preflight 就是這樣一種機制,瀏覽器先單獨請求一次,詢問伺服器某個資源是否可以跨源,如果不允許的話就不發實際的請求。注意先許可再請求等於預設禁止了跨源請求。如果允許的話,瀏覽器會記住,然後發實際請求,且之後每次就都直接請求而不用再詢問伺服器否可以跨源了。於是,伺服器想支援跨源,就只要針對 preflight 進行跨源許可計算。本身真正的響應程式碼則完全不管這個事情。並且因為 preflight 是許可式的,也就是說如果伺服器不打算接受跨源,什麼事情都不用做。
但是這機制只能限於非簡單請求。
在處理簡單請求的時候,如果伺服器不打算接受跨源請求,不能依賴 CORS-preflight 機制,因為不通過 CORS,普通表單也能發起簡單請求,所以預設禁止跨源是做不到的。
既然如此,簡單請求發 preflight 就沒有意義了,就算發了伺服器也省不了後續每次的計算,反而在一開始多了一次 preflight。
簡單請求為何不用預檢請求?
有些人把簡單請求不需要 preflight 理解為『向下相容』。這也不能說錯。但嚴格來說,並不是『為了向下相容』而不能發。理論上瀏覽器可以區別對待表單請求和非表單請求 —— 對傳統的跨源表單提交不發 preflight,從而保持相容,只對非表單跨源請求發 preflight。
但這樣做並沒有什麼好處,反而把事情搞複雜了。比如本來你可以直接用指令碼發跨源普通請求,儘管(在伺服器預設沒有跨源處理的情況下)你無法得到響應結果,但是你的需求可能只是傳送無需返回,比如打個日誌。但現在如果伺服器不理解 preflight 你就幹不了這個事情了。
而且如果真的這樣做,伺服器就變成了預設允許跨源表單,如果想控制跨源,還是得(跟原本一樣)直接在響應處理中執行跨源計算邏輯;另一方面伺服器又需要增加對 preflight 請求的響應支援,執行類似的跨源計算邏輯以控制來自非表單的相同跨源請求。伺服器通常沒有區分表單/非表單差異的需求,這樣搞純粹是折騰伺服器端工程師。
所以簡單請求不發 preflight 不是因為不能相容,而是因為相容的前提下發 preflight 對絕大多數伺服器應用來說沒有意義,反而把問題搞複雜了。
注意
在 Chrome 和 Firefox 中,如果 Access-Control-Allow-Methods 中並未允許 GET/POST/HEAD 請求,但允許跨域了,瀏覽器還是會允許 GET/POST/HEAD 這些 簡單請求 訪問,這時就必須在後臺用其他辦法禁掉這些 Method。
後端伺服器–Nginx 解決方案
這裡的 Nginx 僅做反向代理功能,瀏覽器訪問頁面在 a.com
的 Nginx 上,ajax 請求介面是 b.com
,所以瀏覽器認為是跨域
Nginx在 nginx.conf
上配(vhost 是約定做法,這樣做不修改主檔案)
include vhost/*.config;
建立 cors.conf
server{
listen 80; // 監聽80埠
server_name b.com; // 監聽向 b.com 傳送的請求
location /{
proxy_pass http://ser432ver.53253bb.com:8080; // 轉發到哪裡
add_header Access-Control-Allow-Origin $http_origin; // $http_ 可以獲取請求中相應的 header 引數
add_header Access-Control-Allow-Method *;
add_header Access-Control-Allow-Headers X-Custom-Header;
// 或者
// add_header Access-Control-Allow-Headers $http_access_control_request_headers;
add_header Access-Control-Allow-Credentials true;
add_header Access-Max-age 172800;
// 直接處理預檢命令,if 後要帶空格
if ($request_method = OPTIONS) {
return 200;
}
}
}
前端伺服器解決方案
但其實大部分情況下,我們會把前端應用和請求轉發放在同一臺 Nginx 上
server{
listen 80; // 監聽80埠
server_name a.com; // 監聽向 a.com 傳送的請求
location / {
root html;
index index.html index.htm;
}
locltion /ajaxserver {
proxy_pass http://ser432ver.53253bb.com:8080; // 後端地址
}
}
這樣實質是隱藏跨域,讓瀏覽器認為沒有訪問其他域就不會發生跨域。
前端程式碼需要在每個 ajax 請求前都要加上/ajaxserver
。
參考資料
本作品採用《CC 協議》,轉載必須註明作者和本文連結