「代码实操」 从根儿上理解 Cookie 及携带问题 🍪🔐

408 阅读5分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第23天,点击查看活动详情

前言

Cookie 是什么?为什么 XSS 攻击窃取 Cookie 会造成这么严重的后果?为什么 Cookie 设置了却没有随请求头携带?为了预防 XSS 攻击,Cookie 做了哪些调整?

如果你对这些问题好奇或是一知半解,那么本文将带你一层层剥开 Cookie 的真面目,让你对 Cookie 的理解更上一层。

Cookie 是什么?

我们看看 MDN 对 Cookie 的介绍:

HTTP Cookie(也叫 Web Cookie 或浏览器 Cookie)是服务器发送到用户浏览器并保存在本地的一小块数据,它会在浏览器下次向同一服务器再发起请求时被携带并发送到服务器上。

别的我们先不管,我们先看这句:保存在本地的一小块数据 。实际上 Cookie 是通过一个文本文件存储的,这也是 Cookie 可以具有一定时效性的原因。

接下来我们看看这上半句整句,相信有的刚接触 Cookie 的小伙伴和我一样,对这个 是服务器发送到用户浏览器并保存在本地的一小块数据 表示疑惑:明明在浏览器可以通过 document.cookie 设置 Cookie,为什么说是服务器发送到浏览器的?

我们都知道 HTTP 是 无状态 的,所谓的无状态,就是它只负责 请求-问答,将数据返回给浏览器而已,但是至于是谁发的,它一概不知,比鱼还 健忘 。对于一些商城应用来说,用户可以在自己的购物车添加商品然后进行购买,那么服务端怎么知道当前购买者是谁呢?

演示文稿1.gif

于是,服务端在用户登录成功时,服务端 它会在请求头中添加一个 set-cookie 头部,里面 包含着该用户的标识 ,随后响应数据,浏览器在接收响应时,检测到这个头部字段,就知道要生成一个 Cookie 存到本地了。

随后浏览器发起请求时,会将这个服务器下发的 Cookie 携带在请求头上,服务端接收请求时,解析对应的请求头,就知道当前是和哪个用户在通信了。

Cookie 的生成

express 搭建服务端

接下来我用 express 带大家体验一下这个过程。

这里不得不多提一嘴了,express 和 koa 真滴好用,几行代码起个服务是以前学 Java 体会不到的,没别的词可以形容了,🐄🍺 好吧

三部曲第一步:生成一个 package.json 文件。

npm init -y 

三部曲第二步:安装 express 依赖。

npm install express --save

三部曲第三步:创建一个 index.js 文件。

const express = require('express')
const app = express()
const port = 3000

