Content Security Policy (CSP) 介紹

劉哇勇發表於2018-08-23

當我不經意間在 Twitter 頁面 view source 後,發現了驚喜。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <title>Twitter</title>
  <style>
  body {
    background-color: #ffffff;
    font-family: sans-serif;
  }
  a {
    color: #1da1f2;
  }
  svg {
    color: #1da1f2;
    display: block;
    fill: currentcolor;
    height: 21px;
    margin: 13px auto;
    width: 24px;
  }
  </style>
</head>
<body>
    <noscript>
      
      <center>If you’re not redirected soon, please <a href="/">use this link</a>.</center>
    </noscript>
    <script nonce="SG0bV9rOanQfzG0ccU8WQw==">
      
      document.cookie = "app_shell_visited=1;path=/;max-age=5";
      
      location.replace(location.href.split("#")[0]);
    </script>
</body>
</html>

相比平時看到的其他站點的原始碼,可以說是很清爽了。沒有亂七八糟的標籤,功能卻一樣不少。特別有迷惑性,以為這便是頁面所有的原始碼,但檢視 DevTools 的 Source 皮膚後很容易知道這並不是真實的 HTML 程式碼。但為何頁面原始碼給出的是如此清爽的版本,這裡先不研究。

把目光移向 script 標籤時,發現一個不認識的 nonce 屬性。它以及它後面的神祕字串成功引起了我的好奇。再去看 Google 首頁的原始碼,也有好些 nonce 的運用。是時候去了解一下這裡的 nonce 是什麼了。

! <script nonce="SG0bV9rOanQfzG0ccU8WQw==">
      
      document.cookie = "app_shell_visited=1;path=/;max-age=5";
      
      location.replace(location.href.split("#")[0]);
    </script>

Content Security Policy (CSP)

要了解 nonce, 先了解 Content-Security-Policy(CSP)

我們都知道瀏覽器有同源策略(same-origin policy)的安全限制,即每個站點只允許載入來自和自身同域(origin)的資料,https://a.com 是無法從 https://b.com 載入到資源的。每個站點被嚴格限制在了自已的孤島上,自己就是一個沙盒,這樣很安全,整個網路不會雜亂無章。主要地,它能解決大部分安全問題。假若沒有同源策略,惡意程式碼能夠輕鬆在瀏覽器端執行然後獲取各種隱私資訊:銀行帳號,社交資料等。

那網站間如何進行資料共享,當然是有辦法的,瞭解下 CORS

現實中,問題是同源策略也並不是萬無一失,跨域攻擊 Cross-site scripting (XSS) 便包含五花八門繞開限制的手段,形式上通過向頁面注入惡意程式碼完成資訊的竊取或攻擊。比如 UGC 型別的站點,因為內容依賴使用者建立,這就開了很大一個口子,允許使用者輸入的內容執行在頁面上。當然,因為我們都知道會有注入攻擊,所以對使用者輸入的內容進行防 XSS 過濾也成了標配。

Content-Security-Policy 從另一方面給瀏覽器加了層防護,能極大地減少這種攻擊的發生。

原理

CSP 通過告訴瀏覽器一系列規則,嚴格規定頁面中哪些資源允許有哪些來源, 不在指定範圍內的統統拒絕。相比同源策略,CSP 可以說是很嚴格了。

其實施有兩種途徑:

  • 伺服器新增 Content-Security-Policy 響應頭來指定規則
  • HTML 中新增 <meta> 標籤來指定 Content-Security-Policy 規則

mobile.twitter.com header 中的 CSP 規則
mobile.twitter.com header 中的 CSP 規則

為了測試方便,以下示例均使用 <meta> 標籤來開啟 CSP 規則。但 <meta> 中有些指令是不能使用的,後面會了解到。只有響應頭中才能使用全部的限制指令。

一個簡單示例

建立一個 HTML 檔案放入以下內容:

csp_test.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta http-equiv="Content-Security-Policy" content="script-src `self` https://unpkg.com">
    <title>CSP Test</title>
</head>
<body>
    <script src="https://unpkg.com/react@16/umd/react.development.js"></script>
</body>
</html>

