Skip to content
This page has been auto-translated and may contain errors.View in English

会话与 Cookie

把包放进健身房的储物柜,你会拿到一把编了号的钥匙。这把钥匙不会说明柜子里装着什么,它本身毫无价值,而且除了那间更衣室,拿到任何别的地方都没用。

这就是会话 Cookie。真正有价值的部分留在服务器上,浏览器只携带一个编号。

双方各自持有什么

服务器保存着一个会话(session):一小份代表某个已登录用户的数据。

js
{
  sessionId: 'a3f9c2e7b418',
  userId: 4821,
  role: 'teacher',
  expires: '2026-09-05T14:30:00Z',
}

这就是储物柜本身,它永远不会离开服务器。

浏览器拿到的是一个 Cookie,里面只携带会话 id,别无他物:

js
res.cookie('sid', 'a3f9c2e7b418', { maxAge: 1800000 })

这就是那把钥匙。浏览器不需要谁去要求,就会自动存下它,并且在每次向该域名发起请求时自动带上,所以要保持登录状态,你完全不用写额外的代码。

Juno双方各自持有什么 这个自动的部分总是让人意外。你不需要写任何代码把 Cookie 附加到下一次请求上,浏览器自己就会这么做,因为这就是浏览器对待 Cookie 的方式。

这一点很方便,但也正因为如此,一个被发送到错误地方的 Cookie 才会成为大问题——它会跟着这个域名,去往任何地方。

Juno双方各自持有什么 在 Express 应用里,这两个对象你几乎都不用亲手构建。express-session 会创建会话、存储它、设置 Cookie,然后把 req.session 交给你读写。

而这也正是下面这些失误依然会发生的原因:框架决定了会话存在哪里、Cookie 怎么设置,但你决定了往里面放什么,以及它能活多久。

Juno双方各自持有什么 会话 id 有一个经常被忽略的要求:它必须来自一个密码学安全的随机源。crypto.randomBytes 符合要求,Math.random 不符合——一个谁都能猜到的 id,就等于一个不需要密码就能被别人占用的账号。

Cookie 还需要三个框架不会替你决定的属性。HttpOnly 让 JavaScript 无法读取它,这样一次 XSS 攻击就无法窃取这个会话。

Secure 让它不会通过明文 HTTP 传输。SameSite 控制它是否会随跨站请求一起发送,这正是防御跨站请求伪造(CSRF)的关键:也就是另一个网站发起请求,而你的浏览器却把 Cookie 附加了上去。

不要假设最后这一项有默认值。Chromium 会把未设置的 SameSite 当作 Lax 处理;Firefox 不会这么做,Mozilla 的 bug 1617609 被标记为 WONTFIX(不予修复),理由是"当没有设置 SameSite 属性时,我们默认使用 None"。Safari 的防护则是靠拦截第三方 Cookie 实现的。所以务必显式设置它,并记住 SameSite=None 必须搭配 Secure

登录的完整流程

六个步骤,只有前两步涉及密码:

  1. 用户提交用户名和密码。
  2. 服务器拿这些信息去比对已存储的用户数据。
  3. 验证成功后,服务器创建一个会话,里面存着用户 id 以及其他需要的信息,比如角色。
  4. 服务器返回一个包含会话 id 的 Cookie。
  5. 浏览器存下它,然后在之后每次向该域名发起的请求中都会附带上它。
  6. 服务器读取这个 id,查找对应的会话,从而知道是谁在发起请求。

第六步会在整个会话的生命周期里反复发生。这次查找正是这个模型的核心特征:服务器每次都会被重新问一遍,所以它随时都可以改变主意。

Juno登录的完整流程 密码只在第一步和第二步出现过一次,之后就再也不会出现。

从那以后,真正在起作用的是会话 id,所以保护这个 id,和之前保护密码一样重要。

Juno登录的完整流程 第三步正是授权数据最容易悄悄混进来的地方。把角色存进会话里能省去每次请求都查一次数据库的开销,但也意味着应用是在根据一份"副本"来决定权限——而这份副本从有人改动它的那一刻起,就已经不准确了。

对于很少变动的角色,这样做没问题;但对于可能在突发事件中被撤销的角色,这样做就有问题了。这个取舍应该由你自己来做,而不是交给框架。

