URL 指南

URL 编码详解:百分号编码、分量与 Punycode

URL 保留了哪些字符、为什么规则随分量而变、encodeURI 与 encodeURIComponent 的差别、加号什么时候代表空格,以及国际化域名究竟是怎么编码的。

百分号编码是个很小的机制,但出错的方式多得离谱,而这些错误几乎都源于同一件事:拿一条规则去套整个 URL,而不是给每个分量用对它自己的规则。这篇指南把字符集分开讲清楚,走一遍大家常用的两个 JavaScript 函数,解释加号与 %20 的历史纠葛,最后落到 URL 里唯一完全不做百分号编码的那一部分 —— 主机名。

先把百分号编码说准确

URI 是定义在一个受限的 US-ASCII 字符集合之上的。落在这个集合之外的字符,以及落在集合之内但在当前位置会被读成结构的字符,都必须间接表示。百分号编码的做法是把一个八位字节替换成百分号加两位十六进制数字:空格是字节 0x20,于是写成 %20。

关键词是「字节」。百分号编码作用于字节而非字符,所以字符必须先变成字节才能编码 —— 对现代 URL 来说,这意味着先转 UTF-8。字符 é 是一个码点、两个 UTF-8 字节,因此是两个转义:caf%C3%A9。「中文」两个码点、六个字节,于是是六个转义:%E4%B8%AD%E6%96%87。如果你看到一个非 ASCII 字符只产生了一个转义,说明上游某处选了遗留编码,这个值撑不过一次往返。

规范规定十六进制数字大小写不敏感,但大写是规范化形式,也是现代库统一输出的形式。比较时把 %2F 和 %2f 当作相等,产出时一律用大写。对本就属于非保留集合的字符做编码是合法的,但不是规范化形式 —— %61 和 a 含义相同,可只有一个能在字符串比较中相等。

保留、非保留,以及剩下的一切

RFC 3986 把 ASCII 范围分成三组,知道一个字符属于哪一组,大部分编码问题就直接有答案了。非保留字符在任何位置都不需要编码,也不应该被编码。保留字符带有结构含义 —— 它们是分隔符 —— 所以当它们作为数据而非分隔符出现时必须编码。其余的一切,包括空格、引号、尖括号以及每一个非 ASCII 字节,则永远必须编码。

保留集合又进一步分成通用分隔符和子分隔符:前者划分 URI 的主要分量,后者划分分量内部的字段。按分量区分规则的根源正在这里 —— 与号在查询串里是分隔符,在路径段里只是个普通字符,所以正确处理方式完全取决于它待在哪儿。

还有一个细节,会让对比不同实现的人踩坑:JavaScript 的 encodeURIComponent 早于 RFC 3986,它不会编码 ! ' ( ) * 这五个字符,尽管规范把它们归为子分隔符。多数服务端并不在意,但要求 RFC 3986 规范形式的签名方案在意 —— 比如 AWS Signature Version 4 —— 这也是为什么那些 SDK 会自带一个转义函数,而不是直接用内置的那个。

分类字符是否编码
非保留A-Z a-z 0-9 - . _ ~永远不用
通用分隔符: / ? # [ ] @作为数据出现时必须编码
子分隔符! $ & ' ( ) * + , ; =作为数据出现时必须编码
空格空格字符永远编码,写作 %20 或表单体中的 +
百分号%永远编码为 %25
其他 ASCII" < > \ ^ ` { | } 及控制字符永远编码
非 ASCII其 UTF-8 形式的每一个字节永远编码

规则由分量决定

世界上没有「给一个 URL 做 URL 编码」这回事。URI 的每个分量都有自己的分隔符集合,用一条规则去编码整个 URL,要么毁掉结构,要么留下之后会被误读成结构的数据。唯一可靠的做法是在拼装 URL 的过程中逐个编码用户数据,而不是拼完再编码。

这个区别在两个最常接收外部数据的位置上尤为要紧:路径段和查询值。路径段内部的斜杠必须变成 %2F,否则它会凭空多出一个路径段。查询值里的与号或等号必须变成 %26 或 %3D,否则它会凭空多出一个参数。而这两处的井号都必须变成 %23,否则它后面的一切都会成为片段标识符,根本不会发到服务端 —— 一个值就这样在浏览器和应用日志之间无声地消失了。