在該測試檔案所在目錄開啟一個本地 server 以訪問,這裡使用 Python 自帶的 server:

$ python -m SimpleHTTPServer 8000

然後訪問 localhost:8000 以觀察結果:

符合 CSP 規則情況下的正常訪問
符合 CSP 規則情況下的正常訪問

然後我們將 Content-Security-Policy 改成不允許任何資源再試一下:

csp_test.html

<!DOCTYPE html>
<html lang="en">
<head>
-     <meta http-equiv="Content-Security-Policy" content="script-src `self` https://unpkg.com">
+     <meta http-equiv="Content-Security-Policy" content="script-src ‘none’>
    <title>CSP Test</title>
</head>
<body>
    <script src="https://unpkg.com/react@16/umd/react.development.js"></script>
</body>
</html>

觸發 CSP 規則資源被 block 的情況
觸發 CSP 規則資源被 block 的情況

下面我們來解釋這裡設定的 CSP 規則及理解為何資源載入失敗。

CSP 規則

無論是 header 中還是 <meta> 標籤中指定,其值的格式都是統一的,由一系列 CSP 指令(directive)組合而成。

示例:

Content-Security-Policy: <policy-directive>; <policy-directive>…

這裡 directive,即指令,是 CSP 規範中規定用以詳細詳述某種資源的來源,比如前面示例中使用的 script-src,指定指令碼可以有哪些合法來源,img-src 則指定圖片,以下是常用指令:

  • base-uri 限制可出現在頁面 <base> 標籤中的連結。
  • child-src 列出可用於 worker 及以 frame 形式嵌入的連結。 譬如: child-src https://youtube.com 表示只能從 Youtube 嵌入視訊資源。
  • connect-src 可發起連線的地址 (通過 XHR, WebSockets 或 EventSource)。
  • font-src 字型來源。譬如,要使用 Google web fonts 則需要新增 font-src https://themes.googleusercontent.com 規則。
  • form-action <form> 標籤可提交的地址。
  • frame-ancestors 當前頁面可被哪些來源所嵌入(與 child-src 正好相反)。作用於 <frame><iframe><embed> 及 <applet>。 該指令不能通過 <meta> 指定且只對非 HTML文件型別的資源生效。
  • frame-src 該指令已在 level 2 中廢棄但會在 level 3 中恢復使用。未指定的情況下回退到 tochild-src 指令。
  • img-src 指定圖片來源。
  • media-src 限制音視訊資源的來源。
  • object-src Flash 及其他外掛的來源。
  • plugin-types 限制頁面中可載入的外掛型別。
  • report-uri 指定一個可接收 CSP 報告的地址,瀏覽器會在相應指令不通過時傳送報告。不能通過 <meta> 標籤來指定。
  • style-src 限制樣式檔案的來源。
  • upgrade-insecure-requests 指導客戶端將頁面地址重寫,HTTP 轉 HTTPS。用於站點中有大量舊地址需要重定向的情形。
  • worker-src CSP Level 3 中的指令,規定可用於 worker, shared worker, 或 service worker 中的地址。

child-src  與 frame-ancestors  看起來比較像。前者規定的是頁面中可載入哪些 iframe,後者規定誰可以以 iframe 載入本頁。 比如來自不同站點的兩個網頁 A 與 B,B,B 中有 iframe 載入了 A。那麼

  • A 的 frame-ancestors 需要包含 B
  • B 的 child-src 需要包含 A

預設情況下,這些指令都是最大條件開放的,可以理解為其預設值為 *。比如 img-src,如果不明確指定,則可以從所有地方載入圖片資源。

還有種特殊的指令 default-src,如果指定了它的值,則相當於改變了這些未指定的指令的預設值。可以理解為,上面 img-src 如果沒指定,本來其預設值是 *,可以載入所有來源的圖片,但設定 default-src 後,預設值就成了 default-src 指定的值。

常見的做法會設定 default-src ‘self’,這樣所有資源都被限制在了和頁面同域下。如果此時想要載入從 CDN 來的圖片,將圖片來源單獨新增上即可。

Content-Security-Policy: default-src ‘self’; img-src https://cdn.example.com

