阅读 1077

深入理解 Cookie 的 SameSite 属性

Cookie 简介

HTTP 协议是无状态的,但可以通过 Cookie 来维持客户端与服务端之间的“会话状态”。

简单来说就是:服务端通过 Set-Cookie 响应头设置 Cookie 到客户端,而客户端在下次向服务器发送请求时添加名为 Cookie 的请求头,以携带服务端之前“埋下”的内容,从而使得服务端可以识别客户端的身份。

举个简单的🌰:

// 服务端
const http = require("http");

http
  .createServer((req, res) => {
    if (req.url == "/") {
      res.end("hello world");
    } else if (req.url == "/favicon.ico") {
      res.statusCode = 204;
      res.end();
    } else {
      res.writeHead(200, [
        ["Set-Cookie", "name=haochuan9421"], // 设置 cookie
      ]);
      res.end("some data");
    }
  })
  .listen(80);
复制代码
// 客户端
var xhr = new XMLHttpRequest();
xhr.open('GET', "/someapi");
xhr.send();
复制代码

image

当客户端再次发起请求时就会自动携带上之前“埋下”的 Cookie:

image

简单的介绍完 Cookie 后,我们来看一下它的 SameSite 属性。

SameSite 属性

image

SameSite 有三个可选值:

  • Strict
  • Lax
  • None

从 Chrome 80 开始,如果不指定 SameSite 就等效于设置为 Lax。你可以通过 chrome://flags/#same-site-by-default-cookies 禁用这个行为,禁用后不指定 SameSite 就等效于设置为 None。关于他们的区别我们稍后结合具体的场景来介绍。

image

先来看看上图中出现的 third-party 这个概念,对 Cookie 来说什么是 第三方 呢?

举个例子:假设我们现在访问的网站是 'bar.com',当我们引入 'foo.com' 的图片时,图片服务如果设置了 Cookie,我们就称之为 “第三方 Cookie”。目前在新版的 Chrome 浏览器中,只有指定 Cookie 的 SameSite 属性为 None 且 Secure 属性为 true 才可以设置 “第三方 Cookie”(后面会具体介绍)。用户是可以在浏览器偏好设置中阻止“第三方 Cookie”的。

image.png

简单来说就是:在当前访问的网站请求服务的网站是“跨站”(Cross Site)的情况下,第三方服务设置的 Cookie 就称之为 “第三方 Cookie”

是否是 “跨站” 不是根据同源策略(协议,主机,端口)来判断,而是 PSL(公共后缀列表)。比如 'foo.example.com' 和 'bar.example.com' 就不属于 “跨站”,因为他们同属于 example.com,是“同站”。这里也不能简单理解为二级域名相同,比如 'foo.github.io' 和 'bar.github.io',虽然都是 'github.io' 的子域名,但是他们之间是跨站的,因为 'github.io' 是在 PSL 中的,相当于顶级域名,可以在此处查看哪些域名是属于 PSL 的。

这其实和 Cookie 的 Domain 属性设置是差不多的。我们都知道子域名是可以设置父域名 Cookie 的,比如 'foo.example.com' 的请求是可以设置 Domain 为 '.example.com' 的 Cookie 的。但是 'foo.github.io' 的请求是不可以设置 Domain 为 '.github.io' 的 Cookie 的。这就像你无法设置 Cookie 的 'Domain' 为 '.com' 一样。因为 '.com' 和 'github.io' 都在 PSL 中。

image.png

image.png

更权威的解释可以参考这里"Same-site" and "cross-site" Requests

端口不同时,比如我们的网站是 bar.com:8080 ,我们引入 bar.com:9000 的图片时不会判定为第三方的。

协议(Scheme)不同判定为第三方。比如我们的网站是 'bar.com' ,我们引入 'bar.com' 的图片时会判定为第三方。不过在 Chrome 中你可以通过 chrome://flags/#schemeful-same-site 来忽略协议的限制。

Cookie 本身是不区分端口和协议(Scheme)的。

image

除了加载第三方网站图片的场景,向第三方网站发起 AJAX/fetch 请求嵌入第三方网站的 iframe表单提交到第三方网站链接跳转到第三方网站等都可能涉及到“第三方 cookie”。针对这些可能出现 “第三方cookie” 的场景,SameSite 设置为不同的值又会有哪些不同的效果呢?让我们来一一探究(多图警告😀):

1. AJAX 请求

当我们跨域发送 AJAX 请求时,由于浏览器同源策略的限制,我们的请求是无法发送的:

image

不过我们可以使用 CORS 的方式来解决跨域的问题:

const http = require("http");

http
  .createServer((req, res) => {
    if (req.url == "/") {
      res.end("hello world");
    } else if (req.url == "/favicon.ico") {
      res.statusCode = 204;
      res.end();
    } else {
      res.writeHead(200, [
        ["Set-Cookie", "name=haochuan9421"], // 设置 cookie
        ["Access-Control-Allow-Origin", "*"], // 允许跨域请求
      ]);
      res.end("some data");
    }
  })
  .listen(80, "0.0.0.0");
复制代码

image

但是当我们再次发起请求时,虽然这个跨域请求的响应头中有设置 Cookie,却发现下次请求时并不会携带之前服务器设置的 Cookie。

image

这就带来一个问题,我们失去了利用 Cookie 来维持服务端与客户端“会话状态”的能力。那么如何在向第三方网站请求的时候携带 Cookie 呢?需要满足如下条件:

  1. 网站开启 https 并将 Cookie 的 Secure 属性设置为 true
  2. Access-Control-Allow-Origin 设置为具体的 origin,而不是 *
  3. Access-Control-Allow-Credentials 设置为 true
  4. SameSite 属性设置为 None

想在本地测试这段代码的同学需要注意一下,www.foo.comwww.bar.com 的请求都会打到这个服务上,通过修改电脑的 hosts 文件很容易做到这一点,https 的证书是采用 mkcert 生成的自签名证书。

const https = require("https");
const fs = require("fs");

https
  .createServer(
    {
      key: fs.readFileSync(__dirname + "/key.pem"),
      cert: fs.readFileSync(__dirname + "/cert.pem"),
    },
    (req, res) => {
      if (req.url == "/") {
        res.end("hellow world");
      } else if (req.url == "/favicon.ico") {
        res.statusCode = 204;
        res.end();
      } else {
        res.writeHead(200, [
          ["Set-Cookie", "name=haochuan9421; Secure; SameSite=None"],
          ...(req.headers.origin // 跨域请求时请求头中会包含 origin,也就是请求发出的网站
            ? [
                ["Access-Control-Allow-Origin", req.headers.origin], // 不可以使用 *,必须指定
                ["Access-Control-Allow-Credentials", "true"], // 设置允许跨域请求携带 Cookie
              ]
            : []),
        ]);
        res.end("some data");
      }
    }
  )
  .listen(443, "0.0.0.0");
复制代码

满足上面的条件之后,跨域请求就可以携带 Cookie 了:

var xhr = new XMLHttpRequest();
xhr.withCredentials = true;
xhr.open('GET', "https://www.bar.com/someapi");
xhr.send();
复制代码

image

这四个条件缺一不可:

当不开启 https 的时候:

image

当不设置 Secure 属性:

image

当 Access-Control-Allow-Origin 设置为 * 时

image

当 Access-Control-Allow-Credentials 的值不为 true 时

image

当 SameSite 属性设置为 Strict 或 Lax 时

image image

对于使用浏览器的 fetch API 发送请求也是一样的,使用 fetch 发起跨域请求时如果想携带 cookie,需要设置 "credentials" 为 "include":

fetch("https://www.bar.com/somedata", {
  "method": "GET",
  "credentials": "include"
})
复制代码

2. 嵌套第三方 iframe

const https = require("https");
const fs = require("fs");

https
  .createServer(
    {
      key: fs.readFileSync(__dirname + "/key.pem"),
      cert: fs.readFileSync(__dirname + "/cert.pem"),
    },
    (req, res) => {
      console.log(req.headers.host);
      if (req.url == "/") {
        if (req.headers.host === "www.foo.com") {
          res.setHeader("Content-Type", "text/html;charset=utf-8");
          res.end(`<div>这是父页面</div>
<iframe src="https://www.bar.com/"></iframe>`);
        } else {
          res.writeHead(200, [
            ["Set-Cookie", "name=haochuan9421; Secure; SameSite=None"],
            ["Content-Type", "text/html;charset=utf-8"],
          ]);
          res.end(`<div>这是子页面</div>`);
        }
      } else {
        res.statusCode = 204;
        res.end();
      }
    }
  )
  .listen(443, "0.0.0.0");

复制代码

如果设置了SameSite 为 Strict: image 如果设置了SameSite 为 Lax: image 如果不指定 SameSite: image 如果设置了 SameSite 为 None: image

这说明只有明确的指定了 SameSite 为 None 时,跨域 iframe 页面被引入时 Cookie 才能生效。

