那些年,那些跨域問題

scq000發表於2017-04-08

瀏覽器在請求不同域的資源時,會因為同源策略的影響請求不成功,這就是通常被提到的“跨域問題”。作為前端開發,解決跨域問題應該是一個被熟練掌握的技能。而隨著技術不斷的更迭,針對跨域問題的解決也衍生出了多種解決方案。我們通常會根據專案的不同需要,而採取不同的方式。這篇文章,將詳細總結跨域問題的相關知識點,以便在遇到相同問題的時候,能有一個清晰的解決思路。

跨域問題的產生背景

早期為了防止CSRF(跨域請求偽造)的攻擊,瀏覽器引入了同源策略(SOP)來提高安全性。

CSRF(Cross-site request forgery),跨站請求偽造,也被稱為:one click attack/session riding,縮寫為:CSRF/XSRF。 —— 淺談CSRF攻擊方式

而所謂"同源策略",即同域名(domain或ip)、同埠、同協議的才能互相獲取資源,而不能訪問其他域的資源。在同源策略影響下,一個域名A的網頁可以獲取域名B下的指令碼,css,圖片等,但是不能傳送Ajax請求,也不能操作Cookie、LocalStorage等資料。同源策略的存在,一方面提高了網站的安全性,但同時在面對前後端分離、模擬測試等場景時,也帶來了一些麻煩,從而不得不尋求一些方法來突破限制,獲取資源。

JS跨域

這裡所說的JS跨域,指的是在處理跨域請求的過程中,技術面會偏瀏覽器端較多一些,一般是利用瀏覽器的一些特性進行hack處理,從而避開同源策略的限制。

JSONP

由於同源策略不會阻止動態指令碼的插入到文件中去,所以催生出了一種很常用的跨域方式: JSONP(JSON with Padding)。

原理說起來也很簡單:

假設,我們源頁面是在a.com,想要獲取b.com的資料,我們可以動態插入來源於b.com的指令碼:

script = document.createElement('script');
script.type = 'text/javascript';
script.src = 'http://www.b.com/getdata?callback=demo';複製程式碼

這裡,我們利用動態指令碼的src屬性,變相地傳送了一個http://www.b.com/getdata?callback=demo的GET請求。這時候,b.com頁面接受到這個請求時,如果沒有JSONP,會正常返回json的資料結果,像這樣:

{ msg: 'helloworld' }複製程式碼

而利用JSONP,服務端會接受這個callback引數,然後用這個引數值包裝要返回的資料:

demo({msg: 'helloworld'});複製程式碼

這時候,如果a.com的頁面上正好有一個demo的函式:

function demo(data) {
  console.log(data.msg);
}複製程式碼

當遠端資料一返回的時候,隨著動態指令碼的執行,這個demo函式就會被執行。

到這裡,你應該能明白這個技術為什麼叫JSONP了吧?就是因為使用這種技術伺服器會接受回撥函式名作為請求引數,並將JSON資料填充進回撥函式中去。

不過一般在實際開發的時候,我們一般會利用jQuery對JSONP的支援,而避免手寫很多程式碼。從1.2版本開始,jQuery中加入了對JSONP的支援,可以使用$.getJSON方法來請求跨域資料:

//callback後面的?會由jQuery自動生成方法名
$.getJSON('http://www.b.com/getdata?callback=?', function(data) {
  console.log(data.msg);
});複製程式碼

還有一種更加常用的方法是,利用$.ajax方法,只要指定dataTypejsonp即可:

$.ajax({
  url: 'http://www.b.com/getdata?callback=?', //不指定回撥名,可省略callback引數,會由jQuery自動生成
  dataType: 'jsonp',
  jsonpCallback: 'demo', //可省略
  success: function(data) {
    console.log(data.msg);
  }
});複製程式碼

雖然JSONP在跨域ajax請求方面有很強的能力,但是它也有一些缺陷。首先,它沒有關於JSONP呼叫的錯誤處理,一旦回撥函式呼叫失敗,瀏覽器會以靜默失敗的方式處理。其次,它只支援GET請求,這是由於該技術本身的特性所決定的。因此,對於一些需要對安全性有要求的跨域請求,JSONP的使用需要謹慎一點了。

由於JSONP對於老瀏覽器相容性方面比較良好,因此,對於那些對IE8以下仍然需要支援的網站來說,仍然被廣泛應用。不過,針對高階瀏覽器,建議還是使用接下來會介紹的CORS方法。

document.domain

目前,很多大型網站都會使用多個子域名,而瀏覽器的同源策略對於它們來說就有點過於嚴格了。如,來自www.a.com想要獲取document.a.com中的資料。只要基礎域名相同,便可以通過修改document.domain為基礎域名的方式來進行通訊,但是需要注意的是協議和埠也必須相同。

document.a.com中通過設定

document.domain = 'a.com';複製程式碼

www.a.com中:

document.domain = 'a.com';
var iframe = document.createElement('iframe');
iframe.src = 'http://document.a.com';
iframe.style.display = 'none';
document.body.appendChild(iframe);

iframe.onload = function() {
  var targetDocument = iframe.contentDocument || iframe.contentWindow.document;
  //可以操作targetDocument
}複製程式碼

最後,推薦一個使用iframe跨域的庫github.com/jpillora/xd…

window.name

window.name這個全域性屬性主要是用來獲取和設定視窗名稱的,但是通過結合iframe也可以跨域獲取資料。我們知道,每個iframe都有包裹它的window物件,而這個window是最外層視窗的子物件。所以window.name屬性就可以被共享。

下面這個簡單的例子,展示了a.com域名下獲取b.com域名下的資料:

var iframe = document.createElement('iframe');
var canGetData = false;

//監聽載入事件
iframe.onload = function() {
    if (!canGetData) {
        //修改成同源
        iframe.src = 'http://www.a.com';
        canGetData = true;
    } else {
        var data = iframe.contentWindow.name;
        //獲取資料後清除iframe,防止不斷重新整理
        iframe.contentWindow.document.write('');
        iframe.contentWindow.close();
        document.body.removeChild(iframe);
    }
}

iframe.src = 'http://www.b.com/getdata.html';
iframe.style.display = 'none';
document.body.appendChild(iframe);複製程式碼

b.com/getdata.html中要存放的資料需要儲存在window.name屬性中:

<script>
var data = {msg: 'hello, world'};
window.name = JSON.stringify(data); //name屬性只支援字串,支援最大2MB的資料
</script>複製程式碼

還有一種iframe結合location.hash的方式,跟該方法十分類似:是通過檢測iframe的src的hash屬性來傳遞資料的。由於該方法相應速度較慢,這裡就不做介紹了。

window.name+iframe的方法曾經被作為比JSONP更加安全的替代方案,然而對於託管敏感資料的現代Web應用程式來說,已經不推薦使用window.name來進行跨域訊息傳遞了,而是推薦使用接下來介紹的postMessage API。

window.postMessage

postMessage是HTML5新增在window物件上的方法,目的是為了解決在父子頁面上通訊的問題。該技術有個專有名詞:跨文件訊息(cross-document messaging)。利用postMessage的特性可以實現較為安全可信的跨域通訊。

postMessage方法接受兩個引數:

  1. message: 要傳遞的物件,只支援字串資訊,因此如果需要傳送物件,可以使用JSON.stringify和JSON.parse做處理
  2. targetOrigin: 目標域,需要注意的是協議,埠和主機名必須與要傳送的訊息的視窗一致。如果不想限定域,可以使用萬用字元“*”,但是從安全上考慮,不推薦這樣做。

下面介紹一個例子:

首先,先建立一個demo的html檔案,我們這裡採用的是iframe的跨域,當然也可以跨視窗。

<p>
  <button id="sendMsg">sendMsg</button>
</p>

<iframe id="receiveMsg" src="http://b.html">
</iframe>複製程式碼

然後,在sendMsg的按鈕上繫結點選事件,觸發postMessage方法來傳送資訊給iframe:

window.onload = function() {
  var receiveMsg = document.getElementById('receiveMsg').contentWindow; //獲取在iframe中顯示的視窗
  var sendBtn = document.getElementById('sendMsg');

  sendBtn.addEventListener('click', function(e) {
    e.preventDefault();
    receiveMsg.postMessage('Hello world', 'http://b.html');
  });
}複製程式碼

接著,你需要在iframe的繫結的頁面源中監聽message事件就能正常獲取訊息了。其中,MessageEvent物件有三個重要屬性:data用於獲取資料,source用於獲取傳送訊息的視窗物件,origin用於獲取傳送訊息的源。

window.onload = function() {
  var messageBox = document.getElementById('messageBox');

  window.addEventListener('message', function(e) {
    //do something
    //考慮安全性,需要判斷一下資訊來源
    if(e.origin !== 'http://xxxx') return;
    messageBox.innerHTML = e.data;
  });
}複製程式碼

