URL 的每个组成部分以及它们允许包含什么

用一个完整示例标出 URL 的每个部分,说明哪些字符必须在哪里做百分号编码、查询字符串实际上是怎么运作的,以及如何识别一个在主机名上撒谎的链接。

URL 是一个字符串,它说明要讲哪种协议、要对哪台机器讲,以及要请求什么。正确地读懂一个 URL,主要就是知道每个部分在哪里结束,因为分隔符都是单个字符,而且第一个出现的那个说了算。

https://alex:s3cret@www.example.co.uk:8443/docs/intro?lang=en&page=2#notes
│       │           │                 │   │           │              └─ fragment
│       │           │                 │   │           └──────────────── query
│       │           │                 │   └──────────────────────────── path
│       │           │                 └──────────────────────────────── port
│       │           └────────────────────────────────────────────────── host
│       └────────────────────────────────────────────────────────────── userinfo
└────────────────────────────────────────────────────────────────────── scheme

各个部分

部分由什么界定用途是否到达服务器
协议(scheme)到第一个 : 为止使用哪种协议,以及其余部分适用哪套规则决定如何建立连接
用户信息(userinfo)// 之后,到 @ 为止凭据,对 httphttps 早已废弃只有客户端选择发送时才会
主机(host):/?# 为止要解析并连接的域名或 IP 地址会,放在 Host 头里
端口(port): 之后,到 /?# 为止TCP 端口,http 默认 80,https 默认 443用于建立连接
路径(path)/ 开始,到 ?# 为止该主机上的哪个资源会,放在请求行里
查询(query)? 开始,到 # 为止该资源的参数会,放在请求行里
片段(fragment)# 开始,一直到末尾资源内部的某个位置不会

主机和端口合起来就是 authority。片段是个例外:它在构造请求之前就被剥掉了,所以服务器永远看不到它。浏览器用它做锚点和客户端路由,某些认证流程还刻意把令牌放在片段里返回,好让这些令牌不出现在服务器日志中。但它仍然会进入浏览器历史记录,所以它不是私密的,只是没被发送出去而已。

保留字符与非保留字符

百分号编码把一个字节写成 % 加两位十六进制数字,用的是字符的 UTF-8 字节。é 变成 %C3%A9,两个字节,两个转义。

除了字母和数字之外,还有四个字符属于非保留字符,在任何位置都不需要编码:- . _ ~。其余的要么是保留字符,意思是它在 URL 的某个部分承担结构性职责,要么就必须编码。保留字符集是 : / ? # [ ] @ 以及 ! $ & ' ( ) * + , ; =

“保留”这个词不等于“永远禁止”。它的意思是这个字符在某些部分里是分隔符,只有在会被当成分隔符读的那些部分里才必须编码。

字符在路径段中在查询值中
空格必须是 %20%20+
/必须是 %2F,否则会切开这个路径段可以原样使用
?必须是 %3F,否则路径到此为止可以原样使用
#必须是 %23必须是 %23
&=可以原样使用必须是 %26%3D,否则键值对会被切开
+合法,表示一个加号必须是 %2B,否则可能被解码成空格

实用规则就是这条:把会终结当前所处部分的那个字符编码掉。路径里的空格必须写成 %20,因为在多数解析器看来一个裸空格就把 URL 结束了;而查询值里的空格既可以是 %20,也可以是更老的 +。用 URL 编码工具做这件事最可靠,因为编码整个 URL 和编码单个组成部分是两种不同的操作,而人们想要的几乎总是后者。

查询字符串只是一种约定

规范只说查询是 ?# 之间的全部内容,字符取自允许的集合。key=value&key=value 这种形态来自 HTML 表单编码,而不是 URI 标准。没有什么能阻止服务器按自己的想法去解析 ?a:1;b:2

正因为这种形态只是约定,重复的键并没有定义好的含义,于是每个技术栈都自己选了一个答案:

?id=1&id=2 的行为出现在哪里
保留两个值,作为列表Python 的 parse_qs、Node 的 querystring、Express
只取第一个值Go 的 Query().Get、Java 的 getParameter
只取最后一个值PHP、Rails
拼接成 1,2ASP.NET 的请求集合

数组也继承了同样的问题。ids=1&ids=2ids[]=1&ids[]=2ids[0]=1&ids[1]=2ids=1,2 都在被广泛使用,而只有服务器才决定它能听懂哪一种。严格来说方括号写法必须编码才算合法(ids%5B%5D=1),尽管多数服务器也接受裸方括号。选接收方文档里写明的那种形式,然后保持一致。

加号值得单独警告一次。在 application/x-www-form-urlencoded 数据(也就是表单编码的请求体)里,+ 表示空格。在 URL 路径里它表示一个字面的加号。在查询字符串里则取决于解析器,而多数 Web 框架会在那里套用表单解码,于是 + 会悄悄变成空格。任何有可能合法地包含加号的值,比如标准 Base64 的输出或电话号码,都必须以 %2B 的形式发送。

