Update HTTP.md

补充 SSE 的格式与重连、WebSocket 帧结构、跨域的其它方案、证书校验与抓包原理、TLS 套件中的算法、Cookie 与 Web Storage 的对比,增加实测附录。
This commit is contained in:
CyC2018
2026-10-28 13:35:27 +08:00
parent bdad4acc63
commit 3ae174796f
+153 -4
View File
@@ -64,6 +64,8 @@
* [A3 跨域](#a3-跨域)
* [A4 TLS 握手](#a4-tls-握手)
* [A5 XMLHttpRequest 发送 POST](#a5-xmlhttprequest-发送-post)
* [A6 SSE 推送与断线重连](#a6-sse-推送与断线重连)
* [A7 证书链与密码套件](#a7-证书链与密码套件)
* [参考资料](#参考资料)
<!-- GFM-TOC -->
@@ -368,6 +370,11 @@ Cookie 是服务器发送到用户浏览器并保存在本地的一小块数据
Cookie 曾一度用于客户端数据的存储,因为当时并没有其它合适的存储办法而作为唯一的存储手段,但现在随着现代浏览器开始支持各种各样的存储方式,Cookie 渐渐被淘汰。新的浏览器 API 已经允许开发者直接将数据存储到本地,如使用 Web storage API(本地存储和会话存储)或 IndexedDB。
Cookie 与 Web Storage 的区别:
- Cookie 每次请求都会自动携带,单个大小约 4 KB(RFC 6265 要求浏览器至少支持 4096 字节),可以用 HttpOnly 禁止脚本读取;
- localStorage 和 sessionStorage 不随请求发送,只能由脚本读写,每个源各约 5 MB;localStorage 一直保存到被删除,sessionStorage 按标签页隔离,关闭标签页就被清除。
#### 1. 用途
- 会话状态管理(如用户登录状态、购物车、游戏分数或其它需要记录的信息);
@@ -442,7 +449,7 @@ SameSite 属性控制跨站请求是否携带 Cookie,用于防范跨站请求
除了可以将用户信息通过 Cookie 存储在用户浏览器中,也可以利用 Session 存储在服务器端,存储在服务器端的信息更加安全。
Session 可以存储在服务器上的文件、数据库或者内存中。也可以将 Session 存储在 Redis 这种内存型数据库中,效率会更高。
Session 可以存储在服务器上的文件、数据库或者内存中。也可以将 Session 存储在 Redis 这种内存型数据库中,效率会更高。集群中的多台服务器如何共享 Session,见 [集群下的 Session 管理](集群.md#二集群下的-session-管理)。
使用 Session 维护用户登录状态的过程如下:
@@ -493,7 +500,7 @@ JWT 与 Session 的区别:
令牌在浏览器中的存放位置:放在 localStorage 中,页面上的任何脚本都能读取,一旦存在 XSS 漏洞,令牌就会被盗走;放在 HttpOnly 的 Cookie 中,脚本读不到,但浏览器会自动携带 Cookie,需要防范 [跨站请求伪造](攻击技术.md#二跨站请求伪造)。
需要注意的是,服务器验证时要固定使用的签名算法,不能接受头部中 alg 为 none 的令牌;HMAC 的密钥要随机生成,长度不少于 256 位。
需要注意的是,服务器验证时要固定使用的签名算法,不能接受头部中 alg 为 none 的令牌;HMAC 的密钥要随机生成,长度不少于 256 位。签名也不能防止窃听和重放,令牌被截获后,在过期前任何人都能拿它冒充用户,所以必须通过 HTTPS 传输;签名只证明令牌是服务器签发的,服务器仍要检查令牌中的用户是否有权访问所请求的资源。
JWT 只是令牌的格式,而 OAuth 2.0 是授权框架,规定第三方应用如何获得访问令牌,这个访问令牌可以用 JWT 表示。
@@ -808,14 +815,48 @@ 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 个字节表示。
客户端发出的帧必须用 4 字节的随机掩码与数据逐字节异或,防止中间的代理把精心构造的数据当作 HTTP 请求,从而污染缓存。例如客户端发送文本 hello 的帧共 11 字节:81 85 两个字节、4 字节的掩码和 5 字节掩码后的数据;服务器发送同样的消息只需要 7 字节。
需要注意的是,长时间没有数据时,中间的代理或 NAT 可能会断开空闲的连接,所以通常定时发送 Ping 帧,对方回复 Pong 帧,以此保活并发现已经断开的连接。
SSE(Server-Sent Events)是另一种服务器推送方式:它是一个普通的 HTTP 响应,服务器不断地往响应中写入文本事件,只能由服务器发给客户端,浏览器断开后会自动重连。只需要服务器单向推送时(例如大模型逐字输出),SSE 更简单;客户端和服务器都需要随时发消息时(例如聊天、协同编辑),使用 WebSocket。
SSE(Server-Sent Events)是另一种服务器推送方式:它是一个普通的 HTTP 响应,Content-Type 为 text/event-stream,没有 Content-Length,服务器不断地往响应中写入文本事件,每条事件以空行结束,只能由服务器发给客户端。例如推送订单 1024 的状态(curl 实测,见[附录 A6](#a6-sse-推送与断线重连)):
```text
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 时浏览器停止重连。心跳、续传和代理缓冲等实现细节见 [流式输出](大模型应用.md#流式输出)。
SSE 的局限:
- EventSource 只能发送 GET 请求,不能自定义首部和请求体。需要在 Authorization 首部中携带令牌,或者用 POST 提交一段对话时,通常改用 fetch 读取响应流,自己按空行切分事件,断线重连也要自己实现;
- 在 HTTP/1.1 下,浏览器对同一个域名最多同时打开 6 个连接,每个 SSE 都一直占用一个,打开多个标签页时很快就会用完;HTTP/2 在一个连接上复用多个流,没有这个问题。
只需要服务器单向推送时(例如大模型逐字输出),SSE 更简单;客户端和服务器都需要随时发消息时(例如聊天、协同编辑),使用 WebSocket。
### 跨域
同源指的是协议、域名和端口都相同。浏览器的同源策略限制网页中的脚本读取其它源的响应,例如 https://shop.example.com 页面中的脚本请求 https://api.example.com/orders,请求能够发出去,但浏览器默认不把响应交给脚本。
同源策略保护的是用户浏览器中的身份和网络位置:没有它,用户打开一个恶意网页,网页中的脚本就能以用户的身份(带着 Cookie)请求其它网站,或者访问用户所在内网中的服务,并读取响应的内容。
跨域资源共享(Cross-Origin Resource Sharing, CORS)指的是服务器通过响应首部声明允许哪些源读取响应:
```text
@@ -848,6 +889,12 @@ Access-Control-Max-Age 表示预检结果可以缓存的秒数,期间相同的
需要注意的是,CORS 只决定浏览器是否把响应交给脚本,并不是安全措施:服务器仍然执行了请求,curl 这类不经过浏览器的工具也完全不受限制,权限要靠认证和 [CSRF 防护](攻击技术.md#二跨站请求伪造) 保证。另一种解决跨域的办法是同源的反向代理,例如由 Nginx 把 https://shop.example.com/api 转发给后端,浏览器看到的就是同源的请求。
其它做法:
- JSONP:利用 script 标签加载脚本不受同源策略限制,让服务器返回一段调用回调函数的脚本。只能发送 GET 请求,并且要完全信任对方返回的脚本,已经被 CORS 取代;
- WebSocket:握手请求不受同源策略限制,浏览器会带上 Origin 首部,服务器需要自己检查来源;
- postMessage:不同源的页面之间(例如页面和其中嵌入的 iframe)通过它传递消息,接收方要检查消息的来源。
## 六、HTTPS
HTTP 有以下安全性问题:
@@ -856,7 +903,7 @@ HTTP 有以下安全性问题:
- 不验证通信方的身份,通信方的身份有可能遭遇伪装;
- 无法证明报文的完整性,报文有可能遭篡改。
HTTPS 并不是新协议,而是让 HTTP 先和 SSL(Secure Sockets Layer)通信,再由 SSL 和 TCP 通信,也就是说 HTTPS 使用了隧道进行通信。
HTTPS 并不是新协议,而是让 HTTP 先和 SSL(Secure Sockets Layer)通信,再由 SSL 和 TCP 通信,也就是说 HTTPS 使用了隧道进行通信。HTTPS 的默认端口是 443,HTTP 是 80。
需要注意的是,SSL 已经被 TLS(Transport Layer Security)取代,SSL 3.0 在 2015 年由 RFC 7568 废弃,现在 HTTPS 实际使用的是 TLS,SSL 只是沿用的叫法。
@@ -897,6 +944,15 @@ HTTPS 并不是新协议,而是让 HTTP 先和 SSL(Secure Sockets Layer)
需要注意的是,TLS 1.3 不再用公钥加密传输对称密钥,而是通过 ECDHE 密钥交换协商出会话密钥,非对称密钥只用于签名认证。
TLS 把用到的一组算法称为密码套件,例如访问 www.baidu.com 时协商出的 TLS 1.2 套件 ECDHE-RSA-AES128-GCM-SHA256(LibreSSL 3.3.6 实测,见[附录 A7](#a7-证书链与密码套件)):
- ECDHE:密钥交换,用椭圆曲线算法临时协商出会话密钥;
- RSA:身份认证,服务器用证书的 RSA 私钥对自己发送的 ECDHE 参数签名;
- AES128-GCM:对称加密,用 128 位的会话密钥加密数据,GCM 模式同时生成认证标签,保证完整性;
- SHA256:由协商结果推导会话密钥时使用的哈希算法。
TLS 1.3 的套件名称只包含后两项,例如访问 www.jd.com 时为 TLS_AES_256_GCM_SHA384,密钥交换(X25519)和签名算法单独协商。另一种常用的对称算法是 ChaCha20-Poly1305,在没有 AES 硬件加速的设备上更快;DES、3DES 和 RC4 已经不安全,TLS 1.3 不再支持。
<div align="center"> <img src="pics/https-hybrid.png" width="740px"/> </div><br>
### 认证
@@ -911,6 +967,10 @@ HTTPS 并不是新协议,而是让 HTTP 先和 SSL(Secure Sockets Layer)
<div align="center"> <img src="pics/cert-chain.png" width="760px"/> </div><br>
中间人截获了连接也无法冒充服务器:直接转发服务器的证书,它没有对应的私钥,无法完成握手;换成自己的证书,又拿不到 CA 为这个域名签发的证书,验证会失败。
Charles、Fiddler 等抓包工具能解密 HTTPS,是因为用户手动把它们的根证书加入了信任列表,工具再为访问的每个域名临时签发证书。App 可以在代码中只信任指定的证书或公钥,称为证书固定(Certificate Pinning),这时加入根证书也无法抓包。
### TLS 握手
TLS 握手完成三件事:协商加密算法、验证服务器的证书、协商出会话密钥。目前常用的密钥交换方式是 ECDHE:双方各自临时生成一对椭圆曲线密钥,把公钥发给对方,再用自己的私钥和对方的公钥算出相同的共享密钥。会话密钥本身从不在网络上传输,即使服务器的证书私钥日后泄露,之前录下的通信也无法解密,称为前向安全。服务器还要用证书的私钥对握手内容签名,证明自己就是证书的持有者。
@@ -986,6 +1046,8 @@ HTTP/3 改为基于 UDP 的 QUIC 协议:
- QUIC 内置了 TLS 1.3,传输层握手和加密握手合并,新连接 1 个 RTT 后就能发送请求,恢复之前的会话时可以 0 RTT;
- 连接用连接 ID 标识,而不是 IP 地址和端口,手机从 Wi-Fi 切换到移动网络时连接不用重建。
UDP 本身不保证可靠,QUIC 在它之上自己实现了确认、重传和拥塞控制,见 [在 UDP 上实现可靠传输](计算机网络%20-%20传输层.md#在-udp-上实现可靠传输)。
浏览器通常先用 HTTP/2 访问,从响应首部 Alt-Svc 得知服务器支持 HTTP/3 后再切换,例如 www.example.com 的响应中有 alt-svc: h3=":443"; ma=86400。
## 八、HTTP/1.1 新特性
@@ -1181,6 +1243,87 @@ $ openssl s_client -connect www.example.com:443 -servername www.example.com -tls
POST 的首部和请求体在同一次写入中。第一个请求的首部中有 Accept-Encoding: gzip, deflate, br, zstd。
### A6 SSE 推送与断线重连
服务器用 http.server 实现,GET /orders/1024/events 依次写入 4 条事件后关闭连接,请求带有 Last-Event-ID 时只发送它之后的事件:
```python
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)
```
```text
$ 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。服务器的日志和页面中记录的事件:
```text
第 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:
```text
$ 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)
```
## 参考资料
- [RFC 7519: JSON Web Token (JWT)](https://www.rfc-editor.org/rfc/rfc7519)
@@ -1217,6 +1360,12 @@ POST 的首部和请求体在同一次写入中。第一个请求的首部中有
- [RFC 9114: HTTP/3](https://www.rfc-editor.org/rfc/rfc9114)
- [RFC 9000: QUIC](https://www.rfc-editor.org/rfc/rfc9000)
- [RFC 6455: The WebSocket Protocol](https://www.rfc-editor.org/rfc/rfc6455)
- [WHATWG HTML: Server-sent events](https://html.spec.whatwg.org/multipage/server-sent-events.html)
- [MDN: EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource)
- [MDN: Storage quotas and eviction criteria](https://developer.mozilla.org/en-US/docs/Web/API/Storage_API/Storage_quotas_and_eviction_criteria)
- [MDN: Same-origin policy](https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy)
- [OWASP: Pinning Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Pinning_Cheat_Sheet.html)
- [RFC 8439: ChaCha20 and Poly1305 for IETF Protocols](https://www.rfc-editor.org/rfc/rfc8439)
- [RFC 9110: HTTP Semantics](https://www.rfc-editor.org/rfc/rfc9110)
- [RFC 9111: HTTP Caching](https://www.rfc-editor.org/rfc/rfc9111)
- [RFC 3986: Uniform Resource Identifier (URI): Generic Syntax](https://www.rfc-editor.org/rfc/rfc3986)