问错了问题

大多数 URL 编码介绍都从"哪些字符需要编码?"开始。这是错误的问题。正确的问题是:我正在构造哪个 URI 组件,该组件的编码规则是什么?

同一个字符——斜杠 (/)、加号 (+)、冒号 (:)——在一个组件中是合法的字面量,在另一个组件中则必须被百分号编码。不存在通用的"安全"或"不安全"字符列表。上下文决定编码

URI 语法:五个组件,各有规则

RFC 3986 将 URI 定义为五个组件:

code
  scheme://authority/path?query#fragment
  \____/   \_______/ \__/ \___/ \______/
    |          |       |     |      |
  scheme   authority  path  query  fragment

每个组件都有自己的语法和允许字面出现的字符集。

各组件允许的字面字符

组件 除非保留字符外允许字面出现的字符
scheme +, -, .(首字符之后)
userinfo :(密码分隔符——已废弃)
host [, ](IPv6), :(端口分隔)
path /(段分隔符), :, @
query /, ?, :, @, !, $, &, ', (, ), *, +, ,, ;, =
fragment 与 query 相同

非保留字符集(永远是字面量,永远不需要编码):

code
A-Z  a-z  0-9  -  .  _  ~

其他所有字符——包括在某个组件中是字面量的字符——当它作为数据而非分隔符出现在特定组件中时,都必须被百分号编码。

后果

/ 在路径中是字面量(它分隔路径段)。但如果你的路径段的包含一个字面斜杠(例如文件名 reports/2024),该斜杠必须编码为 %2F 以防止解析器创建额外的段:

code
正确: /files/reports%2F2024
解析: 段 "files",段 "reports/2024"

错误: /files/reports/2024
解析: 段 "files",段 "reports",段 "2024"

百分号编码机制

百分号编码将每个八位字节(字节)替换为 % 加两位大写十六进制数字:

code
空格 (0x20) → %20
é (UTF-8: 0xC3 0xA9) → %C3%A9
🚀 (UTF-8: 0xF0 0x9F 0x9A 0x80) → %F0%9F%9A%80

非 ASCII 字符的编码管线:

  1. 将字符编码为 UTF-8 字节(自 RFC 3987/IRI 以来这是公认的标准)
  2. 对不在目标组件允许集中的每个字节进行百分号编码

这个两步过程解释了为什么 encodeURIComponent("中文") 产生 %E4%B8%AD%E6%96%87——它是"中"的三个 UTF-8 字节和"文"的三个字节,分别被百分号编码。

查询字符串的两套标准

这是最常见的 bug 来源:查询字符串值有两套不同的编码标准

RFC 3986 百分号编码

使用者:URI 规范、encodeURIComponent()、Go net/url、Python urllib.parse.quote()

  • 空格 → %20
  • 所有保留字符在作为数据时都被编码

application/x-www-form-urlencoded

