因為 HTTP 協議是無狀態的,所以很久以前的網站是沒有登入這個概念的,直到網景發明 cookie 以後,網站才開始利用 cookie 記錄使用者的登入狀態。cookie 是個好東西,但它很不安全,其中一個原因是因為 cookie 最初被設計成了允許在第三方網站發起的請求中攜帶,CSRF 攻擊就是利用了 cookie 的這一“弱點”,如果你不瞭解 CSRF,請移步別的地方學習一下再來。
當我們在瀏覽器中開啟 a.com 站點下的一個網頁後,這個頁面後續可以發起其它的 HTTP 請求,根據請求附帶的表現不同,這些請求可以分為兩大類:
1. 非同步請求(不會改變當前頁面,也不會開啟新頁面),比如通過 <script>、<link>、<img>、<iframe> 等標籤發起的請求,還有通過各種傳送 HTTP 請求的 DOM API(XHR,fetch,sendBeacon)發起的請求。
2. 同步請求(可能改變當前頁面,也可能開啟新頁面),比如通過對 <a> 的點選,對 <form> 的提交,還有改變 location.href,呼叫 window.open() 等方式產生的請求。
上面說的同步和非同步並不是正式術語,只是我個人的一種區分方式。
這些由當前頁面發起的請求的 URL 不一定也是 a.com 上的,可能有 b.com 的,也可能有 c.com 的。我們把傳送給 a.com 上的請求叫做第一方請求(first-party request),傳送給 b.com 和 c.com 等的請求叫做第三方請求(third-party request),第三方請求和第一方請求一樣,都會帶上各自域名下的 cookie,所以就有了第一方 cookie(first-party cookie)和第三方 cookie(third-party cookie)的區別。上面提到的 CSRF 攻擊,就是利用了第三方 cookie 。
防止 CSRF 攻擊的辦法已經有 CSRF token 校驗和 Referer 請求頭校驗。為了從源頭上解決這個問題,Google 起草了一份草案來改進 HTTP 協議,那就是為 Set-Cookie 響應頭新增 SameSite 屬性,它用來標明這個 cookie 是個“同站 cookie”,同站 cookie 只能作為第一方 cookie,不能作為第三方 cookie。SameSite 有兩個屬性值,分別是 Strict 和 Lax,下面分別講解:
SameSite=Strict:
嚴格模式,表明這個 cookie 在任何情況下都不可能作為第三方 cookie,絕無例外。比如說假如 b.com 設定瞭如下 cookie:
Set-Cookie: foo=1; SameSite=Strict
Set-Cookie: bar=2
你在 a.com 下發起的對 b.com 的任意請求中,foo 這個 cookie 都不會被包含在 Cookie 請求頭中,但 bar 會。舉個實際的例子就是,假如淘寶網站用來識別使用者登入與否的 cookie 被設定成了 SameSite=Strict,那麼使用者從百度搜尋頁面甚至天貓頁面的連結點選進入淘寶後,淘寶都不會是登入狀態,因為淘寶的伺服器不會接受到那個 cookie,其它網站發起的對淘寶的任意請求都不會帶上那個 cookie。
SameSite=Lax:
寬鬆模式,比 Strict 放寬了點限制:假如這個請求是我上面總結的那種同步請求(改變了當前頁面或者開啟了新頁面)且同時是個 GET 請求(因為從語義上說 GET 是讀取操作,比 POST 更安全),則這個 cookie 可以作為第三方 cookie。比如說假如 b.com 設定瞭如下 cookie:
Set-Cookie: foo=1; SameSite=Strict Set-Cookie: bar=2; SameSite=Lax Set-Cookie: baz=3
當使用者從 a.com 點選連結進入 b.com 時,foo 這個 cookie 不會被包含在 Cookie 請求頭中,但 bar 和 baz 會,也就是說使用者在不同網站之間通過連結跳轉是不受影響了。但假如這個請求是從 a.com 發起的對 b.com 的非同步請求,或者頁面跳轉是通過表單的 post 提交觸發的,則 bar 也不會傳送。
該用哪種模式?
該用哪種模式,要看你的需求。比如你的網站是一個少數人使用的後臺管理系統,所有人的操作方式都是從自己瀏覽器的收藏夾裡開啟網址,那我看用 Strict 也無妨。如果你的網站是微博,用了 Strict 會這樣:有人在某個論壇裡發了帖子“快看這個微博多搞笑 http://weibo.com/111111/aaaaaa”,結果下面人都回復“打不開啊”;如果你的網站是淘寶,用了 Strict 會這樣:某微商在微博上發了條訊息“新百倫正品特賣5折起 https://item.taobao.com/item.htm?id=1111111”,結果點進去顧客買不了,也就是說,這種超多使用者的、可能經常需要使用者從別的網站點過來的網站,就不適合用 Strict 了。
假如你的網站有用 iframe 形式嵌在別的網站裡的需求,那麼連 Lax 你也不能用,因為 iframe 請求也是一種非同步請求。或者假如別的網站有使用你的網站的 JSONP 介面,那麼同樣 Lax 你也不能用,比如天貓就是通過淘寶的 JSONP 介面來判斷使用者是否登入的。
有時安全性和靈活性就是矛盾的,需要取捨。
和瀏覽器的“禁用第三方 cookie”功能有什麼區別?
主流瀏覽器都有禁用第三方 cookie 的功能,它和 SameSite 有什麼區別?我能總結 3 點:
1. 該功能是由使用者決定是否開啟的,是針對整個瀏覽器中所有 cookie 的,即便有些瀏覽器可以設定域名白名單,那最小單位也是域名;而 SameSite 是由網站決定是否開啟的,它針對的是某個網站下的單個 cookie。
2. 該功能同時禁用第三方 cookie 的讀和寫,比如 a.com 發起了對 b.com 的請求,這個請求完全不會有 Cookie 請求頭,同時假如這個請求的響應頭裡有 Set-Cookie: foo=1,foo 這個 cookie 也不會被寫進瀏覽器裡;而 SameSite 只禁用讀,比如 b.com 在使用者瀏覽器下已經寫入了個 SameSite cookie foo,當 a.com 請求 b.com 時,foo 肯定不會被髮送過去,但 b.com 在這個請求的響應裡又返回了: Set-Cookie: bar=1; SameSite=Strcit,這個 bar 會成功寫入瀏覽器的 cookie 裡。
3. 該功能不會把我上面說的那種同步請求(改變了當前頁面或者開啟了新頁面)算在第三方請求裡,因此也不會攔截對應的 cookie。
到底怎樣才算第三方請求?
我上面說的原話是:當一個請求本身的 URL 和它的發起頁面的 URL 不屬於同一個站點時,這個請求就算第三方請求。那麼怎樣算是同一個站點?是我們經常說的同源(same-origin)嗎,cross-origin 的兩個請求就不屬於同一個站點?顯然不是的,foo.a.com 和 bar.a.com 是不同源的,但很有可能是同一個站點的,a.com 和 a.com:8000 是不同源的,但它倆絕對是屬於同一個站點的,瀏覽器在判斷第三方請求時用的判斷邏輯並不是同源策略,而是用了 Public Suffix List 來判斷。
有些同學可能會這麼想:一個域名可以用逗號分成多個欄位,如果兩個域名的最後兩個欄位都是相同的,那它們就是同一個站點的,比如 foo.a.com 和 bar.a.com 就是。但是 sina.com.cn 和 sohu.com.cn 也滿足這個條件啊,它們絕對不是同一個網站吧,那是不是說瀏覽器需要維護一份列表來記錄所有國家頒佈的二級域名啊,但是不僅國家可以開放三級域名給不同的網站使用,普通的網站也可能會,比如新浪就開放 *.sinaapp.com 三級域名註冊,foo.sinaapp.com 和 bar.sinaapp.com 是兩個不同的網站,那 sinaapp.com 也應該加入那個列表中,以及 github.io 等等。
Mozilla 很久之前就將自己維護的這個域名字尾列表放到了 github 上,起名為 Public Suffix List,裡面不僅有 IANA 頒佈的頂級域名,眾多二級域名,還有三級域名比如 compute.amazonaws.com,甚至四級域名比如 compute.amazonaws.com.cn,判斷兩個 URL 是不是同一個網站的,只要判斷兩個 URL 的域名的 public suffix(按能匹配到的最長的算)以及它前面的那個欄位(後面用 public suffix+1 指代)是否都相同,是的話就是同一個站點的,否則不是。比如 www.sina.com.cn 的 public suffix+1 是 sina.com.cn,www.sohu.com.cn 的 public suffix+1 是 sohu.com.cn, 兩者不一樣,所以不屬於同一個站點;再比如 nanzhuang.taobao.com 的 public suffix+1 是 taobao.com,nvzhuang.taobao.com 的 public suffix+1 也是 taobao.com,那麼它倆就是同一個站點的。
Public Suffix List 最初被 Firefox 用在限制 Set-Cookie 響應頭的 Domain 屬性上的, Domain 不能設定成一個比自己網站的 public suffix+1 還高層級的域名,比如 foo.w3c.github.io 就不能設定 Set-Cookie: foo=1; Domain=github.io,最高只能設定成 Set-Cookie: bar=1; Domain=w3c.github.io,現在其它瀏覽器也都在用同樣的列表做同樣的限制。DOM API 裡的 document.domain 後來也加上了這個限制。有些瀏覽器還用這個列表來高亮位址列上的 URL 中的 public suffix+1 部分(Firefox 和 IE 有用,Chrome 是高亮了整個域名),此外瀏覽器們還用該列表幹一些其它瑣事,比如將歷史網址按不同站點排列等等。
瀏覽器們會定期同步這份列表,比如 Chrome 是在每個正式版本釋出之前同步一次。
後臺語言的支援程度
目前還沒有哪個後臺語言的 API 支援了 SameSite 屬性,比如 php 裡的 setcookie 函式,或者 java 裡的 java.net.HttpCookie 類,如果你想使用 SameSite,需要使用更底層的 API 直接修改 Set-Cookie 響應頭。Node.js 本來就沒有專門設定 cookie 的 API,只有通用的 setHeader 方法,不過 Node.js 的框架 Express 已經支援了 SameSite。
使用 document.cookie 測試
如果覺得開 http 服務測試 SameSite cookie 比較麻煩的話,你也可以使用 document.cookie 來代替,比如 document.cookie="foo=1;SameSite=Strict",為 document.cookie 賦值和使用 Set-Cookie 響應頭的效果幾乎一摸一樣,除了不能讀取和設定帶 HttpOnly 屬性的 cookie 以外。