Juno登录的完整流程 第二步和第三步之间还缺了一步,省略它会造成一个有名有姓的漏洞。如果浏览器在登录之前就已经带着一个会话 id,而服务器把新的登录状态挂在了同一个 id 上,那么早先埋下这个 id 的攻击者,现在就拥有了一个已通过身份验证的会话。

这就是会话固定(session fixation)攻击,修复方法是在权限发生变化的那一刻重新生成 id:登录时要重新生成一次,之后每当发生类似提升到管理员这样的权限变化时,也要再重新生成一次。在 Express 里对应的是 req.session.regenerate(),并且旧会话必须被销毁,而不是放着不管。

把真实数据塞进去。 这个诱人的写法看起来很省事:

Vulnerable
js
res.cookie('session', {
  sessionId: 'a3f9c2e7b418',
  userId: 4821,
  role: 'admin',
  email: '[email protected]',
  password: 'hunter2',
  ip: '203.0.113.42',
})

除了 sessionId 之外的一切都是隐患。Cookie 会被截获、被写进日志、在设备间同步,也能被页面上运行的任何 JavaScript 读取。这些隐私信息现在会跟着用户跑遍每个地方,而一个泄露出去的角色或 id,就足以让别人冒充这个用户。

用 STRIDE 模型来说,这是信息泄露和身份伪造;用 OWASP 的说法,这是访问控制失效。

Fixed
js
res.cookie('sid', 'a3f9c2e7b418', { maxAge: 1800000 })

让它永远不过期。 一个没有设置 maxAge 或过期时间的 Cookie,会被很多浏览器无限期保留下去:

Vulnerable
js
res.cookie('sid', 'a3f9c2e7b418')

一旦这样的 Cookie 被盗,只要浏览器还留着它,它就一直有效,那些本该失去访问权限的人,手里还握着一把能用的钥匙。这是一种权限提升,用 OWASP 的说法,是身份验证失效。三十分钟是一个比较合理的起点:

Fixed
js
res.cookie('sid', 'a3f9c2e7b418', { maxAge: 1800000 })
Juno两种毁掉 Cookie 的方式 这两个修复都只需要一行代码,不需要新增任何基础设施或库。

难的是要能意识到问题所在,因为一个塞满有用数据的 Cookie,和一个永不过期的 Cookie,在出事之前都会运行得完美无缺。

Juno两种毁掉 Cookie 的方式 注意单位。Express 的 maxAge 用的是毫秒,而 HTTP 头里的 Max-Age 属性用的是秒,所以同样写 1800,用错了地方,可能是三十分钟,也可能是不到两秒钟,取决于你原本想要哪一个。

审查代码时,对于任何一个 Cookie 都要问两个问题:它装了什么,什么时候失效。抓住这两点,其余的都只是细节。

Juno两种毁掉 Cookie 的方式 短过期时间和良好的用户体验其实并不冲突,只是简单粗暴的实现方式会让它们看起来像是冲突的。滚动会话(rolling session)会随着活动延长有效期,这样正在使用的人不会被打断,而被丢弃不管的会话依然会很快失效。

值得记住的搭配是:空闲超时加上一个绝对上限——空闲三十分钟就失效,同时无论如何,八到十二小时后必须硬性到期。如果没有这个绝对上限,一个被攻击者用自己的流量不断"续命"的被盗 Cookie,就永远不会过期。

两种毁掉会话的方式

塞得太满。 会话本来只是用来标识一个人的,但它很容易越积越多:

Vulnerable
js
{
  sessionId: 'a3f9c2e7b418',
  userId: 4821,
  role: 'teacher',
  cart: [ /* 14 items */ ],
  lastFivePages: [ /* ... */ ],
  theme: 'dark',
  cachedPosts: [ /* ... */ ],
}

会话存在服务器内存里,所以每个登录用户都会占用一份内存开销。更糟的是,这些数据会变得过时:应用开始根据一份数据库早已更新过的旧副本来做决策。STRIDE 把这种情况归为篡改(tampering),不是因为有人真的改动了它,而是因为决策所依据的东西已经不再真实。

