百分号编码检测器

百分号编码不是一条规则,而是好几条,它们在大约十几个字符上彼此冲突。本页把同一段文本分别交给 encodeURIComponent、encodeURI 与 application/x-www-form-urlencoded 序列化并排跑一遍,标出它们意见不一致的字符,逐字符打印一行(含其 UTF-8 字节与 RFC 3986 分类),并说明一个字符串被编码了几次。解码一侧用严格或宽松规则读取字符串,告诉你哪个转义在哪里出了问题,而不是悄悄返回一个错误结果。
所有处理都在你的浏览器本地完成——你的数据绝不会上传到任何服务器。

不确定百分号编码是什么?用大白话解释百分号编码——它是什么、为什么空格会变成 %20 或 +、以及 URL 里出现 %25 意味着什么。
它的作用:
如何读取这些转义:
encodeURIComponent
encodeURI
表单(application/x-www-form-urlencoded)
在第一个输入框里按 Ctrl+Enter 会执行当前模式。
选一个模式并点击它的执行按钮。编码并检测展示文本的三种编码,外加每个字符一行;解码把转义读回来并统计层数。「自检」按钮会重跑每一个记录样本、每一个可打印 ASCII 字符,以及本页数据文件里记录的层链,并把计数报在这里。
编码器们在哪里不一致:只有下面这些字符会被三种方法区别对待。其余的一切,要么在三个编码器里都原样通过,要么被三个编码器以完全相同的方式转义。
每个字符一行:一个字符变成的 UTF-8 字节、RFC 3986 把它归入的类属,以及每个上下文写出的转义。四个上下文列是 WHATWG URL 的百分号编码集合,也就是浏览器构建路径、查询或片段时使用的那几组;灰底行表示三个编码器对它处理不同。
本页为 URL 各部分转义的确切集合:直接从编码器本身打印出来,所以这张表与填充上面几列的代码不可能脱节。每个集合还会转义 C0 控制字符(U+0000 到 U+001F)以及所有高于 U+007E 的码位,这正是非 ASCII 文本在任何上下文里都会被转义的原因。
这些集合由 WHATWG 的百分号编码集合推导而来,并在两处刻意与之不同,两处都让手工组装更安全:查询里的井号会被转义,因为裸井号会开启片段;component 集合被钉死为 encodeURIComponent 所转义的那些字符(包括撇号),而 form 集合则被钉死为 URLSearchParams 所转义的那些。所以上面表格里的两列是平台自身的行为,而不是对它的一次重新实现。这份清单还解释了一个让人意外的结果:百分号会被 encodeURIComponent 与 encodeURI 转义,但它不在 WHATWG 的任何上下文集合里,所以逐字符表的 path、query、fragment 三列会放过它。你手工组装的 URL 里如果有一个裸百分号,仍然有风险,因为标准要求输入事先经过校验:查询中的 %41 对许多服务器框架来说就是单个 A,而本页自己的严格解读会把孤立的 % 视为损坏的转义。自己把它写成 %25 是更安全的习惯,逐字符表会并排展示这两种写法。
这段字符串被编码了多少次:每一轮解码就是一层。被编码过两次的值以%25而单次编码会留下%20,而一个被编码过三次的值则以%2525.
URL 可以原样承载的字符:RFC 3986 把 ASCII 范围分成三组,而转义规则只有对着这个划分才说得通。
保留字符不是被禁止,而是被占用了:它们对 URL 解析器有意义。从这份清单得出的规则很短 —— 如果一个字符是保留字符而你把它当数据用,就转义它;如果它是非保留字符,你就永远不需要转义。
三种方法,以及各自原样保留了什么:
这些清单就是每种方法在 ASCII 范围内原样保留的确切集合;自检会针对它们核对每一个可打印 ASCII 字符,所以这张表不可能与它所描述的编码器脱节。
本页能接受多大的输入,以及为什么:百分号编码只是对文本的一次扫描,所以与本站点上那些进制转换器不同,开销是直线增长而不是平方增长:不存在某个长度会让算术本身变成问题。下面的上限之所以存在,是因为逐字符表是画在文档里的,而一张有一百万行的表才是真正能拖死浏览器的东西。
什么是百分号编码?

百分号编码 —— 也叫 URL 编码,在较旧的文档里叫转义 —— 是让 URL 能够承载它本不允许包含的字符的那套规则。一个百分号转义是三个 ASCII 字符:一个百分号加两个十六进制数字,代表一个字节。%20 是字节 0x20,也就是空格。%2F 是字节 0x2F,也就是斜杠。不涉及别的任何东西:转义命名的是字节,不是字符。

