百分号编码:会破坏 URL 的保留字符
2026-09-30 · 1502 词
URL 不是"一个带转义的字符串"。它是一套小语法,由几个可互换的部分组成 —— scheme、authority、path、query、fragment —— 而一个字符的含义取决于它出现在哪一部分。百分号编码的存在,是为了让数据能被装进这些部分而不被误认为语法本身。当这件事出错时,不会有任何东西报错:值只是到达时更短了、被切成两半了,或者指向了服务器根本看不到的地方。
两个集合,以及关于它们的一个细节
RFC 3986 把字符分成两组。
非保留字符 —— A–Z、a–z、0–9、-、.、_、~。它们从不需要编码,而多此一举地编码它们,会造出同一个 URL 的第二种拼写。这看起来无害,直到两种拼写到达缓存、签名计算或基于 URL 的访问规则,被当成两个不同的资源。
保留字符,分两类:
- gen-delims ——
:、/、?、#、[、]、@ - sub-delims ——
!、$、&、'、(、)、*、+、,、;、=
保留不意味着"永远要编码",而是意味着"这个字符在这里有职务"。: 在 authority 里是分隔符(host:port),在路径段里却是完全普通的数据。& 是查询参数的间隔符,在路径里却是完全普通的数据。规则永远相对于你正在写的那一部分。
一个会绊倒解析器的细节:百分号编码的三元组区分大小写是不敏感的 —— %2F 和 %2f 表示同一个字节 —— 但你代码里的字符串比较是敏感的。在比较、哈希或签名之前统一成大写十六进制,否则两个完全相同的 URL 会产生两个不同的签名。
真正会造成事故的字符
查询值里的 &。 值在 & 处结束,后面的一切变成另一个参数。症状:一个长 token 在任意位置被截断,而请求里出现一个没有客户端打算发送的额外参数。
查询值里任意位置的 #。 从 # 起的一切成为 fragment,而浏览器从不把 fragment 发给服务器。于是服务器看到的只是被截到那一点的值,而浏览器地址栏里显示的却是其余部分 —— 这是这个 bug 最令人困惑的版本,因为在本地检查的人看到的 URL 看起来是完整的。
查询值里的 +。 在 application/x-www-form-urlencoded 规则下它表示空格,所以 C++ 变成 C ;而在路径里它是字面的加号。如果你想让一个字面加号在被两个不同库处理之后仍然存活,就把它写成 %2B。
孤零零的 %。 字面的百分号必须写成 %25。一个 % 后面跟的不是两个十六进制数字,会让整个 URL 非法,而严格的解析器会拒绝整条 URL 而不是那个字符 —— 这就是模板里的 100% 或 %AB 能让一个本来正常的请求崩掉的原因。
空格。 路径里和符合标准的查询里是 %20;表单编码数据里是 +。两者在入口处都被广泛接受,而这种互相容忍恰恰是这个 bug 活得这么久的原因:它在你测过的解析器上能用,在生产里那个上失败。
路径段值里的 /。 编成 %2F 之后值不再像两个段 —— 但很多服务器在路由之前就解码路径,于是 %2F 又被变回分隔符,你的值照样被切开。有些服务器直接拒绝编码后的斜杠,理由是安全:%2F 和 %2E 是路径穿越尝试的积木,所以代理和应用服务器对它们有强烈意见。诚实的结论是:含斜杠的值,不该待在单个路径段里。
查询值里的 ?。 按语法它在查询里合法,但最好还是编码,因为第二个 ? 会招来中间环节的第二轮解析。
非 ASCII 是字节,不是字符
这里是百分号编码不再像"转义方案"、而开始像"编码方案"的地方。
一组 %XX 承载的是一个字节,不是一个字符。非 ASCII 字符必须先变成字节,而唯一可互操作的选择是 UTF-8:
é是 U+00E9,UTF-8 里两个字节(C3 A9),所以编码成%C3%A9。- 一个汉字比如 U+4E2D 是三个字节(
E4 B8 AD),所以编码成%E4%B8%AD—— 九个字符承载一个字符。
如果生产者用的是别的历史编码,这些三元组依然完全合规、也依然能解出东西 —— 只是不是发出去的那段文字。一段 GBK 编码的中文到达 UTF-8 解码器,会产出一页替换字符,而请求本身看起来没有任何问题。所以"编码合法"和"字符正确"是两个问题,而后者需要知道生产者的编码是什么。
与之相关的实现规则:永远对字节做百分号编码,不要对码点、更不要对字符做。在字符串是 UTF-16 码元序列的语言里,直接编码一个字符,正是辅助平面字符被写成代理对半边的原因 —— 那没有任何解码器会接受。
解码器应当拒绝,而不是猜
解码是损伤变成永久损伤的地方,因为一个对非法输入过于慷慨的解码器,会把一个看得见的 bug 变成一个看起来合理的值。
- 输入末尾的不完整三元组(
%E4%B8)是非法的。静默截掉它,得到的是"几乎正确"的文本。 - 非法的 UTF-8 字节序列必须报错。无声地替换成 U+FFFD,意味着调用方永远不知道发送方用错了编码 —— 而一个含替换字符的值,可以被存储、索引、传递很多年。
- 超长编码 —— 把
/写成%C0%AF那个历史手法 —— 必须被拒绝,而不是解码成较短的形式,因为那正是它们在安全语境下危险的原因。 - 一个后面不跟两个十六进制数字的
%是格式错误的 URL。报"格式错误"比当成字面%更有用,因为调用方随后会去找到那个忘了转义的模板。
做相反事情 —— 归一化、修复、然后报告成功 —— 的解码器,正是"两套系统对同一个 URL 有分歧、却都声称自己解码过了"的原因。严格而明确的行为,就是 URL 编解码工具 实现的:它展示自己改了哪些字符,把 UTF-8 字节处理与文本处理分开,并在非法序列的准确位置指出来,而不是悄悄丢掉。
值得记住的规则
- 按组件编码,不要按整条 URL 编码。
&、=、#、+在一部分里是数据,在另一部分里是语法。 - 永远不要解码两次。
%2520是一个百分号后跟20,不是空格;如果第二次解码"修好了"它,说明生产者做了双重编码,那是他们要去修的 bug。 - 比较规范形态:大写十六进制三元组、不必要的非保留字符不编码、每个资源一种拼写。
- 把非 ASCII 当作 UTF-8 字节并显式说明;发送方的编码未知时,去问,而不是假定。
- 宁可否决非法输入,也不要修复它 —— 这就是"解码器"与"数据破坏装置"的区别。
关于这两层该给哪种载荷用,和 Base64 的对比 讲过了;而当载荷是一个必须原样穿过查询串的 token 时,URL 安全字母表 通常比"从一个本来就含 + 和 / 的字母表里转义出一条路"更好的起点。