總得來說,postMessage的使用十分簡單,在處理一些和多頁面通訊、頁面與iframe等訊息通訊的跨域問題時,有著很好的適用性。

伺服器跨域

在實踐過程中,一般我們喜歡讓伺服器來多做一些處理,從而儘可能讓前端簡化。這裡將介紹兩種常用的方法:反向代理和CORS。

反向代理

所謂反向代理伺服器,它是代理伺服器中的一種。客戶端直接傳送請求給代理伺服器,然後代理伺服器會根據客戶端的請求,從真實的資源伺服器中獲取資源返回給客戶端。所以反向代理就隱藏了真實的伺服器。利用這種特性,我們可以通過將其他域名的資源對映成自己的域名來規避開跨域問題。

下面我將以node.js所寫的伺服器來做一個演示:

const http = require('http');

const server = http.createServer((req, res) => {

    const proxy_req = http.request({
        port: 8080,
        host: req.headers['host'],
        method: req.method,
        path: req.url,
        headers: req.headers
    });

    proxy_req.on('response', proxy_res => {
        proxy_res.on('data', data => {
            res.write(data, 'binary');
        });

        proxy_res.on('end', () => {
            res.end();
        });

        res.writeHead(proxy_res.statusCode, proxy_res.headers);
    });

    req.on('end', () => {
        proxy_req.end();
    });

    req.on('data', data => {
        proxy_req.write(data, 'binary');
    });
});

server.listen(80);複製程式碼

以上程式碼會將請求80埠的資源對映到8080埠上去。原理就是在監聽到客戶端請求後,啟動一個代理伺服器,然後獲取代理伺服器返回的結果,直接返回給客戶端。

如果你使用的是express, 則程式碼量將更少,也很方便:

const express = require('express');
const request = require('request');
const app = express();

const proxyServer = 'localhost:8080';

app.use('/', (req, res) => {  

  const url = proxyServer + req.url;

  req.pipe(request(url)).pipe(res);

});

app.listen(process.env.PORT || 80);複製程式碼

利用反向代理,你可以將任何請求委託給另外的伺服器,從而避免在瀏覽器端進行跨域操作。不過你需要注意的是:不要使用bodyParser中介軟體,因為你需要直接將原始請求通過管道傳輸到外部伺服器。

一般來說,如果你的生產環境上應用和API在同一臺伺服器上執行,就沒有必要使用跨域了。 而在開發階段採用這種反向代理,則更加方便我們前端開發和測試。

在使用反向代理上,你也可以藉助node-http-proxy庫來減少程式碼量。

CORS

"跨域資源共享"(Cross-origin resource sharing)是W3C出的一個標準。相容性方面可以支援IE8+(IE8和IE9需要使用XDomainRequest物件來支援CORS),所以現在CORS也已經成為主流的跨域解決方案。

CORS的核心思想是通過一系列新增的HTTP頭資訊來實現伺服器和客戶端之間的通訊。所以,要支援CORS,服務端都需要做好相應的配置,這樣,在保證安全性的同時也更方便了前端的開發。

瀏覽器會將CORS請求分為兩類:簡單請求和非簡單請求:

簡單請求

在CORS標準中,會根據是否觸發CORS preflight(預請求)來區分簡單請求和非簡單請求。

簡單請求需要滿足以下幾個條件:

1.請求方法只允許:GET,HEAD,POST

2.對於請求頭欄位有嚴格的要求,一般情況下不會超過以下幾個欄位:

  • Accept
  • Accept-Language
  • Content-Language
  • Content-Type

3.當發起POST請求時,只允許Content-Typeapplication/x-www-form-urlencoded,multipart/form-data,text/plain

對於簡單請求來說,伺服器和客戶端之間的通訊只是進行簡單的交換。如圖:

那些年,那些跨域問題
簡單請求(來源:MDN)

瀏覽器傳送一個帶有Orgin欄位的HTTP請求頭,用來表明請求來源。伺服器的Access-Control-Allow-Origin響應頭表明該伺服器允許哪些源的訪問,一旦不匹配,瀏覽器就會拒絕資源的訪問。大部分情況,大家都喜歡將Access-Control-Allow-Origin設定為*,即任意外域都能訪問該資源。但是,還是推薦做好訪問控制,以保證安全性。

非簡單請求

對於非簡單請求,情況就稍微複雜了點。在正式傳送請求資料之前,瀏覽器會先傳送一個帶有'OPTIONS'方法的請求來確保該請求對於目標站點來說是安全的,這個請求也被稱為”預請求“(preflight)。

