CS-Notes全部笔记在阅读器中打开

HTTP

本文目录

一、基础概念

请求和响应报文

客户端发送一个请求报文给服务器,服务器根据请求报文中的信息进行处理,并将处理结果放入响应报文中返回给客户端。

请求报文结构:

GET http://www.example.com/ HTTP/1.1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cache-Control: max-age=0
Host: www.example.com
If-Modified-Since: Thu, 17 Oct 2019 07:18:26 GMT
If-None-Match: "3147526947+gzip"
Proxy-Connection: keep-alive
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 xxx

响应报文结构:

HTTP/1.1 200 OK
Age: 529651
Cache-Control: max-age=604800
Connection: keep-alive
Content-Encoding: gzip
Content-Length: 648
Content-Type: text/html; charset=UTF-8
Date: Mon, 02 Nov 2020 17:53:39 GMT
Etag: "3147526947+ident+gzip"
Expires: Mon, 09 Nov 2020 17:53:39 GMT
Keep-Alive: timeout=4
Last-Modified: Thu, 17 Oct 2019 07:18:26 GMT
Proxy-Connection: keep-alive
Server: ECS (sjc/16DF)
Vary: Accept-Encoding
X-Cache: HIT

<!doctype html>
<html>
<head>
    <title>Example Domain</title>
    // 省略... 
</body>
</html>

URL

HTTP 使用 URL(Uniform Resource Locator,统一资源定位符)来定位资源,它是 URI(Uniform Resource Identifier,统一资源标识符)的子集,URL 在 URI 的基础上增加了定位能力。URI 除了包含 URL,还包含 URN(Uniform Resource Name,统一资源名称),它只是用来定义一个资源的名称,并不具备定位该资源的能力。例如 urn:isbn:0451450523 用来定义一个书籍名称,但是却没有表示怎么找到这本书。

URL 的配图

二、HTTP 方法

客户端发送的请求报文第一行为请求行,包含了方法字段。

GET

GET 用于获取资源,当前网络请求中绝大部分使用的是 GET 方法。

HEAD 用于获取报文首部,和 GET 方法类似,但是不返回报文实体主体部分。

主要用于确认 URL 的有效性以及资源更新的日期时间等。

POST

POST 用于传输实体主体,主要用来传输数据,而 GET 主要用来获取资源。

更多 POST 与 GET 的比较见 GET 和 POST 比较。

PUT

PUT 用于创建或者替换目标资源,例如上传文件。它自身不带验证机制,需要配合身份认证使用,否则任何人都可以上传文件。

PUT /new.html HTTP/1.1
Host: example.com
Content-type: text/html
Content-length: 16

<p>New File</p>

PATCH

PATCH 用于对资源进行部分修改。PUT 也可以用于修改资源,但是只能完全替代原始资源,PATCH 允许部分修改。

PATCH /file.txt HTTP/1.1
Host: www.example.com
Content-Type: application/example
If-Match: "e0023aa4e"
Content-Length: 100

[description of changes]

DELETE

DELETE 用于删除资源,与 PUT 功能相反,并且同样不带验证机制。

DELETE /file.html HTTP/1.1

OPTIONS

OPTIONS 用于查询指定的 URL 能够支持的方法。

会返回 Allow: GET, POST, HEAD, OPTIONS 这样的内容。

CONNECT

CONNECT 要求在与代理服务器通信时建立隧道,使用 SSL(Secure Sockets Layer,安全套接层)和 TLS(Transport Layer Security,传输层安全)协议把通信内容加密后经网络隧道传输。

CONNECT www.example.com:443 HTTP/1.1
CONNECT 的配图

TRACE

TRACE 用于追踪路径,服务器会将通信路径返回给客户端。

发送请求时,在 Max-Forwards 首部字段中填入数值,每经过一个服务器就会减 1,当数值为 0 时就停止传输。

通常不会使用 TRACE,并且它容易受到 XST 攻击(Cross-Site Tracing,跨站追踪)。

三、HTTP 状态码

服务器返回的响应报文中第一行为状态行,包含了状态码以及原因短语,用来告知客户端请求的结果。

状态码 类别 含义
1XX Informational(信息性状态码) 接收的请求正在处理
2XX Success(成功状态码) 请求正常处理完毕
3XX Redirection(重定向状态码) 需要进行附加操作以完成请求
4XX Client Error(客户端错误状态码) 服务器无法处理请求
5XX Server Error(服务器错误状态码) 服务器处理请求出错

1XX 信息

2XX 成功

3XX 重定向

301 和 302 的区别也体现在缓存上:没有缓存指令时,301 默认可以缓存,浏览器之后直接跳到新地址(Edge 156 实测,见附录 A13),搜索引擎也会改为收录新地址,适合域名迁移;302 默认不缓存,每次都先访问旧地址,适合登录后跳回原页面这类临时跳转。需要注意的是,服务器无法撤回浏览器已经缓存的 301,在缓存失效或者浏览器重新确认之前,改了服务器的配置也不一定生效,所以不确定是否永久迁移时先用 302,或者给 301 加上 Cache-Control: max-age 限定缓存的时长。

需要注意的是,RFC 9110 出于历史原因允许用户代理在 301、302 重定向时把 POST 方法改成 GET 方法,大多数浏览器也是这么做的,但只改 POST,PUT、DELETE 保持不变;303 则把 GET、HEAD 以外的方法都改为 GET。如果不希望方法被改变,应该使用 307 或者 308。

4XX 客户端错误

5XX 服务器错误

502 和 504 都说明网关访问后端时失败,原因可能在后端服务、网络或网关的配置:502 是连接被拒绝或收到了无效的响应,504 是连接或读取后端超时;后端正常返回的 500 原样传给客户端,不会变成 502。排查时先看网关的错误日志,再看对应时间后端的日志和耗时。

四、HTTP 首部

有 4 种类型的首部字段:通用首部字段、请求首部字段、响应首部字段和实体首部字段。

各种首部字段及其含义如下(不需要全记,仅供查阅)。这是《图解 HTTP》按 RFC 2616 的分类,RFC 9110 不再区分实体首部,表中的 Content-MD5、Warning 已被废弃:

通用首部字段

首部字段名 说明
Cache-Control 控制缓存的行为
Connection 控制不再转发给代理的首部字段、管理持久连接
Date 创建报文的日期时间
Pragma 报文指令
Trailer 报文末端的首部一览
Transfer-Encoding 指定报文主体的传输编码方式
Upgrade 升级为其他协议
Via 代理服务器的相关信息
Warning 错误通知

请求首部字段

首部字段名 说明
Accept 用户代理可处理的媒体类型
Accept-Charset 优先的字符集
Accept-Encoding 优先的内容编码
Accept-Language 优先的语言(自然语言)
Authorization Web 认证信息
Expect 期待服务器的特定行为
From 用户的电子邮箱地址
Host 请求资源所在服务器
If-Match 比较实体标记(ETag)
If-Modified-Since 比较资源的更新时间
If-None-Match 比较实体标记(与 If-Match 相反)
If-Range 资源未更新时发送实体 Byte 的范围请求
If-Unmodified-Since 比较资源的更新时间(与 If-Modified-Since 相反)
Max-Forwards 最大传输逐跳数
Proxy-Authorization 代理服务器要求客户端的认证信息
Range 实体的字节范围请求
Referer 对请求中 URI 的原始获取方
TE 传输编码的优先级
User-Agent HTTP 客户端程序的信息

响应首部字段

首部字段名 说明
Accept-Ranges 是否接受字节范围请求
Age 响应在缓存中已经存放的秒数
ETag 资源的匹配信息
Location 令客户端重定向至指定 URI
Proxy-Authenticate 代理服务器对客户端的认证信息
Retry-After 对再次发起请求的时机要求
Server HTTP 服务器的安装信息
Vary 代理服务器缓存的管理信息
WWW-Authenticate 服务器对客户端的认证信息

实体首部字段

首部字段名 说明
Allow 资源可支持的 HTTP 方法
Content-Encoding 实体主体适用的编码方式
Content-Language 实体主体的自然语言
Content-Length 实体主体的大小
Content-Location 替代对应资源的 URI
Content-MD5 实体主体的报文摘要
Content-Range 实体主体的位置范围
Content-Type 实体主体的媒体类型
Expires 实体主体过期的日期时间
Last-Modified 资源的最后修改日期时间

五、具体应用

连接管理

连接管理的配图

1. 短连接与长连接

当浏览器访问一个包含多张图片的 HTML 页面时,除了请求访问的 HTML 页面资源,还会请求图片资源。如果每进行一次 HTTP 通信就要新建一个 TCP 连接,那么开销会很大:每个连接都要先经过 三次握手,主动关闭的一方还要在 TIME_WAIT 中保留连接(Linux 为 60 秒),由客户端主动关闭时,频繁新建连接访问同一个服务器,可能耗尽本地的临时端口。

长连接只需要建立一次 TCP 连接就能进行多次 HTTP 通信。

长连接也不会一直保持:服务器在连接空闲一段时间后关闭它,例如 Nginx 的 keepalive_timeout 默认 75 秒,一个连接上处理的请求数达到 keepalive_requests(默认 1000)时也会关闭。它和 TCP 的保活机制(keepalive)不是一回事,后者由内核在连接空闲很久后发送探测报文,见 心跳与重连。

2. 流水线

默认情况下,HTTP 请求是按顺序发出的,下一个请求只有在当前请求收到响应之后才会被发出。由于受到网络延迟和带宽的限制,在下一个请求被发送到服务器之前,可能需要等待很长时间。

流水线是在同一条长连接上连续发出请求,而不用等待响应返回,这样可以减少延迟。但服务器必须按请求的顺序返回响应,前面的响应慢了,后面的都要等待,称为队头阻塞,加上很多代理处理不好流水线,主流浏览器都没有启用它。HTTP/2 用多路复用解决了这个问题,见 二进制分帧层;TCP 层的队头阻塞仍然存在,见 HTTP/3。

HTTP 协议是无状态的,主要是为了让 HTTP 协议尽可能简单,使得它能够处理大量事务。可以使用 Cookie 来保存状态信息,Cookie 由 RFC 6265 单独定义,并不是 HTTP/1.1 的一部分。

Cookie 是服务器发送到用户浏览器并保存在本地的一小块数据,它会在浏览器之后向同一服务器再次发起请求时被携带上,用于告知服务端两个请求是否来自同一浏览器。由于之后每次请求都会需要携带 Cookie 数据,因此会带来额外的性能开销(尤其是在移动环境下)。

Cookie 曾经是浏览器中唯一的本地存储手段。现在只在本地使用的数据一般改存到 Web Storage(localStorage 和 sessionStorage)或 IndexedDB 中,Cookie 主要用来保存会话标识这类需要随请求发给服务器的数据。

Cookie 与 Web Storage 的区别:

1. 用途

2. 创建过程

服务器发送的响应报文包含 Set-Cookie 首部字段,客户端得到响应报文后把 Cookie 内容保存到浏览器中。