这条规则在 JavaScript 里的落地形式是:用 URL 和 URLSearchParams 对象来构造 URL,而不是字符串拼接。URLSearchParams 会对它序列化的每个键和值应用表单编码规则,这直接消灭了「值里含分隔符因而越出自己分量」的一整类缺陷。

右列列出的是:若在该位置保持原样,就会改变 URL 含义的那些字符。

分量示例位置作为数据时必须转义
路径段/files/<此处>/v2/ ? # 和空格
查询参数名?<此处>=1& = ? # + 和空格
查询参数值?q=<此处>& = # + 和空格
片段标识符#<此处># 和空格
用户信息https://<此处>@host/: @ / ? #
主机名https://<此处>/不做百分号编码,见下文 Punycode

encodeURI 与 encodeURIComponent

JavaScript 给了两个函数,选错是前端代码里最常见的百分号编码 bug。encodeURIComponent 会转义非保留集合(外加那五个遗留例外)之外的一切,它是处理「单个数据片段」的函数。encodeURI 则原封不动放过每一个保留字符,因为它假定收到的是一个完整的、结构已经确定的 URI,自己只负责收拾空格和非 ASCII 字节。

拿同一个输入分别跑一遍,差别立刻显现。传入一整个 URL,encodeURIComponent 会把协议分隔符和每个斜杠都转义掉,直接毁掉它;传入一个恰好含与号的单值,encodeURI 会把与号原样留下,这个值到了服务端就裂成两个参数。两种输出都是合法的 URI 文本,但只有一种是你想要的意思。

能通过代码评审的规则很简单:对每一个独立的值用 encodeURIComponent,永远不要用在 URL 上;只有当你拿到的是一个字符串形式的完整 URI、且无法重新构造它时,才用 encodeURI。如果你正打算对自己拼出来的东西用 encodeURI,说明拼的方式本身就错了。

同样的输入走两个函数
const url = "https://example.com/a b?x=1&y=2";

encodeURI(url);
// "https://example.com/a%20b?x=1&y=2"        结构保留

encodeURIComponent(url);
// "https%3A%2F%2Fexample.com%2Fa%20b%3Fx%3D1%26y%3D2"   整体变成一个值

const value = "q=1&r=2";

encodeURI(value);            // "q=1&r=2"        没变,也就是坏了
encodeURIComponent(value);   // "q%3D1%26r%3D2"  作为查询值是正确的

encodeURIComponent("中文");   // "%E4%B8%AD%E6%96%87"
encodeURIComponent("café");   // "caf%C3%A9"
encodeURIComponent("100%");   // "100%25"
encodeURIComponent("~_-.!*()'");  // 原样返回:那五个遗留例外

加号、%20,以及混乱从哪儿来

空格在 URL 里既可以写成 %20,也可以写成加号,哪个正确取决于一个语法本身看不出来的区别。application/x-www-form-urlencoded 这个媒体类型 —— 最初是 HTML 表单的序列化方式,如今几乎到处都用在查询串上 —— 规定空格写作加号。而通用 URI 语法 RFC 3986 没有这条规定:在那里加号只是个子分隔符字符,空格就是 %20。

结果就是:除非你知道谁来解析,查询串里的加号是有歧义的。多数 Web 框架会对查询串做表单解码,所以 ?q=a+b 到手的值是 a b。路径段不做表单解码,所以 /search/a+b 到手的是一个字面加号。而数据里真正的加号在查询串中必须转义成 %2B,否则会被读成空格 —— 这正是带加号别名的邮箱地址、以及使用标准字母表的 base64 值在查询串里翻车如此稳定的原因。

实务上 %20 更安全,因为两种模式下的解码器都认它。要注意 JavaScript 自带的两个工具在这件事上彼此矛盾:encodeURIComponent 产出 %20,URLSearchParams 产出加号,因为前者实现的是 RFC 3986,后者实现的是表单编码。各自对各自的规范都没错,但把它们的输出混在同一个 URL 里,用户名字里就会多出一个加号。

两个编码器,两种答案
encodeURIComponent("a b");   // "a%20b"
encodeURIComponent("a+b");   // "a%2Bb"

new URLSearchParams({ q: "a b", r: "c+d" }).toString();
// "q=a+b&r=c%2Bd"
//  空格变成了 "+",而字面的 "+" 变成了 "%2B"

