按规范做百分号编码:RFC 3986 的保留字符与非保留字符
URI 标准 RFC 3986 把字符精确地分成两个有意义的集合。非保留集合 — 字母、数字,加上四个符号 - . _ ~ — 可以不经编码出现在 URL 的任何位置;对它们编码合法但毫无意义:%41 和 A 标识同一个东西,URL 归一化器还应把它们解码回来。设计的精髓在保留集合:gen-delims 的 : / ? # [ ] @ 标记 URL 各组件之间的边界,sub-delims 的 ! $ & ' ( ) * + , ; = 在组件内部承载含义。其余一切 — 空格、引号、尖括号、所有非 ASCII 字符 — 根本不能以原样出现,必须按字节百分号编码为 %HH。
关键的微妙之处在于:对保留字符而言,编码形式与原始形式并不等价。原始的 / 分隔路径段;编码后的 %2F 只是长得像斜杠的数据。因此一个字符"是否需要编码"完全取决于它所在的组件:/ 在路径里没问题,但在恰好包含另一个 URL 的查询参数值里必须变成 %2F;? 第一次出现时是结构,之后就只是普通数据。
这也解释了 URL 构造的头号实务规则:永远不要对拼装完成的整个 URL 做编码,因为那根本无法做对 — 一趟处理要么漏掉数据字符,要么摧毁结构字符。先分别编码每个动态值,再拼装。
encodeURIComponent、encodeURI 与 URLSearchParams
JavaScript 内置了三代编码 API,选错是前端代码里最常见的 URL 缺陷。encodeURI 把输入当作完整 URL:它转义那些永远不能出现的字符(空格、引号、非 ASCII),但保留所有结构分隔符 — : / ? # & = 原样通过。这让它对编码"值"毫无用处:喂给它一个含 & 的搜索词,& 会存活下来,把你的参数劈成两半。encodeURIComponent 才是值编码器:除了非保留字符和 ! ' ( ) * 之外全部转义,于是 &、=、/、? 统统变安全。经验法则很简单:你几乎总是需要 encodeURIComponent,并在拼进 URL 之前对每个值分别调用。
两个函数都不是完美的 RFC 3986 公民。encodeURIComponent 把 RFC 归为 sub-delims 的 ! ' ( ) * 留作原样;OAuth 1.0 签名等要求字节级精确的协议,必须再用一趟 replace 补上这五个字符。远古的 escape() 函数根本不该再用 — 它诞生于 UTF-8 普及之前,会毁掉非拉丁文本。而 decodeURIComponent 遇到畸形序列(孤立的 %、%ZZ)会抛出 URIError,所以解码不可信输入要放进 try/catch。
现代答案是彻底跳过手动编码:URL 和 URLSearchParams 类按组件解析、构建、序列化 URL,并自动以正确规则编码每一部分。
const q = 'fish & chips?';
encodeURI(q) // 'fish%20&%20chips?' & and ? survive!
encodeURIComponent(q) // 'fish%20%26%20chips%3F' safe as a value
// The failure mode:
'https://x.io/s?q=' + encodeURI(q)
// → ?q=fish%20&%20chips? server sees TWO params: q and ' chips?'
// The modern way — encoding handled per component:
const u = new URL('https://x.io/s');
u.searchParams.set('q', q);
u.toString() // 'https://x.io/s?q=fish+%26+chips%3F'+ 与 %20:一个空格,两套标准
空格有两种编码,因为两份不同的规范都认领了它。RFC 3986 的百分号编码规定:空格在任何地方都是 %20。但 HTML 表单早于这条规则:application/x-www-form-urlencoded 格式在 1990 年代初为表单提交而定义,至今仍由 WHATWG URL 标准规定,它把空格编码为 +(把字面加号编码为 %2B)。两种编码在各自语境里都是正确的:表单提交和 URLSearchParams 生成的查询串用 +;路径以及一切用 encodeURIComponent 编码的内容用 %20。
缺陷来自语境交叉。经典受害者是合法含有加号的数据:国际格式电话号码(+8210...)和 base64 字符串。不加编码地发送 phone=+82101234567,服务器的表单解码器会把 + 变成空格,得到 ' 82101234567' — 这种污染常常一路活到数据库里才被发现。加号必须以 %2B 发送。反方向上,JavaScript 的 decodeURIComponent 对表单编码一无所知:它把 + 留作字面加号,用它去解码表单编码的查询串,所有空格都会悄然丢失。URLSearchParams 则能正确解码 +。
各语言库把这种分裂刻进了 API:PHP 有 urlencode(表单风格,+)和 rawurlencode(RFC 3986,%20);Python 的 urllib.parse.quote 用 %20,quote_plus 用 +;Java 那个名字误导人的 URLEncoder 是表单编码器,输出 + — 拿它来构造路径是众所周知的错误。拿不准时,输出 %20 更安全:表单解码器两种都接受,路径解析器只认 %20。
// The international phone number bug
fetch('/api/sms?to=+821012345678')
// server-side form decoding: '+' → ' '
// to = ' 821012345678' ← corrupted, silently
fetch('/api/sms?to=%2B821012345678')
// to = '+821012345678' ← correct
// Decoding mismatch:
decodeURIComponent('a+b%20c') // 'a+b c' (+ kept)
new URLSearchParams('x=a+b%20c').get('x') // 'a b c' (+ = space)双重编码:%2520 缺陷及其安全孪生兄弟
百分号编码不是幂等的:跑两遍会得到不同的输出,因为 % 字符本身会被编码成 %25。把空格编码一次得到 %20;把结果再编码一次,% 变成 %25,得到 %2520。当用户最终在页面或邮件主题里看到 "New%20York",说明管线中某处把值编码了两次、却只解码了一次。常见嫌疑人:手写代码先对值调用了 encodeURIComponent,然后框架、HTTP 客户端或模板引擎又编码了一遍 — 或者重定向链上的每一跳都对已编码的 next-URL 参数重新编码。
镜像缺陷更危险。只要任何一层解码超过一次,%2520 就塌缩成 %20,再塌缩成真正的空格 — 攻击者恰恰把这一点武器化。检查路径中 ../ 序列的安全过滤器可以被 %252e%252e%252f 绕过:过滤器检查解码一次后的字符串,看到人畜无害的 %2e%2e%2f 便放行;后面的组件再解码一次,目录穿越就复活了。代理、应用服务器、框架中都有这种形态的真实 CVE,因此安全准则说得很直白:在信任边界恰好解码一次,然后校验解码后的值,校验之后绝不再解码。
同时防住两个方向的工程纪律是"所有权":恰好一层拥有编码权,在 URL 离开你的代码前的最后一刻执行;恰好一层拥有解码权,在请求进入的那一刻执行。如果你在一个本不该嵌套的 URL 里看到 %25 后面跟两位十六进制数,几乎可以断定这是双重编码缺陷 — 逐步解码,数一数要几遍才回到明文。
'New York'
→ encode once: 'New%20York' correct
→ encode twice: 'New%2520York' bug: % became %25
// Path traversal hidden by double encoding:
'../etc/passwd'
→ once: '%2E%2E%2Fetc%2Fpasswd' filter catches %2E%2E%2F
→ twice: '%252E%252E%252Fetc...' filter sees nothing;
second decode revives ../
// Diagnosis: decode until stable, count the passes
decodeURIComponent('New%2520York') // 'New%20York' (pass 1)
decodeURIComponent('New%20York') // 'New York' (pass 2 → double-encoded)URL 中的非 ASCII:UTF-8 转义、乱码与 Punycode
百分号编码作用于字节而非字符,所以非 ASCII 字符在编码前必须先序列化成字节 — 而选哪种字节编码,正是历史留下烂摊子的地方。现代规则由 RFC 3986 阐明、由浏览器实现的 WHATWG URL 标准强制执行:用 UTF-8。韩文音节"한"变成三个字节 ED 95 9C,于是编码为 %ED%95%9C;一个 emoji 可能膨胀成十几个百分号转义。浏览器对用户隐藏这一切 — 地址栏显示解码后的 Unicode,而线上请求携带的是转义序列。
乱码(mojibake)发生在两端对字节序列化意见不一时。用 EUC-KR 或 Latin-1 字节编码、却按 UTF-8 解码的值,会产生垃圾字符或直接解码报错;这在 UTF-8 普及前的网络上是流行病,如今仍会在遗留系统和默认 URI 字符集为 ISO-8859-1 的老 Java Servlet 容器里冒头。更隐蔽的变种是 Unicode 归一化:café 可以序列化为一个预组合码点(NFC),也可以是 e 加组合重音(NFD),两种形式百分号编码出不同的字节序列,字符串比较时无法匹配。编码前先归一化为 NFC。
主机名遵循完全不同的规则手册:host 组件里根本不允许百分号编码。国际化域名走的是 IDNA,把 Unicode 标签转换为 ASCII 兼容的 Punycode 形式 — 한글.example 变成 xn--bj0bj06e.example。证书或日志里的 xn-- 前缀不是数据损坏,而是 Unicode 域名的 DNS 安全拼写;也正因近似字符钓鱼(IDN 同形异义攻击)的存在,浏览器会把可疑的混合文字主机名以 Punycode 形式显示。