現在來看開頭那個示例,也許現在就能看明白了。因為頁面中需要從 CDN 載入 React 庫,所以我們<meta> 標籤指定了如下 CSP 規則:

script-src `self` https://unpkg.com

這裡的 self 及後來改成的 none 是預設值,需用引號包裹,否則會當成 URI 來解析。這裡的 CSP 規則表示頁面中指令碼只能從同域及 https://unpkg.com 載入。假如我們把後者去掉,同樣會像上圖截圖那樣 React 庫會載入失敗,同時控制檯中會有載入失敗的日誌及被觸發的規則列出來。

改成 none 之後表示頁面不載入任何指令碼,即使自己站點上的指令碼都無法被載入執行。這裡不妨試一下在 csp_test.html 旁邊建立一個指令碼檔案 test.js:

test.js

alert(‘來自 test.js 的問候!’)

同時在頁面中引用它:

csp_test.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta http-equiv="Content-Security-Policy" content="script-src `none`">
    <title>CSP Test</title>
</head>
<body>
    <script src="https://unpkg.com/react@16/umd/react.development.js"></script>
+    <script src="./test.js"></script>
</body>
</html>

頁面執行結果:

script-src none 時頁面將不載入任何指令碼
script-src none 時頁面將不載入任何指令碼

是的,哪怕是自己的指令碼也無法被載入執行。CSP 就是這樣嚴格和明確,不存在模稜兩可的情況。所以在指定來源時,我們需要確認 URI 是否正確。

指令可接受的值

指令後面跟的來源,有兩種寫法

  • 預設值
  • URI 萬用字元

預設值

其中預設值有以下這些:

  • none 不匹配任何東西。
  • self 匹配當前域,但不包括子域。比如 example.com 可以,api.example.com 則會匹配失敗。
  • unsafe-inline 允許內嵌的指令碼及樣式。是的,沒看錯,對於頁面中內嵌的內容也是有相應限制規則的。
  • unsafe-eval 允許通過字串動態建立的指令碼執行,比如 evalsetTimeout 等。

特別地,在 CSP 的嚴格控制下,頁面中內聯指令碼及樣式也會受影響,在沒有明確指定的情況下,其不能被瀏覽器執行。

考慮下面的程式碼:

csp_test.html

<!DOCTYPE html>
<html lang="en">
<head>
    <title>CSP Test</title>
    <style>
        body{
            color:red;
        }
    </style>
</head>
<body>
    <h1>Hello, World!</h1>
    <script>
        window.onload=function(){
            alert(`hi jack!`)
        }
    </script>
</body>
</html>


未指定 CSP 的情況
未指定 CSP 的情況

根據 MDN 上的描述,如果站點未指定 CSP 無則,瀏覽器預設不會開啟相應檢查,所以上面一切執行正常,只受正常的同域限制 。

