Skip to content

http.server: BaseHTTPRequestHandler does not enforce RFC 7230 framing validation (3 defects, hardening for keep-alive) #150499

@tonghuaroot

Description

@tonghuaroot

Feature or enhancement

Summary

http.server.BaseHTTPRequestHandler does not enforce three RFC 7230
framing-validation rules. The module's docstring already states it is
not intended for production, and PSRT (via Stan Ulbrych and Seth
Larson, 2026-05-26) suggested filing these as a public RFC-compliance
improvement rather than as security issues, so this issue collects them
in one place for a single hardening PR. The three defects are
independently reproducible on main (commit 776573c) and on the
3.13 / 3.14 maintenance branches; each one is a single-rule deviation
that can be fixed in BaseHTTPRequestHandler without changing the
public API.

Defect A — duplicate Content-Length with conflicting values is accepted

RFC 7230 §3.3.3 rule 4 requires the recipient to reject a message that
has multiple Content-Length field-values that disagree (MUST close
the connection and respond 400 Bad Request). BaseHTTPRequestHandler
calls http.client.parse_headers and never inspects
headers.get_all("Content-Length"), so a request such as

POST / HTTP/1.1
Host: 127.0.0.1
Content-Length: 4
Content-Length: 0

ABCD

is dispatched to the handler. A handler that reads
int(self.headers.get("Content-Length")) silently sees only the first
value (4), while a different handler that takes get_all and picks
the last value sees 0. The behaviour is implementation-dependent
even within the standard library, which is the exact situation RFC
7230 §3.3.3 was written to prevent.

  • file: Lib/http/server.py
  • function: BaseHTTPRequestHandler.parse_request
  • observed: request accepted; handler dispatched
  • expected: 400 Bad Request + Connection: close

Defect B — Transfer-Encoding header is accepted but never decoded

RFC 7230 §3.3.3 rule 3 states that if a Transfer-Encoding header is
present, it overrides Content-Length and the recipient must dechunk
the body. BaseHTTPRequestHandler does not implement a chunked
decoder, but it also does not reject the header, so a request such as

POST / HTTP/1.1
Host: 127.0.0.1
Transfer-Encoding: chunked
Content-Length: 5

0

GET /pwn HTTP/1.1
Host: 127.0.0.1

is accepted and a handler that reads int(Content-Length) bytes
leaves the rest of the chunked frame (and the trailing request) in the
socket buffer, so the keep-alive loop parses GET /pwn HTTP/1.1 as a
new top-level request line. Per RFC 7230 §3.3.3 the correct response
is 400 Bad Request + close, because the server cannot honour the
Transfer-Encoding semantics it received.

  • file: Lib/http/server.py
  • function: BaseHTTPRequestHandler.parse_request
  • observed: request accepted; handler dispatched; framing desynchronised
  • expected: 400 Bad Request + Connection: close

Defect C — request body is not drained between requests on a persistent connection

RFC 7230 §6.3 requires the server to either consume the entire
request body or close the connection before reading the next request
on a persistent connection. BaseHTTPRequestHandler.handle_one_request
does neither: after do_<method>() returns, the next iteration of
handle() calls rfile.readline(65537) directly. A request such as

GET / HTTP/1.1
Host: 127.0.0.1
Content-Length: 100

AAAAAAAAAAAAAAAA...AAAGET /pwn HTTP/1.1
Host: 127.0.0.1

results in SimpleHTTPRequestHandler (which does not read the body of
a GET) leaving 100 bytes plus the smuggled request line in the
buffer. The keep-alive loop then reads
AAAA...AAAAGET /pwn HTTP/1.1 as a malformed request line and replies
501 Unsupported method ('AAAA...GET'), generated entirely from
attacker-controlled input.

  • file: Lib/http/server.py
  • function: BaseHTTPRequestHandler.handle_one_request / handle
  • observed: leftover body parsed as next request line
  • expected: leftover body drained (within a sane cap) or connection closed

Reachability

http.server is documented as not for production, but
BaseHTTPRequestHandler is the basis for wsgiref.simple_server,
which is used in many quick-start guides, development servers
(manage.py runserver style flows, Flask app.run in some
configurations, plenty of python -m http.server tutorials), CI
fixtures, internal admin tools and embedded devices. RFC 7230 framing
rules are the foundation of HTTP request-boundary integrity; honouring
them does not change semantics for any well-formed client and only
costs three header checks plus a bounded body drain.

Reproduction

Three self-contained reproductions (one per defect) are available on
request; each starts a ThreadingHTTPServer on 127.0.0.1, sends the
raw bytes shown above over socket.socket, and prints the observed
response.

Suggested fix

A single PR can address all three defects in BaseHTTPRequestHandler:

  1. In parse_request, after http.client.parse_headers returns,
    reject requests with conflicting Content-Length values (defect A).
  2. In parse_request, after the same point, reject any
    Transfer-Encoding header (defect B) — once http.server grows a
    chunked decoder this check can be narrowed to "anything other than
    identity".
  3. In handle_one_request, wrap self.rfile with a small
    byte-counting reader for the duration of the request, then after
    do_<method>() returns drain up to a bounded number of bytes of
    any unread declared body (defect C). If the declared body exceeds
    the drain cap, close the connection instead.

All three checks are conservative: well-formed clients are unaffected.

Linked PR

A pull request implementing the fix and adding regression tests in
Lib/test/test_httpservers.py plus a Misc/NEWS.d entry is being
prepared.

Acknowledgement

Per PSRT (Stan Ulbrych, 2026-05-26 14:51 UTC and Seth Larson,
2026-05-26 21:55 UTC) these defects are out of scope for the security
process and were invited to be filed as a public issue plus PR.

Reported by tonghuaroot.

Linked PRs

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions