跨源通訊、跨域訪問

alalala發表於2020-05-03

什麼是跨源、跨域

所謂同源(即指在同一個域)就是兩個頁面具有相同的協議(protocol),主機(host)埠號(port)。

同源策略是瀏覽器的一個安全功能,不同源的客戶端指令碼在未明確授權的情況下,不能讀寫對方的資源。

同源策略 是瀏覽器安全的基石。

同源策略會阻止一個域的 javascript 指令碼和另外一個域的內容進行互動,例如辦公內外網環境,當我們訪問外網一個惡意網站的時候,惡意網站就會利用我們的主機向內網的 url 傳送 ajax 請求,破壞或盜取資料。

瀏覽器的非同源限制、解決思路

非同源限制

  1. 無法讀取非同源網頁的 CookieLocalStorageIndexedDB
  2. 無法操作非同源網頁的 DOM
  3. 無法向非同源地址傳送 AJAX 請求,即 XHR 請求。

跨域的解決思路-3種

避免非同源限制—“躲著走”

  1. 讓瀏覽器不做限制,指定引數,讓瀏覽器不做校驗,但該方法不太合理,因為它需要每個使用者都去對瀏覽器設定做改動;
  2. 不要發出 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 ,服務端憑藉這個引數獲得回撥函式名稱;

  • JsonpCallbackjsonp 請求成功後,在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 的弊端

  1. 需要伺服器改動程式碼;
  2. 只支援 GET 請求;
  3. 傳送的不是 XHR請求;
  4. 相對不安全。

後端解決跨域

跟使用者資料有關的就是動態請求,沒有資料的是靜態請求,比如 css js,So,HTTP 伺服器(Apache、Nginx 等)至少做了兩個作用

  • HTTP 伺服器,處理靜態請求;
  • 反向代理,負載均衡。

在伺服器端解決跨域有2種解決思路:

  1. 在被呼叫後端應用解決:在響應頭增加指定欄位,告訴瀏覽器,允許呼叫,這種解決方案的請求是直接從瀏覽器傳送給後端伺服器,在瀏覽器上會看到 b.comurl
  2. 在前端伺服器解決:這是隱藏跨域的解決方案。這種跨域請求不是直接從瀏覽器傳送的,而是從中間的 http 伺服器(前端應用所在伺服器)轉發過去的,在瀏覽器中看到的還是 a.comurl,所以不會認為是跨域,但是該到 b.com 的請求還是會到 b.com

CORS解決跨域相關原理

為了解決瀏覽器跨域問題,W3C 提出了跨源資源共享方案,即 CORS(Cross-Origin Resource Sharing)

CORS 可以在不破壞即有規則的情況下,通過後端伺服器實現 CORS 介面,就可以實現跨域通訊。

CORS 將請求分為兩類:簡單請求和非簡單請求,分別對跨域通訊提供了支援。

簡單請求

簡單請求就是普通 HTML Form 在不依賴指令碼的情況下可以發出的請求,比如表單的 method 如果指定為 POST ,可以用 enctype 屬性指定用什麼方式對錶單內容進行編碼,合法的值就是前述這三種。

  1. 在 CORS 出現前,傳送 HTTP 請求時在頭資訊中不能包含任何自定義欄位,且 HTTP 頭資訊不超過以下幾個欄位:
    1. Accept
    2. Accept-Language
    3. Content-Language
    4. Last-Event-ID
    5. Content-Type 僅為這3種
      • application/x-www-form-urlencoded
      • multipart/form-data
      • text/plain
  2. 請求方法是 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.comNginx 上,ajax 請求介面是 b.com,所以瀏覽器認為是跨域

Nginxnginx.conf 上配(vhost 是約定做法,這樣做不修改主檔案)

include vhost/*.config;

建立 cors.conf

server{
    listen 80; // 監聽80server_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 協議》,轉載必須註明作者和本文連結

相關文章