If the site doesn`t offer the CSP header, browsers likewise use the standard same-origin policy.
— 來自 MDN 關於 Content Security Policy (CSP) 的描述

我們加上 CSP 限制:

csp_test.html

<!DOCTYPE html>
<html lang="en">
<head>
+    <meta http-equiv="Content-Security-Policy" content="default-src `self`">
    <title>CSP Test</title>
    <style>
        body{
            color:red;
        }
    </style>
</head>
<body>
    <h1>Hello, World!</h1>
    <script>
        window.onload=function(){
            alert(`hi jack!`)
        }
    </script>
</body>
</html>

配置站點預設只資訊同域的資源,但注意,這個設定並不包含內聯的情況,所以結果會如下圖。

內聯程式碼被禁止
內聯程式碼被禁止

如何修復它呢。如果我們想要允許頁面內的內聯指令碼或樣式,則需要明確地通過 script-srcstyle-src 指出來。

csp_test.html

<!DOCTYPE html>
<html lang="en">
<head>
!    <meta http-equiv="Content-Security-Policy" content="default-src `self` ‘unsave-inline’”>
    <title>CSP Test</title>
    <style>
        body{
            color:red;
        }
    </style>
</head>
<body>
    <h1>Hello, World!</h1>
    <script>
        window.onload=function(){
            alert(`hi jack!`)
        }
    </script>
</body>
</html>

這裡 default-src `self` ‘unsave-inline’ 配置預設可信的來源有這些: 和頁面同域的,以及內聯的。

重新整理頁面,樣式及指令碼又可以正常執行了。

通常是不建議使用 unsafe-inline 的(同樣也不推薦使用 unsafe-eval),因為內聯的指令碼和樣式維護不便,也不利用良好地組織程式碼。最佳實踐是樣式抽離到樣式檔案,指令碼放到單獨的 js 檔案中載入,讓 HTML 檔案純粹一點才是好的做法。即使是 onclick=“myHandler”href=“javascript:;” 這種平時常見的寫法,也屬於內聯的指令碼,是需要改造的。

如果頁面中非得用內聯的寫法,還有種方式。即頁面中這些內聯的指令碼或樣式標籤,賦值一個加密串,這個加密串由伺服器生成,同時這個加密串被新增到頁面的響應頭裡面。

<script nonce=EDNnf03nceIOfn39fn3e9h3sdfa>
  // 這裡放置內聯在 HTML 中的程式碼
</script>

頁面 HTTP 響應頭的 Content-Security-Policy 配置中包含相同的加密串:

Content-Security-Policy: script-src `nonce-EDNnf03nceIOfn39fn3e9h3sdfa`

注意這裡的 nonce- 字首。

這也就是文章開頭看到的方式,到這裡明白了。

<style> 標籤也是類似的處理。

這裡的加密串一定是隨機不可預測的,否則達不到安全效果,且每次頁面被訪問時重新生成。

除了使用 noce 指定加密串,還可以通過混淆的 hash 值來達到目的。這種做法不需要在標籤上加 nonce 而是將需要內嵌的程式碼本身使用加密演算法生成 hash 後放入 CSP 指令中作為值使用,這裡的加密演算法支援 sha256, sha384 和 sha512。此時 CSP 中使用的字首為相應的演算法名。

hash 方式的示例:

<script>alert(`Hello, world.`);</script>
Content-Security-Policy: script-src `sha256-qznLcsROx4GACP2dm0UCKCzCG-HiZ1guq6ZZDob_Tng=`

eval

js 中好些地方是可以以字串方式動態建立程式碼並執行,這被認為是不安全的,所以不推薦使用,一般最佳實踐裡都會提。

  • setTimeout/setInterval 可接收一段字串作為程式碼執行。setTimout(‘alert(1)’,1000)
  • evaleval(‘alert(1)’)
  • Function 建構函式。 new Function(‘alert(1)’)

和內聯一樣,有專門的指令 unsafe-eval 以允許類似程式碼的執行。但建議的做法是對於 evalFunction 構造器,杜絕使用,而 setTimeout/setInterval 可改造為非字串形式。

setTimout(function(){
    alert(1);
}, 1000)

URI

除了上面的預設值,還可通過提供完整的 URI 或帶萬用字元 * 的地址來匹配,以指定資源的合法來源。這裡 URI 的規則和配置伺服器的跨域響應頭是一樣的,參考 Same-origin policy

  • *://*.example.com:* 會匹配所有 example.com 的子域名,但不包括 example.com
  • http://example.comhttp://www.example.com 是兩個不同的 URI。
  • http://example.com:80http://example.com 也是是兩個不同的 URI,雖然網站預設埠就是 80

根據維基百科 Uniform Resource Identifier 頁面 給出的解釋,一個完整的 URI 由以下部分組成:
URI = scheme:[//authority]path[?query][#fragment]

其中 authority 又包含:
authority = [userinfo@]host[:port]

所以你可以認為其中某一項不同,那都是兩個 URI。瞭解這點很重要,一如上面列出的第一條例子 *.example.com, 我們很容易先入為主地認為既然已經允許了該域名的所有子域名,那必然 example.com 也是合法的。

因為 URI 是進行動態匹配的,所以解釋了上面提到的預設值緣何要加引號。因為如果不加引號的話, self 會表示 host 是 self 的資源地址,而不會表示原有的意思。

優先順序

CSP 的配置是很靈活的。每條指令可指定多個來源,空格分開。而一條 CSP 規則可由多條指令組成,指令間用分號隔開。各指令間沒有順序的要求,因為每條指令都是各司其職。甚至一次響應中, Content-Security-Policy 響應頭都可以重複設定。

我們來看這些情形下 CSP 的表現。

  • 對於設定了多次響應頭的情況,最嚴格的規則會生效。比如下面兩條響應頭中,雖然 第二條中設定 connect-src 允許 http://example.com/,但第一條裡面設定了 connect-srcnone,所以更加嚴格的 none 會生效。參見 Multiple content security policies
Content-Security-Policy: default-src `self` http://example.com;
                         connect-src `none`;
Content-Security-Policy: connect-src http://example.com/;
                         script-src http://example.com/
  • 同一指令多次指定,以第一個為準,後續的會被忽略。

csp_test.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta http-equiv="Content-Security-Policy" content="default-src `self`;default-src `unsafe-inline`;">
    <title>CSP Test</title>
    <style>
        body{
            color:red;
        }
    </style>
</head>
<body>
    <h1>Hello, World!</h1>
    <script>
        window.onload=function(){
            alert(`hi jack!`)
        }
    </script>
</body>
</html>

重複配置同一指令時效果展示
重複配置同一指令時效果展示

很智慧地, 瀏覽器不僅會將檢測不過的資源及指令列印出來,重複配置時被忽略的指令也會提示出來。

  • 指定 default-src 的情況下,它會充當 Fetch 類指令 的預設值。即 default-src 並不對所有指令生效,其他指令預設值仍是 *

傳送報告

當檢測到非法資源時,除了控制檯看到的報錯資訊,也可以讓瀏覽器將日誌傳送到伺服器以供後續分析使用。接收報告的地址可在 Content-Security-Policy 響應頭中通過 report-uri 指令來配置。當然,服務端需要編寫相應的服務來接收該資料。

Content-Security-Policy: default-src `self`; ...; report-uri /my_amazing_csp_report_parser;

服務端拿到的是以 JSON 形式傳來的資料。

{
  "csp-report": {
    "document-uri": "http://example.org/page.html",
    "referrer": "http://evil.example.com/",
    "blocked-uri": "http://evil.example.com/evil.js",
    "violated-directive": "script-src `self` https://apis.google.com",
    "original-policy": "script-src `self` https://apis.google.com; report-uri http://example.org/my_amazing_csp_report_parser"
  }
}

報告模式

CSP 提供了一種報告模式,該模式下資源不會真的被限制載入,只會對檢測到的問題進行上報 ,以 JSON 資料的形式傳送到 report-uri 指定的地方。

通過指定 Content-Security-Policy-Report-Only 而不是 Content-Security-Policy,則開啟了報告模式。

Content-Security-Policy-Report-Only: default-src `self`; ...; report-uri /my_amazing_csp_report_parser;

當然,你也可以同時指定兩種響應頭,各自裡的規則還會正常執行,不會互相影響。比如:

Content-Security-Policy: img-src *;
Content-Security-Policy-Report-Only: img-src ‘none’; report-uri http://reportcollector.example.com/collector.cgi

這裡圖片還是會正常載入,但是 img-src ‘none’ 也會檢測到並且傳送報告。

報告模式對於測試非常有用。在開啟 CSP 之前肯定需要對整站做全面的測試,將發現的問題及時修復後再真正開啟,比如上面提到的對內聯程式碼的改造。

推薦的做法

這樣的安全措施當然是能儘快啟用就儘快。以下是推薦的做法:

  • 先只開啟報告模式,看影響範圍,修改問題。
  • 新增指令時從 default-src ‘none’ 開始,檢視報錯,逐步新增規則直至滿足要求。
  • 上線後觀察一段時間,穩定後再由報告模式轉到強制執行。

瀏覽器相容性

目前釋出的 Level 3 規範 中大部分還未被瀏覽器實現,通過 Can I Use 的資料 來看,除 IE 外,Level 2 的功能已經得到了很好的支援。這裡還有一分來自 W3C 跟蹤的各瀏覽器實現情況的統計:Implementation Report for Content Security Policy Level 2

對於瀏覽器不支援的情況,也不必擔心,會回退到同源策略的限制上。

相關資源

相關文章