HTTP/1.0 200 OK
Content-type: text/html
Set-Cookie: yummy_cookie=choco
Set-Cookie: tasty_cookie=strawberry

[page content]

客户端之后对同一个服务器发送请求时,会从浏览器中取出 Cookie 信息并通过 Cookie 请求首部字段发送给服务器。

GET /sample_page.html HTTP/1.1
Host: www.example.org
Cookie: yummy_cookie=choco; tasty_cookie=strawberry

3. 分类

Set-Cookie: id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT;

4. 作用域

Domain 标识指定了哪些主机可以接受 Cookie。如果不指定,默认为当前文档的主机(不包含子域名)。如果指定了 Domain,则一般包含子域名。例如,如果设置 Domain=mozilla.org,则 Cookie 也包含在子域名中(如 developer.mozilla.org)。

Path 标识指定了主机下的哪些路径可以接受 Cookie(该 URL 路径必须存在于请求 URL 中)。以字符 %x2F ("/") 作为路径分隔符,子路径也会被匹配。例如,设置 Path=/docs,则以下地址都会匹配:

5. JavaScript

浏览器通过 document.cookie 属性可创建新的 Cookie,也可通过该属性访问非 HttpOnly 标记的 Cookie。

document.cookie = "yummy_cookie=choco";
document.cookie = "tasty_cookie=strawberry";
console.log(document.cookie);

6. HttpOnly

标记为 HttpOnly 的 Cookie 不能被 JavaScript 脚本调用。跨站脚本攻击(XSS)常常使用 JavaScript 的 document.cookie API 窃取用户的 Cookie 信息,因此使用 HttpOnly 标记可以在一定程度上避免 XSS 攻击。

Set-Cookie: id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Secure; HttpOnly

7. Secure

标记为 Secure 的 Cookie 只能通过被 HTTPS 协议加密过的请求发送给服务端。但即便设置了 Secure 标记,敏感信息也不应该通过 Cookie 传输,因为 Cookie 有其固有的不安全性:Secure 只保证 Cookie 不在明文连接中发送,不保证它不被篡改,例如同一父域名下的其它子域名可以写入同名的 Cookie,让服务器读到被替换的值。

SameSite 属性控制跨站请求是否携带 Cookie,取值有 Strict、Lax 和 None,None 表示跨站请求也携带,必须同时设置 Secure。它用于防范跨站请求伪造,见 设置 Cookie 的 SameSite 属性。

8. Session

除了可以将用户信息通过 Cookie 存储在用户浏览器中,也可以利用 Session 存储在服务器端,存储在服务器端的信息更加安全。

Session 可以存储在服务器上的文件、数据库或者内存中。也可以将 Session 存储在 Redis 这种内存型数据库中,效率会更高。集群中的多台服务器如何共享 Session,见 集群下的 Session 管理。

使用 Session 维护用户登录状态的过程如下:

8. Session 的配图

Session 的过期由服务器控制,通常是一段时间没有访问就失效,例如 Tomcat 和 Spring Boot 默认 30 分钟;存在 Redis 中时给 Key 设置 TTL,每次访问时刷新,例如 session:9f2c7a41 → {"uid":1024},TTL 1800 秒。关闭浏览器后,即使会话期 Cookie 被删除,服务器上的 Session 也要等到超时才被清除;用户主动退出时,服务器直接删除这个 Session。

Session ID 相当于用户的登录凭证,拿到它就能冒充用户,所以它必须足够随机、不能被猜到;登录成功后要重新生成 Session ID,防止攻击者事先让用户使用一个自己知道的 Session ID(会话固定攻击)。在对安全性要求极高的场景下,例如转账等操作,除了使用 Session 管理用户状态之外,还需要对用户进行重新验证,比如重新输入密码,或者使用短信验证码等方式。

此时无法使用 Cookie 来保存用户信息和 Session ID。如果仍使用 Session,可以用 URL 重写技术,将 Session ID 作为 URL 的参数进行传递,例如 /orders;jsessionid=9f2c7a41。URL 中的 Session ID 容易随分享的链接和日志泄露,前后端分离的应用更常见的做法是由前端保存令牌,放在 Authorization 首部中发送,见 JWT。

11. JWT

JSON Web Token(JWT)是一种自带签名的令牌。用户登录成功后,服务器把用户 ID、过期时间等信息签名后生成 JWT 返回给客户端,客户端之后在请求的 Authorization 首部中携带它。服务器验证签名,就能确认令牌是自己签发的、内容没有被修改,不需要像 Session 那样在服务器保存会话。

JWT 由三部分组成,用 . 连接:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEwMjQsInJvbGUiOiJ1c2VyIiwiZXhwIjoxNzY3MjI1NjAwfQ.35OYguRMXfGxb4cYZPiP9KDH3-Ny6vMNwrPe-lNHBBw

头部和载荷只是编码而不是加密,任何人都能解码看到内容,所以载荷中不能放密码等敏感信息。签名的作用是防篡改:如果把载荷中的 role 改成 admin,没有密钥就算不出新的签名,服务器按篡改后的内容重新计算的签名与令牌中的不一致,请求会被拒绝:

令牌中的签名        35OYguRMXfGxb4cYZPiP9KDH3-Ny6vMNwrPe-lNHBBw
按篡改后的内容计算  Az4UO74FtqslT1MDLrbi7CMlKtd4KICR1jcwA7rJE4U

JWT 与 Session 的区别:

无法主动失效是 JWT 最常被追问的问题,常用的解决方法是双令牌:访问令牌(Access Token)的有效期很短,例如 15 分钟,每次请求都携带;刷新令牌(Refresh Token)的有效期较长,服务器记录已签发的刷新令牌,只在访问令牌过期时用它换取新的访问令牌。用户退出或者被封禁时撤销刷新令牌,已经发出的访问令牌最多 15 分钟后自然失效:

11. JWT 的配图

需要立即失效时,可以把令牌的 ID 放入 Redis 黑名单,过期时间设为令牌的剩余有效期,但这又回到了在服务器保存状态。黑名单适合让单个令牌失效;改密码、“退出所有设备”要让一个用户的全部令牌失效,常见的做法是在令牌中带一个版本号,服务器为每个用户记录当前版本,改密码时加一,校验时版本对不上就拒绝,代价是每次请求都要查一次这个版本(可以放在缓存中)。

刷新令牌有效期长,被盗后的危害更大。常用的做法是刷新令牌轮换:每次刷新都发一个新的刷新令牌,旧的作废,服务器记下它们属于同一组。被盗的令牌和合法的令牌先后来刷新时,必然有一方用的是已经作废的令牌,服务器发现后撤销这一组中仍然有效的刷新令牌,攻击者和合法的客户端都不能再刷新,合法客户端要重新获取授权(RFC 9700);已经签发的访问令牌不会因此失效,没有黑名单时要等到过期。客户端要把并发的刷新请求串行化,否则自己的两个请求拿同一个刷新令牌去换,也会被当成被盗。

令牌在浏览器中的存放位置:放在 localStorage 中,页面上的任何脚本都能读取,一旦存在 XSS 漏洞,令牌就会被盗走;放在 HttpOnly 的 Cookie 中,脚本读不到,但浏览器会自动携带 Cookie,需要防范 跨站请求伪造。放在 Authorization 首部中的令牌由页面脚本显式添加,浏览器不会自动带上,所以不受 CSRF 影响,代价是令牌对脚本可见,仍怕 XSS。

需要注意的是,任何存放位置都无法阻止已经在页面中执行的恶意脚本冒用用户的身份发出请求,它即使读不到 HttpOnly Cookie,也可以直接从页面发请求,所以关键是不让恶意脚本在页面中执行,例如防范 XSS、不加载被篡改的第三方脚本。

令牌只放在内存中,可以把暴露范围限制在当前页面,代价是刷新页面后就丢失,要重新获取。

要求更高时可以采用面向前端的后端(Backend for Frontend, BFF)模式:令牌只保存在后端,浏览器只持有会话 Cookie,由后端代为调用接口。RFC 10017 对业务应用和处理个人数据的应用强烈推荐这种做法。

需要注意的是,服务器验证时要固定使用的签名算法,不能接受头部中 alg 为 none 的令牌;HMAC 的密钥要随机生成,长度不少于 256 位。签名也不能防止窃听和重放,令牌被截获后,在过期前任何人都能拿它冒充用户,所以必须通过 HTTPS 传输;签名只证明令牌是服务器签发的,服务器仍要检查令牌中的用户是否有权访问所请求的资源。

JWT 只是令牌的格式,而 OAuth 2.0 是授权框架,规定第三方应用如何获得访问令牌,这个访问令牌可以用 JWT 表示。

12. 单点登录与 OAuth 2.0

单点登录(Single Sign-On, SSO)指的是在一个地方登录后,访问同一家公司的多个系统都不需要再登录。

多个系统在同一个父域名下时(a.example.com、b.example.com),把会话的 Cookie 设置为 Domain=example.com 就能共享。域名不同时(shop.com、pay.com),Cookie 无法跨域,需要一个统一的认证中心 sso.example.com:

  1. 用户访问 pay.com,pay.com 发现没有会话,重定向到 sso.example.com/login?redirect=https://pay.com/;
  2. 用户之前在 shop.com 登录时已经在认证中心登录过,认证中心的域名下有它自己的 Cookie,于是不需要输入密码,直接生成一个一次性的票据,重定向回 https://pay.com/?ticket=ST-1024;
  3. pay.com 的后端拿票据向认证中心验证,得到用户信息后建立 pay.com 自己的会话。

票据经过浏览器的地址栏,可能被记录或截获,所以只能使用一次、有效期很短,并且必须由后端去认证中心验证,而不是直接信任。

OAuth 2.0 解决的是另一个问题:授权第三方应用访问用户在某个服务上的数据,而不把密码交给它,例如笔记网站读取用户在账号中心的昵称和头像。“用微信登录”这类功能就是基于 OAuth 2.0 实现的,最常用的是授权码模式:

12. 单点登录与 OAuth 2.0 的配图

按上图走一遍,第 ③ 步浏览器访问的地址和第 ⑥ 步笔记网站后端发送的请求如下(用 Python 实现的最小授权服务器实测,见附录 A1,域名已替换,省略了部分字段):

GET https://id.example.com/authorize?response_type=code&client_id=notes-app
    &redirect_uri=https://notes.example.com/callback&scope=profile&state=x7Kp2
    &code_challenge=RQgc0f_GtuKCUitkJiCz7wNdN-_2JLHQ0JGLD9XxGtg&code_challenge_method=S256

POST https://id.example.com/token
grant_type=authorization_code&code=8XNe0QukdUBz_2wmZ34JLA
    &redirect_uri=https://notes.example.com/callback&client_id=notes-app
    &code_verifier=notes-demo-verifier-0123456789abcdefghijklmnop

{"access_token": "7B0Gmo164bDXm_sgupjN4O9ANvUg1_SK", "token_type": "Bearer", "expires_in": 3600}

几个参数的作用:

