URL 解析与百分号编码

从 RFC 3986 出发讲透 URL:五段式结构(scheme/authority/path/query/fragment)、百分号编码的保留字符与 unreserved 集合、query 与 form 编码的差异、加号与空格的歧义、IDN 与 UTF-8 编码、相对引用与点段移除、各语言解析 API 的行为差异,以及开放重定向、SSRF、路径穿越等安全陷阱与规范化防御。

引言

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:porthost 不区分,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](Python parse_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
&可保留分隔参数,需 %26query 里的 & 必须编码
=可保留分隔键值,需 %3Dquery 值里的 = 应编码
?可保留需编码避免被误认为 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备注
Pythonurllib.parse.urlparseurlencode(空格→+)quote 空格→%20
JavaScriptnew URL()URLSearchParamsURL 遵循 WHATWG 标准
Javajava.net.URIURLEncoder(空格→+)URI 更严格,推荐
Gonet/url.Parseurl.Values.Encode空格→+
PHPparse_urlhttp_build_queryparse_str 会改写键名
Rusturl crateform_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 是否「相同」,需要一套规范化流程:

  1. scheme 与 host 转小写。
  2. 移除默认端口(http:80、https:443)。
  3. 百分号编码的十六进制统一大写。
  4. 解码 unreserved 字符(%41 → A)。
  5. 移除点段(.、..)。
  6. 空路径补 /。
  7. 按需排序 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 专题 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「others」更多文章

  1. HTTP 缓存与条件请求
  2. CSV/TSV 解析陷阱
  3. 模板引擎原理与选型