4.2 HTTP/1.1 Framing 与最小服务器
信标塔第一次开放 HTTP 接收口,第一名信使却只递来半行 POST /echo HT,停了一会儿才送到剩余 header。下一名信使把两条 request 紧贴在同一段 byte 里。若管理员仍用“一次 recv 等于一封信”的规则,门口很快会乱套。
HTTP/1.1 在 TCP byte stream 上定义 message boundary。Parser 必须增量读取 start-line、field section 和 content,同时限制时间与空间;代理和 origin 若对边界理解不同,还会给 request smuggling 留出缝隙。
这一课先把接收合同写清,再实现一个只支持 HTTP/1.1、Content-Length 和每连接一条 request 的教学服务器。功能刻意收窄,边界会逐项说明。
1. 一次 recv 没有协议意义
下面的写法不成立:
request = connection.recv(65536)
method, target, version = request.split(b"\r\n", 1)[0].split()recv 返回“当前最多可取多少 byte”,不是“下一条 HTTP message”:
- start-line 或
\r\n\r\n可能被拆开; - body 可能尚未全部抵达;
- header 与 body 可能一次到达;
- persistent connection 上,下一条 request 也可能已经进入 buffer;
- peer 可以只发一点后停住;
- EOF 可能发生在声明长度之前。
因此 parser 需要一个跨 recv 保存的 buffer,以及清晰的读取状态。
2. HTTP/1.1 request 的边界
对普通 request,先寻找:
request-line CRLF
field-line CRLF
...
CRLF最后一个空行结束 header section。是否存在 message body,不由“POST 通常有 body”这种经验决定,而由 framing field 指示。
本课接受:
Content-Length: 11暂不实现:
Transfer-Encoding: chunked不支持某项功能时要明确拒绝,不能退回“读到 socket 暂时没数据为止”。在 persistent connection 上,那会同时失去 message boundary 与连接活性。
3. 解析器先写资源上限
代码开始前先定合同:
| 项目 | 教学服务器策略 |
|---|---|
| HTTP version | 只接受 HTTP/1.1 |
| request-target | 只接受 origin-form,即以 / 开头 |
| header section | 最多 16 KiB,包括结束空行 |
| body | 最多 1 MiB |
| Host | 必须恰好出现一次且非空 |
| Content-Length | 最多一个,只接受十进制非负整数 |
| Transfer-Encoding | 不支持;与 Content-Length 同时出现直接 400 |
| timeout | 每条已接受连接设置读取 timeout |
| connection reuse | response 后发送 Connection: close |
这些数字是本实验的配置,不是 HTTP 的全球常量。生产 server 应按业务、proxy chain 和资源预算选择限制,并区分 header 过大、content 过大与读取超时。
4. 模糊 framing 为什么危险
假设前端 proxy 按 Content-Length 切 request,后端却优先按 Transfer-Encoding 切。同一串 byte 会被两端划成不同 message,攻击者便可能把隐藏 request “走私”到后端。
RFC 9112 对 framing 冲突给出严格规则。本实验采取更窄的策略:
- 同时出现
Transfer-Encoding与Content-Length:400 并关闭; - 出现任何
Transfer-Encoding:501 并关闭; - 重复
Content-Length:400 并关闭; - header name、空白或控制字符非法:400 并关闭。
教学代码不处在 proxy chain 中,但仍不使用宽松、猜测式解析。不同 hop 对同一 message 必须得到同一边界。
5. 可运行的最小服务器
下面保存为 mini_http11.py,使用 Python 3.10+。它支持:
GET /health;HEAD /health;POST /echo;- 404、405 与若干 parser error;
- 任意 TCP 分段下的 header/body 重组。
import re
import socket
from dataclasses import dataclass
from urllib.parse import urlsplit
MAX_HEADER_SECTION = 16 * 1024
MAX_BODY = 1024 * 1024
TOKEN = re.compile(rb"^[!#$%&'*+\-.^_\x60|~0-9A-Za-z]+$")
class HTTPError(Exception):
def __init__(self, status, message):
super().__init__(message)
self.status = status
self.message = message
@dataclass(frozen=True)
class Request:
method: str
target: str
version: str
headers: dict[str, list[str]]
body: bytes
def receive_header_section(connection):
buffer = bytearray()
marker = b"\r\n\r\n"
while marker not in buffer:
if len(buffer) >= MAX_HEADER_SECTION:
raise HTTPError(431, "header section too large")
chunk = connection.recv(
min(4096, MAX_HEADER_SECTION - len(buffer))
)
if chunk == b"":
raise HTTPError(400, "EOF before header section")
buffer.extend(chunk)
marker_at = buffer.find(marker)
return bytes(buffer[:marker_at]), bytes(buffer[marker_at + 4:])
def receive_exact(connection, initial, length):
body = bytearray(initial[:length])
while len(body) < length:
chunk = connection.recv(min(4096, length - len(body)))
if chunk == b"":
raise HTTPError(400, "EOF before complete request content")
body.extend(chunk)
return bytes(body)
def parse_request(connection):
head, buffered_after_head = receive_header_section(connection)
lines = head.split(b"\r\n")
if not lines or not lines[0]:
raise HTTPError(400, "missing request line")
request_parts = lines[0].split(b" ")
if len(request_parts) != 3 or any(not part for part in request_parts):
raise HTTPError(400, "malformed request line")
method_bytes, target_bytes, version_bytes = request_parts
if not TOKEN.fullmatch(method_bytes):
raise HTTPError(400, "invalid method")
try:
method = method_bytes.decode("ascii")
target = target_bytes.decode("ascii")
version = version_bytes.decode("ascii")
except UnicodeDecodeError as error:
raise HTTPError(400, "non-ASCII request line") from error
if version != "HTTP/1.1":
raise HTTPError(400, "only HTTP/1.1 is supported")
if not target.startswith("/") or " " in target:
raise HTTPError(400, "only origin-form targets are supported")
headers: dict[str, list[str]] = {}
for raw_line in lines[1:]:
if not raw_line:
raise HTTPError(400, "unexpected empty field line")
if raw_line[:1] in (b" ", b"\t"):
raise HTTPError(400, "obsolete folded field")
if b":" not in raw_line:
raise HTTPError(400, "field without colon")
name_bytes, value_bytes = raw_line.split(b":", 1)
if not TOKEN.fullmatch(name_bytes):
raise HTTPError(400, "invalid field name")
value_bytes = value_bytes.strip(b" \t")
if any(
byte < 32 and byte != 9 or byte == 127
for byte in value_bytes
):
raise HTTPError(400, "control character in field value")
name = name_bytes.decode("ascii").lower()
value = value_bytes.decode("latin-1")
headers.setdefault(name, []).append(value)
hosts = headers.get("host", [])
if len(hosts) != 1 or not hosts[0]:
raise HTTPError(400, "Host must appear exactly once")
content_lengths = headers.get("content-length", [])
transfer_encodings = headers.get("transfer-encoding", [])
if transfer_encodings and content_lengths:
raise HTTPError(400, "ambiguous message framing")
if transfer_encodings:
raise HTTPError(501, "Transfer-Encoding is not supported")
if len(content_lengths) > 1:
raise HTTPError(400, "duplicate Content-Length")
body_length = 0
if content_lengths:
raw_length = content_lengths[0]
if not raw_length.isascii() or not raw_length.isdecimal():
raise HTTPError(400, "invalid Content-Length")
body_length = int(raw_length)
if body_length > MAX_BODY:
raise HTTPError(413, "request content too large")
body = receive_exact(
connection,
buffered_after_head,
body_length,
)
return Request(method, target, version, headers, body)
REASONS = {
200: "OK",
400: "Bad Request",
404: "Not Found",
405: "Method Not Allowed",
408: "Request Timeout",
413: "Content Too Large",
431: "Request Header Fields Too Large",
500: "Internal Server Error",
501: "Not Implemented",
}
def encode_response(status, body=b"", headers=None, head_only=False):
fields = {
"Content-Type": "text/plain; charset=utf-8",
"Connection": "close",
}
if headers:
fields.update(headers)
fields["Content-Length"] = str(len(body))
start = f"HTTP/1.1 {status} {REASONS[status]}\r\n"
field_lines = "".join(
f"{name}: {value}\r\n"
for name, value in fields.items()
)
head = (start + field_lines + "\r\n").encode("ascii")
return head if head_only else head + body
def application(request):
path = urlsplit(request.target).path
if path == "/health":
if request.method in {"GET", "HEAD"}:
return 200, b"healthy\n", {}
return 405, b"method not allowed\n", {
"Allow": "GET, HEAD",
}
if path == "/echo":
if request.method == "POST":
return 200, request.body, {
"Content-Type": "application/octet-stream",
}
return 405, b"method not allowed\n", {
"Allow": "POST",
}
return 404, b"not found\n", {}
def handle_connection(connection):
try:
request = parse_request(connection)
status, body, headers = application(request)
response = encode_response(
status,
body,
headers,
head_only=request.method == "HEAD",
)
except socket.timeout:
response = encode_response(408, b"request timeout\n")
except HTTPError as error:
response = encode_response(
error.status,
(error.message + "\n").encode("utf-8"),
)
except Exception:
response = encode_response(500, b"internal error\n")
connection.sendall(response)
def serve(host="127.0.0.1", port=8080):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener:
listener.setsockopt(
socket.SOL_SOCKET,
socket.SO_REUSEADDR,
1,
)
listener.bind((host, port))
listener.listen(128)
print(f"listening on http://{host}:{port}")
while True:
connection, peer = listener.accept()
with connection:
connection.settimeout(5)
handle_connection(connection)
print("served", peer)
if __name__ == "__main__":
try:
serve()
except KeyboardInterrupt:
print("\nstopped")启动:
python3 mini_http11.py另一个终端验证:
curl -i http://127.0.0.1:8080/health
curl -i -X POST --data-binary 'hello world' \
http://127.0.0.1:8080/echo
curl -i -X DELETE http://127.0.0.1:8080/health预期分别得到 200、200 和 405;405 response 带 Allow: GET, HEAD。
6. Parser 的几个关键决定
为什么 header value 用 latin-1 解码
这里用 latin-1 做 byte 到 code point 的一一映射,避免宽松 UTF-8 替换悄悄改变原始 octet。它不表示所有 field value 的语义都是 ISO-8859-1。真正使用某个 field 前,仍要按该 field 的 ABNF 或 library API 解析。
为什么拒绝重复 Content-Length
RFC 9112 对相同值的重复 Content-Length 留有规范化选择,但 security-sensitive parser 可以直接拒绝。教学 server 不承担 compatibility proxy 的职责,确定性比宽容更重要。
为什么不处理 buffer 中多出来的 byte
receive_header_section 可能同时读到 body 之后的 byte。当前 server 每连接只处理一条 request,并在 response 后关闭,因此不会把这些 byte 当成另一条 message。若要支持 persistent connection,parser 必须返回 remainder,并在下一轮继续解析,不能丢弃。
为什么所有 response 都 Connection: close
HTTP/1.1 默认支持 persistent connection,但“支持”意味着 server 要正确循环解析多条 request、按顺序发送 response、设置 idle timeout,并在 error 后决定是否还能安全复用。这个实验主动关闭,缩小状态机,不把未实现的能力伪装成 keep-alive。
7. 用碎片化输入测试边界
故事里的信使现在每次只递三枚 byte。下面的测试使用 socketpair,不占用端口,也不依赖外网。保存为 test_mini_http11.py,与 server 文件放在同一目录。
import socket
import threading
from mini_http11 import handle_connection
def exchange(request):
client, server = socket.socketpair()
def serve_once():
with server:
server.settimeout(2)
handle_connection(server)
worker = threading.Thread(target=serve_once)
worker.start()
with client:
for offset in range(0, len(request), 3):
client.sendall(request[offset:offset + 3])
client.shutdown(socket.SHUT_WR)
chunks = []
while True:
chunk = client.recv(4096)
if chunk == b"":
break
chunks.append(chunk)
worker.join(timeout=2)
assert not worker.is_alive()
return b"".join(chunks)
def body_of(response):
return response.split(b"\r\n\r\n", 1)[1]
echo = exchange(
b"POST /echo HTTP/1.1\r\n"
b"Host: atlas.example\r\n"
b"Content-Length: 11\r\n"
b"\r\n"
b"hello world"
)
assert echo.startswith(b"HTTP/1.1 200 OK\r\n")
assert body_of(echo) == b"hello world"
missing_host = exchange(
b"GET /health HTTP/1.1\r\n\r\n"
)
assert missing_host.startswith(b"HTTP/1.1 400 Bad Request\r\n")
ambiguous = exchange(
b"POST /echo HTTP/1.1\r\n"
b"Host: atlas.example\r\n"
b"Content-Length: 4\r\n"
b"Transfer-Encoding: chunked\r\n"
b"\r\n"
)
assert ambiguous.startswith(b"HTTP/1.1 400 Bad Request\r\n")
too_large = exchange(
b"POST /echo HTTP/1.1\r\n"
b"Host: atlas.example\r\n"
b"Content-Length: 1048577\r\n"
b"\r\n"
)
assert too_large.startswith(
b"HTTP/1.1 413 Content Too Large\r\n"
)
head = exchange(
b"HEAD /health HTTP/1.1\r\n"
b"Host: atlas.example\r\n"
b"\r\n"
)
assert b"Content-Length: 8\r\n" in head
assert body_of(head) == b""
print("HTTP/1.1 fragmented-input tests passed")运行:
python3 test_mini_http11.py预期输出:
HTTP/1.1 fragmented-input tests passed这个测试仍没有证明 parser 覆盖全部 RFC 语法。它只验证本文承诺的子集:任意 TCP 切分、Host、明确 body 长度、冲突 framing、大小上限与 HEAD。
8. 从教学 server 到生产 server
信标塔已经能可靠拆开一封受支持的 request,但离公开服务还有很远。生产实现至少还要处理:
- persistent connection、pipelining 与完整 remainder buffer;
- chunked transfer coding 和 trailer;
- absolute-form、authority-form、asterisk-form 等 request-target;
- TLS、ALPN 与 HTTP/2;
- 并发、排队、backpressure、graceful shutdown;
- access log、request ID、metrics 与 trace;
- routing、media type、authentication、authorization;
- header timeout、body deadline、rate limit 与全局资源预算;
- proxy trust boundary,例如哪些 forwarded field 可以相信;
- fuzzing、差分测试和 request smuggling 回归集。
这些不是“再补几个 if”就能安全完成的功能。真实服务优先使用维护良好的 HTTP library/server;手写版本适合学习协议边界和构造隔离实验。
9. 常见错误
“没读到数据就说明 request 结束。” Blocking socket 会等待,timeout 也不等于 message boundary。
“POST 才检查 Content-Length。” Request framing 独立于 method semantics。
“出现两个相同 Content-Length 可以随便取第一个。” Parser disagreement 会形成安全边界,应按统一规则处理或拒绝。
“限制 body 大小就不会耗尽资源。” Header、连接数、读取时间、response queue 和 parser CPU 都要有预算。
“server 回 400 后还能继续复用这条连接。” Framing error 后 buffer 边界可能已经不可信;本实验始终关闭。
“自己写的 server 通过 curl 就能上线。” curl 正常路径不能覆盖畸形输入、slow client、代理差异和并发生命周期。
10. 练习与验收
- 把 header 每次拆成 1 byte,测试结果应保持不变。
- 增加重复 Host、带空白的 field name、负 Content-Length 测试,均应 400。
- 为
POST /echo增加Content-Typeallowlist,不支持时返回 415。 - 设计 persistent connection 状态机:标出 remainder buffer、idle timeout、何时关闭。
- 阅读 RFC 9112 的 chunked decoding algorithm,列出实现 trailer 前还需要哪些上限。
验收条件:你能解释 parser 每次为什么继续读取或停止,能指出每个内存/time limit,并且不会把一次 recv、EOF 或 timeout 当作通用 message delimiter。
11. 信标塔开始营业
碎片化测试把一封 request 拆得七零八落,服务器仍按 CRLF 与 Content-Length 重组成同一条 POST /echo。冲突 framing 则在门口被拒绝,没有机会让不同接收者各自猜测。
下一章进入HTTPS 与 TLS。HTTP 已经有了语义和边界,接下来要确认对面的信标塔是谁,并让沿途观察者无法读取或篡改 content。