问错了问题
大多数 URL 编码介绍都从"哪些字符需要编码?"开始。这是错误的问题。正确的问题是:我正在构造哪个 URI 组件,该组件的编码规则是什么?
同一个字符——斜杠 (/)、加号 (+)、冒号 (:)——在一个组件中是合法的字面量,在另一个组件中则必须被百分号编码。不存在通用的"安全"或"不安全"字符列表。上下文决定编码。
URI 语法:五个组件,各有规则
RFC 3986 将 URI 定义为五个组件:
scheme://authority/path?query#fragment
\____/ \_______/ \__/ \___/ \______/
| | | | |
scheme authority path query fragment
每个组件都有自己的语法和允许字面出现的字符集。
各组件允许的字面字符
| 组件 | 除非保留字符外允许字面出现的字符 |
|---|---|
| scheme | +, -, .(首字符之后) |
| userinfo | :(密码分隔符——已废弃) |
| host | [, ](IPv6), :(端口分隔) |
| path | /(段分隔符), :, @ |
| query | /, ?, :, @, !, $, &, ', (, ), *, +, ,, ;, = |
| fragment | 与 query 相同 |
非保留字符集(永远是字面量,永远不需要编码):
A-Z a-z 0-9 - . _ ~
其他所有字符——包括在某个组件中是字面量的字符——当它作为数据而非分隔符出现在特定组件中时,都必须被百分号编码。
后果
/ 在路径中是字面量(它分隔路径段)。但如果你的路径段的值包含一个字面斜杠(例如文件名 reports/2024),该斜杠必须编码为 %2F 以防止解析器创建额外的段:
正确: /files/reports%2F2024
解析: 段 "files",段 "reports/2024"
错误: /files/reports/2024
解析: 段 "files",段 "reports",段 "2024"
百分号编码机制
百分号编码将每个八位字节(字节)替换为 % 加两位大写十六进制数字:
空格 (0x20) → %20
é (UTF-8: 0xC3 0xA9) → %C3%A9
🚀 (UTF-8: 0xF0 0x9F 0x9A 0x80) → %F0%9F%9A%80
非 ASCII 字符的编码管线:
- 将字符编码为 UTF-8 字节(自 RFC 3987/IRI 以来这是公认的标准)
- 对不在目标组件允许集中的每个字节进行百分号编码
这个两步过程解释了为什么 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) - 星号
*不编码(历史原因) - 波浪号
~编码行为因实现版本而异
// 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
encodeURIComponent(value) // RFC 3986 组件编码
encodeURI(fullUrl) // 编码空格但不编码 :/?#[]@!$&'()*+,;=
new URLSearchParams({k: value}) // x-www-form-urlencoded
# 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
import "net/url"
url.PathEscape(value) // RFC 3986 路径段编码
url.QueryEscape(value) // x-www-form-urlencoded(空格 → +)
(&url.URL{RawQuery: ...}).String() // 完整 URL 构造
// 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(保留结构分隔符)。它不编码:
: / ? # [ ] @ ! $ & ' ( ) * + , ; =
这意味着 encodeURI 不能用于编码参数值——值中的 & 或 = 不会被编码:
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:
// 错误:连 scheme 和 path 中的 : 和 / 都被编码了
encodeURIComponent("https://example.com/path")
// → "https%3A%2F%2Fexample.com%2Fpath"
正确模式:分别编码每个组件值,然后用字面分隔符拼装。
双重编码:静默腐蚀
双重编码发生在已编码字符串被再次编码时:
原始: "hello world"
单次编码: "hello%20world"
双重编码: "hello%2520world" ← %25 是 % 的编码
一次解码后得到 hello%20world(仍是编码态)。两次解码才得到 hello world。当编码/解码次数不匹配时 bug 就会出现。
常见原因
- 框架自动编码:你手动编码了参数,然后传给一个会再次编码的库
- 日志/调试:从日志中复制已编码的值,粘贴到会再次编码的代码中
- 重定向链:每个重定向处理器都对目标 URL 编码
检测方法
如果你在 URL 中看到 %25 后跟两位十六进制数字,几乎可以确定是双重编码。%25 意味着字面 % 被编码了,说明原来的 %XX 序列被当作数据而非编码处理。
URL 规范化与比较
RFC 3986 §6 定义了 URI 比较的规范化规则。看起来不同的两个 URI 可能是等价的:
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——如果服务器在路径边界检查之前解码,就会发生遍历
解码顺序漏洞
经典攻击模式:
- 攻击者发送
%252e%252e%252fetc%252fpasswd - 第一次解码(如负载均衡器):
%2e%2e%2fetc%2fpasswd - 第二次解码(如应用层):
../../etc/passwd
负载均衡器的路径检查没有看到 ../,因为它还是双重编码态。应用解码了两次,得到了遍历路径。
防御:只在你解释值的那个点解码一次。永远不要对已经解码的值再次解码。
通过编码的开放重定向
https://trusted.com/redirect?url=https%3A%2F%2Fevil.com
如果重定向处理器解码 url 参数并不经验证就重定向,用户就会跳转到 evil.com。编码使人类更难在原始 URL 中发现恶意目标。
IRI、Punycode 与浏览器显示
浏览器使用复杂的渲染管线显示 URL:
- IRI (RFC 3987):允许 Unicode 字符出现在显示的 URL 中
- Punycode (RFC 3492):将 Unicode 域名编码为 ASCII 兼容形式(
münchen.de→xn--mnchen-3ya.de) - 显示启发式:浏览器为了展示会解码百分号编码字符,但传输时会重新编码
这意味着地址栏中看到的 URL 不一定是浏览器实际发送的:
显示: https://example.com/路径/文件
传输格式: https://example.com/%E8%B7%AF%E5%BE%84/%E6%96%87%E4%BB%B6
对于域名,编码使用 Punycode(不是百分号编码):
显示: https://münchen.de/
传输格式: https://xn--mnchen-3ya.de/
这个区分对钓鱼检测很重要:视觉相似的 Unicode 字符(同形文字)可以创建看起来像可信网站但实际解析到不同服务器的域名。
总结
URL 编码不是"把特殊字符替换成百分号代码"。它是一个上下文相关的操作,受 RFC 3986 组件语法约束。同一个字符在 scheme、authority、path 段、query 参数键、query 参数值和 fragment 中需要不同的处理。
两个最常见的 bug:
- 对上下文使用了错误的编码标准(RFC 3986 vs form-urlencoded)
- 编码或解码了错误的次数(双重编码、遗漏解码)
这两种 bug 都是静默的——它们产生看起来有效的 URL,只在特定边缘情况(参数值包含 +、&、=、/ 或 %)下才会失败。