app.get('/', (req, res) => {
  res.send('Hello World!')
})

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`)
})

浏览器打开 localhost:3000 看看刚起的服务:

微信截图_20221015180323.png

不出意外我们看到的是这个画面。紧接着就可以进行下一步了。

服务端设置 Cookie

既然 MDN 上说了,Cookie 是服务端发送给客户端的一小块数据,那我们先从服务端设置 Cookie 讲起。

服务器通过 Set-Cookie 响应头部告知客户端保存 Cookie 信息(这里小伙伴们注意一下,服务器是通知浏览器保存 Cookie 的,实际上还是要通过浏览器生成,新的 Cookie 符不符合规则由浏览器说了算)。一般形式如下:

Set-Cookie: <cookie 名>=<cookie 值>

如果要设置多条,就写多个 Set-Cookie

Set-Cookie: cookie1=value1
Set-Cookie: cookie2=value2

我们自己试试先,在 index.js 中添加一段代码:

// index.js
app.get('/login', (req, res) => {
    res.setHeader('set-cookie', 'name=CatWatermelon')
    res.send('set-cookie!')
})

然后重启服务。

有的小伙伴不知道怎么重启服务啊,首先我们要让光标聚焦在控制台:

微信截图_20221015184030.png

然后 Ctrl + C 就可以退出了:

微信截图_20221015184121.png

接下来再执行 node index.js 就行了。

重启好后我们请求 http://localhost:3000/login 路径试试:

微信截图_20221015180821.png

可以看到页面返回的数据没有问题,同时 Cookie 中也增加了一条名为 nameCookie

浏览器端设置 Cookie

浏览器端可以通过 document.cookie 设置一个 Cookie,一般形式如下:

document.cookie = 'name=value'

也可以通过 document.cookie 访问特定的 Cookie

首先我们在浏览器端设置一个 Cookie

document.cookie = 'test=123'

微信截图_20221015172152.png

可以看到此时浏览器已经有两条 Cookie 啦。

生成 Cookie 小结

  1. 虽然 MDN 中介绍说 “Cookie 是服务器发送到用户浏览器并保存在本地的一小块数据” ,但是我们要知道 在浏览器端也是可以生成 Cookie 的
  2. 服务端通过响应头中的 Set-Cookie 字段设置 Cookie 信息,通过响应头通知浏览器生成 Cookie ,如果要设置多条,则写多个 Set-Cookie
  3. 浏览器端通过 document.cookie 设置 Cookie ,设置多条同样是写多个 document.cookie

随请求发送到服务器

我们再任意发起一个 //login 的请求。

微信截图_20221017162720.png

可以看到请求头中包含了 Cookie 字段,并且 不管是服务端下发的 Cookie 还是浏览器生成的 Cookie 都会随请求携带 ,真的是这样吗?一会再解释。

有的小伙伴会疑惑:既然服务端能下发 Cookie ,浏览器端也能生成 Cookie,并且它们都会随请求携带,那我只要浏览器实现或者是服务器实现 Cookie 不就好了,为什么要多此一举?

这就不得不提到 XSS 攻击了,XSS 可以通过 document.cookie 获取我们的 Cookie 信息,通过刚刚购物车的例子你应该明白如果被别人获取了 Cookie 那该有多可怕了吧。

但是我们可以在 服务端 设置 set-cookie 头时,给 cookie 添加一个 HttpOnly 的字段:

Set-Cookie: id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Secure; HttpOnly

JavaScript document.cookie API 无法访问带有 HttpOnly 属性的 Cookie。同样的,也不能通过 document.cookie 去设置一个带有 HttpOnly 属性的 Cookie

document.cookie 的访问性

app.get('/httponly', (req, res) => {
    res.setHeader('set-cookie', 'safecookie=safe;httponly')
    res.send('set-safe-cookie!')
})

接下来我们访问 http://localhost:3000/httponly。

微信截图_20221015173915.png

可以看到 Cookie 中新增了一条名为 safecookieCookie,同时,注意看图中 safecookie 这条 Cookie 下, HttpOnly 属性是个 √,表示该条 Cookie 不能通过 document.cookie 访问。

我们测试一下:

微信截图_20221015174259.png

可以发现输出的 cookie 中没有 safecookie,验证了上面的说法。

接下来我们看看请求头中是否会携带 HttpOnlycookie

微信截图_20221015174619.png

可以,毛闷台。

Secure 属性

标记为 Secure 的 Cookie 只应通过被 HTTPS 协议加密过的请求发送给服务端,也就是说,HTTP 请求中是不会携带具有 Secure 属性的 Cookie 数据的。同时,浏览器端也只能在具有安全协议的条件下设置带有 Secure 属性的 Cookie

所有情况下都会随请求发送吗?

实际上我们刚刚做的所有操作都是在 同域 下进行的,并且通过刚刚的测试,我们已经知道了在同域下,所有符合条件的 Cookie 都是会 自动随请求携带

那浏览器向服务端进行跨域请求的时候呢?我们来做个试验就知道了。

首先我们在 index.js 中增加一段代码:

app.get('/test', (req, res) => {
    res.send({ name: 'CatWatermelon' });
})

表示请求 localhost:3000/test 接口时,服务端响应我们一个对象数据。

接下来我们创建一个 index.html 文件:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
  </head>
  <body>
    <button id="login">登录</button>
    <button id="send">发起请求</button>
    <button id="set-cookie">浏览器端设置 Cookie</button>
    <script>
      const sendBtn = document.querySelector("#send");
      sendBtn.addEventListener("click", () => {
        axios.get("http://localhost:3000/test");
      });

      const loginBtn = document.querySelector("#login");
      loginBtn.addEventListener("click", () => {
        axios.get("http://localhost:3000/login");
      });

      const setCookieBtn = document.querySelector("#set-cookie");
      setCookieBtn.addEventListener("click", () => {
        document.cookie = "add=newcookie";
      });
    </script>
  </body>
</html>

逻辑很简单:

  1. 点击“登录”按钮后,浏览器会向服务端发起一个请求,服务端响应时会下发一个 Cookie
  2. 点击“发起请求”按钮后,浏览器会向服务端发起一个请求(这个是用来测试 Cookie 是否携带的接口)。
  3. 点击“浏览器端设置 Cookie”按钮后,会在浏览器端生成一个 Cookie

我们先点击 “浏览器端设置 Cookie” 按钮,在浏览器端设置一个 Cookie

微信截图_20221017175433.png

接着我们点击“发起请求”按钮测试一下:

微信截图_20221017173232.png

哦豁,喜闻乐见 —— 跨域了。

想必大家日常开发中见怪不怪了吧,这是 浏览器的同源策略 导致的,服务端之间通信是没有跨域这个说法的。由于本文讲的是 Cookie ,因此这里直接给出跨域的解决方案 —— CORS,大家对同源策略感兴趣的可以自行查找资料。

我们看看此时 index.js 的全貌:

// index.js
const express = require('express')
const app = express()
const port = 3000

app.all("*", function (req, res, next) {
    //设置允许跨域的域名,*代表允许任意域名跨域
    res.header("Access-Control-Allow-Origin", "*");
    //允许的header类型
    res.header("Access-Control-Allow-Headers", "content-type");
    //跨域允许的请求方式 
    res.header("Access-Control-Allow-Methods", "DELETE,PUT,POST,GET,OPTIONS");
    if (req.method == 'OPTIONS')
        res.sendStatus(200); //让options尝试请求快速结束
    else
        next();
});

app.get('/', (req, res) => {
    res.send('Hello World!')
})

app.get('/login', (req, res) => {
    res.setHeader('set-cookie', 'name=CatWatermelon')
    res.send('set-cookie!')
})


app.get('/httponly', (req, res) => {
    res.setHeader('set-cookie', 'safecookie= safe;httponly')
    res.send('set-safe-cookie!')
})


app.get('/test', (req, res) => {
    res.send({ name: 'CatWatermelon' });
})

app.listen(port, () => {
    console.log(`Example app listening on port ${port}`)
})

重启服务端后重新点击“发起请求”按钮测试:

微信截图_20221017175522.png

可以看到跨域问题解决了,成功接收到数据。接下来我们打开请求头看看 Cookie 有没有携带上:

微信截图_20221017175633.png

我们发现刚刚在浏览器端设置的 Cookie , 发起请求时并 没有被携带上 ,这是为什么呢?

结论为先:axios 发起跨域请求默认是不携带 Cookie 的,如果需要,我们要给请求加上额外的配置:

sendBtn.addEventListener("click", () => {
    axios.get("http://localhost:3000/test", {
      withCredentials: true,
    });
});

withCredentials 表示跨域请求时是否需要使用凭证。

此时我们点击“发送请求”按钮:

微信截图_20221017180012.png

还真是坎坷,我们需要对服务端做一些配置,让它 允许携带凭证

app.all("*", function (req, res, next) {
    res.header("Access-Control-Allow-Credentials", true);
    ...
});

再次点击“发送请求”按钮,实际上还是没有携带上 Cookie 的。这时有的小伙伴就急了:不是说加上 withCredentials 就可以携带了吗?

确实是这样的,不过这里面有些 规则 需要我们了解。我们看看刚刚生成的 Cookie

微信截图_20221017194014.png

重点关注其中的 DomainPath 两个属性,分别表示 所属域名路径 。它们共同限定了能访问到当前 Cookie 的范围。这里的意思就是只能在 localhost 域名下 /cookie-server 路径下的 URL 访问,我们的请求携带的也只能是 符合规则Cookie ,因此这条 Cookie 不会被携带上。

我们测试一下符合规则的 Cookie

首先给 /login 请求加点料:

loginBtn.addEventListener("click", () => {
    axios.get("http://localhost:3000/login", {
      withCredentials: true,
    });
});

为什么要加呢?从上文中我们可以知道,为了 使跨域请求能够携带 Cookie ,我们在 axios 中需要添加 withCredential 属性,除此之外 ! 为了使得服务端 Set-Cookie 下发的 Cookie 浏览器能够成功添加,这步也是不可或缺的(不信的朋友可以先去掉 withCredential 属性,然后去请求,事实是响应头里确实有 Set-Cookie 字段,但是浏览器不会生成 Cookie 的)。

接下来我们点击“登录”按钮:

微信截图_20221017194414.png

服务端下发的 Cookie 也正常生成了,接下来点击“发送请求”按钮,查看 Cookie 的携带情况:

微信截图_20221017194744.png

这下终于有了。

Cookie 的缺点

  1. 携带在报文中的 Cookie 数据会增加网络流量,如果 Cookie 一多起来,开销是非常大的。
  2. Cookie 是明文传输,不安全。
  3. 不够大。Cookie 的大小在各个浏览器中都只有 4K 左右,超过的话会被裁切。因此如果用来存储数据是有点吃紧的。

Github 源码地址

juejin-demo/cookie-server at main · catwatermelon/juejin-demo (github.com)

结束语

至此,本文对 Cookie 的讲解就结束了,希望大家阅读之后能有所收获。

如果大家对 WEB 安全感兴趣,可以关注本专栏,本专栏将持续输出关于 WEB 安全的方方面面。

如果文中有不对的地方,或是大家有不同的见解,欢迎指出 🙏🙏。

如果大家觉得所有收获,欢迎一键三连💕💕。