早期还有一种隐式模式,授权服务器直接把访问令牌放在重定向的地址中返回,令牌会出现在浏览器历史记录和日志中。RFC 9700(OAuth 2.0 安全最佳实践)规定不应该再使用隐式模式,没有能力保存密钥的客户端(例如单页应用、手机 App)必须使用 PKCE。

OAuth 2.0 本身只负责授权,访问令牌表示“可以读取昵称”,并不说明当前用户是谁。OpenID Connect 在它的基础上增加了 ID Token,这是一个包含用户 ID 的 JWT,专门用于登录,很多“第三方账号登录”使用的就是它。

扫码登录也是类似的思路,把手机上已有的登录状态安全地交给电脑:

  1. 电脑网页向服务器申请一个二维码 ID,状态为“等待扫码”,并开始轮询这个 ID 的状态;
  2. 已登录的手机 App 扫码后,带着手机自己的令牌把二维码 ID 发给服务器,状态变为“已扫码”,电脑上显示“请在手机上确认”;
  3. 用户在手机上点击确认,服务器为电脑签发一个新的令牌,状态变为“已确认”,电脑轮询拿到令牌后完成登录。

需要注意的是,电脑拿到的是新签发的令牌,而不是手机的令牌;二维码 ID 有效期很短。确认这一步要求用户在手机上手动点击,避免扫一下就登录。

手动确认防不住诱导扫码:攻击者把自己电脑上的二维码发给用户,用户被说服后照样会确认,令牌就发给了攻击者的电脑。所以确认页还要显示发起登录的设备和地点,提醒用户确认电脑就在自己面前,这也只能降低风险。RFC 8628(设备授权)对远程钓鱼给出的也是这类建议。

缓存

1. 优点

2. 实现方法

3. Cache-Control

HTTP/1.1 通过 Cache-Control 首部字段来控制缓存。

3.1 禁止进行缓存

no-store 指令规定不能对请求或响应的任何一部分进行缓存。

Cache-Control: no-store
3.2 强制确认缓存

no-cache 指令规定缓存服务器需要先向源服务器验证缓存资源的有效性,只有当缓存资源有效时才能使用该缓存对客户端的请求进行响应。

Cache-Control: no-cache

需要注意的是,no-cache 并不是不缓存,而是每次使用缓存之前都要向服务器确认;完全不允许缓存的是 no-store。

3.3 私有缓存和公共缓存

private 指令规定了将资源作为私有缓存,只能被单独用户使用,一般存储在用户浏览器中。

Cache-Control: private

public 指令明确表示响应可以被缓存,例如允许共享缓存保存带 Authorization 首部的请求的响应;响应本来就可以缓存时不需要加 public。

Cache-Control: public
3.4 缓存过期机制

max-age 指令出现在响应报文中,表示响应在多少秒内是新鲜的,例如 max-age=600 表示缓存 10 分钟之内可以直接使用。

max-age 指令出现在请求报文中,表示客户端希望得到已缓存时间不超过这个秒数的响应(请求中的指令只是建议,缓存可以不遵守),例如刷新页面时浏览器对页面本身发送 max-age=0,要求缓存先向服务器确认,页面中还在有效期内的脚本、图片则直接使用缓存,不再确认;强制刷新时页面和其中的资源都带 no-cache(Edge 156 实测,见附录 A2)。

Cache-Control: max-age=31536000

Expires 首部字段也可以用于告知缓存服务器该资源什么时候会过期。

Expires: Wed, 04 Jul 2012 08:26:05 GMT

Expires 是一个绝对时间,直接与本地时钟比较的话,客户端和服务器的时钟不一致时会算错,所以 RFC 9111 规定用 Expires 减去响应中的 Date 计算有效期,两个时间都来自服务器。max-age 直接给出有效期,计算时不需要比较两个日期,但判断响应是否仍然新鲜还要算出它已经存放了多久,其中用到本地收到响应的时间,仍可能受时钟偏差影响。

响应经过 CDN 等代理缓存时,代理会在 Age 首部中写明它已经存放了多少秒,新鲜期要扣掉这部分。例如浏览器收到 max-age=600、Age=540 的响应,只能再直接使用约 60 秒,而不是重新计时 600 秒。

4. 缓存验证

需要先了解 ETag 首部字段的含义,它是服务器为资源的某个版本生成的标识,资源的内容变化时强 ETag 随之改变。同一个 URL 按内容协商返回不同的版本(例如中文和英文页面)时,每个版本也有各自的 ETag。

ETag: "82e22293907ce725faf67773957acd12"

可以将缓存资源的 ETag 值放入 If-None-Match 首部,服务器收到该请求后,判断缓存资源的 ETag 值和资源的最新 ETag 值是否一致,如果一致则表示缓存资源有效,返回 304 Not Modified。

If-None-Match: "82e22293907ce725faf67773957acd12"

ETag 前面带 W/ 的是弱 ETag,内容不同但被服务器视为等价的版本可以共用同一个弱 ETag。If-None-Match 按弱比较判断,W/"1" 与 "1" 视为一致。

Last-Modified 首部字段也可以用于缓存验证,它包含在源服务器发送的响应报文中,指示源服务器对资源的最后修改时间。但是它是一种弱校验器:时间只精确到秒,同一秒内的多次修改区分不出来;文件被重新生成而内容没变时,修改时间也会变,导致不必要的重新下载。所以它通常作为 ETag 的备用方案。

如果响应首部字段里含有这个信息,客户端可以在后续的请求中带上 If-Modified-Since 来验证缓存。服务器只在所请求的资源在给定的日期时间之后对内容进行过修改的情况下才会将资源返回,状态码为 200 OK。如果请求的资源从那时起未经修改,那么返回一个不带有实体主体的 304 Not Modified 响应报文。

Last-Modified: Wed, 21 Oct 2015 07:28:00 GMT
If-Modified-Since: Wed, 21 Oct 2015 07:28:00 GMT

请求中同时带有 If-None-Match 和 If-Modified-Since 时,服务器只按 If-None-Match 判断,忽略 If-Modified-Since。

5. 强缓存与协商缓存

浏览器使用缓存分为两种情况:

5. 强缓存与协商缓存的配图

例如 a.js 返回 Cache-Control: max-age=600,b.js 返回 Cache-Control: no-cache,两者都带 ETag,用浏览器打开同一个页面两次,服务器收到的请求如下(Edge 156 实测,见附录 A2):

第 1 次  /a.js  无条件请求                 -> 200
         /b.js  无条件请求                 -> 200
第 2 次  /b.js  If-None-Match: "b-v1"     -> 304

第 2 次打开时 a.js 没有发出请求,直接使用了强缓存;b.js 每次都要确认,但内容没有变化时只返回 304。常见的做法是 HTML 使用 no-cache,保证总能拿到最新的页面;JS、CSS 的文件名中带上内容的哈希值,例如 app.3f2a1c.js,设置很长的 max-age,内容变化时文件名随之改变,旧的缓存自然不再被使用。

内容协商

通过内容协商返回最合适的内容,例如根据浏览器的默认语言选择返回中文界面还是英文界面。

1. 类型

1.1 服务端驱动型

客户端设置特定的 HTTP 首部字段,例如 Accept、Accept-Charset、Accept-Encoding、Accept-Language,服务器根据这些字段返回特定的资源。

它存在以下问题:

1.2 代理驱动型

服务器返回 300 Multiple Choices 或者 406 Not Acceptable,客户端从中选出最合适的那个资源。

2. Vary

Vary: Accept-Language

在使用内容协商的情况下,只有当缓存服务器中的缓存满足内容协商条件时,才能使用该缓存,否则应该向源服务器请求该资源。

例如,一个客户端发送了一个包含 Accept-Language 首部字段的请求之后,源服务器返回的响应包含 Vary: Accept-Language 内容,缓存服务器对这个响应进行缓存之后,在客户端下一次访问同一个 URL 资源,并且 Accept-Language 与缓存中的对应的值相同时才会返回该缓存。

内容编码

内容编码将实体主体进行压缩,从而减少传输的数据量。

常用的内容编码有 gzip、deflate 和 br(Brotli),identity 表示不压缩。较新的浏览器还支持 zstd,例如 Edge 156 发出的请求中带的是 Accept-Encoding: gzip, deflate, br, zstd(见附录 A5)。

浏览器发送 Accept-Encoding 首部,其中包含有它所支持的压缩算法,以及各自的优先级。服务器则从中选择一种,使用该算法对响应的消息主体进行压缩,并且发送 Content-Encoding 首部来告知浏览器它选择了哪一种算法。由于响应的内容取决于请求中的 Accept-Encoding,响应报文的 Vary 首部字段要包含 Accept-Encoding,否则缓存服务器可能把压缩过的内容返回给不支持该算法的客户端。

范围请求

如果网络出现中断,服务器只发送了一部分数据,范围请求可以使得客户端只请求服务器未发送的那部分数据,从而避免服务器重新发送所有数据。

1. Range

在请求报文中添加 Range 首部字段指定请求的范围。

GET /z4d4kWk.jpg HTTP/1.1
Host: i.imgur.com
Range: bytes=0-1023

请求成功的话服务器返回的响应包含 206 Partial Content 状态码。

HTTP/1.1 206 Partial Content
Content-Range: bytes 0-1023/146515
Content-Length: 1024
...
(binary content)

2. Accept-Ranges

响应首部字段 Accept-Ranges 用于告知客户端是否能处理范围请求,可以处理使用 bytes,否则使用 none。

Accept-Ranges: bytes

3. 响应状态码

分块传输编码

Chunked Transfer Encoding,可以把数据分割成多块,让浏览器逐步显示页面。响应中带有 Transfer-Encoding: chunked 首部,不需要 Content-Length,每一块先写明长度,再写内容,最后以长度为 0 的块结束,适合事先不知道总长度的场景,例如边生成边返回的页面。HTTP/2 有自己的分帧机制,不再使用分块传输编码。

多部分对象集合

一份报文主体内可含有多种类型的实体同时发送,每个部分之间用 boundary 字段定义的分隔符进行分隔,每个部分都可以有首部字段。

例如,上传多个表单时可以使用如下方式:

Content-Type: multipart/form-data; boundary=AaB03x

--AaB03x
Content-Disposition: form-data; name="submit-name"

Larry
--AaB03x
Content-Disposition: form-data; name="files"; filename="file1.txt"
Content-Type: text/plain

... contents of file1.txt ...
--AaB03x--

虚拟主机

HTTP/1.1 使用虚拟主机技术,使得一台服务器拥有多个域名,并且在逻辑上可以看成多个服务器。

通信数据转发

1. 代理

代理服务器接受客户端的请求,并且转发给其它服务器。

使用代理的主要目的是:

代理服务器分为正向代理和反向代理两种:

1. 代理的配图

2. 网关

与代理服务器不同的是,网关服务器会将 HTTP 转化为其它协议进行通信,从而请求其它非 HTTP 服务器的服务。

3. 隧道

