解密与探究:理解WebSocket协议与报文格式

阅读量325608

发布时间 : 2023-08-30 14:36:15

1. 背景

过去,在创建需要在客户端和服务端之间进行双向通信的 Web 应用程序(比如,即时通讯和游戏应用程序)时,需要滥用 HTTP,轮询服务端以获取更新,并且通过单独的 HTTP 调用发送上行通知。

这导致许多问题:

  1. 服务器被迫为每个客户端使用多个不同的底层 TCP 连接:一个用于向客户端发送信息,每个传入的消息都需要建立新连接。
  2. 协议开销较高,每个客户端到服务端的消息都带有 HTTP 头。
  3. 客户端脚本被迫维护从出站连接到入站连接的映射,以跟踪回复。

更简单的解决方案是在两个方向上使用单个 TCP 连接进行通信。这就是 WebSocket 协议所提供的。它为网页与远程服务器之间的双向通信提供一种替代 HTTP 轮询的选择。

该技术可以用于各种 Web 应用程序,比如游戏、股票行情、支持并发编辑的多用户应用程序、实时公开服务器端服务的用户界面等。

WebSocket 协议旨在取代使用 HTTP 作为传输层的双向通信技术,以便利用现有基础设施(代理、过滤、身份验证)。由于 HTTP 最初并非为双向通信而设计,因此这些技术是在效率和可靠性之间进行权衡的情况下实施的。

WebSocket 协议的目标是在现有的 HTTP 基础设施环境中,实现双向 HTTP 技术。因此,WebSocket 协议被设计为可以在 HTTP 端口 80 和 443 上运行,并且支持 HTTP 代理和中间设备,即使可能引入一些特定于当前环境的复杂性。然而,WebSocket 的设计不局限于 HTTP,未来的实现可以在专用端口上使用更简单的握手方式,而无需重新设计整个协议。最后一点很重要,因为交互式消息的流量模式与标准 HTTP 流量不完全匹配,某些组件可能产生异常负载。

 

2. WebSocket 握手

WebSocket 服务端使用标准 TCP 套接字监听进入的连接。下文假定服务端监听 example.com 的 8000 端口,响应 example.com/chat 上的 GET 请求。

握手是 WebSocket 中 “Web”。它是从 HTTP 到 WebSocket 的桥梁。在握手过程中,协商连接的细节,并且如果行为不合法,那么任何一方都可以在完成前退出。服务端必须仔细理解客户端的所有要求,否则可能出现安全问题。

 

2.1 客户端握手请求

客户端通过联系服务端,请求 WebSocket 连接的方式,发起 WebSocket 握手流程。客户端发送带有如下请求头的标准 HTTP 请求(HTTP 版本必须是 1.1 或更高,并且请求方法必须是 GET):

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

在这里,客户端可以请求扩展和/或子协议。此外,也可以使用常见的请求头,比如 User-Agent、Referer、Cookie 或者身份验证请求头。这些请求头与 WebSocket 没有直接关联。

如果存在不合法的请求头,那么服务端应该发送 400 响应(“Bad Request”),并且立即关闭套接字。通常情况下,服务端可以在 HTTP 响应体中提供握手失败的原因 。如果服务端不支持该版本的 WebSocket,那么它应该发送包含它支持的版本的 Sec-WebSocket-Version 头。在上面的示例中,它指示 WebSocket 协议的版本为 13。

在请求头中,最值得关注的是 Sec-WebSocket-Key。接下来,将讲述它。

 

2.2 服务端握手响应

当服务端收到握手请求时,将发送一个特殊响应,该响应表明协议将从 HTTP 变更为 WebSocket。该响应头大致如下(记住,每个响应头行以 \r\n 结尾,在最后一行的后面添加额外的 \r\n,以说明响应头结束):

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

此外,服务端可以在这里对扩展/子协议请求做出选择。Sec-WebSocket-Accept响应头很重要,服务端必须通过客户端发送的Sec-WebSocket-Key请求头生成它。具体的方式是,将客户端的Sec-WebSocket-Key与字符串"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"(“魔法字符串”)连接在一起,然后对结果进行 SHA-1 哈希运算,最后返回哈希值的 Base64 编码。

因此,如果 Key 为"dGhlIHNhbXBsZSBub25jZQ==",那么Sec-WebSocket-Accept响应头的值是"s3pPLMBiTxaQ9kYGzzhZRbK+xOo="。服务端发送这些响应头后,握手完成,可以开始交换数据。

下面的 Python 代码根据Sec-WebSocket-Key请求头生成Sec-WebSocket-Accept响应头的值:

import typing
from hashlib import sha1
import base64
SEC_WS_MAGIC_STRING: bytes = b”258EAFA5-E914-47DA-95CA-C5AB0DC85B11″
def get_sec_ws_accept(sec_ws_key: typing.Union[bytes, str]) -> bytes:
if isinstance(sec_ws_key, str):
sec_ws_key = sec_ws_key.encode()
return base64.b64encode(sha1(sec_ws_key + SEC_WS_MAGIC_STRING).digest())
if __name__ == “__main__”:
assert get_sec_ws_accept(b”dGhlIHNhbXBsZSBub25jZQ==”) == b”s3pPLMBiTxaQ9kYGzzhZRbK+xOo=”

3. 数据帧(Data Framing)

 

3.1 概览

在 WebSocket 协议中,使用一系列帧传输数据。为避免混淆网络中间人(比如拦截代理),以及出于安全考虑,客户端必须对发送给服务端的所有帧进行掩码(Mask)处理。(注意,无论 WebSocket 协议是否运行在 TLS 上,都需要进行掩码处理。)服务端在收到未进行掩码处理的帧时,必须关闭连接。在这种情况下,服务端可以发送状态码为 1002(协议错误)的关闭帧。服务端不得对发送给客户端的任何帧进行掩码处理。如果客户端检测到掩码帧,那么必须关闭连接。在这种情况下,可以使用状态码 1002(协议错误)。

基础帧协议定义了一种帧类型,包括操作码(Opcode)、有效载荷长度,以及“扩展数据”和“应用数据”的指定位置,它们一起定义“有效载荷数据”。一些位和操作码被保留,以供未来扩展协议。

在握手完成后,端点被发送关闭帧前,客户端和服务端可以随时传输数据帧。

 

3.2 基础帧协议

帧的格式如下图所示:

FIN:1 比特

表示该帧是消息中的最后一个分片。第一个分片也可能是最后一个分片。

RSV1、RSV2、RSV3:每个 1 比特

除非协商了定义非零值含义的扩展,否则必须为 0。如果收到非零值,并且没有协商的扩展定义该非零值的含义,那么接收端点必须使该 WebSocket 连接失败。

操作码:4 比特

定义对“有效载荷数据”的解释。如果收到未知操作码,那么接收端点必须使该 WebSocket 连接失败。定义的值如下:

  1. %x0 表示延续帧
  2. %x1 表示文本帧
  3. %x2 表示二进制帧
  4. %x3-7 为将来的非控制帧预留
  5. %x8 表示连接关闭
  6. %x9 表示 PING
  7. %xA 表示 PONG
  8. %xB-F 为将来的控制帧保留

掩码:1 比特

定义“有效载荷数据”是否被掩码处理。如果设置为 1,那么掩码键出现在 Masking-key 中,它用于解除“有效载荷数据”的掩码。从客户端发送到服务器的所有帧都将此位设置为 1。

有效载荷长度:7 比特,7+16 比特,或 7+64 比特

“有效载荷数据”的长度,单位是字节:如果设置为 0-125,那么它是有效载荷长度。如果设置为 126,那么接下来的 2 个字节(被解释为 16 位无符号整数)是有效载荷长度。如果设置为 127,那么接下来的 8 个字节(被解释为 64 位无符号整数,最高有效位必须为 0)是有效载荷长度。多字节长度量使用网络字节序表示。注意,在所有情况下,必须使用最小字节数编码长度,比如,124 字节长的字符串的长度不能编码为序列 126, 0, 124。有效载荷的长度是“扩展数据”的长度 + “应用数据”的长度。“扩展数据”的长度可能为 0,在这种情况下,有效载荷长度是“应用数据”的长度。

掩码键:0 或 4 字节

从客户端发送到服务端的所有帧必须通过包含在帧里的 32 位数值进行掩码处理。如果掩码位为 1,那么该字段存在,如果掩码位为 0,那么该字段不存在。

有效载荷数据:(x+y) 字节

“有效载荷数据”被定义为将 “扩展数据” 与 “应用数据” 连接在一起。

扩展数据:x 字节

除非已经协商了扩展,否则“扩展数据”为 0 字节。所有扩展必须指定”扩展数据”的长度,或者如何计算该长度,并且在开始握手期间,必须协商扩展的使用方式。如果存在,那么“扩展数据”包含在总有效载荷长度中。

应用数据:y 字节

任意“应用数据”,占用帧中“扩展数据”后面的剩余部分。“应用数据”的长度等于有效载荷长度减去“扩展数据”的长度。

 

3.3 消息分片(Message Fragmentation)

FIN 和 Opcode 字段共同协作,发送被拆分成单独帧的消息。这被称为消息分片。分片仅适用于 Opcode 0x0 到 0x2 的情况。

