URL vs URI vs URN: 区别是什么
一次性厘清这三个标识符规范之间的关系。
开发者在交流中混用「URL」和「URI」,日常场合这没问题。但两者的区别是真实存在的,写在RFC 3986里;而对其底层——通用语法及其编码规则——的误解,才是真正导致生产事故的根源,从损坏的查询字符串到服务端请求伪造(SSRF)。
一段话讲清层次
URI(统一资源标识符)是标识资源的任何字符串的总称。URL(统一资源定位符)是同时告诉你如何到达资源的URI——它带有定位机制,比如scheme和主机。URN(统一资源名称)是以位置无关、持久的方式为资源命名的URI。每个URL都是URI;每个URN也都是URI;但URN不是URL。
URI ─┬─ URL: https://example.com/spec.html
└─ URN: urn:ietf:rfc:3986
通用语法 (RFC 3986)
每个URI可分解为五个组件:
https://[email protected]:8443/v2/items?sort=asc#top
\___/ \__________________________/\_______/ \______/ \_/
scheme authority path query fragment
- scheme — 不区分大小写,规范形式为小写;定义其余部分如何解释。
- authority — 可选的userinfo、主机(注册名、IPv4或带方括号的IPv6如
[::1])和可选端口。user:password@形式作为凭据用途已被废弃,如今主要存活在钓鱼和SSRF载荷中。 - path — 层级结构;
.和..段在规范化时被解析。 - query — 约定俗成是用与号连接的key=value对,但RFC 3986根本不强制任何结构。
- fragment — 永远不会发送到服务器。仅存在于客户端,这就是SPA中
#之后的路由不出现在访问日志里的原因。
百分号编码是按组件进行的
被误解最深的事实:不存在一个统一的「必须编码的字符」集合。每个组件有自己的保留集。字面的 ? 在路径中是分隔符,但在查询串内部是完全合法的数据;& 在路径段中是数据字符,在查询中却是分隔符。这就是为什么对完整URL整体执行一次 encodeURIComponent 会毁掉它,而正确做法是在组装时对每个动态部分分别编码:
const url = new URL('https://api.example.com/search');
url.searchParams.set('q', 'a&b=c?');
url.toString(); // https://api.example.com/search?q=a%26b%3Dc%3F
还要记住空格的两种写法:%20 是RFC 3986的编码,而 + 只在 application/x-www-form-urlencoded 数据中表示空格。在路径中把 + 解码为空格是一个bug。
URN: 没有地址的名字
URN(RFC 8141)存在于注册的命名空间中,承诺的是持久性,而非可解析性:
urn:isbn:9780134685991
urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6
urn:ietf:rfc:3986
ISBN永久地命名那本书;找到一本实体书是另外的解析步骤。实践中,URN出现在XML命名空间、UUID序列化和标准文档中。值得内化的模式:当你需要一个即使托管迁移也绝不能失效的标识符时,URN风格的名字(或任何位置无关的ID)胜过URL。
两份规范,一个Web
这是多数文章跳过的部分:现代浏览器和JavaScript实现的不是RFC 3986,而是WHATWG URL Standard——一份为匹配浏览器实际行为而编写的活标准。会咬人的差异:
- WHATWG解析更宽容:在特殊scheme中把反斜杠当作斜杠(
https:\\example.com能解析)、剥离制表符和换行符、把主机转为小写。 new URL(relative)在没有base参数时会抛出异常——WHATWG解析器没有独立相对引用的概念,而RFC 3986为此专门写了一整节。- 校验器、代理和较老的服务端框架倾向于遵循RFC 3986,因此同一个URL在每一跳可能被判定得不一样。
这种分歧是可利用的。URL解析器混淆是反复出现的SSRF根因:使用一种解析器的校验器放行了 https://trusted.com\@evil.com/,而使用另一种解析器的HTTP客户端把请求发给了evil.com。防御要从架构层面做:只解析一次,校验解析后的组件(scheme白名单、主机白名单),并把解析后的对象——而不是原始字符串——传给发起请求的代码。
实用规则
- 无论构建还是解析,都使用
URL和URLSearchParams(或所用语言的等价物),而不是字符串拼接。 - 比较前先规范化:scheme和主机转小写、解析点号段、百分号编码的十六进制转大写、去掉默认端口。
- 把
/blog和/blog/视为不同资源;选定一个规范形式,另一个做301重定向。 - 永远不要把完整URL一次性解码后再拆分;先拆分,再对每个组件解码。
国际化标识符
RFC 3986只允许ASCII,因此非ASCII标识符是叠加在其上的一层。IRI(RFC 3987)直接允许Unicode;把IRI转换为URI时,路径和查询会被百分号编码,主机则通过IDNA进行Punycode编码——bücher.example 变成 xn--bcher-kva.example。这带来两个运维后果。第一,比较必须在规范化之后进行,因为同一逻辑地址有多种编码。第二,同形异义攻击:西里尔字母 а 与拉丁字母 a 视觉上完全相同,让 аpple.com 可以冒充 apple.com。浏览器通过把混合文字的主机名渲染为Punycode来缓解,任何展示用户提交链接的系统都应采用同样的策略,而不是相信字形。
在 sdk.is/url-encoder 的URL编码工具中试验那些棘手的组件编码吧。