相对引用

相对引用是通过替换基准 URL 中最后一个 / 之后的所有内容来解析的。规则全在那个最后的斜杠上,这也正是结尾斜杠为什么重要。

基准 URL引用结果
https://ex.com/docs/introguidehttps://ex.com/docs/guide
https://ex.com/docs/intro/guidehttps://ex.com/docs/intro/guide
https://ex.com/docs/intro/guidehttps://ex.com/guide
https://ex.com/docs/intro/../guidehttps://ex.com/docs/guide
https://ex.com/docs/intro?a=1?b=2https://ex.com/docs/intro?b=2
https://ex.com/docs/intro?a=1#tophttps://ex.com/docs/intro?a=1#top
https://ex.com/docs/intro//cdn.ex.com/x.jshttps://cdn.ex.com/x.js

最后一行是协议相对引用:开头的两个斜杠保留基准 URL 的协议,并替换掉 authority。以 ? 开头的引用保留路径并丢弃原有的查询,以 # 开头的引用则两者都保留。

什么时候两个 URL 指向同一个资源

协议和主机不区分大小写,所以 HTTPS://Example.COMhttps://example.com 是同一个地址。就标准而言,主机之后的一切都区分大小写,哪怕某台服务器自己选择忽略大小写。

下面这些成对的写法是等价的:

  • https://example.com:443/ahttps://example.com/a,因为那是默认端口
  • https://example.comhttps://example.com/,因为空路径就表示根路径
  • /a/%7Euser/a/~user,因为 ~ 是非保留字符,对它做百分号编码什么也没改变

下面这些则不等价:

  • /docs/docs/,它们是不同的资源,尽管多数服务器会把其中一个重定向到另一个
  • /p?a=1&b=2/p?b=2&a=1,因为参数顺序也是字符串的一部分
  • /index.html/,除非服务器另有规定

缓存和 CDN 是按精确的字节来做键的,所以多加一个跟踪参数、或者把查询参数换个顺序,就会把一个缓存对象拆成两个,命中率直接减半。搜索引擎也会把这些变体当成重复页面,除非有 canonical 链接指向选定的那一种形式。解决办法是选定单一形态,用 301 把其余形态重定向过去,并去掉那些不改变响应内容的参数。

长度、日志与隐私

规范里没有长度限制,但其他地方到处都是限制。常见的服务器默认把整条请求行限制在 8 KB 左右,一些代理和网络设备是 4 KB,较老的客户端大约是 2 KB。任何需要经受住邮件客户端、二维码和短链接服务的 URL,控制在 2000 个字符以内最稳妥。

把 URL 保持简短的更有力的理由是:查询字符串并不私密。它会被写进访问日志,通过 Referer 头传给第三方,留在浏览器历史里,随书签一起保存,并且在有人分享链接时被一并复制。因此会话令牌、密码重置码、API 密钥和个人数据都不该出现在那里。体积大或者敏感的数据应该放进请求体,请求体没有实际的大小上限,默认也不会被记入日志。

点击链接之前先读懂它

authority 从 :// 之后开始,到最靠前的那个 /?# 结束。先读出这一段,再看它最后的两三个标签。那才是真正的主机,它左边的任何东西都改变不了这一点。

https://www.paypal.com@198.51.100.7/secure/login
        ^^^^^^^^^^^^^^ ^^^^^^^^^^^^
        userinfo       real host

一些需要认得出来的常见伪装:

  • @ 之前的文本是用户信息,绝不是主机,而且它可以是任何一个品牌名。
  • paypal.com.secure-login.examplesecure-login.example 的一个子域名。标签要从右往左读。
  • https://short.example/https://www.bank.com/login 把一整个看着可信的 URL 塞进了路径里。
  • xn-- 开头的主机是 Punycode,也就是 IDNA 产生的国际化域名的 ASCII 形式。münchen.de 在链路上是 xn--mnchen-3ya.de,这本身是正当的,但同一套机制也让同形字攻击成为可能:西里尔字母 а(U+0430)看起来和拉丁字母 a 完全一样,用它构成的域名会编码成类似 xn--pple-43d.com 的东西。当一个标签混用了多种文字时,浏览器会显示 Punycode 形式,不过各家规则不一,并不能作为保证。
  • 主机里出现 %2F%40 这类被编码的分隔符,是在蓄意迷惑解析器。

把链接粘进 URL 解析器一步就能判定,因为真正的解析器会套用浏览器同样的“第一个分隔符说了算”规则,并把主机单独显示出来。要一次核对大量链接时,正则测试工具是确认它们当中哪些真的具备你预期的 authority 的快捷办法。