前言
主要讨论了处理 HTTP Cookie 的复杂性和挑战,以及不同浏览器和编程语言标准库在解析和发送 Cookie 时的不一致性。今日前端早读课文章由 @飘飘翻译分享。
正文从这开始~~
HTTP cookies 是由 JavaScript 或 HTTP 服务器生成的一小段数据,对于被称为无状态系统来说,它是维持状态的关键。一旦设置,浏览器将在每个具有有效范围内的 HTTP 请求中继续转发它们,直到它们过期为止。
我原本乐意无视 cookies 的工作原理,直到世界末日,但有一天我偶然发现了这段无害的 JavaScript 代码:
const favoriteCookies = JSON.stringify({
ginger: "snap",
peanutButter: "chocolate chip",
snicker: "doodle",
});
document.cookie = ` cookieNames=${favoriteCookies}` ;
这段代码在浏览器中运行完全正常。它将一段乏味但美味的 JSON 取出,并将其值保存到会话 cookie 中。虽然这种做法有点不寻常 —— 大多数代码在将 JSON 数据设置为 Cookie 之前会将其序列化为 base64 格式,但这里并没有任何浏览器会遇到的问题。浏览器愉快地允许将 cookie 设置并作为 HTTP 头发送到后端 Web 服务器:
GET / HTTP/1.1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9
Connection: keep-alive
Cookie: cookieNames={"ginger":"snap","peanutButter":"chocolate chip","snicker":"doodle"}
Host: example.com
一切都很顺利,直到这个 cookie 被传递给使用 Go 标准库的某些代码。Go 标准库无法解析 cookie,导致整个堆栈上连锁的失败。那么到底发生了什么?
规范
cookies 最初在 RFC 2109(1997 年)中被定义,随后在 RFC 2965(2000 年)和 RFC 6265(2011 年)中进行了更新,目前有一个正在更新的草案版本(这就是本文使用的)。
RFC 中有两部分内容与 Cookie 值有关:
Informally, the Set-Cookie response header field contains a cookie,
which begins with a name-value-pair, followed by zero or more
attribute-value pairs. Servers SHOULD NOT send Set-Cookie header
fields that fail to conform to the following grammar:
set-cookie = set-cookie-string
set-cookie-string = BWS cookie-pair *( BWS ";" OWS cookie-av )
cookie-pair = cookie-name BWS "=" BWS cookie-value
cookie-name = 1*cookie-octet
cookie-value = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE )
cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E
; US-ASCII characters excluding CTLs,
; whitespace DQUOTE, comma, semicolon,
; and backslash
A user agent MUST use an algorithm equivalent to the following algorithm
to parse a set-cookie-string:
1. If the set-cookie-string contains a %x00-08 / %x0A-1F / %x7F character
(CTL characters excluding HTAB):
Abort these steps and ignore the set-cookie-string entirely.
2. If the set-cookie-string contains a %x3B (";") character:
The name-value-pair string consists of the characters up to, but not
including, the first %x3B (";"), and the unparsed-attributes consist
of the remainder of the set-cookie-string (including the %x3B (";")
in question).
Otherwise:
1. The name-value-pair string consists of all the characters contained in
the set-cookie-string, and the unparsed-attributes is the empty string.
有三件事应该立刻引起你的注意:
服务器应该发送的内容与浏览器必须接受的内容并不一致,这是遵循 Postel 定律所导致的经典悲剧。
除了分号分隔符外,这里没有任何限制浏览器向服务器发送的 Cookie 值的内容。如果服务器只接收它们自己设置的 cookie,这可能没问题,但 cookie 也可以来自其他来源,并且其值可以包含除 %x21
、 %x23-2B
、 %x2D-3A
、 %x3C-5B
和 %x5D-7E
字符之外的任何字符,这符合 Set-Cookie 的规定。
它没有考虑到处理 Cookie
头的标准库的行为规范:它们应该像用户代理还是像服务器一样工作?它们应该是允许性的还是禁止性的?它们在不同上下文中的行为是否应该不同?
而这就是我遇到的问题的核心所在:一切都表现得不一样,能够正常工作的 Cookie 简直是个奇迹。
Web 浏览器
首先,让我们从浏览器的行为谈起。Gecko(Firefox)、Chromium 和 WebKit(Safari)背后的团队一直在紧密合作,因此可以合理地预期它们会表现出相同的行为…… 对吧?
在我们深入探讨之前,请记住,RFC 的说明存在矛盾之处: Set-Cookie
头可以包含除了控制字符、双引号、逗号、分号和反斜杠之外的任何 ASCII 字符,但浏览器应该接受任何不含控制字符的 Cookie 值。
Firefox
Firefox 的代码对于有效的 cookie 值看起来是这样的:
bool CookieCommons::CheckValue(const CookieStruct& aCookieData) {
// reject cookie if value contains an RFC 6265 disallowed character - see
// https://bugzilla.mozilla.org/show_bug.cgi?id=1191423
// NOTE: this is not the full set of characters disallowed by 6265 - notably
// 0x09, 0x20, 0x22, 0x2C, and 0x5C are missing from this list.
const char illegalCharacters[] = {
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x0A, 0x0B, 0x0C,
0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17,
0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x3B, 0x7F, 0x00};
const auto* start = aCookieData.value().BeginReading();
const auto* end = aCookieData.value().EndReading();
auto charFilter = [&](unsigned char c) {
if (StaticPrefs::network_cookie_blockUnicode() && c >= 0x80) {
return true;
}
return std::find(std::begin(illegalCharacters), std::end(illegalCharacters),
c) != std::end(illegalCharacters);
};
return std::find_if(start, end, charFilter) == end;
}
接受 0x7F
在 Firefox 108 中的 bug 1797235 已经被修复
Firefox 接受五个字符,这是 RFC 建议服务器不要发送的:
0x09
(水平制表符)
0x20
(空格)
0x22
(双引号)
0x2C
(逗号)
0x5C
(反斜杠)
这是很久以前为了与 Chrome 保持一致而做的,这两个代码库中仍然保留着这一做法。
明察秋毫的人可能会注意到,Firefox 有一个 network.cookie.blockUnicode
设置,该代码会检查该设置,并拒绝所有高于 0x80
的值。这项研究为此奠定了基础,可以在 bug 1797231 中追踪到。
Chromium
Chrome 中用于验证有效 Cookie 值的代码如下所示:
bool ParsedCookie::IsValidCookieValue(const std::string& value) {
// IsValidCookieValue() returns whether a string matches the following
// grammar:
//
// cookie-value = *cookie-value-octet
// cookie-value-octet = %x20-3A / %x3C-7E / %x80-FF
// ; octets excluding CTLs and ";"
//
// This can be used to determine whether cookie values contain any invalid
// characters.
//
// Note that RFC6265bis section 4.1.1 suggests a stricter grammar for
// parsing cookie values, but we choose to allow a wider range of characters
// than what's allowed by that grammar (while still conforming to the
// requirements of the parsing algorithm defined in section 5.2).
//
// For reference, see:
// - https://crbug.com/238041
for (char i : value) {
if (HttpUtil::IsControlChar(i) || i == ';')
return false;
}
return true;
}
// Whether the character is a control character (CTL) as defined in RFC 5234
// Appendix B.1.
static inline bool IsControlChar(char c) {
return (c >= 0x00 && c <= 0x1F) || c == 0x7F;
}
Chrome 比 Firefox 稍微严格一些,拒绝接受其 cookie 值中的 0x09
(水平标签)。
然而(与 RFC 相反),它完全能够接收和发送空格、双引号、逗号、反斜杠和 Unicode 字符。
Safari(WebKit)
我无法访问存储 cookie 的代码,因为它被封装在封闭源代码的 CFNetwork 中。不过,我们可以通过运行下面这段 JavaScript 代码来查看其内部实现:
for (i=0; i<256; i++) {
let paddedIndex = i.toString().padStart(3, '0') +
'_' + '0x' + i.toString(16).padStart(2, '0');
// set a cookie with name of "cookie" + decimal char + hex char
// and a value of the character surrounded by a space and two dashes
document.cookie=` cookie${paddedIndex}=-- ${String.fromCharCode(i)} --` ;
}
document.cookie='cookieUnicode=🍪';
:::text
cookie007_0x07 -- localhost / Session 16 B
cookie008_0x08 -- localhost / Session 16 B
cookie009_0x09 -- -- localhost / Session 21 B
cookie010_0x0a -- localhost / Session 16 B
cookie011_0x0b -- localhost / Session 16 B
(snip for brevity)
cookie030_0x1e -- localhost / Session 16 B
cookie031_0x1f -- localhost / Session 16 B
cookie032_0x20 -- -- localhost / Session 21 B
cookie033_0x21 -- ! -- localhost / Session 21 B
cookie034_0x22 -- " -- localhost / Session 21 B
cookie035_0x23 -- # -- localhost / Session 21 B
(snip for brevity)
cookie042_0x2a -- * -- localhost / Session 21 B
cookie043_0x2b -- + -- localhost / Session 21 B
cookie044_0x2c --,-- localhost / Session 19 B
(snip for brevity)
cookie044_0x5c -- \ -- localhost / Session 19 B
因为 Safari 在看到禁止字符后就会停止处理 Cookie,因此很容易看出 0x09
(水平制表符)、 0x20
(空格)、 0x22
(双引号)和 0x5C
(反斜杠)是允许的,但 0x7F
(删除)、 0x80-FF
(高 ASCII/Unicode)字符是不允许的。
与遵循 RFC 指示的 Firefox 和 Chrome 不同,当遇到包含控制字符的 Cookie 时,它们会 “中止这些步骤并完全忽略该 Cookie”。而 Safari 不会忽略该 Cookie,而是接受该 Cookie 值,直到遇到控制字符为止。
令人奇怪的是,这个任务揭示了一个奇怪的 Safari bug—— 设置一个值为 --
, --
的 Cookie 设置后,似乎会导致它自动删除逗号周围的空格。
标准库
让我们从 Go 语言 的 cookie 代码开始,这就是我最初遇到问题的地方。
// sanitizeCookieValue produces a suitable cookie-value from v.
// https://tools.ietf.org/html/rfc6265#section-4.1.1
//
// We loosen this as spaces and commas are common in cookie values
// but we produce a quoted cookie-value if and only if v contains
// commas or spaces.
// See https://golang.org/issue/7243 for the discussion.
func sanitizeCookieValue(v string) string {
v = sanitizeOrWarn("Cookie.Value", validCookieValueByte, v)
if len(v) == 0 {
return v
}
if strings.ContainsAny(v, " ,") {
return `"` + v + `"`
}
return v
}
func validCookieValueByte(b byte) bool {
return 0x20 <= b && b < 0x7f && b != '"' && b != ';' && b != '\\'
}
Golang 在如何让服务器处理 Set-Cookie
时的表述上与 RFC 的表述较为接近,仅在允许 0x20
(空格)和 0x2C
(逗号)方面有所不同,因为这些符号在实际应用中较为常见。
你可以看到编程语言所面临的困境 —— 它们既要按照第 5 节的规定从浏览器接收值,又要按照第 4.1.1 节的规定发送 cookie。
这可能会产生相当严重的后果,从运行这段代码就可以看出来:
package main
import (
"fmt"
"net/http"
)
func main() {
rawCookies :=
` cookie1=foo; ` +
` cookie2={"ginger":"snap","peanutButter":"chocolate chip","snicker":"doodle"}; ` +
` cookie3=bar`
header := http.Header{}
header.Add("Cookie", rawCookies)
request := http.Request{Header: header}
fmt.Println(request.Cookies())
}
它只输出:
[cookie1=foo cookie3=bar]
隐蔽地放置了一个所有主要浏览器都接受的 cookies,没有任何例外说明这是在发生什么。不过,在没有任何其他副作用的情况下放置一个自己不理解的 cookie 并不像想象中那么糟糕。
【第3337期】解密Cookies和Tokens
PHP
许多语言,如 PHP,没有内置的函数用于解析 cookie,这使得我们很难明确地说明它允许和不允许的内容。
也就是说,我们可以通过下面的代码设置 cookies,并观察 PHP 的响应情况:
[0x09, 0x0D, 0x10, 0x20, 0x22, 0x2C, 0x5C, 0x7F, 0xFF].forEach(i => {
let paddedIndex = i.toString().padStart(3, '0') + '_' +
'0x' + i.toString(16).padStart(2, '0');
document.cookie=` cookie${paddedIndex}=-- ${String.fromCharCode(i)} --` ;
});
document.cookie='cookieUnicode=🍪';
输出:
cookie009_0x09: -- --
cookie009_0x10: -- --
cookie009_0x0d: -- --
cookie032_0x20: -- --
cookie034_0x22: -- " --
cookie044_0x2c: -- , --
cookie092_0x5c: -- \ --
cookie255_0x7f: -- --
cookie255_0xff: -- ÿ --
cookieUnicode: 🍪
在涉及到控制字符时,PHP 的行为非常混乱。 所有 0x00-0x09
都工作正常,以及像 0x0D
(回车)这样的字符也正常,但如果你使用 0x10
(数据链路转义符)或 0x7F
(删除符),PHP 将完全报错,并返回一个 400 Bad Request 错误。
Python
import http.cookies
raw_cookies = (
'cookie1=foo; '
'cookie2={"ginger":"snap","peanutButter":"chocolate chip","snicker":"doodle"}; '
'cookie3=bar'
)
c = http.cookies.SimpleCookie()
c.load(raw_cookies)
print(c)
输出:
>>> Set-Cookie: cookie1=foo
会在遇到不理解的 Cookie 时,无声地终止对 SimpleCookie.load()
内部其他 Cookie 的加载。当你考虑到子域有可能在主域上设置一个 cookies,这可能会完全破坏给定站点的所有域中的所有 cookies 时,这种情况非常危险。
当涉及到控制字符时,情况甚至更混乱:
import http.cookies
for i in range(0, 32):
raw_cookie = f"cookie{hex(i)}={chr(i)}"
c = http.cookies.SimpleCookie()
c.load(raw_cookie)
for name, morsel in c.items():
print(f"{name}: value: {repr(morsel.value)}, length: {len(morsel.value)}")
输出:
>>> cookie0x9: value: '', length: 0
>>> cookie0xa: value: '', length: 0
>>> cookie0xb: value: '', length: 0
>>> cookie0xc: value: '', length: 0
>>> cookie0xd: value: '', length: 0
在这里,我们可以看到 Python 会自动丢弃许多带有控制字符 cookies,并会错误加载其他 cookie。请注意,如果你用类似于的方式保护这些值:
raw_cookie = f"cookie{hex(i)}=aa{chr(i)}aa"
然后,所有控制字符 cookies 都不会被加载。总的来说,Python 在加载 cookies 时的行为极其不一致且难以预测。
Ruby
require "cgi"
raw_cookie = 'ginger=snap; ' +
"cookie=chocolate \x13 \t \" , \\ \x7f 🍪 chip; " +
'snicker=doodle'
cookies = CGI::Cookie.parse(raw_cookie)
puts cookies
puts cookies["cookie"].value()
puts cookies["cookie"].value().to_s()
输出:
{"ginger"=>#<CGI::Cookie: "ginger=snap; path=">, "cookie"=>#<CGI::Cookie: "cookie=chocolate+%13+%09+%22+%2C+%5C+%7F+%F0%9F%8D%AA+chip; path=">,
"snicker"=>#<CGI::Cookie: "snicker=doodle; path=">}
chocolate " , \ 🍪 chip
cookie=chocolate+%13+%09+%22+%2C+%5C+%7F+%F0%9F%8D%AA+chip; path=
这个 Ruby 库看起来相当宽容,在解析过程中似乎接受任何字符,然后在从 cookie jar 中提取时对其进行百分比编码。
这可能是与 Cookie 相关的最优行为(如果这种行为确实存在的话),但我肯定能想到一些情况,其中代码通过 document.cookie
设置 Cookie 时,并不期望看到它以百分比编码的形式反射回来。
Rust
use cookie::Cookie;
fn main() {
let c = Cookie::parse("cookie=chocolate , \" \t foo \x13 ñ 🍪 chip;").unwrap();
println!("{:?}", c.name_value());
}
输出:
("cookie", "chocolate , \" \t foo \u{13} ñ 🍪 chip")
Rust 默认不提供任何 cookie 处理功能,因此这里将查看流行的 cookie 库。按照默认配置,它似乎是所有编程语言中最宽松的,接受任何投给它的 UTF-8 字符串。
【第3371期】Cookie的secure属性引起循环登录问题分析及解决方案
万维网,又称为什么重要
不同浏览器和语言之间迥异的行为确实能产生一些令人着迷的表格,但在现实世界中,这一切又会如何发挥作用呢?
当我第一次在现实世界中发现这个问题时,仅仅是运气好才没有酿成大祸。一个手动测试人员在尝试更新第三方库时,在测试站点遇到了一系列奇怪的错误。如果没有向我报告,这个更新(在自动化测试中不太可能被发现)肯定会被推送到生产环境中。因此,每一个未来的网站访问者都会收到一个损坏的 cookie,并因为一个难以理解的错误而被锁定,直到更新被回滚,cookie 被清除。
而正是这种规格说明的模糊性问题 —— 这是一个很容易犯的错误,以至于数百万个网站和公司只差一个实习生就能导致完全崩溃。这不仅影响那些使用不知名框架的小型网站,像 Facebook、Netflix、WhatsApp 和苹果这样的大型网站也受到影响。
你可以亲自通过将这段简单的代码片段粘贴到浏览器控制台中,并将 .grayduck.mn
替换为你正在测试的域名,例如 .facebook.com
来自行验证这有多么容易出错。
document.cookie="unicodeCookie=🍪; domain=.grayduck.mn; Path=/; SameSite=Lax"
关于本文
译者:@飘飘
作者:@April King
原文:https://grayduck.mn/2024/11/21/handling-cookies-is-a-minefield/
这期前端早读课
对你有帮助,帮” 赞 “一下,
期待下一期,帮” 在看” 一下 。