因为它命名的是字节,所以文本必须先被转成字节,而字节编码就是 UTF-8。长的转义就是这么来的:一个汉字是三个 UTF-8 字节,因此是三个转义;一个 emoji 是四个字节,因此是十二个 ASCII 字符。

30 秒版本:空格作为 URL 值是 %20,在表单数据里则是 +;# 是 %23;字面百分号是 %25。本页上的三个编码器对字母、数字以及 -_.~ 意见一致,却对大约十来个标点字符意见不一致 —— 这正是 encodeURIComponent 与 encodeURI 对同一段文本产生不同字符串的原因,也正是用错其中一个会把一个值拆成两个查询参数的原因。
为什么 URL 根本需要它

一个 URI 被定义为一串 ASCII 字符(RFC 3986 第 2.1 节),而在这个 ASCII 范围内,有些字符是保留的:它们就是赋予 URL 结构的那些标点。冒号加两条斜线分隔协议,@ 分隔用户信息与主机,问号开启查询,# 开启片段,& 分隔查询参数,而百分号则引入一个转义。

这既造成了一个问题,也留下了一个后门。问题是:一个本身就含有这些字符的值 —— 含有 "a&b" 的搜索框、含有斜杠的文件名、含有冒号的密码 —— 本来会改变它所在 URL 的结构。后门则是百分号本身,于是编码器可以直接扩展自己的字母表。% 写成 %25,而它后面那两个十六进制数字是普通的非保留字符。

可用的示例

这些是本页运行的三个编码器的确切输出,输入之所以这样选,是因为它们彼此表现不同。本页会在你面前重新计算它们,而它的自检会针对平台自带的实现重新核对每一行。

三种方法,以及各自该在什么时候用

能省掉大多数 bug 的简短版本:要放进单个参数值的任何东西,都经过 encodeURIComponent(或者经过表单序列化器,它用不同的安全集合做同一件事),而已经组装好的完整 URL 如果需要转义,才经过 encodeURI。抓错这两个中的一个,是 Web 代码里最常见的百分号编码错误。

URL 可以承载的字符,完整版
两个加号,两种含义

空格是唯一一个编码方式取决于文本去向的字符,而加号就是原因。

陷阱就藏在那张表的中间。在表单数据里,加号表示空格,所以一个真的含有加号的值 —— 写成 +44 20 7946 0958 的电话号码、一个算术表达式、一个带加号标签的邮箱地址 —— 必须以 %2B 的形式传输。如果以字面加号传输,接收方读回来就是空格,+44 会变成“ 44”。

被编码了两次,以及怎么认出来

二次编码发生在一段字符串已经被转义、然后又被转义一次的时候。第一次转义里的百分号也是一个普通字符,所以它会变成 %25,而下面那一层就以可见文本的形式存活下来。

认出它靠的是数:把字符串解码一次,看结果。如果里面仍有看起来是刻意留下的转义 —— 一个合法的 %XX,或者本该是空格的位置上有一个表单加号 —— 那就还有一层。如果里面含有 %25 后跟两个十六进制数字,那么下一层一定是转义。

两点实用提示。第一,解码两次并非无害:这是一个让安全过滤器与它后面的代码看到不同字符串的老办法,所以在一个过滤器之后再解码一次,那过滤器过滤的就是错的文本。第二,这里的层数是字符串自身的属性,不是对意图的承诺:一个合法的值可以只有一层深,而一个坏了的值可以有两层。

本页要抓的那些错误
  • 用 encodeURI 编码的值。那些分隔符会存活下来,所以一个含有 & 或 = 的值,在另一端会被拆成好几个参数。把输入 a&b=c 在本页顶部的两列上对比一下:component 列会转义这两个字符,encodeURI 列则保留它们。
  • 作为表单数据发送的字面加号。它到达时是一个空格。请用 %2B。
  • 「为了保险」而被转义两次的值。它并不会更安全。接收方解码一次,得到的字符串里仍然含有百分号转义 —— 这与字符集错误是同一种失败,只是深了一层。
  • 解码从未被编码过的东西。文本里的一个字面百分号 —— 100% sure —— 并不是转义。严格读取下它是一个损坏的转义,位置会由本页报告;宽松读取下它则能存活,这也正是这里默认是宽松而不是相反的原因。
  • 以为转义必须大写。解码把 %2f 与 %2F 当成同一个字节,两者都合法。大写只是 RFC 3986 推荐的规范写法,也是其他工具会产生的那种。
  • 以为百分号编码能让文本变安全。它只是让文本在 URL 里不含歧义。它挡不住注入,不是加密,也不是净化器:转义必须是组装之前的最后一步,而且必须针对文本将要进入的那个上下文。