使用 SSL 等加密手段,在客户端和服务器之间建立一条安全的通信线路。

WebSocket

HTTP 是请求-响应模式,服务器不能主动给客户端发消息。聊天、实时通知这类场景以前只能轮询:短轮询定时发请求,大部分请求都是空的;长轮询让服务器挂起请求,直到有新消息再响应,但每条消息仍要一次新的请求。WebSocket 在一个 TCP 连接上提供全双工通信,建立连接后双方随时都可以发送消息。

WebSocket 的配图

WebSocket 连接由一次 HTTP 请求升级而来:

GET /chat HTTP/1.1
Host: www.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: Y3Mtbm90ZXMtMjAyNi0xMA==
Sec-WebSocket-Version: 13

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: N5dfgp7eKvv+Po1vS+eAAHg5F1I=

Sec-WebSocket-Key 是客户端随机生成的 16 字节的 Base64 编码。服务器在它后面拼上协议规定的 GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11,计算 SHA-1 后再做 Base64 编码,作为 Sec-WebSocket-Accept 返回,证明自己确实支持 WebSocket,而不是把请求当作普通的 HTTP 请求处理。之后连接上传输的不再是 HTTP 报文,而是 WebSocket 帧,帧头最短只有 2 字节,不用像 HTTP 那样每次都携带首部。

WebSocket 是基于 TCP 的应用层协议。帧头中有区分文本、二进制、关闭、Ping 和 Pong 的操作码,以及载荷的长度:不超过 125 字节时直接写在第 2 个字节中,更长时再用 2 或 8 个字节表示。协议对消息大小几乎不设上限(一条消息还可以拆成多个帧),实际的上限由接收端配置,超过时以关闭码 1009(消息过大)关闭连接,例如 Tomcat 未启用分段接收时,整条二进制消息不能超过默认 8 KB 的缓冲区。

客户端发出的帧必须用 4 字节的随机掩码与数据逐字节异或,防止中间的代理把精心构造的数据当作 HTTP 请求,从而污染缓存。例如客户端发送文本 hello 的帧共 11 字节:81 85 两个字节、4 字节的掩码和 5 字节掩码后的数据;服务器发送同样的消息只需要 7 字节。

需要注意的是,长时间没有数据时,中间的代理或 NAT 可能会断开空闲的连接,所以通常定时发送 Ping 帧,对方回复 Pong 帧,以此保活并发现已经断开的连接。浏览器收到 Ping 会自动回复 Pong,但网页中的 JavaScript 不能主动发送 Ping,所以网页端一般由服务端发 Ping,或者在应用层发心跳消息。WebSocket 断开后不会自动重连,客户端要自己按逐渐增大的间隔重连,并补拉断线期间的消息,见 心跳与重连。

SSE(Server-Sent Events)是另一种服务器推送方式:它是一个普通的 HTTP 响应(HTTP/1.1 和 HTTP/2 下同样基于 TCP),Content-Type 为 text/event-stream,没有 Content-Length,服务器不断地往响应中写入 UTF-8 文本事件(二进制要先编码),每条事件以空行结束,只能由服务器发给客户端。例如推送订单 1024 的状态(curl 实测,见附录 A6):

GET /orders/1024/events HTTP/1.1
Host: 127.0.0.1:8781
Accept: text/event-stream

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache

id: 1
event: status
data: {"order": 1024, "status": "已下单"}

id: 2
event: status
data: {"order": 1024, "status": "已发货"}

浏览器的 EventSource 在连接断开后会自动重连,并在 Last-Event-ID 首部中带上最后收到的事件编号,服务器据此只发送之后的事件;重连的间隔可以用 retry 字段设置,服务器返回 204 时浏览器停止重连。心跳、续传和代理缓冲等实现细节见 流式输出。

SSE 的局限:

大模型对话是“提交一次问题、持续返回回答”,正好对应一次 POST 请求加一个流式响应,所以通常用 SSE,现有的鉴权、网关都能直接使用,而轮询的延迟和开销都更大。客户端和服务器都需要随时发消息时(例如聊天、实时语音对话),使用 WebSocket,代价是换成另一种协议,代理要专门配置转发 Upgrade 首部。

跨域

同源指的是协议、域名和端口都相同。浏览器的同源策略限制网页中的脚本读取其它源的响应,例如 https://shop.example.com 页面中的脚本请求 https://api.example.com/orders,请求能够发出去,但浏览器默认不把响应交给脚本。

同源策略保护的是用户浏览器中的身份和网络位置:没有它,用户打开一个恶意网页,网页中的脚本就能以用户的身份(带着 Cookie)请求其它网站,或者访问用户所在内网中的服务,并读取响应的内容。

跨域资源共享(Cross-Origin Resource Sharing, CORS)指的是服务器通过响应首部声明允许哪些源读取响应:

GET /orders HTTP/1.1
Host: api.example.com
Origin: https://shop.example.com

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://shop.example.com

方法是 GET、HEAD 或 POST,并且脚本只设置了 Accept、Content-Type 等少数几个首部(Content-Type 只能是表单或纯文本)时,称为简单请求,直接发送;其它请求,例如 PUT、Content-Type 为 application/json 的 POST、带 Authorization 首部的 GET,浏览器会先发一个 OPTIONS 预检请求,询问服务器是否允许,允许之后才发送真正的请求:

OPTIONS /orders/1024 HTTP/1.1
Origin: https://shop.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://shop.example.com
Access-Control-Allow-Methods: GET, PUT
Access-Control-Allow-Headers: content-type
Access-Control-Max-Age: 600

Access-Control-Max-Age 表示预检结果可以缓存的秒数,期间同一个源以相同的凭证模式访问同一个 URL,方法和首部都在已允许的范围内时,不再预检。不返回时按 5 秒;浏览器还有上限,设置得更大也只缓存这么久,按 2026 年 10 月的源码,Chromium 为 2 小时、Firefox 为 24 小时、Safari 使用的 WebKit 为 10 分钟。

跨域请求默认不携带 Cookie,前端设置 credentials: 'include' 后,服务器还要返回 Access-Control-Allow-Credentials: true,并且 Access-Control-Allow-Origin 不能是 *,必须写明具体的源;Access-Control-Allow-Methods、Access-Control-Allow-Headers 的 * 这时也不再表示全部允许。两个源属于不同的站点时(例如 shop.com 和 api.pay.com),Cookie 还要设置 SameSite=None 和 Secure 才会被携带,默认拦截第三方 Cookie 的浏览器(例如 Safari)中仍然不携带(见 Cookie 的 Secure 与 SameSite)。

用两个端口模拟两个源验证(Edge 156 实测,见附录 A3):服务器不返回 Access-Control-Allow-Origin 时,fetch 失败,但服务器的日志显示请求已经执行了;预检请求没有允许 PUT 时,浏览器不再发送 PUT;携带 Cookie 而服务器返回 * 时,fetch 同样失败。

跨域的配图

需要注意的是,CORS 并不是服务器的安全措施:简单请求在服务器上已经执行,只是响应不交给脚本(只有需要预检的请求,预检失败后才不会发送),curl 这类不经过浏览器的工具也完全不受限制,权限要靠认证和 CSRF 防护 保证。另一种解决跨域的办法是同源的反向代理,例如由 Nginx 把 https://shop.example.com/api 转发给后端,浏览器看到的就是同源的请求。

其它做法:

六、HTTPS

HTTP 有以下安全性问题:

HTTPS 并不是新协议,而是在 HTTP 和 TCP 之间加了一层 SSL(Secure Sockets Layer):HTTP 报文先交给 SSL 加密,再由 TCP 发送。HTTPS 的默认端口是 443,HTTP 是 80。

需要注意的是,SSL 已经被 TLS(Transport Layer Security)取代,SSL 3.0 在 2015 年由 RFC 7568 废弃,现在 HTTPS 实际使用的是 TLS,SSL 只是沿用的叫法。

通过使用 SSL,HTTPS 具有了加密(防窃听)、认证(防伪装)和完整性保护(防篡改)。

例如同一个登录请求 POST /login,表单为 user=alice&password=123456,分别用 HTTP 和 HTTPS 发给 shop.example,同时抓包(OpenSSL 3.0 实测,见附录 A8):HTTP 的报文中可以直接读到请求行和密码;HTTPS 的报文中找不到 /login 和 password,只能看到域名 shop.example。这是因为未启用加密 ClientHello(Encrypted Client Hello, ECH)时,客户端在 ClientHello 的 SNI(Server Name Indication)扩展中以明文告诉服务器要访问哪个域名,服务器据此选择证书。需要注意的是,HTTPS 保护的是报文的内容,访问了哪个 IP、传输了多少数据,仍然可以被网络中的设备看到,未启用 ECH 时域名也是如此。

六、HTTPS 的配图

HTTPS 只防网络中的窃听、冒充服务器和篡改。SQL 注入、XSS、CSRF 利用的是网站本身的漏洞,HTTPS 无法防范。

打开 http://shop.example 时,浏览器可能先用明文的 HTTP 访问,服务器再返回 301 跳转到 HTTPS。攻击者可以拦下这次跳转,自己与服务器用 HTTPS 通信,与用户继续用 HTTP 通信,称为 SSL 剥离。HTTP 严格传输安全(HTTP Strict Transport Security, HSTS)用来解决这个问题:github.com 的 HTTPS 响应带有首部 Strict-Transport-Security: max-age=31536000; includeSubdomains; preload,浏览器在之后一年内访问这个域名及其子域名都直接使用 HTTPS。第一次访问仍有风险,域名加入浏览器内置的 HSTS 预加载列表后才能避免。

反过来,HTTPS 页面通过 HTTP 加载资源称为混合内容,浏览器会阻止其中的脚本,图片等资源则改用 HTTPS 加载或者阻止。

加密

1. 对称密钥加密

对称密钥加密(Symmetric-Key Encryption),加密和解密使用同一密钥。

1. 对称密钥加密的配图

2. 非对称密钥加密

非对称密钥加密,又称公开密钥加密(Public-Key Encryption),加密和解密使用不同的密钥。

公开密钥所有人都可以获得,通信发送方获得接收方的公开密钥之后,就可以使用公开密钥进行加密,接收方收到通信内容后使用私有密钥解密。

非对称密钥除了用来加密,还可以用来进行签名。因为私有密钥无法被其他人获取,因此通信发送方使用其私有密钥进行签名,通信接收方使用发送方的公开密钥对签名进行验证,就能判断这个签名是否正确。

例如在 Apple M1 Pro 上,RSA-2048 用私钥解密每秒约 1600 次,每次只能处理两百多字节,合计不到 0.4 MB/s;对称加密 AES-128-GCM 每秒能加密约 6 GB(OpenSSL 3.6 实测,见附录 A14)。所以非对称加密只适合处理密钥、签名这类很短的数据。

2. 非对称密钥加密的配图

3. HTTPS 采用的加密方式

HTTPS 采用混合的加密机制,用非对称加密解决密钥的传输,用对称加密保证效率:

需要注意的是,用公钥加密传输密钥是 TLS 1.2 的 RSA 密钥交换,现在的握手大多改用 ECDHE 协商会话密钥,证书的密钥只用于签名,TLS 1.3 已删除 RSA 密钥交换,见 TLS 握手。

TLS 把用到的一组算法称为密码套件,例如访问 www.baidu.com 时协商出的 TLS 1.2 套件 ECDHE-RSA-AES128-GCM-SHA256(LibreSSL 3.3.6 实测,见附录 A7):

TLS 1.3 的套件名称只包含后两项,例如访问 www.jd.com 时为 TLS_AES_256_GCM_SHA384,密钥交换(X25519)和签名算法单独协商。另一种常用的对称算法是 ChaCha20-Poly1305,在没有 AES 硬件加速的设备上更快;DES、3DES 和 RC4 已经不安全,TLS 1.3 不再支持。

3. HTTPS 采用的加密方式的配图

认证

通过使用证书来对通信方进行认证。

数字证书认证机构(CA,Certificate Authority)是客户端与服务器双方都可信赖的第三方机构。

服务器的运营人员把域名和服务器的公开密钥提交给 CA,CA 核实申请者确实控制这个域名之后,用自己的私钥对域名、公钥等信息签名,生成证书。

进行 HTTPS 通信时,服务器会把证书发送给客户端。客户端先验证证书上 CA 的签名,验证通过才相信证书中的公钥属于这个域名,再用它完成握手。

认证的配图

证书中的签名是 CA 用自己的私钥对证书内容(域名、公钥、有效期、签发者等)的哈希值计算出来的。客户端用上一级证书中的公钥验证签名:证书内容只要被改动一个字节,哈希值就会变化,签名验证就会失败。例如把 baidu.com 证书中的 CN 改成 baidu.con,再用中间证书验证,结果为 certificate signature failure(OpenSSL 3.0 实测,见附录 A9)。

根证书是信任的起点,它是自签名的,无法再向上验证,因此通常不在握手中发送,而是预装在操作系统或浏览器的信任列表中。客户端沿证书链逐级验证,链的最上端必须是信任列表中的根证书。除了签名,客户端还要检查证书是否在有效期内、是否已被吊销,以及访问的域名是否在证书的 Subject Alternative Name 中,例如 baidu.com 的证书列出了 53 个域名,其中的 *.baidu.com 匹配 www.baidu.com。

证书在有效期内也可能被提前吊销,例如私钥泄露。查询方式有两种,证书中可以写明相应的地址(baidu.com 的证书两者都有,见附录 A9):证书吊销列表(Certificate Revocation List, CRL)是 CA 定期发布的全部被吊销证书的序列号;在线证书状态协议(Online Certificate Status Protocol, OCSP)只查询这一张证书。每次连接都去问 CA 既慢又暴露用户的访问记录,所以服务器可以在握手时附带 CA 签过名的 OCSP 响应,称为 OCSP Stapling;浏览器也大多改为定期下载厂商汇总的吊销列表。

中间人截获了连接也无法冒充服务器:直接转发服务器的证书,它没有对应的私钥,无法完成握手;换成自己的证书,又拿不到 CA 为这个域名签发的证书,验证会失败。用 curl 模拟这两种情况(见附录 A9),分别报告证书中的域名与访问的域名不匹配(no alternative certificate subject name matches target host name)和证书不受信任(self-signed certificate)。

Charles、Fiddler 等抓包工具能解密 HTTPS,是因为用户手动把它们的根证书加入了信任列表,工具再为访问的每个域名临时签发证书。App 可以在代码中只信任指定的证书或公钥,称为证书固定(Certificate Pinning),这时加入根证书也无法抓包。

TLS 握手

TLS 握手完成三件事:协商加密算法、验证服务器的证书、协商出会话密钥。目前常用的密钥交换方式是 ECDHE:双方各自临时生成一对椭圆曲线密钥,把公钥发给对方,再用自己的私钥和对方的公钥算出相同的共享密钥。会话密钥本身从不在网络上传输,即使服务器的证书私钥日后泄露,之前录下的通信也无法解密,称为前向安全。服务器还要用证书的私钥对握手内容签名,证明自己就是证书的持有者。

下图是用 openssl s_client 连接两个网站时记录的握手报文(见附录 A4):

TLS 握手的配图

TLS 1.2 要 2 个 RTT 之后才能发送 HTTP 请求。TLS 1.3 把 ECDHE 公钥直接放在 ClientHello 中,服务器回复 ServerHello 时双方就能算出密钥,之后的握手报文(图中花括号内)都已加密,1 个 RTT 后就能发送请求。如果客户端猜错了服务器支持的曲线,服务器会回复 HelloRetryRequest 要求重发 ClientHello,多花 1 个 RTT。TLS 1.3 还去掉了 RSA 密钥交换等不安全的算法。

1. 握手报文的内容

以 TLS 1.2 的 ECDHE 握手为例,各报文携带的主要内容如下(OpenSSL 3.0 本地实测,见附录 A10):

客户端验证证书,再用证书中的公钥验证 ServerKeyExchange 中的签名,确认 ECDHE 公钥确实来自证书的持有者。之后双方用自己的私钥和对方的公钥算出相同的预主密钥(pre-master secret),再把预主密钥和两个随机数一起输入伪随机函数(启用扩展主密钥时两个随机数换成从 ClientHello 到 ClientKeyExchange 这些握手报文的哈希,OpenSSL 3 默认启用),得到 48 字节的主密钥,最后由主密钥派生出加密和完整性校验用的会话密钥。

2. 密钥交换

ECDHE 基于 Diffie-Hellman(DH)密钥交换,双方只交换公钥,就能得到相同的密钥。用小数字演示如下,公开的参数是素数 p = 29 和底数 g = 2:

客户端                                  服务器
私钥 a = 9,不发送                      私钥 b = 13,不发送
A = 2^9 mod 29 = 19           发送 A →
                              ← 发送 B  B = 2^13 mod 29 = 14
B^a mod 29 = 14^9 mod 29 = 3            A^b mod 29 = 19^13 mod 29 = 3

两边算出的都是 3,这是因为 B^a 和 A^b 都等于 2^(ab) mod 29。窃听者知道 p、g、A、B,要算出 3,就要从 19 = 2^a mod 29 反推出 a,这称为离散对数问题。29 这样的小数字可以逐个尝试,实际使用的 X25519 是 255 位的椭圆曲线,目前没有可行的算法。ECDHE 中的 E(Ephemeral)表示每次握手都生成新的私钥,用完即丢弃。

DH 本身无法防止中间人:攻击者可以把 A、B 换成自己的公钥,分别与客户端和服务器各完成一次密钥交换。所以服务器要用证书私钥对自己的 ECDHE 公钥签名,客户端验证签名后才使用它。

TLS 1.2 还支持 RSA 密钥交换:客户端生成 48 字节的随机预主密钥,用服务器证书中的 RSA 公钥加密后发给服务器(例子中加密后为 256 字节),只有持有私钥的服务器能解密。常说的“三个随机数”指的就是这种方式中的客户端随机数、服务器随机数和预主密钥。前两个以明文传输,作用是让每次连接的会话密钥都不同,攻击者即使重放录下的握手报文,算出的密钥也和原来不一样;需要保密的只有预主密钥。

RSA 密钥交换没有前向安全:攻击者录下通信,日后拿到服务器的私钥,就能解密录下的预主密钥,进而解密全部通信。ECDHE 的私钥在握手后就丢弃,证书私钥只用来签名,泄露后也无法还原以前的会话密钥。因此 TLS 1.3 删除了 RSA 密钥交换,完整握手只能使用 (EC)DHE 这类前向安全的方式;2026 年 7 月发布的 RFC 10015 进一步要求 TLS 1.2 的客户端不再提供、服务器不再选择 RSA 密钥交换的密码套件。

Chrome 131、OpenSSL 3.5 起,TLS 1.3 握手默认提供 X25519MLKEM768,它把 X25519 的 ECDHE 与后量子算法 ML-KEM-768 组合起来,防止现在录下的通信将来被量子计算机解密,是否选用由服务器决定。它的密钥同样每次握手临时生成,仍然前向安全。

3. Finished 与会话恢复

Finished 报文的内容是用主密钥对此前全部握手报文的哈希值计算出的校验值(TLS 1.2 中为 12 字节)。握手的前几个报文是明文,攻击者可以篡改,例如删掉 ClientHello 中安全的密码套件,迫使双方使用弱算法,称为降级攻击。双方各自根据自己收发的报文计算校验值,报文被改过,两边的结果就对不上,握手失败。

完整的握手要验证证书和签名,开销较大。客户端再次连接同一个服务器时可以恢复会话:服务器在握手后发给客户端一个会话票据(即上面 TLS 1.2 握手中的 NewSessionTicket),客户端下次连接时带上它,双方直接由之前协商的密钥派生出新的会话密钥,不再验证证书。

TLS 1.3 恢复会话时通常还会再做一次 ECDHE,即使票据对应的密钥泄露,握手之后的应用数据密钥也算不出来;只用票据派生密钥则没有前向安全。

TLS 1.3 恢复会话时,客户端还可以在第一个报文中就带上请求,称为 0-RTT。0-RTT 的数据只用票据派生的密钥加密,不享有上面的前向安全保证。需要注意的是,0-RTT 的数据可以被攻击者录下后重放,通常只有 GET 这类安全的请求才放在 0-RTT 中发送。TLS 只要求每个服务器实例对同一次握手的 0-RTT 数据最多接受一次(例如每张票据只能用一次),客户端重试造成的重复请求仍要由应用处理;服务器不想冒险时可以返回 425 Too Early,让客户端在握手完成后重发。

4. 一次 HTTPS 请求的过程

以 TLS 1.3 为例,浏览器访问 https://www.jd.com/ 依次经过:

  1. DNS 查询,得到服务器的 IP 地址,过程见域名系统;
  2. TCP 三次握手,1 个 RTT;
  3. TLS 握手,1 个 RTT,协商算法和密钥并验证证书;
  4. 用会话密钥加密 HTTP 请求并发送,服务器解密后处理,响应同样加密后返回。

TLS 1.2 的第 3 步需要 2 个 RTT。连接建立以后,同一个连接上的后续请求只需要第 4 步。从 DHCP、ARP 到渲染页面的完整过程见 Web 页面请求过程。

完整性保护

SSL 提供报文摘要功能来进行完整性保护。

HTTP 也提供了 MD5 报文摘要功能,但不是安全的。例如报文内容被篡改之后,同时重新计算 MD5 的值,通信接收方是无法意识到发生了篡改。

HTTPS 的报文摘要功能之所以安全,是因为计算摘要时用到了握手协商出的会话密钥(HMAC,或者 AES-GCM 等算法自带的认证标签)。攻击者没有这个密钥,篡改报文之后无法算出正确的摘要,接收方校验失败就会断开连接。

HTTPS 的缺点