举例说明一下:假设我们希望在自己的网站内嵌 bilibili 的视频播放器,直接通过 iframe 把 B 站播放器引入到我们自己的网站是无法使用 1080p 画质的。

<iframe
  src="//player.bilibili.com/player.html?bvid=BV1Vv41157uK&high_quality=1"
  allowfullscreen="allowfullscreen"
  width="100%"
  height="500"
  scrolling="no"
  frameborder="0"
></iframe>
复制代码

image

这是由于 B 站 Cookie 的 SameSite 属性并没有设置为 None,内嵌在其他第三方网站时 B 站播放器无法传递 Cookie 到服务器,服务器也就拿不到用户的登录态,对于未登录的用户 B 站是不提供 1080p 播放的。

不过在 Chrome 中我们可以通过禁用 chrome://flags/#same-site-by-default-cookies 来让”第三方 cookie“默认为 None,当我们关闭这个选项并重启浏览器之后,就可以在内嵌 iframe 中播放 1080p 的 B站视频了(前提是在 B 站已经登录过)。 image

3. 加载第三方图片或脚本等

const https = require("https");
const fs = require("fs");

https
  .createServer(
    {
      key: fs.readFileSync(__dirname + "/key.pem"),
      cert: fs.readFileSync(__dirname + "/cert.pem"),
    },
    (req, res) => {
      console.log(req.headers.host, req.url);
      if (req.url == "/") {
        if (req.headers.host === "www.foo.com") {
          res.setHeader("Content-Type", "text/html;charset=utf-8");
          res.end(`<div>这是父页面</div>
<img src="https://www.bar.com/"></img>`);
        } else {
          res.writeHead(200, [
            ["Set-Cookie", "name=haochuan9421; Secure; SameSite=Strict"],
            ["Content-Type", "image/png"],
          ]);
          fs.createReadStream("logo.png").pipe(res);
        }
      } else {
        res.statusCode = 204;
        res.end();
      }
    }
  )
  .listen(443, "0.0.0.0");
复制代码

image image image image

这和引入第三方的 iframe 是一样的,只有 SameSite 属性为 None,Cookie 才能生效。

举个应用的例子:下图是一个添加了谷歌广告的网站,可以看到谷歌广告相关的 Cookie 会把 SameSite 属性设置为 None。这样当足够多的网站引入了谷歌的广告脚本等资源时,他就可以构建出用户在各个网站的浏览轨迹以及访问偏好了,从而精准的推送广告。

image

4. 提交表单到第三方网站

const https = require("https");
const fs = require("fs");

https
  .createServer(
    {
      key: fs.readFileSync(__dirname + "/key.pem"),
      cert: fs.readFileSync(__dirname + "/cert.pem"),
    },
    (req, res) => {
      if (req.url == "/") {
        if (req.headers.host === "www.foo.com") {
          res.setHeader("Content-Type", "text/html;charset=utf-8");
          res.end(`<form action="https://www.bar.com/" method="post" enctype="multipart/form-data">
<input type="text" name="name" />
<input type="number" name="age" />
<button type="submit">提交</button>
</form>`);
        } else {
          console.log(req.headers.host, req.url, req.method, req.headers.cookie);
          res.writeHead(200, [
            ["Set-Cookie", "name=haochuan9421; Secure; SameSite=Strict"],
          ]);
          res.end("ok");
        }
      } else {
        res.statusCode = 204;
        res.end();
      }
    }
  )
  .listen(443, "0.0.0.0");
复制代码

image image image

从上面的测试中可以看出将 SameSite 设置为 None 是一种危险的行为,它会使得针对你的网站发起 CSRF (Cross-site request forgery) 攻击变得非常容易,因为从一个第三方恶意网站向你的网站发起的请求也会携带 Cookie,这使得伪造的请求会被识别为一次普通用户发起的请求。下面具体演示一下,我们假设 www.foo.com 是一个恶意网站,www.bar.com 是我们自己的网站:

这部分的示例只是为了说明问题,只展示一些关键步骤,具体的细节,比如登录和登陆态校验的实现会被简化

// 这是我们自己正常的网站
const https = require("https");
const fs = require("fs");

https
  .createServer(
    {
      key: fs.readFileSync(__dirname + "/key.pem"),
      cert: fs.readFileSync(__dirname + "/cert.pem"),
    },
    (req, res) => {
      if (req.url == "/") {
        // 我们网站首页有一个转账的表单
        res.setHeader("Content-Type", "text/html;charset=utf-8");
        res.end(`<form action="/transfer" method="post">
<input type="number" name="money" />
<button type="submit">提交</button>
</form>`);
      } else if (req.url == "/login") {
        // 登录后,客户端会存储用户的 Cookie 信息
        res.setHeader("Set-Cookie", "name=haochuan9421; Secure; SameSite=None");
        res.end("login success");
      } else if (req.url == "/transfer") {
        // 登录后的用户可以转账,未登录的不能转账
        res.end(req.headers.cookie ? "ok" : "fail");
      } else {
        res.statusCode = 204;
        res.end();
      }
    }
  )
  .listen(443, "0.0.0.0");
