引言
URL 是 Web 的基本寻址单位,也是最容易被「想当然」处理的东西。?a=1&b=2 看似简单,但空格到底该编成 %20 还是 +?中文域名怎么表示?http://evil.com#@good.com 的主机是谁?这些问题的答案散落在 RFC 3986、WHATWG URL 标准和各语言的实现差异里。
本文从 URL 的规范结构讲起,深入到百分号编码的每个细节,再落到解析陷阱与安全防御。读完你会明白:为什么「用字符串拼接 URL」几乎总是错的。
相关:curl 与 HTTP 调试实战 、HTTP 状态码与请求语义 。字符编码基础见 Unicode 与字符编码 。
1. URL 的规范结构
1.1 RFC 3986 的五段式
foo://example.com:8042/over/there?name=ferret#nose
\_/ \______________/\_________/ \_________/ \__/
| | | | |
scheme authority path query fragment
| _____________________|__
/ \ / \
urn:example:animal:ferret:nose
| 组成部分 | 说明 | 是否区分大小写 |
|---|---|---|
| scheme(方案) | http、https、ftp、mailto | 不区分 |
| authority(授权) | userinfo@host:port | host 不区分,path 区分 |
| path(路径) | /over/there,由 / 分隔的段 | 区分 |
| query(查询) | ? 之后、# 之前 | 区分 |
| fragment(片段) | # 之后,不发给服务器 | 区分 |
关键认知:fragment 从不参与网络请求。它只被浏览器和客户端使用,服务端永远看不到 # 后面的内容。这就是为什么 SPA 的路由有时用 #(hash 路由)——它能避免整页刷新。
1.2 authority 的细分
userinfo @ host : port
| | | |
user:pass | | |
example.com 8042
- userinfo 用于
ftp://user:pass@host这类场景,HTTP 中已被废弃(浏览器会剥离并告警)。 - host 可以是 IPv4、IPv6(方括号包裹
[::1])、或域名。 - port 省略时用 scheme 默认端口(http 80、https 443)。
1.3 通用语法与语法图
RFC 3986 用 ABNF(巴科斯范式的一种扩展)定义:
URI = scheme ":" hier-part [ "?" query ] [ "#" fragment ]
scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )
authority = [ userinfo "@" ] host [ ":" port ]
这里的语法描述方式可参考 巴科斯范式 。
2. 百分号编码(Percent-Encoding)
2.1 基本原理
百分号编码把不能直接出现的字节写成 % 加两个十六进制数字。注意它编码的是字节,不是字符——所以先要确定字符用哪种字符编码(URL 中默认 UTF-8)。
"中" 的 UTF-8 字节是 E4 B8 AD
→ %E4%B8%AD
2.2 三种字符分类
RFC 3986 把字符分成三类:
| 类别 | 字符 | 是否需编码 |
|---|---|---|
| unreserved(未保留) | A-Z a-z 0-9 - . _ ~ | 不编码 |
| reserved(保留) | :/?#[]@!$&'()*+,;= | 视位置 |
| 其他 | 空格、中文、控制字符等 | 必须编码 |
unreserved 集合是编码的「安全区」:这些字符编码与否语义相同(%41 等价于 A),规范化时应解码还原。
2.3 reserved 字符的语义
reserved 字符是 URL 的「标点」,改变结构:
- 通用分隔符:
:/?#[]@ - 子分隔符:
!$&'()*+,;=
当 reserved 字符出现在数据中(而非结构位置)时,必须编码。例如路径段里含 /,要写成 %2F:
/data/a%2Fb → 一个段 "a/b"
/data/a/b → 两个段 "a"、"b"
这个区别是路径穿越漏洞的根源之一。
2.4 编码时的字节选择
from urllib.parse import quote, unquote
# 默认安全字符是 '/',会保留路径分隔符
print(quote("/path/to/文件")) # /path/to/%E6%96%87%E4%BB%B6
# 全部编码,连 '/' 也不放过
print(quote("/path/to/文件", safe="")) # %2Fpath%2Fto%2F%E6%96%87%E4%BB%B6
print(unquote("%E6%96%87%E4%BB%B6")) # 文件
编码什么、保留什么,是语义决策,不是格式化决策。编码一个「完整的 URL」和编码一个「路径段」要用不同的 safe 集合。
3. query 与 form 编码的差异
这是最容易混淆的一对概念。
3.1 application/x-www-form-urlencoded
HTML 表单提交时用这种编码,关键区别:空格编成 +,且 + 需要转义为 %2B。
name=张 三&city=北京
→ name=%E5%BC%A0+%E4%B8%89&city=%E5%8C%97%E4%BA%AC
3.2 URL query 中的空格
RFC 3986 规定 query 中空格应编成 %20。但实践中,+ 表示空格被大量服务器接受(因为历史沿用了 form 编码)。这导致解析歧义:
| 上下文 | 空格 | 字面 + |
|---|---|---|
| 标准 URL query | %20 | %2B |
| form-urlencoded | + 或 %20 | %2B |
3.3 解析侧的对策
from urllib.parse import parse_qs, urlparse
url = "https://x.com/s?q=a+b&lang=c%2B%2B"
q = parse_qs(urlparse(url).query)
print(q) # {'q': ['a b'], 'lang': ['c++']}
parse_qs 默认把 + 当空格。如果你明确知道 query 里 + 是字面加号(如搜索引擎场景),需要 parse_qs(..., keep_blank_values=True) 并自行处理,或对输入先做 %2B 替换。永远不要假设 + 的含义,要看数据来源。
3.4 重复键与顺序
?a=1&a=2&a=3
- 有的语言解析成数组
[1,2,3](Pythonparse_qs)。 - 有的取最后一个(PHP
$_GET默认)。 - 有的取第一个。
需要多值时用 a[]=1&a[]=2(PHP 风格)或 a=1&a=2(需服务端支持)。跨系统传参时,重复键的行为必须显式约定。
4. 解析陷阱
4.1 相对引用与点段移除
base = "http://a/b/c/d;p?q"
"g" → http://a/b/c/g
"../g" → http://a/b/g
"./g" → http://a/b/c/g
"/g" → http://a/g
"//g" → http://g
"?y" → http://a/b/c/d;p?y
"" → http://a/b/c/d;p?q
相对引用解析遵循 RFC 3986 第 5 节的算法,其中「点段移除(remove_dot_segments)」会消除 . 和 ..。这一步是路径穿越防御的关键,但注意:只在路径段层面移除,不会解码 %2e%2e——/a/%2e%2e/b 的点段在解码前不参与移除。
4.2 用户信息与主机混淆
http://trusted.com@evil.com/
这个 URL 的主机是 evil.com,trusted.com 只是 userinfo。钓鱼攻击常用这种形式。判断主机时必须用解析器的 host 字段,不能靠肉眼。
4.3 IDN 与 Punycode
国际化域名(IDN,Internationalized Domain Name)用 Punycode 编码非 ASCII 域名:
例子.测试 → xn--fsqu00a.xn--0zwm56d
print("例子.测试".encode("idna")) # b'xn--fsqu00a.xn--0zwm56d'
安全风险是同形异义字(homograph)攻击:аpple.com 里的 а 是西里尔字母,与拉丁 a 视觉相同。浏览器会用 Punycode 显示混合脚本的域名来警示。
4.4 端口与默认值
http://example.com:80/ == http://example.com/
https://example.com:443/ == https://example.com/
规范化时移除默认端口,但**http://example.com:443 不等于 https://example.com**——scheme 不同,端口含义不同。
4.5 大小写规范化
- scheme 与 host:转小写。
- path、query、fragment:保持原样(大小写敏感)。
- 百分号编码:十六进制数字统一大写(
%2f→%2F)。
4.6 空路径与末尾斜杠
http://example.com == http://example.com/
对 HTTP 而言,空路径等价于 /。但 http://example.com/a 与 http://example.com/a/ 是两个不同的 URL——服务器可能对它们返回不同的重定向或内容。REST API 设计中要统一约定,避免 /users 和 /users/ 混用导致缓存命中率下降或 301 跳转链。
4.7 非法百分号序列
%2 # 不完整,缺一位十六进制
%GG # 非法十六进制
% # 孤立百分号
严格解析器会报错,宽容解析器会原样保留。同一个 URL 在浏览器、Python、Go 里可能得到不同结果。处理不可信输入时,要么严格拒绝,要么明确用宽容模式并记录。
4.8 编码位置差异
同一个字符在不同位置是否需要编码,取决于它在那个位置是否具有结构含义:
| 字符 | 在 path 中 | 在 query 中 | 说明 |
|---|---|---|---|
/ | 分隔段,需编码表示字面 | 可保留 | 路径段里的 / 必须 %2F |
& | 可保留 | 分隔参数,需 %26 | query 里的 & 必须编码 |
= | 可保留 | 分隔键值,需 %3D | query 值里的 = 应编码 |
? | 可保留 | 需编码 | 避免被误认为 query 起始 |
# | 需编码 | 需编码 | 避免被误认为 fragment |
+ | 可保留 | 视约定 | query 中可能表示空格 |
这就是为什么「先整体编码再拼 URL」会出错——编码必须针对每个组成部分分别进行,且各自用不同的 safe 集合。
5. 安全陷阱
5.1 开放重定向(Open Redirect)
# 危险:直接信任用户传入的 next
next_url = request.args["next"]
return redirect(next_url) # 攻击者可传 http://evil.com
防御:只允许相对路径,或校验 host 在白名单内:
from urllib.parse import urlparse, urljoin
def safe_redirect(target, base="https://mysite.com"):
u = urlparse(target)
if u.scheme or u.netloc: # 绝对 URL,拒绝
return base
return urljoin(base, target)
5.2 SSRF(Server-Side Request Forgery)
服务端根据用户输入发起请求时,攻击者可指向内网地址:
http://169.254.169.254/latest/meta-data/ # 云元数据
http://127.0.0.1:6379/ # 内网 Redis
防御要点:解析 host 后解析为 IP 再校验(防 DNS 重绑定),封禁私有网段与链路本地地址,限制重定向次数。解析到的 IP 与请求时解析的 IP 可能不同,这是 DNS rebinding 攻击的核心。
5.3 路径穿越(Path Traversal)
# 危险
open(os.path.join("/var/www", request.args["file"]))
# file=../../etc/passwd
防御:规范化后确认前缀,且拒绝含 .. 的路径段(解码后再判断):
import os
base = "/var/www"
target = os.path.realpath(os.path.join(base, user_path))
if not target.startswith(base + os.sep):
raise ValueError("path escape")
注意要先解码再检查,否则 %2e%2e%2f 会绕过检查。这也是为什么 nginx 的 real_ip 与代理协议
配置要格外小心——代理层和应用的解析不一致会制造绕过空间。
6. 各语言解析 API 对照
| 语言 | 解析 URL | 编码 query | 备注 |
|---|---|---|---|
| Python | urllib.parse.urlparse | urlencode(空格→+) | quote 空格→%20 |
| JavaScript | new URL() | URLSearchParams | URL 遵循 WHATWG 标准 |
| Java | java.net.URI | URLEncoder(空格→+) | URI 更严格,推荐 |
| Go | net/url.Parse | url.Values.Encode | 空格→+ |
| PHP | parse_url | http_build_query | parse_str 会改写键名 |
| Rust | url crate | form_urlencoded | 遵循 WHATWG |
WHATWG URL 标准(浏览器、JS、Rust url)与 RFC 3986 有几处差异(如对 \ 的处理、更多宽容解析),跨环境处理同一 URL 时要意识到这点。
6.1 不要手工拼接
# 错误:没有编码,空格与 & 会破坏结构
url = "https://api.x.com/search?q=" + user_input
# 正确:让库编码
from urllib.parse import urlencode
url = "https://api.x.com/search?" + urlencode({"q": user_input})
// 正确
const url = new URL("https://api.x.com/search");
url.searchParams.set("q", userInput);
7. URL 规范化与等价判断
7.1 规范化(Normalization)步骤
判断两个 URL 是否「相同」,需要一套规范化流程:
- scheme 与 host 转小写。
- 移除默认端口(http:80、https:443)。
- 百分号编码的十六进制统一大写。
- 解码 unreserved 字符(
%41→A)。 - 移除点段(
.、..)。 - 空路径补
/。 - 按需排序 query 参数(仅当语义允许)。
from urllib.parse import urlsplit, urlunsplit
def normalize(url):
s = urlsplit(url)
scheme = s.scheme.lower()
netloc = s.netloc.lower()
# 移除默认端口
if netloc.endswith(":80") and scheme == "http":
netloc = netloc[:-3]
if netloc.endswith(":443") and scheme == "https":
netloc = netloc[:-4]
path = s.path or "/"
return urlunsplit((scheme, netloc, path, s.query, ""))
print(normalize("HTTP://Example.COM:80/a/../b/"))
print(normalize("http://example.com/b/"))
7.2 为什么规范化关乎安全
解析差异(parser differential) 是最隐蔽的漏洞来源:防火墙、WAF、反向代理、应用框架各自解析 URL,若它们对 %2F、..、大小写、@ 的处理不一致,攻击者就能构造「WAF 放行、后端执行」的请求。防御的第一原则是:让所有组件用同一套规范化,或在边界处只放行已规范化的 URL。
7.3 缓存键与规范化
CDN 和缓存系统用 URL 做键。若 /a 与 /a/、/a? 与 /a、%41 与 A 不统一,缓存会碎片化甚至被投毒。这也是 nginx 缓存
配置中要显式设定规范化规则的原因。
8. 小结
URL 解析的核心是两条:区分「结构字符」与「数据字符」,以及编码的是字节而非字符。query 里 + 与 %20 的歧义、reserved 字符在数据中的转义、fragment 不发给服务器、IDN 的 Punycode,都是必须显式处理的细节。安全上,凡是从 URL 取出的 host、path、query 用于重定向、文件访问、发起请求,都要先规范化再校验。记住一条铁律:永远用标准库解析和构造 URL,永远不要手工拼接字符串。传输层与协议演进(HTTP/2、HTTP/3)可见 HTTP/3 与 QUIC 性能
与 network 专题
。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。