瀏覽器和伺服器之間具體的互動過程如圖所示:

那些年,那些跨域問題
非簡單請求(來源:MDN)

瀏覽器會在預檢請求中,多傳送兩個欄位Access-Control-Request-MethodAccess-Control-Request-Headers,前者用於告知伺服器實際請求所用的方法,後者用於告知伺服器實際請求所攜帶的自定義請求首部欄位。然後,伺服器將根據請求頭的資訊來判斷是否允許該請求。

針對非簡單請求,伺服器端可以設定幾個相關欄位:

  1. Access-Control-Allow-Methods, 用來限制允許的方法名,
  2. Access-Control-Allow-Header,用來限制允許的自定義欄位名
  3. Access-Control-Allow-Credentials,用來表明伺服器是否允許credentials標誌為true的場景。
  4. Access-Control-Max-Age,用來表明預檢請求響應的有效時間
  5. Access-Control-Expose-Headers,用來指定伺服器端允許的首部欄位集合

另外,如果是在具體的實踐過程中,除錯OPTIONS請求可以使用

curl -X OPTIONS http://xxx.com複製程式碼

來進行檢視相應頭資訊。也可以通過chrome://net-internals/#events來獲取更加詳細的網路請求資訊。

優化CORS

針對非簡單請求來說,由於每個請求都會傳送預請求,這就導致介面資料的返回會有所延遲,時間被加長。所以,在使用CORS的過程中,可以採用一些方案來優化請求,將非簡單請求轉換成簡單請求,從而提高請求的速度。

1.請求快取

可以在伺服器端使用Access-Control-Max-Age來快取預請求的結果。從而提高網站效能。但是需要注意的是,大部分瀏覽器不會允許快取‘OPTIONS‘請求太長時間,如:火狐是24小時(86400s),chromium是10分鐘(600s)。

2.針對GET請求

對於GET請求,沒必要使用Content-Type, 儘可能地保持GET請求是簡單請求。這樣就可以減少Header上所攜帶的欄位。從安全性上考慮,所有的API呼叫應該儘可能使用https協議,而這樣可以將一些授權認證資訊(如token)直接放在url中去,而不必放在頭部。

3.針對POST請求

對於POST請求,我們可以儘量使用FormData這種原生的格式:

function sendQuery(url, postData) {
  let formData = new FormData();
  for(var key in postData) {
    formData.append(key, postData.key);
  }

  return fetch(url, {
    body: formData,
    headers: {
      'Accept': '*/*'
    },
    method: 'POST'
  });
}

sendQuery('http://www.xxx.com', {msg: 'hello'}).then(function(response) {
  //do something with response
});複製程式碼

附帶憑證資訊的請求

CORS預請求會將使用者的身份認證憑據排除在外,包括cookie、http-authentication報頭等。如果需要支援使用者憑證,需要在XHR的withCredentials屬性設定為true,同時Access-control-allow-origin不能設定為*。在伺服器端會利用響應報頭Access-Control-Allow-Credentials來宣告是否支援使用者憑證。

同時,利用withCredentials這個屬性,也可以檢測瀏覽器是否支援CORS。下面建立一個帶有相容性處理的cors請求:

function createCORSRequest(method, url){
    var xhr = new XMLHttpRequest();
    if ("withCredentials" in xhr){
        xhr.open(method, url, true);
    } else if (typeof XDomainRequest != "undefined"){
        xhr = new XDomainRequest();
        xhr.open(method, url);
    } else {
        xhr = null;
    }
    return xhr;
}

var request = createCORSRequest("POST", "http://www.xxx.com");
if (request){
    request.onload = function(){
        //do something with request.responseText
    };
    request.send();
}複製程式碼

如果瀏覽器支援fetch,則使用它做跨域請求更加方便:

fetch('http://www.xxx.com', {
  method: 'POST',
  mode: 'cors',
  credentials: 'include' //接受憑證
}).then(function(response) {
  //do something with response
});複製程式碼

總結

以上介紹的這些跨域方法,可能有些已經很少使用了,但是這些方法在解決問題的思路上都有著一定的參考意義。所以當面對不可避免的跨域問題的時候,也希望這篇文章對你能有所幫助。

參考資料

developer.mozilla.org/zh-CN/docs/…

damon.ghost.io/killing-cor…

blog.teamtreehouse.com/cross-domai…

相關文章