侧边栏壁纸
博主头像
一笑痕

人生若只如初见,
是可喜亦或者是可悲?

  • 累计撰写 104 篇文章
  • 累计收到 7 条评论

Set-Cookie 别拼成一行,两个 Cookie 会变成一个

2026-9-23 / 0 评论 / 8 阅读

Set-Cookie 别拼成一行,两个 Cookie 会变成一个

上游回两个 Cookie,网关转出去以后浏览器只认出来一个,另一个像是没设过。这种情况多半是把 Set-Cookie 的值拼成了一行。

规范先说清楚了不许合并

HTTP 里同名的头字段一般可以合并成一行,用逗号分隔。Set-Cookie 是明文规定的例外。RFC 7230 在讲字段合并的那一节留了这段说明:

Note: In practice, the "Set-Cookie" header field ([RFC6265]) often
appears multiple times in a response message and does not use the
list syntax, violating the above requirements on multiple header
fields with the same name.  Since it cannot be combined into a
single field-value, recipients ought to handle "Set-Cookie" as a
special case while processing header fields.

理由是下一步才看清的:Set-Cookie 的值里本身就带逗号。RFC 6265 给出的语法里,过期时间这个属性的值是一个 HTTP 日期:

expires-av        = "Expires=" sane-cookie-date
sane-cookie-date  = <rfc1123-date, defined in [RFC2616], Section 3.3.1>

RFC 1123 格式的日期长这样:Wed, 21 Oct 2026 07:28:00 GMT。星期几后面那个逗号就是致命的。用逗号当分隔符去连接两条 Set-Cookie,任何按逗号切分的解析器都会把一条 cookie 切成两条,切出来的第二半还长得像一条合法的 cookie。

批注:拼成一行的报文长什么样

本机起了一个上游和一个网关,上游一次回两个 Set-Cookie,其中一个带 Expires。网关有两个分支,一个把数组拼成一行,一个原样给数组。下面是客户端收到的原始字节。

上游原始回来的 Set-Cookie 数组:
  sid=abc123; Path=/; HttpOnly; SameSite=Lax
  theme=dark; Path=/; Expires=Wed, 21 Oct 2026 07:28:00 GMT

=== 网关 /naive 的响应头(原始字节)===
  Set-Cookie: sid=abc123; Path=/; HttpOnly; SameSite=Lax, theme=dark; Path=/; Expires=Wed, 21 Oct 2026 07:28:00 GMT
  -- Set-Cookie 行数:1
  -- 客户端 getSetCookie() 拿到 1 条:
       sid=abc123; Path=/; HttpOnly; SameSite=Lax, theme=dark; Path=/; Expires=Wed, 21 Oct 2026 07:28:00 GMT
  -- headers.get('set-cookie'):"sid=abc123; Path=/; HttpOnly; SameSite=Lax, theme=dark; Path=/; Expires=Wed, 21 Oct 2026 07:28:00 GMT"

那两行中间只差一个逗号加空格,一个还能用,一个已经坏了。

批注:坏在哪一步

把朴素分支发出的那一行按逗号切开,再按分号取每段的第一个键值对,得到的是三条:

=== 朴素写法真正发出的那一行 ===
  sid=abc123; Path=/; HttpOnly; SameSite=Lax, theme=dark; Path=/; Expires=Wed, 21 Oct 2026 07:28:00 GMT

按逗号切开(很多粗暴解析器就是这么干的):
  [0] "sid=abc123; Path=/; HttpOnly; SameSite=Lax"
  [1] "theme=dark; Path=/; Expires=Wed"
  [2] "21 Oct 2026 07:28:00 GMT"

再按 ; 取第一个 name=value:
  "sid=abc123"
  "theme=dark"
  "21 Oct 2026 07:28:00 GMT"

theme 那条的 Expires 只剩 Wed,不是一个合法日期,过期属性被当作无效丢掉,于是这条 cookie 从"明年过期"变成"关掉浏览器就没"。第三条更荒唐,21 Oct 2026 07:28:00 GMT 会被当成一个新的 cookie 名塞进去。同一个响应在合规的解析器里可能报错丢掉整行,在宽松的解析器里就变成上面这种半坏状态,具体表现随客户端而变。

批注:Node 的 setHeader 本来就收数组

上游响应在 Node 里读出来的时候,set-cookie 就是数组,不需要手动拼。转发时把这个数组原样交给 setHeader,每个元素发一行:

const upstream = await fetch(upstreamUrl);
const bff = http.createServer((req, res) => {
  res.statusCode = upstream.status;
  // 数组原样传,Node 会为每个元素写一行 Set-Cookie
  res.setHeader("Set-Cookie", upstream.headers.getSetCookie());
  upstream.body.pipe(res);
});

如果一定要经过一层自己的处理,那就别用字符串。res.setHeader("Set-Cookie", list.join(", "))res.setHeader("Set-Cookie", list) 在代码里只差几个字符,发出的报文一个是一行、一个是两行。实测的数组分支:

=== 网关 /array 的响应头(原始字节)===
  Set-Cookie: sid=abc123; Path=/; HttpOnly; SameSite=Lax
  Set-Cookie: theme=dark; Path=/; Expires=Wed, 21 Oct 2026 07:28:00 GMT
  -- Set-Cookie 行数:2
  -- 客户端 getSetCookie() 拿到 2 条:
       sid=abc123; Path=/; HttpOnly; SameSite=Lax
       theme=dark; Path=/; Expires=Wed, 21 Oct 2026 07:28:00 GMT
  -- headers.get('set-cookie'):"sid=abc123; Path=/; HttpOnly; SameSite=Lax, theme=dark; Path=/; Expires=Wed, 21 Oct 2026 07:28:00 GMT"

最后一行值得单独看一眼:这条正确响应里,headers.get('set-cookie') 返回的仍然是一根拼好的字符串,和坏掉的那个分支完全相同。两条分支在这一行上无法区分,因为 get 会把多个同名头按逗号拼起来返回。用它来验证这类改动,等于拿一把量不出差别的尺子。

批注:客户端用 getSetCookie

Node 18.14 之后客户端侧有了 res.headers.getSetCookie(),拿到的是数组,长度对应实际行数。上面的实测里,两个分支的 getSetCookie() 一个返回 2 条,一个返回 1 条,差别就出来了。写代理、网关、mock 服务的时候用这个方法断言,才不会漏掉合并。

游戏规则在两个方向上不一样。请求方向的 Cookie 头本来就可以合并,浏览器发出去的 Cookie: a=1; b=2 就是多条用分号连成一条,接收端再把分号切开。响应方向的 Set-Cookie 反过来,必须一行一条。同一个字段名,两个方向的合并规则相反,这也是它容易被写错的原因。

结论

网关里跟 Cookie 有关的值一律按列表处理,拼接和排序都不要做。这类地方我倾向在评审时专门看一眼 setHeader 的第二个参数是不是数组。

排查同类问题时的顺序:先看原始字节里的 Set-Cookie 行数,再用 getSetCookie() 的长度确认,最后才去看 cookie 属性和值。用 headers.get('set-cookie') 做中间那一步,会把两个分支看成一回事。

    🤞 分享