快速问答

「URL 里的加号是空格吗?」

只在表单数据里,而且只在表单提交的查询部分。在路径、片段,以及普通链接的查询里,加号就是字面加号。这正是本页为它准备一个开关、而不是去猜的原因。

「为什么我的链接里显示 %25?」

因为有东西把一个百分号编码了。要么是一个字面百分号经过了编码器 —— 100% sure 变成 100%25%20sure —— 要么是一段已经含有转义的字符串被第二次编码。把字符串解码一次,看看回来的是什么:如果结果里仍有 %XX,就还有一层。

「查询值里的斜杠写成 %2F 还是 / 有关系吗?」

对 URL 解析器来说没关系:两者到达服务器时都是斜杠,而 WHATWG 的查询集合会放过斜杠。对互操作性来说有时有关系:不少服务端框架会对裸查询串跑自己的解码,其中少数对从未被转义过的字符意见不一致。当一个值是手工组装而不是由序列化器产生时,显式转义保留字符是更安全的习惯。

「为什么表单列会转义另外两个都放过的波浪号?」

因为表单编码器的安全集合更窄:只有字母、数字和 -_.*。WHATWG 表单编码器会转义 ~ ! ' ( ),尽管 RFC 3986 把 ~ 列为非保留字符。两种写法解码后是同一个值;差别只在逐字节比较字符串时才重要。

「同样的字符能以未转义的形式出现在浏览器地址栏里吗?」

是的,但那是地址栏,不是 URL。浏览器会显示 URL 的可读形式,并在发送时把它编码回去,所以链接里的 中文 在线路上会变成 %E4%B8%AD%E6%96%87。把 URL 从地址栏里拷出来,你会拿到哪一种,取决于浏览器。

「这里的每个转义看起来都合法,但出来的字符却不是我预期的那个。为什么?」

「解码错了」这句话背后藏着两种不同的失败,而本页对它们的报告方式不同。第一种是损坏的转义:一个后面没有跟两个十六进制数字的百分号,比如结尾的 100% 或 %zz。那是语法问题,严格读取会拒绝它并说明它在哪里,宽松读取则把它作为字面文本保留并列出。第二种是字节本身完全合法、但那些字节拼不出合法 UTF-8 字符的转义,比如单独的 %FF,或者被截断成 %E4%B8 的三字节字符。那不是语法问题,所以两种读取都会返回文本,把无法解码的字节替换成替换字符 U+FFFD("�"),并列出一条说明这一点的警告。警告才是有用的部分:以替换字符形式到达的文本已经不是原文了。

「本页不做什么?」

它不会把 URL 拆成协议、主机、路径和查询 —— 那是URL 解析器的活。它也不检查一条链接是否安全可打开,不联系任何东西,也不去猜转义背后字节的字符集:它一律按 UTF-8 读取,这正是 URL 标准的要求。

「解码需要哪一种兜底,我又怎么知道它是对的?」

按自检。它会把本页自己的编码器与平台自带的 encodeURIComponent、encodeURI 和 URLSearchParams 就每一个可打印 ASCII 字符做对比,重跑上面表格里的每一个示例,并重新核对记录下来的层链,然后把检查项数报在这里。

标准与适用范围

百分号编码规定于 RFC 3986 第 2.1 节,保留与非保留字符的定义在第 2.2 与 2.3 节,大小写规则在第 6.2.2.1 节。浏览器构建 URL 时实际使用的集合在 WHATWG URL 标准里,它按上下文分成几组:片段一组、查询一组、特殊查询一组、路径一组、用户信息一组,还有一个用于单个组件的更窄的集合。表单提交(包括加号)在 HTML 标准的 application/x-www-form-urlencoded 部分。而 encodeURI 与 encodeURIComponent 这两个函数属于 ECMAScript 本身。

本页用自己逐上下文的集合填充 path、query、fragment 三列,并在结果旁边打印每个上下文的确切清单。这些集合由 WHATWG 的集合推导而来,并在两处刻意与之不同:查询里的 # 会被转义,因为裸 # 会开启片段;而 component 集合被钉死为 encodeURIComponent 会转义的那些字符。凡是平台函数本身就是答案定义的地方 —— encodeURIComponent、encodeURI 与表单序列化 —— 本页就直接调用那个函数,所以那几列是浏览器自己的行为,而不是对它的一次重新实现。

本页正文由本站撰写。表格里的每个值都由本页自己的代码在加载时生成,并已在本站的测试工具中与平台实现核对;自检会在你的浏览器里重复这一核对。