跳到内容

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 没有协议意义

下面的写法不成立:

python
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,先寻找:

text
request-line CRLF
field-line CRLF
...
CRLF

最后一个空行结束 header section。是否存在 message body,不由“POST 通常有 body”这种经验决定,而由 framing field 指示。

本课接受:

http
Content-Length: 11

暂不实现:

http
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 reuseresponse 后发送 Connection: close

这些数字是本实验的配置,不是 HTTP 的全球常量。生产 server 应按业务、proxy chain 和资源预算选择限制,并区分 header 过大、content 过大与读取超时。

4. 模糊 framing 为什么危险

假设前端 proxy 按 Content-Length 切 request,后端却优先按 Transfer-Encoding 切。同一串 byte 会被两端划成不同 message,攻击者便可能把隐藏 request “走私”到后端。

RFC 9112 对 framing 冲突给出严格规则。本实验采取更窄的策略:

  • 同时出现 Transfer-EncodingContent-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 重组。
python
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")

启动:

bash
python3 mini_http11.py

另一个终端验证:

bash
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 文件放在同一目录。

python
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")

运行:

bash
python3 test_mini_http11.py

预期输出:

text
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. 练习与验收

  1. 把 header 每次拆成 1 byte,测试结果应保持不变。
  2. 增加重复 Host、带空白的 field name、负 Content-Length 测试,均应 400。
  3. POST /echo 增加 Content-Type allowlist,不支持时返回 415。
  4. 设计 persistent connection 状态机:标出 remainder buffer、idle timeout、何时关闭。
  5. 阅读 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。

参考

Built with VitePress | Software Systems Atlas