因為 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 以外。
文章列表