使用者:HTML 表单提交、URLSearchParams、Java URLEncoder、Python urllib.parse.urlencode()

  • 空格 → +(不是 %20
  • 星号 * 不编码(历史原因)
  • 波浪号 ~ 编码行为因实现版本而异
javascript
// RFC 3986 风格
encodeURIComponent("hello world")  // "hello%20world"

// 表单编码风格
new URLSearchParams({q: "hello world"}).toString()  // "q=hello+world"

两者在各自的上下文中都是正确的。Bug 发生在你混用它们时:用 encodeURIComponent 构造查询字符串(产生 %20),但用表单解析器解码(将 + 当作空格、%20 当作字面 %20),或者反过来。

何时用哪个

上下文 标准 空格编码
从零构建 URI RFC 3986 %20
HTML 表单 GET 提交 x-www-form-urlencoded +
fetch() body + Content-Type: application/x-www-form-urlencoded x-www-form-urlencoded +
OAuth 签名基字符串 RFC 3986 %20
AWS Signature V4 RFC 3986(有特定规范化) %20

跨语言 API 对比

同一个任务——编码查询参数值——在不同语言中 API 行为不同:

javascript
// JavaScript
encodeURIComponent(value)       // RFC 3986 组件编码
encodeURI(fullUrl)              // 编码空格但不编码 :/?#[]@!$&'()*+,;=
new URLSearchParams({k: value}) // x-www-form-urlencoded
python
# Python
from urllib.parse import quote, urlencode

quote(value)                    # RFC 3986,默认 safe='/'
quote(value, safe='')           # RFC 3986,编码除非保留字符外的一切
urlencode({'k': value})         # x-www-form-urlencoded(空格 → +)
go
// Go
import "net/url"

url.PathEscape(value)           // RFC 3986 路径段编码
url.QueryEscape(value)          // x-www-form-urlencoded(空格 → +)
(&url.URL{RawQuery: ...}).String() // 完整 URL 构造
java
// Java
import java.net.URLEncoder;
import java.net.URI;

URLEncoder.encode(value, StandardCharsets.UTF_8)  // x-www-form-urlencoded(空格 → +)
// Java 没有内置的 RFC 3986 编码器——必须手动将 + 替换为 %20
// 或使用 URI 构造器(在构造期间编码)
new URI("https", "example.com", "/path", "q=" + value, null).toASCIIString()

注意 Java 陷阱:URLEncoder 产生的是表单编码,不是 URI 编码。如果你用它构建 URI 路径,空格会变成 +,而 + 在路径上下文中是字面加号。

encodeURI 与 encodeURIComponent:何时各自错误

encodeURI 设计用于编码完整 URI(保留结构分隔符)。它不编码:

code
: / ? # [ ] @ ! $ & ' ( ) * + , ; =

这意味着 encodeURI 不能用于编码参数值——值中的 &= 不会被编码:

javascript
const value = "a=1&b=2";

// 错误:& 和 = 未编码,创建了幽灵参数
encodeURI("https://api.example.com/search?filter=" + value)
// → "https://api.example.com/search?filter=a=1&b=2"
// 解析为:filter=a, 额外参数:1&b=2

// 正确:单独编码值
"https://api.example.com/search?filter=" + encodeURIComponent(value)
// → "https://api.example.com/search?filter=a%3D1%26b%3D2"

encodeURIComponent 不能用于编码完整 URL

javascript
// 错误:连 scheme 和 path 中的 : 和 / 都被编码了
encodeURIComponent("https://example.com/path")
// → "https%3A%2F%2Fexample.com%2Fpath"

正确模式:分别编码每个组件值,然后用字面分隔符拼装。

双重编码:静默腐蚀

双重编码发生在已编码字符串被再次编码时:

code
原始:     "hello world"
单次编码: "hello%20world"
双重编码: "hello%2520world"  ← %25 是 % 的编码

一次解码后得到 hello%20world(仍是编码态)。两次解码才得到 hello world。当编码/解码次数不匹配时 bug 就会出现。

常见原因

  1. 框架自动编码:你手动编码了参数,然后传给一个会再次编码的库
  2. 日志/调试:从日志中复制已编码的值,粘贴到会再次编码的代码中
  3. 重定向链:每个重定向处理器都对目标 URL 编码

检测方法

如果你在 URL 中看到 %25 后跟两位十六进制数字,几乎可以确定是双重编码。%25 意味着字面 % 被编码了,说明原来的 %XX 序列被当作数据而非编码处理。

URL 规范化与比较

RFC 3986 §6 定义了 URI 比较的规范化规则。看起来不同的两个 URI 可能是等价的:

code
http://EXAMPLE.COM/a%2f → http://example.com/a%2f  (大小写规范化)
http://example.com/%41   → http://example.com/A     (解码非保留字符)
http://example.com/a/../b → http://example.com/b    (路径规范化)
http://example.com:80/   → http://example.com/      (默认端口移除)

重要:%2F(编码斜杠)和 /(字面斜杠)在路径上下文中不等价。服务器可能将 /a%2Fb/a/b 路由到不同的处理器。规范化规则不允许跨组件边界解码保留字符。

安全边界

百分号编码不是消毒

URL 编码确保的是语法正确性,它不能防止:

  • SQL 注入'; DROP TABLE-- 百分号编码后是 %27%3B%20DROP%20TABLE--。服务器解码后,payload 完整无损。
  • XSS<script>alert(1)</script> 能无损通过编码/解码往返
  • 路径遍历..%2F..%2Fetc%2Fpasswd——如果服务器在路径边界检查之前解码,就会发生遍历

解码顺序漏洞

经典攻击模式:

  1. 攻击者发送 %252e%252e%252fetc%252fpasswd
  2. 第一次解码(如负载均衡器):%2e%2e%2fetc%2fpasswd
  3. 第二次解码(如应用层):../../etc/passwd

负载均衡器的路径检查没有看到 ../,因为它还是双重编码态。应用解码了两次,得到了遍历路径。

防御:只在你解释值的那个点解码一次。永远不要对已经解码的值再次解码。

通过编码的开放重定向

code
https://trusted.com/redirect?url=https%3A%2F%2Fevil.com

如果重定向处理器解码 url 参数并不经验证就重定向,用户就会跳转到 evil.com。编码使人类更难在原始 URL 中发现恶意目标。

IRI、Punycode 与浏览器显示

浏览器使用复杂的渲染管线显示 URL:

  1. IRI (RFC 3987):允许 Unicode 字符出现在显示的 URL 中
  2. Punycode (RFC 3492):将 Unicode 域名编码为 ASCII 兼容形式(münchen.dexn--mnchen-3ya.de
  3. 显示启发式:浏览器为了展示会解码百分号编码字符,但传输时会重新编码

这意味着地址栏中看到的 URL 不一定是浏览器实际发送的:

code
显示:     https://example.com/路径/文件
传输格式: https://example.com/%E8%B7%AF%E5%BE%84/%E6%96%87%E4%BB%B6

对于域名,编码使用 Punycode(不是百分号编码):

code
显示:     https://münchen.de/
传输格式: https://xn--mnchen-3ya.de/

这个区分对钓鱼检测很重要:视觉相似的 Unicode 字符(同形文字)可以创建看起来像可信网站但实际解析到不同服务器的域名。

总结

URL 编码不是"把特殊字符替换成百分号代码"。它是一个上下文相关的操作,受 RFC 3986 组件语法约束。同一个字符在 scheme、authority、path 段、query 参数键、query 参数值和 fragment 中需要不同的处理。

两个最常见的 bug:

  1. 对上下文使用了错误的编码标准(RFC 3986 vs form-urlencoded)
  2. 编码或解码了错误的次数(双重编码、遗漏解码)

这两种 bug 都是静默的——它们产生看起来有效的 URL,只在特定边缘情况(参数值包含 +&=/%)下才会失败。