导读
这篇文章基于[RFC 6265],简单说明 Cookie 的使用和特性。大概包括如下四个内容:1)介绍 Cookie 的使用;2)详解 Cookie 的格式;3)测试 Cookie 在各种情况下的反应;4)CSRF 攻击说明
环境、工具及前置知识
- 系统:
macOS Mojava - IDE:
IDEA SwitchHostsOpenSSLChromejs基础NodeJS基础HTTP基础
0x001 Cookie 的使用
cookie 的使用非常简单,可以归纳为 4 步:
- 前端发送
HTTP请求到后端 - 后端生成要放到
cookie中的信息并设置到响应中的Set-Cookie头部 - 前端取出响应中的
Set-Cookie的内容,保存cookie信息到本地 - 前端继续访问后端页面,将保存到本地的
cookie放到请求的Cookie头部
用图说明:

用代码说明:
const http = require('http');
http.createServer((req, res) => {
res.writeHead(200, {'Set-Cookie': 'name=123'})
res.write(`
<script>document.write(document.cookie)</script>
`)
res.end()
}).listen(3000)
这段代码的功能很简单:
- 创建一个
HTTP服务器 - 为每一个响应添加一个
Set-Cookie头部,它的值是name=123 - 响应的正文是一个
html,其中只有一个script标签,标签内的脚本会将cookie输出到当前页面
启动这个脚本,然后打开浏览器访问:
$ node index.js
$ open http://localhost:3000
就可以看到:
- 页面上显示
name=123,正是我们Set-Cookie的内容 HTTP的response头部有Set-Cookie: name=123

打开 Chrome 调试工具的 Application -> cookies -> localhost:3000,就可以看见我们设置的 cookie 了:

此时再打开一个 tab,然后再访问这个页面(或者直接刷新一下就好,不过为了对比,可以再开一个 tab):

可以看到,此时,对比第一次访问的时候,在 request 中多了一个 Cookie,而 Cookie 的内容就是我们 Set-Cookie 的内容(这不表示 Cookie === Set-Cookie,只是说明 Cookie 的内容来自 Set-Cookie,在后续会有详细说明 Set-Cookie 到 Cookie 的转化)。
这就是最简单的 cookies 使用了。
0x002 cookies 的格式详解

从 Chrome 的 cookies 管理工具可以看出一条 cookie 的属性是有很多的:
NameValueDomainPathExpire / Max-AgeSize(忽略,应该只是前端统计 Cookie 键值对的长度,比如"name"和"123"的长度是 7,如果有大神知道请告知)HttpOnlySecureSameSite
在接下来的章节,将会慢慢解释这些属性。
1. Expire
Expires 是用来为一个 cookie 设置过期时间,它是一个 UTC 格式的时间,过了这个时间以后,这个 cookie 就失效了,浏览器不会将这条失效的 cookie 包含在 Cookie 请求头部中,并且会删除这条过期的 cookie。
代码:
const http = require('http');
http.createServer((req, res) => {
const cookie =req.headers['cookie']
const date = new Date();
date.setMinutes(date.getMinutes()+1)
if (!cookie || !cookie.length){
res.writeHead(200, {'Set-Cookie': `name=123; expires=${date.toUTCString()}`})
}
res.write(`
<script>document.write(document.cookie)</script>
`)
res.end()
}).listen(3000)
上面的代码在给 response 添加的 Set-Cookie 中多了一个 expires属性,就是用来设置过期时间,这里将它设置为一分钟以后:
const date = new Date();
date.setMinutes(date.getMinutes()+1)
同时做了一个判断,如果 request 中有 cookie 了,就不添加 Set-Cookie 头部,否则永远不会过期。
打开浏览器(先删除之前的 cookies),访问localhost:3000:

Date: Sun, 01 Dec 2019 15:24:47 GMT
Set-Cookie: name=123; expires=Sun, 01 Dec 2019 15:25:47 GMT
Set-Cookie 的格式变了,多了一个 expires 属性,并且时间是 Date 属性的 1分钟以后。在这一分钟之内,如果我们刷新页面,会发现 request 中有 Cookie,而 response 中没有 Set-Cookie:

而在 1 分钟以后,则会重新生成一个 Set-Cookie,并且 request 中的 Cookie 没了:

expires 属性的的格式是:
expires-av = "Expires=" sane-cookie-date
sane-cookie-date = <rfc1123-date, 定义在 [RFC2616], 章节 3.3.1>
sane-cookie-date 是一个时间格式,大概如下:
Sun, 06 Nov 1994 08:49:37 GMT
js 可以使用如下获得:
$ new Date().toUTCString()
"Sun, 01 Dec 2019 15:18:12 GMT"
expires 的默认值是 session,也就是在当前浏览器关闭以后就会删除。
2. Max-Age
Max-Age 也是用来设置 cookie 的过期时间,但是它设置的是相对于资源获取的时间的相对秒数。比如,第一次访问的时候,response 的 Date 是 Sun, 01 Dec 2019 15:44:43 GMT,那么这条 cookie 的过期时间就是 Sun, 01 Dec 2019 15:45:43 GMT
第一次请求的时候,可以看到,cookie 的格式是:Set-Cookie: name=123; max-age=60,多了一个 max-age=60。

如果在 60 s 内访问,则会看到 request 中包含了一个 Cookie: name=123,而 response 中没有Set-Cookie

在 60 s 以后访问,则又创建了新的 cookie。
max-age 的默认值是 session,当前浏览器关闭以后就会被删除。
max-age 的格式是:
max-age-av = "Max-Age=" non-zero-digit *DIGIT
3. Domain
Domain 限制在哪个域名下会将这个 cookie 包含到 request 的 Cookie 头部中。
比如,如果我们设置一个 cookie 的 Domain 属性是 example.com,则用户代理会在发往 example.com,www.example.com,www.corp.example.com 的请求的 Cookie 中携带这个 cookie。
上代码:
const http = require('http');
http.createServer((req, res) => {
const cookie =req.headers['cookie']
const date = new Date();
date.setMinutes(date.getMinutes()+1)
if (!cookie || !cookie.length){
res.writeHead(200, {'Set-Cookie': 'name=123;domain=example.com'})
}
res.write(`
<script>document.write(document.cookie)</script>
`)
res.end()
}).listen(3000)
这里我们添加了一个 domain=example.com。然后使用 SwitchHost 配置几个 host:
127.0.0.1 example.com
127.0.0.1 www.example.com
127.0.0.1 www.corp.example.com
访问 example.com:3000,第一次访问的时候,会返回
Set-Cookie: name=123;domain=example.com

然后我们刷新页面,就会发现 cookie 被携带到 request 中:

访问 www.example.com:3000,也存在这个 cookie

访问 www.corp.example.com:3000,也存在这个 cookie

但是如果访问 exampel2.com:3000,就不存在了,并且,因为 cookie 中的 domain 和当前的 domain 不同,用户代理会拒绝存储这个 cookie,所以我们看不到 cookie 的输出:

cookie 管理中,也不存在这条 cookie:

domain 的默认值是当前的域名。
domain 的格式是:
domain-av = "Domain=" domain-value
4. Path
相对于 Domain 限于 cookie 的域名,Path 限制 cookie 的路径,比如,如果我们设置 cookie 的路径是 /a,则在 / 或者 /b 就无法使用
上代码:
const http = require('http');
http.createServer((req, res) => {
const cookie =req.headers['cookie']
const date = new Date();
date.setMinutes(date.getMinutes()+1)
if (!cookie || !cookie.length){
res.writeHead(200, {'Set-Cookie': 'name=123;path=/a'})
}
res.write(`
<script>document.write(document.cookie)</script>
`)
res.end()
}).listen(3000)
这里添加了一个 path=/a,其他就没有变化了,访问 localhost:3000/a,得到一个 cookie:

刷新页面,发送刚刚得到的 cookie:

访问 /a/b,却可以:

访问 /b,不会发送 cookie,并且会生成一个新的 cookie:

但是由于和当前路径不匹配,被拒绝:

访问 /,则和访问 /b 一样的结果:

因此,Path 可以限制一个 cookie 在哪个路径及其子路径下可用,祖先路径和兄弟路径则不在允许之列,如果没有这个值,则是 /
Path 的默认值是 /,也就是对整个站是有效的。
path 的格式是:
path-av = "Path=" path-value
5. Secure
Secure 用来指示一个 cookie 只在“安全”通道发送,这里的安全通道,一般指的就是 https。意思就是只有在 https 的时候才发送,http 不发送。
上代码:
let https = require("https");
let http = require("http");
let fs = require("fs");
const options = {
key: fs.readFileSync('./server.key'),
cert: fs.readFileSync('./server.pem')
};
const app = (req, res) => {
const cookie =req.headers['cookie']
const date = new Date();
date.setMinutes(date.getMinutes()+1)
if (!cookie || !cookie.length){
res.writeHead(200, {'Set-Cookie': 'name=123;secure'})
}
res.write(`
<script>document.write(document.cookie)</script>
`)
res.end()
}
https.createServer(options, app).listen(443);
http.createServer(app).listen(80);
这里同时兼通 80、443 端口,提供 http 和 https 服务,其中,https 服务所需的证书由 openssl 生成,这里免去不讲。访问 https://example.com,得到 cookie:

刷新页面,发送 cookie:

访问 http://example.com,得到 cookie:

但是被拒绝:

secure 的默认值是 false,也就是 http/https都能访问。
secure 的格式是:
secure-av = "Secure"
6. HttpOnly
Secure 限制 cookie 只能在安全通道中使用,别以为 HttpOnly 就是只能在 Http 中使用的意思,HttpOnly 的意思是只能通过 http 才能访问,在前面的例子中,我们一直通过document.cookie来在前端访问 cookie,但是如果指定了这个,就无法通过document.cookie来操作 cookie 了。
上代码:
const http = require('http');
http.createServer((req, res) => {
const cookie =req.headers['cookie']
const date = new Date();
date.setMinutes(date.getMinutes()+1)
if (!cookie || !cookie.length){
res.writeHead(200, {'Set-Cookie': 'name=123;httpOnly'})
}
res.write(`
<script>document.write(document.cookie)</script>
`)
res.end()
}).listen(3000)
多了一个 httpOnly,访问 localhost:3000,得到 cookie:

刷新,发送 cookie:

使用 document.cookie 操作 cookie:
> document.cookie = 'name=bar'
< "name=bar"
> document.cookie
< ""
然后查看 cookie,可以看见 value 依旧是 123,而 HttpOnly 则被打勾,无情。

httpOnly 的默认值是 fasle,也就是可以用document.cookie来操作 cookie
httpOnly 的格式是
httponly-av = "HttpOnly"
7. SameSite
注意,在说这个属性之前,有一点需要说明,那就是请求头中发送 cookie 并不是只有在地址栏输入这个网址的时候才会发送这个 cookie,而是在整个浏览器的所有页面内,发送的所有 http 请求,比如 img 的 src,script 的 src,link 的 src,ifram 的 src,甚至 ajax 配置之后,只要满足 上面提到的条件,这个 http 请求都会包含这个请求地址的 cookie,就算你是在其他网站访问也是一样,这也就是后面讲的 csrf 有机可趁的原因。
上代码:
const http = require('http');
http.createServer((req, res) => {
if (req.headers.host.startsWith('example')) {
res.writeHead(200, {'Set-Cookie': `host=${req.headers.host}`})
}
if (req.headers.host.startsWith('localhost')) {
res.write(`
<script src="http://example.com:3000/script.js"></script>
<img src="http://example.com:3000/img.jpg"/>
<iframe src="http://example.com:3000/"/>
`)
}
res.end()
}).listen(3000)
如果访问的地址是 example 开头,设置了一个 cookie,名字为 host,值为当前访问的地址。
如果访问的地址是localhost开头,就返回 3 个元素,他们的 src 指向 example.com:3000 三个不同的资源(尽管不存在,但是足够了)。
访问 example.com:3000,得到 cookie:host=example.com:3000

刷新页面,正常发送 cookie:

此时访问 localhost:3000,因为前面的代码,所以会返回 script、img、iframe,然后用户代理会加载这三个资源:

打开这三个资源,可以看到,每个资源都携带了一个 cookie。
-
script.js

-
img.jpg

-
index.html