复制代码

用户直接访问 www.bar.com 提交表单转账,由于没有登录(没有 Cookie)会提示失败,所以用户会先进入 www.bar.com/login 登录,登录后客户端会有 Cookie,当用户回到首页再次提交转账表单时,就会转账成功,这模拟了一个简单的基于 Cookie 鉴权的网站。

接下来我们一起来看看攻击者是如何突破 www.bar.com 的鉴权滴。当攻击者知道了你网站有转账的功能,那么他就可以诱导用户进入准备好的恶意网站,在这个恶意网站中向你的网站发起转账请求,如果进入恶意网站的用户之前登录过你的网站并且登录态没有过期,那么这次伪造的请求就会成功把用户的钱转走。下面是恶意网站的代码:

// 这是一个要伪造请求的恶意网站
const https = require("https");
const fs = require("fs");

https
  .createServer(
    {
      key: fs.readFileSync(__dirname + "/key.pem"),
      cert: fs.readFileSync(__dirname + "/cert.pem"),
    },
    (req, res) => {
      if (req.url == "/") {
        res.setHeader("Content-Type", "text/html;charset=utf-8");
        res.end(`<div>这是一个恶意网站</div>
<form
id="fake-form"
action="https://www.bar.com/transfer"
method="post"
target="submit-target"
>
    <input type="hidden" name="money" value="1000" />
</form>
<iframe name="submit-target"></iframe>
<script>document.getElementById("fake-form").submit();</script>`);
      } else {
        res.statusCode = 204;
        res.end();
      }
    }
  )
  .listen(443, "0.0.0.0");
复制代码

image

可以看到,用户被诱导进入恶意网站后,恶意网站自动像你的服务器发起了伪造的转账请求,由于你 Cookie 中的 SameSite 属性设置为 None,这就导致这次伪造的请求也会携带用户的 Cookie,单纯基于 Cookie 做的接口鉴权就被攻破了,用户的资金面临安全风险。这也是为什么最新版的浏览器都会把 SameSite 的默认值从 None 调整为 Lax 的一个重要原因。

5. 链接跳转第三方网站

const https = require("https");
const fs = require("fs");

https
  .createServer(
    {
      key: fs.readFileSync(__dirname + "/key.pem"),
      cert: fs.readFileSync(__dirname + "/cert.pem"),
    },
    (req, res) => {
      if (req.url == "/") {
        if (req.headers.host === "www.foo.com") {
          res.setHeader("Content-Type", "text/html;charset=utf-8");
          res.end(`<div>foo page</div>
<a href="https://www.bar.com/">www.bar.com</a>`);
        } else {
          console.log(req.headers.host, req.url, req.headers.cookie);
          res.writeHead(200, [
            ["Set-Cookie", "name=haochuan9421; Secure; SameSite=None"],
            ["Content-Type", "text/html;charset=utf-8"],
          ]);
          res.end("bar page");
        }
      } else {
        res.statusCode = 204;
        res.end();
      }
    }
  )
  .listen(443, "0.0.0.0");
复制代码

image image image

Strict 这个规则过于严格,可能造成非常不好的用户体验。比如,当前网页有一个 GitHub 链接,用户点击跳转就不会带有 GitHub 的 Cookie,跳转过去总是未登陆状态。

总结

现代浏览器针对 Cookie 的 SameSite 属性的默认值已经很合理了,作为网站所有者通常不需要手动设置这个属性,一般只有当我们的服务需要和“第三方”对接时才考虑怎么设置更合理。

Strict 最为严格,表示完全禁止“第三方 Cookie”,只有当前网页的 URL 与请求目标一致时,才会带上 Cookie,一般用于保证系统的封闭性和安全性。

Lax 是目前大多数现代浏览器的默认值,他在保证安全性的前提下,也可以避免一些不好的用户体验,比如从别的网站跳转过时会没有登录态。

None 是最为宽松的一种设定,通常用于开放我们的服务给不同的第三方接入,同时又需要追踪用户的场景,比如广告,设置为 None 时需要考虑开放的安全性。

文章分类
前端
文章标签