百分号编码:会破坏 URL 的保留字符

2026-09-30 · 1502 词

URL 不是"一个带转义的字符串"。它是一套小语法,由几个可互换的部分组成 —— scheme、authority、path、query、fragment —— 而一个字符的含义取决于它出现在哪一部分。百分号编码的存在,是为了让数据能被装进这些部分而不被误认为语法本身。当这件事出错时,不会有任何东西报错:值只是到达时更短了、被切成两半了,或者指向了服务器根本看不到的地方。

两个集合,以及关于它们的一个细节

RFC 3986 把字符分成两组。

非保留字符 —— A–Z、a–z、0–9、-、.、_、~。它们从不需要编码,而多此一举地编码它们,会造出同一个 URL 的第二种拼写。这看起来无害,直到两种拼写到达缓存、签名计算或基于 URL 的访问规则,被当成两个不同的资源。

保留字符,分两类:

保留不意味着"永远要编码",而是意味着"这个字符在这里有职务"。: 在 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:

如果生产者用的是别的历史编码,这些三元组依然完全合规、也依然能解出东西 —— 只是不是发出去的那段文字。一段 GBK 编码的中文到达 UTF-8 解码器,会产出一页替换字符,而请求本身看起来没有任何问题。所以"编码合法"和"字符正确"是两个问题,而后者需要知道生产者的编码是什么。

与之相关的实现规则:永远对字节做百分号编码,不要对码点、更不要对字符做。在字符串是 UTF-16 码元序列的语言里,直接编码一个字符,正是辅助平面字符被写成代理对半边的原因 —— 那没有任何解码器会接受。

解码器应当拒绝,而不是猜

解码是损伤变成永久损伤的地方,因为一个对非法输入过于慷慨的解码器,会把一个看得见的 bug 变成一个看起来合理的值。

做相反事情 —— 归一化、修复、然后报告成功 —— 的解码器,正是"两套系统对同一个 URL 有分歧、却都声称自己解码过了"的原因。严格而明确的行为,就是 URL 编解码工具 实现的:它展示自己改了哪些字符,把 UTF-8 字节处理与文本处理分开,并在非法序列的准确位置指出来,而不是悄悄丢掉。

值得记住的规则

  1. 按组件编码,不要按整条 URL 编码。&、=、#、+ 在一部分里是数据,在另一部分里是语法。
  2. 永远不要解码两次。%2520 是一个百分号后跟 20,不是空格;如果第二次解码"修好了"它,说明生产者做了双重编码,那是他们要去修的 bug。
  3. 比较规范形态:大写十六进制三元组、不必要的非保留字符不编码、每个资源一种拼写。
  4. 把非 ASCII 当作 UTF-8 字节并显式说明;发送方的编码未知时,去问,而不是假定。
  5. 宁可否决非法输入,也不要修复它 —— 这就是"解码器"与"数据破坏装置"的区别。

关于这两层该给哪种载荷用,和 Base64 的对比 讲过了;而当载荷是一个必须原样穿过查询串的 token 时,URL 安全字母表 通常比"从一个本来就含 + 和 / 的字母表里转义出一条路"更好的起点。

相关阅读

试用 Base64 转图片工具 →