速度上的主要开销是建立连接时的 TLS 握手。例如直连 www.baidu.com(TLS 1.2)时,TCP 建连约 12 ms,TLS 握手约 34 ms;www.jd.com(TLS 1.3)的 TLS 握手约 15 ms,与 TCP 建连的 13 ms 相当(各 5 次的中位数,macOS 实测,见附录 A11)。对称加密本身很快,现代 CPU 有 AES 指令加速;长连接和会话恢复可以避免每次请求都完整握手。

七、HTTP/1.1 新特性

与 HTTP/1.0 相比,HTTP/1.1 主要增加了以下特性,详细内容见上文:

八、HTTP/2.0

HTTP/1.x 缺陷

HTTP/1.x 实现简单是以牺牲性能为代价的:

二进制分帧层

HTTP/2.0 将报文分成 HEADERS 帧和 DATA 帧,它们都是二进制格式的。

二进制分帧层的配图

在通信过程中,客户端与同一个服务器通常只建立一个 TCP 连接,它承载了任意数量的双向数据流(Stream)。

二进制分帧层的配图

一个连接上的并发流数也有上限,由对方在 SETTINGS 帧的 SETTINGS_MAX_CONCURRENT_STREAMS 中声明,规范建议不小于 100,例如 Nginx 默认为 128,www.example.com 声明的是 256(2026-10-30 实测,见附录 A12)。超过上限的请求要等前面的流结束,但仍然不需要新建连接。

浏览器只支持基于 TLS 的 HTTP/2:客户端在 ClientHello 的 ALPN 扩展中列出 h2 和 http/1.1,服务器选定 h2 后直接使用 HTTP/2。不加密的 HTTP/2(h2c)一般只用在内网的服务之间,例如明文的 gRPC 连接。

服务端推送

HTTP/2.0 允许服务端在客户端请求一个资源时,把相关的资源一起发送给客户端,客户端就不需要再次发起请求了。例如客户端请求 page.html 页面,服务端就把 script.js 和 style.css 等与之相关的资源一起发给客户端。

服务端推送的配图

需要注意的是,Chrome 106 起默认禁用了服务端推送,其它基于 Chromium 的浏览器也随之跟进,Firefox 132(2024 年 10 月)也移除了这个功能。需要提前加载资源时,常用 103 Early Hints 或者 preload 提示浏览器,由浏览器决定是否请求。

首部压缩

HTTP/1.1 的首部带有大量信息,而且每次都要重复发送。

HTTP/2.0 的首部压缩称为 HPACK,客户端和服务器各自维护两张表:静态表是协议规定的 61 个常见首部,例如第 2 项是 :method: GET;动态表保存本连接中出现过的首部。表中已有的首部通常只发送 1 字节的序号,新的值用 Huffman 编码压缩后发送。

例如在同一个连接上先后请求 /index.html 和 /style.css,第二次请求的 User-Agent 等首部都已经在动态表中,编码后只有 21 字节,同样的内容用 HTTP/1.1 的文本格式是 148 字节:

首部压缩的配图

HTTP/3

HTTP/2 在一个 TCP 连接上复用多个流,但 TCP 只保证整个字节流按序到达:一个报文段丢失时,后面已经到达的数据也要等它重传,所有的流都被阻塞,称为队头阻塞。

HTTP/3 改为基于 UDP 的 QUIC 协议:

需要注意的是,QUIC 并没有消除全部的队头阻塞:同一个流内仍要按顺序交付;首部压缩 QPACK 引用了解码端还没有收到的动态表条目时,这个流也要等待(RFC 9000 §2.2,RFC 9204 §2.1.2)。此外,连接级的流量控制额度用完时,这个方向上所有的流都不能再发送新数据(RFC 9000 §4.1)。

连接迁移时,客户端从新地址继续发包,通常换用服务器事先发给它的另一个连接 ID,避免旁观者通过相同的连接 ID 关联新旧路径;服务器先用 PATH_CHALLENGE 和 PATH_RESPONSE 帧验证新地址,验证完成前发往这个地址的数据不超过从它收到的 3 倍。迁移只能由客户端在握手确认之后发起(RFC 9000 §8、§9)。

HTTP/3 的配图

UDP 本身不保证可靠,QUIC 在它之上自己实现了确认、重传和拥塞控制。之所以不直接改进 TCP,是因为 TCP 实现在操作系统内核中,中间设备还会检查 TCP 首部,新特性很难普及,见 在 UDP 上实现可靠传输。

浏览器通常先用 HTTP/2 访问,从响应首部 Alt-Svc 得知服务器支持 HTTP/3 后再切换,例如 www.example.com 的响应中有 alt-svc: h3=":443"; ma=86400。

HTTP/3 并不总是更快:优势主要体现在丢包多、延迟高、经常切换网络的移动场景,网络良好时与 HTTP/2 差别不大;部分网络会屏蔽 UDP,这时浏览器回退到基于 TCP 的 HTTP/2。

九、GET 和 POST 比较

作用

GET 用于获取资源,而 POST 用于传输实体主体。

参数

GET 和 POST 的请求都能使用额外的参数,但是 GET 的参数是以查询字符串出现在 URL 中,而 POST 的参数存储在实体主体中。不能因为 POST 参数存储在实体主体中就认为它的安全性更高,因为照样可以通过一些抓包工具(Fiddler)查看。

因为 URL 只支持 ASCII 码,因此 GET 的参数中如果存在中文等字符就需要先进行编码。例如 中文 会转换为 %E4%B8%AD%E6%96%87,而空格会转换为 %20。POST 请求体的编码由 Content-Type 决定,例如表单同样按这种方式编码,JSON 则直接用 UTF-8。

参数放在哪里只是惯例:POST 也可以同时带查询参数,例如 POST /orders?dryRun=true;GET 的请求体在规范中没有定义含义,有的服务器和代理会忽略或者拒绝,不应该依赖。

HTTP 规范没有限制 URL 和请求体的长度,限制来自浏览器和服务器的实现,例如 Nginx 默认请求行不能超过 8 KB(超出返回 414),请求体不能超过 1 MB(超出返回 413)。参数很长或者要上传文件时用 POST。

GET /test/demo_form.asp?name1=value1&name2=value2 HTTP/1.1
POST /test/demo_form.asp HTTP/1.1
Host: w3schools.com
Content-Type: application/x-www-form-urlencoded

name1=value1&name2=value2

安全

安全的 HTTP 方法在语义上是只读的,服务器顺带写访问日志、更新统计不影响它是安全的。

GET 方法是安全的,而 POST 却不是,因为 POST 的目的是传送实体主体内容,这个内容可能是用户上传的表单数据,上传成功之后,服务器可能把这个数据存储到数据库中,因此状态也就发生了改变。

安全的方法除了 GET 之外还有:HEAD、OPTIONS、TRACE。

不安全的方法除了 POST 之外还有 PUT、DELETE。

幂等性

幂等的 HTTP 方法,同样的请求执行一次与连续执行多次,对服务器的效果是一样的。幂等看的是对服务器状态的效果,不要求每次的响应相同,也不要求请求不改变状态。

所有的安全方法也都是幂等的。在正确实现的条件下,GET、HEAD、PUT 和 DELETE 等方法都是幂等的,而 POST 方法不是。

PUT 是幂等的,因为它用请求中的内容整个替换资源,执行多少次,订单 1024 的状态都是“已发货”:

PUT /orders/1024 {"status": "已发货"}   -> 200
PUT /orders/1024 {"status": "已发货"}   -> 200,状态不变

POST /add_row HTTP/1.1 不是幂等的,如果调用多次,就会增加多行记录:

POST /add_row HTTP/1.1   -> Adds a 1st row
POST /add_row HTTP/1.1   -> Adds a 2nd row
POST /add_row HTTP/1.1   -> Adds a 3rd row

DELETE /idX/delete HTTP/1.1 是幂等的,即使不同的请求接收到的状态码不一样:

DELETE /idX/delete HTTP/1.1   -> Returns 200 if idX exists
DELETE /idX/delete HTTP/1.1   -> Returns 404 as it just got deleted
DELETE /idX/delete HTTP/1.1   -> Returns 404

幂等决定了请求失败后能不能放心地重试:客户端没收到响应时,重发 PUT、DELETE 不会多出数据,重发 POST 可能会重复下单。代理和网关也按方法决定能否重试,例如 Nginx 默认不会把已经发给一台后端的 POST、PATCH 再转给下一台,PUT、DELETE 在出错或超时时则可能被重试,所以这两个方法的实现必须真的幂等。让 POST 也能安全重试的做法见 幂等。

可缓存

如果要对响应进行缓存,需要满足以下条件:

XMLHttpRequest

XMLHttpRequest 是浏览器提供的 API,用于在不刷新页面的情况下向服务器发送请求,是 AJAX 的基础,现在更常用的是 fetch()。

网上流传一种说法:XMLHttpRequest 发送 POST 时,会先发送首部,再单独发送请求体,GET 则一次发完。HTTP 规范中没有这样的规定,这只是个别浏览器早期的实现细节,不能作为 GET 和 POST 的区别。用 Edge 记录网络日志,通过 XMLHttpRequest 发送一个带 21 字节请求体的 POST 请求,首部和请求体是在同一次 socket 写入中发出的,共 723 字节(Edge 156 实测,见附录 A5)。

附录:实测

以下实验的环境为 Apple M1 Pro、macOS,浏览器为 Edge 156,服务器都是用 Python 标准库 http.server 写的最小实现,运行在 127.0.0.1 上。

A1 OAuth 2.0 授权码模式

授权服务器监听 9001 端口,笔记网站的回调地址在 9002 端口。授权码 10 分钟过期、只能用一次,换令牌时检查 redirect_uri,并核对 SHA-256(code_verifier) 是否等于第 ① 步的 code_challenge:

verifier_hash = base64.urlsafe_b64encode(hashlib.sha256(q.get("code_verifier", "").encode()).digest()).rstrip(b"=").decode()
if not c or c["exp"] < time.time() or c["redirect_uri"] != q.get("redirect_uri") or c["challenge"] != verifier_hash:
    return self._json(400, {"error": "invalid_grant"})
① GET http://127.0.0.1:9001/authorize?response_type=code&client_id=notes-app&redirect_uri=http%3A%2F%2F127.0.0.1%3A9002%2Fcallback&scope=profile&state=x7Kp2&code_challenge=RQgc0f_GtuKCUitkJiCz7wNdN-_2JLHQ0JGLD9XxGtg&code_challenge_method=S256
② 302 Location: http://127.0.0.1:9002/callback?code=8XNe0QukdUBz_2wmZ34JLA&state=x7Kp2
③ POST /token grant_type=authorization_code&code=8XNe0QukdUBz_2wmZ34JLA&redirect_uri=http%3A%2F%2F127.0.0.1%3A9002%2Fcallback&client_id=notes-app&code_verifier=notes-demo-verifier-0123456789abcdefghijklmnop
   -> {"access_token": "7B0Gmo164bDXm_sgupjN4O9ANvUg1_SK", "token_type": "Bearer", "expires_in": 3600}