解码侧,两种写法都能正确往返:
  new URLSearchParams("q=a+b").get("q")   // "a b"
  new URLSearchParams("q=a%20b").get("q") // "a b"
  decodeURIComponent("a+b")               // "a+b"  <- 不是空格

双重编码,以及如何一眼认出它

双重编码发生在一个已经编码过的值又被编码了一次,通常是因为它经过了两层,而每一层都以为编码是自己的职责。第一次转义里的百分号本身也是必须转义的字符,于是 %20 变成了 %2520,这个值现在解码出来是字面文本 %20,而不是一个空格。

认出它的形状并不难。任何 %25 后面又跟着两位十六进制数字,几乎可以断定是双重编码,而不是数据里真有个百分号。%2520、%253A、%252F 这些序列就是指纹。用户侧的症状是:URL 直接粘进浏览器能用,走你的应用就不行;或者文件名里带着肉眼可见的转义序列。

修法绝不是在末端再加一次解码,那只是替「本不该编码却编码了」的那一层遮丑。去找边界。典型情况是客户端编码了一次、框架又对承载它的查询串编码了一次,或者代理在重写路径时重新编码了一遍。确定哪一层拥有转义职责,让其余层原样透传。另外,盲目解码两次还是个安全问题:写成 %252e%252e%252f 的路径穿越载荷,解码两次之后就是 ../ —— 「先过滤后解码」的顺序正是这样被绕过的。

编一次与编两次
encodeURIComponent("a b");        // "a%20b"
encodeURIComponent("a%20b");      // "a%2520b"     <- 编了两次

decodeURIComponent("a%2520b");    // "a%20b"       <- 仍然是编码态
decodeURIComponent("a%20b");      // "a b"

双重编码值的指纹:
  %2520   原本是空格
  %253A   原本是冒号
  %252F   原本是斜杠
  %2526   原本是与号

主机名是另一套:IDN 与 Punycode

URL 里有一部分永远不做百分号编码,而它恰恰是最多人以为会做的那一部分。DNS 标签只允许字母、数字和连字符,百分号转义在主机名里根本不合法。因此国际化域名用的是一套完全独立的机制:IDNA —— 先对每个标签做规范化,再用 Punycode 把含非 ASCII 的标签转写成以 xn-- 开头的 ASCII 形式。

转换是按标签进行的,不是按整个域名,所以只有需要转换的标签才会被转换,点号保持原位。münchen.de 变成 xn--mnchen-3ya.de:ASCII 字母按原顺序保留,双连字符之后那段紧凑的后缀编码了非 ASCII 字符该插在哪里。整个域名都是非 ASCII 时则每个标签都要转换,于是 例子.测试 变成 xn--fsqu00a.xn--0zwm56d。

这件事值得关心,有两个超出好奇心的理由。一是比较:浏览器可能显示 Unicode 形式,而日志、证书和配置文件里存的是 xn-- 形式,天真的字符串比较会直接失败。二是安全:由于许多文字系统里存在形似拉丁字母的字符,同形异义域名可以渲染得和正牌域名几乎一模一样。浏览器的缓解手段是:当一个标签可疑地混用了多种文字系统时,直接显示 punycode 形式 —— 所以地址栏里冒出意料之外的 xn--,值得认真读一遍,而不是当成噪声划过去。

Unicode 形式ASCII(Punycode)形式说明
münchen.dexn--mnchen-3ya.de只有第一个标签被转写
bücher.examplexn--bcher-kva.exampleASCII 字母保持原有顺序
例子.测试xn--fsqu00a.xn--0zwm56d每个标签都被转写

要点回顾

  • 在把值放进 URL 的那一刻就编码它,而不是拼好之后再整体编码,因为正确的转义集合取决于分量。
  • 单个值用 encodeURIComponent,只有拿到字符串形式的完整 URI 时才用 encodeURI;对自己拼出来的东西用 encodeURI,说明拼法本身有问题。
  • 记住只有在表单编码下加号才代表空格,所以任何查询值里的字面加号都要转义成 %2B,输出可控时优先用 %20。
  • 把任何「%25 后跟两位十六进制」都当成双重编码的指纹,去修那个多编了一次的层,而不是在末端补一次解码。
  • 主机名用的是 Punycode 而不是百分号编码;地址栏里意料之外的 xn-- 标签是值得核查的信号,不是噪声。

继续了解相关检查与工具