chunked编码格式引发的问题

1、问题描述

在适配某 CAS(Central Authentication Service)系统时,票据校验一直失败。通常导致该问题的原因包括:

  1. 网络连通性问题:网关与 CAS 服务器的网络不通,导致票据校验请求无法发送
  2. DNS 解析问题:CAS 服务器的票据校验接口配置了域名,但在 Nginx 中未配置 DNS,导致域名无法解析,请求无法发送
  3. Host 限制问题:配置了 IP 地址,但 CAS 服务端限制了 Host 头部,禁用了 IP 访问
  4. 参数组装问题:票据校验请求的 service 参数组装不正确

经过排查后,发现以上原因均不成立。通过抓包分析,确认 CAS 服务端正常返回了响应。

2、代码分析

2.1、问题定位

既然 CAS 服务端正常返回了响应,问题应该出在网关侧。通过代码打印状态码和响应体,发现状态码确实是 200,但响应体(body)为 nil,即未能获取到响应体内容。

我们使用的是 agentzh(章亦春)开发的 lua-resty-http 模块:https://github.com/liseen/lua-resty-http

1
2
3
4
5
6
7
8
local  xxx   =  hc:request{
url = cas_validate_uri,
method = "GET",
}

--结果调用的是

function request(self, reqt)

其中接收响应body的代码是

1
2
3
4
5
6
7
8
9
10
11
-- receive body
if shouldreceivebody(nreqt, code) then
body, err = receivebody(sock, headers, nreqt) --接收body
if err then
sock:close()
if code == 200 then
return 1, code, headers, status, nil
end
return nil, "read body failed " .. err
end
end

所以我们重点分析receivebody

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
local function receivebody(sock, headers, nreqt)
-- 接收响应体的函数
-- 参数:
-- sock: 套接字对象,用于读取数据
-- headers: HTTP 响应头
-- nreqt: 配置参数表,包含最大允许的响应体大小和回调函数

local t = headers["transfer-encoding"] -- 获取 Transfer-Encoding 头部
local body = {} -- 存储响应体数据块
local callback = nreqt.body_callback -- 获取回调函数

-- 如果没有提供回调函数,使用默认回调
if not callback then
local function bc(data, chunked_header, ...)
if chunked_header then return end -- 跳过 chunked 头部
body[#body+1] = data -- 将数据块添加到 body 表
end
callback = bc
end

-- 处理 Chunked 传输编码
if t and t ~= "identity" then
while true do
-- 读取块头部(十六进制长度 + \r\n)
local chunk_header = sock:receiveuntil("\r\n")
local data, err, partial = chunk_header()

if not data then
return nil, err -- 读取失败,返回错误
end

if data == "0" then
-- 块大小为 0,表示传输结束
return table.concat(body)
else
-- 将十六进制长度转换为数字
local length = tonumber(data, 16)

-- TODO: 检查块大小是否超过 max_body_size

-- 读取指定长度的数据块
local ok, err = read_body_data(sock, length, nreqt.fetch_size, callback)
if err then
return nil, err
end
end
end
-- 处理 Content-Length 方式
elseif headers["content-length"] ~= nil and tonumber(headers["content-length"]) >= 0 then
local length = tonumber(headers["content-length"])

-- 如果内容长度超过最大限制,进行截断
if length > nreqt.max_body_size then
ngx.log(ngx.INFO, 'content-length > nreqt.max_body_size !! Tail it !')
length = nreqt.max_body_size
end

local ok, err = read_body_data(sock, length, nreqt.fetch_size, callback)
if not ok then
return nil, err
end
-- 既没有 Transfer-Encoding 也没有 Content-Length,假设在连接关闭时结束
else
local ok, err = read_body_data(sock, nreqt.max_body_size, nreqt.fetch_size, callback)
if not ok then
return nil, err
end
end

return table.concat(body) -- 合并所有数据块并返回
end

通过抓包分析,响应的编码格式为 Transfer-Encoding: chunked,因此确认代码走到了上述解析 chunked 的逻辑分支。在关键代码处打印日志后,发现读取失败,返回了错误:

1
2
3
if not data then
return nil, err
end

那么为什么读取会失败?需要进一步分析服务端返回的数据格式。

2.2、Chunked 传输编码原理

HTTP 中的 Chunked 传输编码是一种在不预先知道响应数据大小的情况下进行数据传输的方式。通常在 HTTP 响应中,服务器会在头部发送 Content-Length 字段,指定即将发送的数据的总长度。然而,有时候服务器在生成响应内容时并不知道最终的内容长度(例如动态生成的内容),这时候可以使用 Chunked 传输编码。

Chunked 传输编码的基本工作原理

Chunked 传输编码中,响应体被分割成一系列块(chunks),每个块可以有不同的大小。每个块由两部分组成:

  1. 块头部(Chunk Header):指定块的大小(以十六进制表示),后面紧跟着回车换行符(\r\n
  2. 数据块(Chunk Data):紧跟在块头部之后的实际数据,数据块的长度由块头部指定。块数据后面也跟着回车换行符(\r\n

响应的最后一个块是一个特殊的块,它的大小为 0,表示数据传输的结束。

客户端解析 Chunked 数据

客户端接收到 Chunked 编码的响应时,会逐块解析数据,直到遇到大小为 0 的块。具体的解析步骤如下:

  1. 读取块头部,确定当前块的大小
  2. 读取指定大小的数据块
  3. 继续读取下一个块,重复步骤 1 和 2
  4. 当遇到大小为 0 的块时,停止读取

常规的内容传输方式

在通常情况下,当服务器要发送响应时,会先计算好整个响应体的大小,并在 HTTP 响应头的 Content-Length 字段中告知客户端。例如,服务器在发送一个 HTML 页面时,可能会先生成整个页面内容,计算出它的大小,然后在响应头中设置 Content-Length,再将整个内容一次性发送给客户端。

这种方式的缺点:

  1. 延迟高:服务器必须等待整个内容生成完毕,才能开始发送,这增加了初始延迟
  2. 不适合动态生成的内容:如果内容是逐步生成的(例如来自数据库的查询结果或通过流处理的内容),服务器需要等到所有内容都生成后才能计算总大小并发送

Chunked 传输编码的优势

使用 Chunked 传输编码,服务器不需要预先知道响应内容的总大小。相反,它可以在生成内容的同时逐步将内容以块的形式发送给客户端,从而降低延迟并支持流式传输。

2.3、问题分析

了解了 Chunked 传输编码的原理后,我们来分析服务端返回的数据是否正常。使用 TCP 追踪流展示数据:

chunked-01

十六进制视图:

chunked-02

问题发现:数据格式明显有问题。让我们回到代码逻辑:

  • 代码期望读取到块头部为 "0"(十六进制 30)时,才认为数据传输结束
  • 但实际返回的是 "0000"(十六进制 30 30 30 30),而不是单个 "0"

从十六进制视图可以明显看出:

  • 期望格式30 0d 0a(即 "0\r\n",表示结束块)
  • 实际格式30 30 30 30 0d 0a(即 "0000\r\n"

因此客户端会一直尝试读取数据,无法识别结束标志。从响应头 Connection: close 可以知道,这个连接在传输完数据后会关闭。当连接关闭时,chunk_header() 函数会返回错误,err 实际上是 "closed"

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
local chunk_header = sock:receiveuntil("\r\n")
local data, err, partial = chunk_header()
if not data then
return nil, err
else
if data == "0" then -- 只有读到 "0" 才会认为数据结束
return table.concat(body) -- end of chunk
else
local length = tonumber(data, 16) -- 将十六进制长度转换为数字

-- TODO: 检查 nreqt.max_body_size

local ok, err = read_body_data(sock, length, nreqt.fetch_size, callback)
if err then
return nil, err
end
end
end

结论:问题可以确认是 CAS 服务端返回的 Chunked 数据格式有问题,结束块应该是 "0\r\n",但实际返回了 "0000\r\n",导致客户端无法正确识别传输结束标志。

3、问题解决

3.1、本地验证

联系了 CAS 服务商,被告知该 CAS 系统是基于开源版本使用的,服务商对源码的掌握程度一般,沟通比较困难。由于对这个问题比较感兴趣,因此使用对应的开源版本进行验证:

简单运行后,成功对接我们的网关。抓包查看返回的数据:

chunked-03

验证结果:开源版本返回的数据格式非常正确,结束块为 "0\r\n"

3.2、最终结论

通过对比分析,可以确认:

  1. 开源 CAS 版本:返回的 Chunked 数据格式正确
  2. 服务商使用的版本:返回的 Chunked 数据格式有误(结束块为 "0000\r\n" 而非 "0\r\n"

CAS 是用 Java 实现的,深入分析源码需要一定的研究成本。研究源码后,追踪到了视图层,但未找到具体设置传输编码的位置。后续有时间再深入研究,目前先将问题反馈给服务商处理。


chunked编码格式引发的问题
https://zjfans.github.io/2024/08/13/chunked编码格式引发的问题/
作者
张三疯
发布于
2024年8月13日
许可协议