④ GET /userinfo -> {"sub": "zhangsan", "name": "张三"}
再用一次同一个授权码 -> 400 {"error": "invalid_grant"}
截获授权码但没有 code_verifier -> 400 {"error": "invalid_grant"}

A2 强缓存与协商缓存

页面引用 a.js 和 b.js,服务器对它们的响应首部分别为 Cache-Control: max-age=600、ETag: "a-v1" 和 Cache-Control: no-cache、ETag: "b-v1",收到的 If-None-Match 与 ETag 相同时返回 304。用 Edge 打开页面两次,服务器的日志:

/        无条件请求                        -> 200
/a.js    无条件请求                        -> 200
/b.js    无条件请求                        -> 200
/favicon.ico 无条件请求                        -> 200
/        无条件请求                        -> 200
/b.js    If-None-Match: "b-v1" -> 304
/favicon.ico 无条件请求                        -> 200

另一个页面引用 style.css、logo.png 和 app.js,响应首部都带 Cache-Control: max-age=3600。用 Edge 156 的开发者协议打开页面后,依次普通刷新(Page.reload)和强制刷新(Page.reload,ignoreCache 为 true),服务器收到的请求:

普通刷新  /           Cache-Control: max-age=0, If-Modified-Since   -> 304
强制刷新  /           Cache-Control: no-cache, Pragma: no-cache     -> 200
强制刷新  /style.css  Cache-Control: no-cache, Pragma: no-cache     -> 200
强制刷新  /logo.png   Cache-Control: no-cache, Pragma: no-cache     -> 200
强制刷新  /app.js     Cache-Control: no-cache, Pragma: no-cache     -> 200

普通刷新时,三个子资源都没有发出请求。

A3 跨域

页面在 127.0.0.1:8001,接口在 127.0.0.1:8002。/plain 不返回 Access-Control-Allow-Origin;/cors 返回它,但预检时不返回 Access-Control-Allow-Methods;/full 的预检允许 PUT;/star 返回 Access-Control-Allow-Origin: *。用无头模式的 Edge 打开页面,页面中 fetch 的结果和接口服务器的日志:

1 GET no-cors-header FAIL TypeError: Failed to fetch
2 GET with ACAO OK 200 data
3 PUT json, preflight denied FAIL TypeError: Failed to fetch
4 PUT json, preflight allowed OK 200 updated
5 credentials with ACAO * FAIL TypeError: Failed to fetch

API GET /plain {'Origin': 'http://127.0.0.1:8001'}
API GET /cors {'Origin': 'http://127.0.0.1:8001'}
API OPTIONS /cors {'Access-Control-Request-Method': 'PUT', 'Access-Control-Request-Headers': 'content-type', 'Origin': 'http://127.0.0.1:8001'}
API OPTIONS /full {'Access-Control-Request-Method': 'PUT', 'Access-Control-Request-Headers': 'content-type', 'Origin': 'http://127.0.0.1:8001'}
API PUT /full {'Origin': 'http://127.0.0.1:8001'}
API GET /star {'Origin': 'http://127.0.0.1:8001'}

第 1 个请求在服务器上已经执行了,只是响应没有交给脚本;第 3 个请求预检之后没有发出 PUT。

A4 TLS 握手

openssl 为 macOS 自带的 LibreSSL 3.3.6:

$ openssl s_client -connect www.baidu.com:443 -servername www.baidu.com -tls1_2 -msg
>>> TLS 1.3 Handshake [length 00d3], ClientHello
<<< TLS 1.3 Handshake [length 003b], ServerHello
<<< TLS 1.2 Handshake [length 12a3], Certificate
<<< TLS 1.2 Handshake [length 014d], ServerKeyExchange
<<< TLS 1.2 Handshake [length 0004], ServerHelloDone
>>> TLS 1.2 Handshake [length 0046], ClientKeyExchange
>>> TLS 1.2 ChangeCipherSpec [length 0001]
>>> TLS 1.2 Handshake [length 0010], Finished
<<< TLS 1.2 Handshake [length 00aa], NewSessionTicket
<<< TLS 1.2 ChangeCipherSpec [length 0001]
<<< TLS 1.2 Handshake [length 0010], Finished
    Protocol  : TLSv1.2

$ openssl s_client -connect www.example.com:443 -servername www.example.com -tls1_3 -msg
>>> TLS 1.3 Handshake [length 00ce], ClientHello
<<< TLS 1.3 Handshake [length 007a], ServerHello
<<< TLS 1.3 Handshake [length 000a], EncryptedExtensions
<<< TLS 1.3 Handshake [length 0e66], Certificate
<<< TLS 1.3 Handshake [length 004f], CertificateVerify
<<< TLS 1.3 Handshake [length 0024], Finished
>>> TLS 1.3 Handshake [length 0024], Finished

A5 XMLHttpRequest 发送 POST

页面先用 XMLHttpRequest 发送 GET /api?m=GET,再发送 POST /api?m=POST,请求体为 user=alice&amount=100(21 字节)。Edge 加上 --log-net-log 启动,从日志中取出每次 SOCKET_BYTES_SENT 事件写入的字节数和内容的开头、结尾:

740 b'GET / HTTP/1.1\r\nHost: 127.0.0.1:8770\r\nConnection: keep-alive' ... b'en-US;q=0.5\r\n\r\n'
649 b'GET /api?m=GET HTTP/1.1\r\nHost: 127.0.0.1:8770\r\nConnection: k' ... b'en-US;q=0.5\r\n\r\n'
723 b'POST /api?m=POST HTTP/1.1\r\nHost: 127.0.0.1:8770\r\nConnection:' ... b'q=0.5\r\n\r\nuser=alice&amount=100'
666 b'GET /favicon.ico HTTP/1.1\r\nHost: 127.0.0.1:8770\r\nConnection:' ... b'en-US;q=0.5\r\n\r\n'

POST 的首部和请求体在同一次写入中。第一个请求的首部中有 Accept-Encoding: gzip, deflate, br, zstd。

A6 SSE 推送与断线重连

服务器用 http.server 实现,GET /orders/1024/events 依次写入 4 条事件后关闭连接,请求带有 Last-Event-ID 时只发送它之后的事件:

last = int(self.headers.get("Last-Event-ID", 0))
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.end_headers()                       # 没有 Content-Length,连接保持打开
for i in range(last + 1, len(EVENTS) + 1):
    self.wfile.write(f"id: {i}\nevent: status\ndata: {{\"order\": 1024, \"status\": \"{EVENTS[i-1]}\"}}\n\n".encode())
    self.wfile.flush(); time.sleep(0.5)
$ curl -v -N -H "Accept: text/event-stream" http://127.0.0.1:8781/orders/1024/events
> GET /orders/1024/events HTTP/1.1
> Host: 127.0.0.1:8781
> Accept: text/event-stream
< HTTP/1.1 200 OK
< Content-Type: text/event-stream
< Cache-Control: no-cache
id: 1
event: status
data: {"order": 1024, "status": "已下单"}
...
id: 4
event: status
data: {"order": 1024, "status": "已签收"}

$ curl -N -H "Accept: text/event-stream" -H "Last-Event-ID: 2" http://127.0.0.1:8781/orders/1024/events
id: 3
...
id: 4
...

再用无头模式的 Edge 打开一个使用 EventSource 的页面:第 1 次连接先发送 retry: 500,再发送事件 1、2 后断开;第 2 次连接发送之后的 2 条事件后断开;第 3 次连接返回 204。服务器的日志和页面中记录的事件:

第 1 次连接 Accept='text/event-stream' Last-Event-ID=None
第 2 次连接 Accept='text/event-stream' Last-Event-ID='2'
第 3 次连接 Accept='text/event-stream' Last-Event-ID='4'

4ms id=1 已下单
4ms id=2 已发货
4ms error readyState=0
504ms id=3 运输中
504ms id=4 已签收
514ms error readyState=0
1014ms error readyState=2

readyState 为 0 表示正在重连,为 2 表示已经关闭、不再重连。

A7 证书链与密码套件

openssl 为 macOS 自带的 LibreSSL 3.3.6,LibreSSL 把 TLS 1.3 的套件 TLS_AES_256_GCM_SHA384 显示为 AEAD-AES256-GCM-SHA384:

$ openssl s_client -connect www.baidu.com:443 -servername www.baidu.com
depth=2 OU = GlobalSign Root CA - R3, O = GlobalSign, CN = GlobalSign
depth=1 C = BE, O = GlobalSign nv-sa, CN = GlobalSign RSA OV SSL CA 2018
depth=0 C = CN, ST = Beijing, L = Beijing, O = "Beijing Baidu Netcom Science Technology Co., Ltd.", CN = baidu.com
Server Temp Key: ECDH, P-256, 256 bits
    Protocol  : TLSv1.2
    Cipher    : ECDHE-RSA-AES128-GCM-SHA256
    Verify return code: 0 (ok)

$ openssl s_client -connect www.jd.com:443 -servername www.jd.com
depth=2 OU = GlobalSign Root CA - R3, O = GlobalSign, CN = GlobalSign
depth=1 C = BE, O = GlobalSign nv-sa, CN = GlobalSign RSA OV SSL CA 2018
depth=0 C = CN, ST = Beijing, L = Beijing, O = "BEIJING JINGDONG SHANGKE INFORMATION TECHNOLOGY CO., LTD.", CN = *.jd.com
Server Temp Key: ECDH, X25519, 253 bits
Server public key is 2048 bit
    Protocol  : TLSv1.3
    Cipher    : AEAD-AES256-GCM-SHA384
    Verify return code: 0 (ok)

A8 HTTP 与 HTTPS 的抓包对比

Docker Desktop 中的 Ubuntu 24.04 容器,OpenSSL 3.0.13,curl 8.5.0。用 openssl 为 shop.example 生成自签名证书,Python 的 http.server 在 8080 端口提供 HTTP、在 8443 端口提供 HTTPS,curl 用 --resolve 把 shop.example 解析到 127.0.0.1,各发送一次登录请求,同时用 tcpdump -A 在回环网卡上抓包:

curl --resolve shop.example:8080:127.0.0.1 -d 'user=alice&password=123456' http://shop.example:8080/login
curl --cacert cert.pem --resolve shop.example:8443:127.0.0.1 -d 'user=alice&password=123456' https://shop.example:8443/login

HTTP 的报文(节选):

IP 127.0.0.1.35942 > 127.0.0.1.8080: Flags [P.], seq 1:182, ack 1, length 181: HTTP: POST /login HTTP/1.1
POST /login HTTP/1.1
Host: shop.example:8080
User-Agent: curl/8.5.0
Content-Length: 26
Content-Type: application/x-www-form-urlencoded

user=alice&password=123456

HTTPS(协商出的版本为 TLS 1.3)的报文中,可读的字符串只有客户端第一个报文(ClientHello,517 字节)中的域名:

IP 127.0.0.1.59008 > 127.0.0.1.8443: Flags [P.], seq 1:518, ack 1, length 517
...9.....3.....=.<.5./.....u.........shop.example.........
...............................h2.http/1.1.........1.....*.(...........

8443 端口的全部报文中:包含 password:False;包含 /login:False;包含 shop.example:True

A9 证书验证

同一个容器,OpenSSL 3.0.13。baidu.com 的服务器证书和中间证书由 openssl s_client -showcerts 取得(2026-10-28),证书有效期为 2026-07-09 至 2027-01-24,Subject Alternative Name 中共 53 个域名,包含 *.baidu.com。用中间证书验证服务器证书,再把证书中 CN 的 baidu.com 改为 baidu.con(只改 1 个字节)后验证:

$ openssl verify -partial_chain -CAfile baidu_inter.pem baidu_leaf.pem
baidu_leaf.pem: OK
$ openssl verify -partial_chain -CAfile baidu_inter.pem tampered.pem
C = CN, ST = Beijing, L = Beijing, O = "Beijing Baidu Netcom Science Technology Co., Ltd.", CN = baidu.con
error 7 at 0 depth lookup: certificate signature failure
error tampered.pem: verification failed

用 openssl s_server 加载 A8 中签发给 shop.example 的证书,curl 以另一个域名访问,或者不把这张证书加入信任:

$ curl --cacert cert.pem --resolve evil.example:9443:127.0.0.1 https://evil.example:9443/
curl: (60) SSL: no alternative certificate subject name matches target host name 'evil.example'
$ curl --resolve shop.example:9443:127.0.0.1 https://shop.example:9443/
curl: (60) SSL certificate problem: self-signed certificate

证书中的吊销查询地址(2026-10-30,LibreSSL 3.3.6 的 openssl s_client -status 连接 www.baidu.com,再用 openssl x509 -text 查看服务器证书):

OCSP response: no response sent
            Authority Information Access:
                CA Issuers - URI:http://secure.globalsign.com/cacert/gsrsaovsslca2018.crt
                OCSP - URI:http://ocsp.globalsign.com/gsrsaovsslca2018
            X509v3 CRL Distribution Points:
                Full Name:
                  URI:http://crl.globalsign.com/gsrsaovsslca2018.crl

A10 TLS 握手报文与密钥交换

同一个容器,OpenSSL 3.0.13,openssl s_server 加载一张 RSA 2048 位的自签名证书,openssl s_client -trace 分别以 TLS 1.2 和 TLS 1.3 连接(节选):

$ openssl s_client -connect 127.0.0.1:9443 -servername shop.example -tls1_2 -trace
    ClientHello, Length=200
      client_version=0x303 (TLS 1.2)
      Random:
        gmt_unix_time=0xF8A84002
        random_bytes (len=28): C71DE57BA94C8B46C0BCF0E81D006A4BB4DB80625A55EA34C1948233
      cipher_suites (len=56)
        {0xC0, 0x2C} TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
        {0xC0, 0x30} TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
        ...
        extension_type=server_name(0), length=17
          0000 - 00 0f 00 00 0c 73 68 6f-70 2e 65 78 61 6d 70   .....shop.examp
        extension_type=supported_groups(10), length=12
          ecdh_x25519 (29)
          secp256r1 (P-256) (23)
          ...
    ServerHello, Length=61
      Random:
        gmt_unix_time=0xF5C13007
        random_bytes (len=28): 6CE4C1B95FB5F49A26E80834543037CCB33A35F5444F574E47524401
      cipher_suite {0xC0, 0x30} TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
    Certificate, Length=818
    ServerKeyExchange, Length=296
      KeyExchangeAlgorithm=ECDHE
        named_curve: ecdh_x25519 (29)
        point (len=32): 0FB8BADAB5220392F4037DDA7EEC182D9495363D4307EFAF2FFD0ABC164DEE48
      Signature Algorithm: rsa_pss_rsae_sha256 (0x0804)
      Signature (len=256): 355C9E535D193EC599AF0FAA82E45BA1...
    ServerHelloDone, Length=0
    ClientKeyExchange, Length=33
      KeyExchangeAlgorithm=ECDHE
        ecdh_Yc (len=32): C3DABAA64C3321A21C1BA63668472D9F6C5C7382C4E1688912559E532F7FB961
    change_cipher_spec (1)
    Finished, Length=12
      verify_data (len=12): CA45594F81D3C16AA18FE6B7
    NewSessionTicket, Length=182

客户端只提供 RSA 密钥交换的套件(-cipher AES128-GCM-SHA256)时,ClientKeyExchange 中是用证书公钥加密的预主密钥:

    ClientKeyExchange, Length=258
      KeyExchangeAlgorithm=rsa
        EncryptedPreMasterSecret (len=256): 50837CC49B664182D3DEDC66976484D9...

TLS 1.3 中,ClientHello 和 ServerHello 的 key_share 扩展各带 32 字节的 x25519 公钥,ServerHello 之后的握手报文都以加密的 ApplicationData 记录发送:

    ClientHello, Length=237
        extension_type=key_share(51), length=38
            key_exchange:  (len=32): 4B81D2B18B229E0363B5464672D5E06082531CE08124B589427D746ADED0564A
    ServerHello, Length=118
      cipher_suite {0x13, 0x02} TLS_AES_256_GCM_SHA384
        extension_type=key_share(51), length=36
            key_exchange:  (len=32): AB369663AEC645C6489E405CEC678A9121620BDBCDD9A6E9E0BC36D8F200EF7A
  Content Type = ApplicationData (23)
  Inner Content Type = Handshake (22)
    EncryptedExtensions, Length=2
  ...
    Certificate, Length=821

正文中小数字的 DH 演示用 Python 计算:

p, g = 29, 2
a, b = 9, 13
A, B = pow(g, a, p), pow(g, b, p)       # 19, 14
print(pow(B, a, p), pow(A, b, p))        # 3 3
print([x for x in range(1, p) if pow(g, x, p) == A])   # 窃听者逐个尝试:[9]

A11 HTTPS 各阶段的耗时

macOS 上的 curl 8.7.1(LibreSSL 3.3.6),不经过代理,对每个网站交替请求 HTTP 和 HTTPS 各 5 次(2026-10-28)。TCP 建连时间为 time_connect 减去 time_namelookup,TLS 握手时间为 time_appconnect 减去 time_connect:

curl -s -o /dev/null -w "dns %{time_namelookup} tcp %{time_connect} tls %{time_appconnect}\n" https://www.baidu.com/
www.baidu.com  TLSv1.2 / ECDHE-RSA-AES128-GCM-SHA256
  TCP 建连(ms):21.6  11.5   9.3  10.1  13.6    中位数 11.5
  TLS 握手(ms):28.3  33.8  56.6  43.9  32.8    中位数 33.8
www.jd.com     TLSv1.3 / AEAD-AES256-GCM-SHA384
  TCP 建连(ms):14.9   5.9   7.5  13.2  24.3    中位数 13.2
  TLS 握手(ms):14.8  11.0  14.5  15.5  20.0    中位数 14.8

A12 HTTP/2 的并发流上限

macOS 上的 Python 3.13(2026-10-30)。用 ALPN 协商 h2 后发送连接前言和一个空的 SETTINGS 帧,解析服务器发来的第一个 SETTINGS 帧(RFC 9113 第 6.5 节):

import socket, ssl, struct
ctx = ssl.create_default_context(cafile="/etc/ssl/cert.pem"); ctx.set_alpn_protocols(["h2"])
s = ctx.wrap_socket(socket.create_connection((host, 443)), server_hostname=host)
s.sendall(b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" + b"\x00\x00\x00\x04\x00\x00\x00\x00\x00")
def recv_exact(n):                       # recv() 可能只返回一部分,读满 n 字节为止
    buf = b""
    while len(buf) < n:
        chunk = s.recv(n - len(buf))
        if not chunk: raise EOFError
        buf += chunk
    return buf
while True:
    head = recv_exact(9)                 # 帧头:3 字节长度、1 字节类型、1 字节标志、4 字节流 ID
    length, ftype = int.from_bytes(head[:3], "big"), head[3]
    body = recv_exact(length)
    if ftype == 4 and not head[4] & 1:   # 类型 4 为 SETTINGS,跳过 ACK
        break
for i in range(0, length, 6):
    print(struct.unpack(">HI", body[i:i + 6]))   # (参数编号, 值),编号 3 为 MAX_CONCURRENT_STREAMS
== www.example.com ALPN h2
  MAX_CONCURRENT_STREAMS = 256
  INITIAL_WINDOW_SIZE = 65536
  MAX_FRAME_SIZE = 16777215
== www.taobao.com ALPN h2
  MAX_CONCURRENT_STREAMS = 128
  INITIAL_WINDOW_SIZE = 65536
  MAX_FRAME_SIZE = 16777215

A13 301 与 302 的缓存

服务器把 /p301、/p302 分别重定向到 /new,重定向响应中没有 Cache-Control:

from http.server import BaseHTTPRequestHandler, HTTPServer

class H(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path in ("/p301", "/p302"):
            self.send_response(301 if self.path == "/p301" else 302)
            self.send_header("Location", "/new"); self.send_header("Content-Length", "0"); self.end_headers()
        else:
            body = b"new"
            self.send_response(200); self.send_header("Cache-Control", "no-store")
            self.send_header("Content-Length", str(len(body))); self.end_headers(); self.wfile.write(body)

HTTPServer(("127.0.0.1", 8795), H).serve_forever()

运行服务器后,从一个新建的配置目录开始,用 msedge --headless=new --user-data-dir=<目录> --dump-dom http://127.0.0.1:8795/p301 依次打开两个地址,共两轮,两轮之间保留配置目录中的缓存。服务器收到的请求如下(省略 favicon):

第 1 轮  GET /p301 -> 301   GET /new -> 200
         GET /p302 -> 302   GET /new -> 200
第 2 轮                     GET /new -> 200
         GET /p302 -> 302   GET /new -> 200

第 2 轮打开 /p301 时浏览器没有再请求它,直接使用缓存的 301 跳到 /new;/p302 每次都要请求。

A14 对称加密与非对称加密的速度

OpenSSL 3.6.0,Apple M1 Pro,各运行 2 秒。RSA-2048 的 decrypt 是私钥运算,AES-128-GCM 每次加密 16 KB,结果的单位是千字节每秒:

$ openssl speed -seconds 2 rsa2048
                   sign    verify    encrypt   decrypt   sign/s verify/s  encr./s  decr./s
rsa  2048 bits 0.000613s 0.000016s 0.000017s 0.000618s   1632.3  61772.4  59997.6   1617.5
$ openssl speed -seconds 2 -evp aes-128-gcm -bytes 16384
type          16384 bytes
AES-128-GCM    6280411.97k

使用 PKCS#1 v1.5 填充时,RSA-2048 一次最多加密 245 字节,1617.5 × 245 ≈ 0.40 MB/s。

参考资料

在阅读器中可以用目录和搜索、切换深色模式、练习这篇笔记中的面试题:打开《HTTP》