持续创作,加速成长!这是我参与「掘金日新计划 · 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 是 无状态 的,所谓的无状态,就是它只负责 请求-问答,将数据返回给浏览器而已,但是至于是谁发的,它一概不知,比鱼还 健忘 。对于一些商城应用来说,用户可以在自己的购物车添加商品然后进行购买,那么服务端怎么知道当前购买者是谁呢?
于是,服务端在用户登录成功时,服务端 它会在请求头中添加一个 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 看看刚起的服务:
不出意外我们看到的是这个画面。紧接着就可以进行下一步了。
服务端设置 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!')
})
然后重启服务。
有的小伙伴不知道怎么重启服务啊,首先我们要让光标聚焦在控制台:
然后 Ctrl + C 就可以退出了:
接下来再执行 node index.js
就行了。
重启好后我们请求 http://localhost:3000/login 路径试试:
可以看到页面返回的数据没有问题,同时 Cookie
中也增加了一条名为 name
的 Cookie
。
浏览器端设置 Cookie
浏览器端可以通过 document.cookie
设置一个 Cookie
,一般形式如下:
document.cookie = 'name=value'
也可以通过 document.cookie
访问特定的 Cookie
。
首先我们在浏览器端设置一个 Cookie
:
document.cookie = 'test=123'
可以看到此时浏览器已经有两条 Cookie
啦。
生成 Cookie 小结
- 虽然 MDN 中介绍说 “Cookie 是服务器发送到用户浏览器并保存在本地的一小块数据” ,但是我们要知道 在浏览器端也是可以生成 Cookie 的 。
- 服务端通过响应头中的
Set-Cookie
字段设置Cookie
信息,通过响应头通知浏览器生成Cookie
,如果要设置多条,则写多个Set-Cookie
。 - 浏览器端通过
document.cookie
设置Cookie
,设置多条同样是写多个document.cookie
。
随请求发送到服务器
我们再任意发起一个 /
或 /login
的请求。
可以看到请求头中包含了 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。
可以看到 Cookie 中新增了一条名为 safecookie
的 Cookie
,同时,注意看图中 safecookie
这条 Cookie
下, HttpOnly
属性是个 √,表示该条 Cookie
不能通过 document.cookie
访问。
我们测试一下:
可以发现输出的 cookie
中没有 safecookie
,验证了上面的说法。
接下来我们看看请求头中是否会携带 HttpOnly
的 cookie
:
可以,毛闷台。
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>
逻辑很简单:
- 点击“登录”按钮后,浏览器会向服务端发起一个请求,服务端响应时会下发一个
Cookie
。 - 点击“发起请求”按钮后,浏览器会向服务端发起一个请求(这个是用来测试
Cookie
是否携带的接口)。 - 点击“浏览器端设置 Cookie”按钮后,会在浏览器端生成一个
Cookie
。
我们先点击 “浏览器端设置 Cookie” 按钮,在浏览器端设置一个 Cookie
。
接着我们点击“发起请求”按钮测试一下:
哦豁,喜闻乐见 —— 跨域了。
想必大家日常开发中见怪不怪了吧,这是 浏览器的同源策略 导致的,服务端之间通信是没有跨域这个说法的。由于本文讲的是 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}`)
})
重启服务端后重新点击“发起请求”按钮测试:
可以看到跨域问题解决了,成功接收到数据。接下来我们打开请求头看看 Cookie
有没有携带上:
我们发现刚刚在浏览器端设置的 Cookie
, 发起请求时并 没有被携带上 ,这是为什么呢?
结论为先:axios 发起跨域请求默认是不携带 Cookie
的,如果需要,我们要给请求加上额外的配置:
sendBtn.addEventListener("click", () => {
axios.get("http://localhost:3000/test", {
withCredentials: true,
});
});
withCredentials
表示跨域请求时是否需要使用凭证。
此时我们点击“发送请求”按钮:
还真是坎坷,我们需要对服务端做一些配置,让它 允许携带凭证。
app.all("*", function (req, res, next) {
res.header("Access-Control-Allow-Credentials", true);
...
});
再次点击“发送请求”按钮,实际上还是没有携带上 Cookie
的。这时有的小伙伴就急了:不是说加上 withCredentials
就可以携带了吗?
确实是这样的,不过这里面有些 规则 需要我们了解。我们看看刚刚生成的 Cookie
:
重点关注其中的 Domain
和 Path
两个属性,分别表示 所属域名 和 路径 。它们共同限定了能访问到当前 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
的)。
接下来我们点击“登录”按钮:
服务端下发的 Cookie
也正常生成了,接下来点击“发送请求”按钮,查看 Cookie
的携带情况:
这下终于有了。
Cookie 的缺点
- 携带在报文中的
Cookie
数据会增加网络流量,如果Cookie
一多起来,开销是非常大的。 Cookie
是明文传输,不安全。- 不够大。
Cookie
的大小在各个浏览器中都只有 4K 左右,超过的话会被裁切。因此如果用来存储数据是有点吃紧的。
Github 源码地址
juejin-demo/cookie-server at main · catwatermelon/juejin-demo (github.com)
结束语
至此,本文对 Cookie 的讲解就结束了,希望大家阅读之后能有所收获。
如果大家对 WEB 安全感兴趣,可以关注本专栏,本专栏将持续输出关于 WEB 安全的方方面面。
如果文中有不对的地方,或是大家有不同的见解,欢迎指出 🙏🙏。
如果大家觉得所有收获,欢迎一键三连💕💕。