Fixed
js
{
  sessionId: 'a3f9c2e7b418',
  userId: 4821,
  role: 'teacher',
  expires: '2026-09-05T14:30:00Z',
}

让它永远不过期。 这和 Cookie 的那个失误如出一辙,只是发生在更底层,也更严重,因为现在轮到服务器自己在信任一份过时的数据了。一个没有过期时间的会话,意味着一个被盗的 Cookie 能永远有效,一次权限撤销永远不会生效,而想把某人从所有地方都强制登出,也变得不可能。

Juno两种毁掉会话的方式 这两个会话失误和前面 Cookie 的失误如出一辙,这也让它们更容易记住:让它保持精简,并给它一个终点。

区别在于,Cookie 的问题在浏览器里是看得见的,而会话的问题藏在服务器上,没人会去看那里。

Juno两种毁掉会话的方式 让会话保持精简的原则是:存身份,不存状态。一个用户 id,也许再加一个角色。其他任何能查出来的东西,都去查,而不是存。

购物车应该放在数据库里,这样它能在服务器重启后依然存在,也能跟着用户换到另一台设备上。放进会话里,往往意味着会在最糟糕的时刻把它弄丢。

Juno两种毁掉会话的方式 Express 默认的会话存储是进程内内存,而它自己的文档里也明确说了这不适用于生产环境。它会内存泄漏,会随进程一起消失,而且对隔壁那个实例来说,它根本不存在。

这带来的实际后果是:一次部署就会把所有人都登出,而在负载均衡器后面,你的一半用户会随机地被登出。Redis 是常见的解决方案。要注意版本差异:connect-redis v8 把 RedisStore 作为命名导出,而 v6 和 v7 的写法不同,这经常让升级的人踩坑。

框架在机制层面处理得都不错。Express、Django、Rails 和 Laravel 都会管理存储,并设置合理的 Cookie 默认值。但它们都不会替你决定往会话里放什么、放多久——而这恰恰就是前面这四个失误全都发生的地方。

动手试一试

四个场景。找出问题所在,并逐一修复。

js
// 1. 登录后设置的 Cookie
res.cookie('session', {
  sessionId: 'a3f9c2e7b418',
  userId: 4821,
  password: 'hunter2',
  ip: '203.0.113.42',
}, { maxAge: 1000000000000 })

// 2. 登录后设置的 Cookie
res.cookie('sid', 'a3f9c2e7b418')

// 3. 一个存储的会话
{ sessionId: 'b7d1', userId: 91, role: 'admin', cart: [], theme: 'dark',
  ip: '203.0.113.42', expires: '3000-01-01T00:00:00Z' }

// 4. 一个 Cookie 及其对应的会话
res.cookie('sid', { userId: 91, role: 'admin' }, { maxAge: 2000 })
{ sessionId: 'c4e2', userId: 91, role: 'admin', expires: '2099-01-01T00:00:00Z' }
对照你的答案
#问题修复方法
1Cookie 里存了敏感数据,且 maxAge 长达约 32 年只保留 sessionIdmaxAge: 1800000
2完全没有设置过期时间,很多浏览器会无限期保留它加上 maxAge
3塞得太满,而且过期时间是公元 3000 年只保留 sessionIduserIdrole,有效期设为 30 分钟
4Cookie 里没有会话 id,反而携带了 userIdrole;它 2 秒的 maxAge 根本没法用;而会话本身要到 2099 年才过期把会话 id 放进 Cookie,其余全部去掉,maxAge: 1800000,有效期设为 30 分钟

场景 4 是最有意思的一个,因为它同时在两个方向上都出了问题:这个 Cookie 既短命到无法使用,又携带着本不该离开服务器的数据,而它背后的会话却几乎能活上一个世纪。

如果你的修复方案在细节上有所不同,那也没关系。重要的是抓住这背后的思路。

接下来往哪走

有状态的身份模型之所以牢固,是因为服务器始终掌控全局。而上面这四个失误,每一个都在一点点侵蚀这份掌控:要么是数据逃出了服务器,要么是某个决策的依据早已过时,决策本身却还在继续生效。

不透明的持有者令牌开启了一条相反的路线:服务器不再保存任何会话,改由客户端自己携带证明。