Opcode 说明帧的用途。如果为 0x1,那么有效载荷是文本。如果为 0x2,那么有效载荷是二进制数据。如果为 0x0,那么该帧是延续帧;这意味着服务端应该将该帧的有效载荷连接到其从该客户端收到的最后一个帧。在下面的草图中,服务端对发送文本消息的客户端做出响应。第一条消息以单个帧发送,而第二条消息用三个帧发送。下图仅显示客户端的 FIN 和 Opcode 细节:

Client: FIN=1, opcode=0x1, msg=”hello”
Server: (process complete message immediately) Hi.
Client: FIN=0, opcode=0x1, msg=”and a”
Server: (listening, new message containing text started)
Client: FIN=0, opcode=0x0, msg=”happy new”
Server: (listening, payload concatenated to previous message)
Client: FIN=1, opcode=0x0, msg=”year!”
Server: (process complete message) Happy new year to you too!

注意,第一个帧包含整个消息(FIN=1,并且opcode!=0x0),因此服务端可以按需处理或响应。客户端发送的第二个帧的有效载荷是文本(opcode=0x1),但整个消息尚未到达(FIN=0)。该消息的所有剩余部分使用延续帧(opcode=0x0)发送,并且消息的最后一帧用FIN=1标记。

4. 搭建测试环境

  1. 操作系统:macOS 12.6
  2. Wireshark 4.0.7
  3. Python 3.10.6

 

4.1 初始化虚拟环境

python3 -m venv wsenv
source ./wsenv/bin/activate
pip3 install ‘uvicorn[standard]’ fastapi

 

 

4.2 服务端代码

ws_server.py:

from typing import List
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
from fastapi.responses import HTMLResponse
import uvicorn
app = FastAPI()
html = “””
<!DOCTYPE html>
<html>
<head>
<title>聊天室</title>
</head>
<body>
<h1>聊天室</h1>
<h2>你的 ID:<span id=”ws-id”></span></h2>
<form action=”” onsubmit=”sendMessage(event)”>
<input type=”text” id=”messageText” autocomplete=”off” />
<button>发送</button>
</form>
<ul id=”messages”>
</ul>
<script>
const characters = “ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*-+”
let client_id = “”
for (let i = 0; i < 10; i++) {
const randomIndex = Math.floor(Math.random() * characters.length)
client_id += characters.charAt(randomIndex)
}
document.querySelector(“#ws-id”).textContent = client_id
var ws = new WebSocket(`ws://localhost:8080/ws/${client_id}`)
ws.onmessage = function(event) {
var messages = document.getElementById(“messages”)
var message = document.createElement(“li”)
var content = document.createTextNode(event.data)
message.appendChild(content)
messages.appendChild(message)
}
function sendMessage(event) {
var input = document.getElementById(“messageText”)
ws.send(input.value)
input.value = “”
event.preventDefault()
}
</script>
</body>
</html>
“””
# 将 HTML 返回给前端
@app.get(“/”)
async def get():
return HTMLResponse(html)
class ConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
self.active_connections.remove(websocket)
async def send_personal_message(self, message: str, websocket: WebSocket):
await websocket.send_text(message)
async def broadcast(self, message: str):
for connection in self.active_connections:
await connection.send_text(message)
manager = ConnectionManager()
@app.websocket(“/ws/{client_id}”)
async def websocket_endpoint(client_id: str, websocket: WebSocket):
await manager.connect(websocket)
await manager.broadcast(f”{client_id} 进入聊天室”)
try:
while True:
data = await websocket.receive_text()
await manager.broadcast(f”{client_id} 发送消息:{data}”)
await manager.send_personal_message(f”服务端回复 {client_id}:你发送的信息是:{data}”, websocket)
except WebSocketDisconnect:
manager.disconnect(websocket)
await manager.broadcast(f”{client_id} 离开聊天室”)
if __name__ == ‘__main__’:
uvicorn.run(app=app, host=”0.0.0.0″, port=8080)

 

 

4.3 启动 WebSocket 服务

python3 ws_server.py

 

 

4.4 启动 Wireshark

启动 Wireshark 后,输入过滤条件tcp.port==8080

 

 

4.5 访问 WebSocket 服务

在浏览器地址栏中输入 http://127.0.0.1:8080/。

 

 

4.6 通过 Wireshark 查看报文

由上图可见,在 TCP 三次握手后,客户端向服务端发起 HTTP GET 请求,服务端的响应码是 101(Switching Protocol),至此 WebSocket 握手完成。

下图是握手过程中的服务端响应报文:

可见,客户端和服务端协商使用扩展“permessage-deflate”,也就是对每条消息使用 deflate 压缩。

下图是序号为 1371 的 WebSocket 文本帧: