解析来自 TCP 流的 HTTP 响应

Posted

技术标签:

【中文标题】解析来自 TCP 流的 HTTP 响应【英文标题】:Parse HTTP responses from a TCP stream 【发布时间】:2021-09-28 18:41:17 【问题描述】:

TCP 不是基于消息的协议,但它是一个简单的字节流。 HTTP 协议实际上是基于 TCP 的基于消息的协议。那么,如何解析来自 TCP 流连接的原始 HTTP 数据呢?

例如,我们通过python中的TCP套接字连接到代理服务器:

import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((host, port))  # host and port are proxy's address

然后,我们询问代理,是否可以通过它CONNECT 到目标主机(例如google.com):

request = b'CONNECT %s:%i HTTP/1.0\r\n\r\n' % ("google.com".encode(), 443) 
s.sendall(request)

然后,我们需要从socket 接收数据。 但是如何?如果我们recv 数据,我们会将其保存到缓冲区中,如下所示:

buffer = s.recv(1024)

我检查过,当主机关闭连接时,它会发送一条 0 字节长的消息(例如 404502400 状态代码)。但是当连接处于活动状态时(主机返回状态码 200),它不发送终止的 0 字节。当然,它不应该,但是,我们怎么知道,这是信息的结尾?

我对 HTTP 协议所做的是,标头由\r\n 划分,正文与标头由\r\n\r\n 划分。 HTTP 消息总是以\r\n 结尾。因此,理论上,我们可以只阅读消息,直到遇到 \r\n\r\n,然后我们知道消息的其余部分,直到另一个 \r\n,是响应的正文。

但是,如果某个小丑服务器想要在 http 响应正文中添加另一个 \r\n inside 怎么办?然后整个解析就坏了! 现在算法认为正文结束了,消息的其余部分是下一条消息的标头并抛出异常,试图解析它!如果某个有趣的人编写了一个服务器,其中放置了 @987654339 @ 在自定义响应标头中?

那么我们如何从原始套接字进行解析,它是如何正确完成的?我们如何避免在某些错误配置的服务器响应上出现失误?

【问题讨论】:

“HTTP 消息总是以 \r\n 结尾” - 不,它不是。长度由Content-length 标头定义,或者在分块传输编码的情况下使用其他方式定义。详情请see the actual standard. 我投票结束这个问题,因为它基于错误的假设,即\r\n 结束了 HTTP 正文。由于这个假设是错误的,因此问题中提出的整个问题是无关紧要的。 【参考方案1】:

这不是对 HTTP 的非常精确的描述。尽管该协议当然有其缺陷,但它比您的总结所表明的要强大得多,正如成功传输的大量数据所证明的那样。

当然,成功传输需要服务器正确实现协议。服务器错误将导致无法正确接收消息。例如,如果服务器要在标头中发送额外的 CR-LF,则客户端会假设接下来是消息正文,这可能会导致某种故障。但是,消息的正文不是那么敏感。任何任意字节流,包括任意行结尾,甚至 NUL 字节都可以通过 HTTP 传输。

共有三种机制用于对主体进行打包。在最初的 HTTP 规范中,主体只是简单地扩展到 TCP 连接被服务器关闭的点,因此单个 TCP 连接只能提供单个 HTTP 响应。

顺便说一句,服务器在关闭连接之前不会发送零长度消息。没有办法做到这一点,因为正如您所指出的,TCP 只是一个字节流。它根本不是基于消息的协议。所以不可能发送任何长度的消息,包括零。

来自read() 的零长度返回是由接收端的标准库制造的,以便与read() 的调用者沟通没有更多数据;换句话说,连接已被另一端关闭。这与来自文件的read() 表示已到达文件末尾的方式相同。当您从文件中read() 并接收零字节时,那不是因为文件中有圆顶“零长度数据包”。与 TCP 流一样,文件只是一系列未区分的字节,没有消息标记。

但是要回到 HTTP。由于没有什么能阻止客户端打开与单个服务器的任意数量的连接,因此最初的“一个连接,一个请求”通信协议是可行的。但是打开和断开 TCP 连接有相当大的开销,而且很多 HTTP 消息都很短。所以这不是很好的可扩展性,下一个 HTTP 版本必须包含一种通过单个 TCP 连接发送多条消息的机制(称为“流水线”)。

但是,拥有大量处于休眠状态的打开 TCP 连接也会给服务器带来不必要的开销。所以仍然允许随时关闭连接;如果客户端想要发出一个新的请求,它必须打开一个新的连接。

客户端请求和服务器响应都包含可能后跟正文的标头。正文的存在取决于标头的内容,管道传输的正确功能需要服务器和客户端就特定的消息标头是否会跟随正文达成一致。意外的正文将在另一端被解释为新的标头,这可能是格式错误的。

发件人有两种方式来描述邮件正文的范围。最直接的方法是简单地包含一个包含正文字节精确长度的标头。 (“Content-Length”标头。)标头完成后(由两个连续的 CRLF 序列发出信号),内容长度标头指示的下一个字节数作为正文,无需查看字节。 (如果服务器注入了未计入声明的内容长度的额外字节,那将在另一端导致解析错误,如果它遗漏字节也是如此。但是包含任意数量的连续 CRLF 的消息没有问题。 )

一旦正文已完全发送,发送者可以通过发送 CRLF 来指示另一条消息,或者它可以关闭其一侧的连接。如果另一端的主机厌倦了等待,它也可以关闭连接。

如果发件人知道内容的长度(例如,如果内容是一个文件),则发送 Content-Length 标头很容易,但通常消息体是动态生成的,并且它们的完整长度直到整个消息已生成,这可能需要很长时间。因此需要另一种机制来覆盖该用例:所谓的“分块”。

在分块消息中,正文被发送者分成任意长度的块。每个块都以它的长度开始,然后是一个 CRLF。发送方通过发送一个长度为零的块(即包含字符0 后跟一个CRLF 的行)来指示消息已完全发送。

分块让发件人可以发送动态生成的未知长度的消息。它需要做的就是积累消息的一些字节并将它们作为一个块发送。它可以累积一个固定长度的缓冲区,或者在固定的时间内,或者使用任何其他标准。 (一些嵌入式库只是将每个 send() 调用变成一个单独的,通常非常小的块。这也很好。块没有语义功能;块的结尾可以在任何地方,甚至在多字节 UTF- 8 码。)

这是对 HTTP 如何允许发送多条消息的非常简明的概述;我遗漏了很多细节。如果你想编写一个实现,你应该查阅实际的协议规范。

【讨论】:

【参考方案2】:

使用 TCP 套接字时,您并不真正知道何时收到整个消息。我过去做事的一种方法是使用固定长度的消息并发送一条初始消息,消息传输的大小以字节为单位。然后我可以接收消息的字节,直到我收到预期的字节总数。我认为最佳答案来自 Python socket receive - incoming packets always have a different size

帮助。

【讨论】:

以上是关于解析来自 TCP 流的 HTTP 响应的主要内容,如果未能解决你的问题,请参考以下文章

Http请求和Http响应详细解析

无法解析来自谷歌的 json 响应

使用body.json()解析来自http.get()的响应时出错

解析来自串行窗口的 json 响应

HTTP通信与Web框架

HTTP中的请求头和响应头属性解析