而 SameSite 就是用来限制这种情况:
Strict
当 SameSite 为 Strict 的时候,用户代理只会发送和网站 URL 完全一致的 cookie,就算是子域名都不行。
上代码:
const http = require('http');
http.createServer((req, res) => {
if (req.headers.host.startsWith('example')) {
res.writeHead(200, {'Set-Cookie': `host=${req.headers.host};SameSite=Strict`})
}
if (req.headers.host.startsWith('localhost')) {
res.write(`
<a href="http://example.com:3000/index.html">http://example.com:3000/index.html</a>
<script src="http://example.com:3000/script.js"></script>
<img src="http://example.com:3000/img.jpg"/>
<iframe src="http://example.com:3000/index.html"/>
`)
}
res.end()
}).listen(3000)
-
访问
example.com:3000获得cookie:
-
刷新正常发送
cookie:
-
访问
localhost:3000任何指向example.com:3000的资源都不会发送cookie:
-
包括从这个页面跳转过去:

-
子域名

Lax
新版浏览器默认的 SameSite 是 Lax,如果 SameSite 是 Lax,则将会为一些跨站子请求保留,如图片加载或者 frames 的调用,但只有当用户从外部站点导航到URL时才会发送。如 link 链接。(我没能测试出来,具体看阮一峰-Cookie 的 SameSite 属性和MDN-HTTP cookies)
上代码:
const http = require('http');
http.createServer((req, res) => {
if (req.headers.host.startsWith('example')) {
res.writeHead(200, {'Set-Cookie': `host=${req.headers.host};SameSite=Lax`})
}
if (req.headers.host.startsWith('localhost')) {
res.write(`
<a href="http://example.com:3000/index.html">http://example.com:3000/index.html</a>
<form action="http://example.com:3000/form" method="post">
<button>post</button>
</form>
<form action="http://example.com:3000/form" method="get">
<button>get</button>
</form>
<script src="http://example.com:3000/script.js"></script>
<img src="http://example.com:3000/img.jpg"/>
<iframe src="http://example.com:3000/index.html"/>
`)
}
res.end()
}).listen(3000)
以下是我的测试结果
- a 链接跳转:带 cookie
- form[action=get]:带 cookie
- form[action=post]:不带 cookie
- iframe:不带 cookie
- script:不带 cookie
- img:不带 cookie
也就是简单导航跳转带,而资源加载不带。
None
在新版浏览器,如果要让 cookie 支持同站和跨站,需要明确指定 SameSite=None(我测试出来的结果是和没有添加这个属性是一致的)。
上代码:
const http = require('http');
http.createServer((req, res) => {
if (req.headers.host.startsWith('example')) {
res.writeHead(200, {'Set-Cookie': `host=${req.headers.host};SameSite=None`})
}
if (req.headers.host.startsWith('localhost')) {
res.write(`
<a href="http://example.com:3000/index.html">http://example.com:3000/index.html</a>
<form action="http://example.com:3000/form" method="post">
<button>post</button>
</form>
<form action="http://example.com:3000/form" method="get">
<button>get</button>
</form>
<script src="http://example.com:3000/script.js"></script>
<img src="http://example.com:3000/img.jpg"/>
<iframe src="http://example.com:3000/index.html"/>
`)
}
res.end()
}).listen(3000)
8. Set-Cookie 的格式
其实 Set-Cookie 的格式是由 cookie 键值对和属性列表构成的,所以可以用如下表示(不用 ABNF):
Set-Cookie: cookie-pair ";" cookie-av ";" cookie-av....
其中 cookie-pair 就是我们上面写的类似 name=123 的格式:
cookie-pair = cookie-name "=" cookie-value
后面可以跟一系列的属性,cookie-pair 和 cookie-av 之间使用 ; 分割,而 cookie-av 可以表示为:
cookie-av = expires-av / max-age-av / domain-av /
path-av / secure-av / httponly-av /
extension-av
其中的expires-av、max-age-av、domain-av、path-av、secure-av、httponly-av 就对应前面一张图的各个属性,但是有点区别,Chrome 实现了 same-origin属性,但是在 RFC 6265 并没有这个属性,可以看作是 extension-av,
再做一次对应:
- Name:cookie-name
- Value:cookie-value
- Domain:domain-av
- Path:path-av
- Expire / Max-Age :expires-av / max-age-av
- Size(忽略,应该只是前端统计 Cookie 键值对的长度,比如"name"和"123"的长度是 7,如果有大神知道请告知)
- HttpOnly:httponly-av
- Secure:secure-av
- SameSite:extension-av
可以看到其实 cookie 的格式并不是和 Chrome 中的 cookies 一致,它将 cookie-pair 和 cookie-av 平铺开来。
0x003 Cookie 在各种情况下的反映
1. 每种状态码下 Set-Cookie 都会被处理吗?包括 301、404、500?
根据 RFC 6265,用户代理可能忽略包含在 100 级别的状态码,但是必须处理其他响应中的 Set-Cookie(包括 400 级别和 500 级别)。
根据测试,除了 100,101,还有一个 407(未知为啥)不会处理 Set-Cookie,甚至 999 都会处理

2. 如何发送多个 cookie 到 Set-Cookie,Cookie 又是如何处理的?
上代码:
const http = require('http');
http.createServer((req, res) => {
res.setHeader('Set-Cookie', ['cookie1=1','cookie1=2','cookie2=2','cookie3=3'])
res.end()
}).listen(3000)
访问 localhost:3000,得到 cookie:

可以看到,cookie 被放到多个 Set-Cookie 头部中,而在 RFC 7230 中其实规定,一个报文中不应该出现重复的头部,如果一个头部有多个值,应该使用 , 分隔,就像下面的Accept-Encoding 和 Accept-Language。但问题是,, 可以作为 cookie-value 的合法值存在,如果降它作为分隔符会破坏 cookie 的解析😢,所以就这样咯。
刷新发送 Cookie,可以看到,被乖乖折叠了,并且重复的 cookie-name 的 cookie 被按顺序覆盖了:

3. ajax 如何发送 Cookie,ajax 返回的 Set-Cookie 会被接受吗?
答案是会,这里以 axios 为例子,上代码:
const http = require('http');
http.createServer((req, res) => {
res.writeHead(200, {'Set-Cookie': 'name=ajax'})
res.write(`
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script >
axios.get('http://example.com:3000/', {
withCredentials: true
})
</script>
`)
res.end()
}).listen(3000)
访问 localhost:3000,得到 cookie:

查看 cookie 存储,已经存上了:

查看 ajax 请求,已经发送了:

这里遵循同源策略,如果是同源,则默认会发送,如果是跨域,则需要添加withCredentials: true才行,XMLHttpRequest 和 Fetch 配置可能有所不同。
暂时想不到了
0x004 CSRF
例子
- 打开登陆页面
localhost:3000/login,登陆之后,后端返回一个Set-Cookie:name=bob,后端读取Cookie判断用户是否登陆,如果登陆,就会显示账户金额,和一个转账表单

- 转账是通过
http://localhost:3000/transfer?name=lucy&money=1000来转账的,name是转账目标用户,money是金额。这里为了方便,直接使用GET。

- 重新开一个服务,假装是另一个站点,这个站点只有一个
img,该img的src指向了上面的转账地址:
const http = require('http');
http.createServer((req, res) => {
res.writeHead(200, {'Content-Type':'text/html'})
res.write(`
<img src="http://localhost:3000/transfer?name=bad&money=10000000">
`)
res.end()
}).listen(3001)
- 已经在
localhost:3000登陆的bob访问example.com:3001,就会发现,虽然只是一个图片指向了localhost:3000,但是因为能够获取到localhost:3000的cookie,所以会被带过去,而这个地址根据cookie判断用户,并执行转账操作:

- 如果再 bob 回到自己的账户页面,就会发现,他已然破产,被限制高消费:

防御
(本文章主要讲 cookie,csrf 原理和防御不是重点)
- 检测
Referrer头:但是一些浏览器可以禁用这个头部(我也没找到哪个浏览器可以设置) SameSite:尚未完全普及csrftoken:一般csrf发起攻击都是类似上面,搭建一个钓鱼网站,其实该钓鱼网站是无法访问源站的cookie的,发送cookie是浏览器自带的行为,所以只要生成一个随机的token,在每个表单发送的时候都带上就行了,因为钓鱼网站无法预测这个token的值。甚至可以将这个token直接存储在cookie中,在表单发送的时候取出来和表单一起发送。也可以后端生成表单的时候注入到一个inputp[typehidden]域。- 二次验证,比如验证码、支付密码等
0x005 资源
0x006 带货
最近发现一个好玩的库,作者是个大佬啊--基于 React 的现象级微场景编辑器。