Repository navigation
fix(web): refuse HTTP/0.9 before dispatch with a framed 505 (#989) - #999
Merged
Merged
Conversation
A versionless request line left self.headers a plain dict, so every refusal raised AttributeError in _declared_body_length: a reset and a traceback on stderr. The explicit 'HTTP/0.9' form was admitted and answered unframed, which desynchronised a kept-alive connection. parse_request now refuses both with a status-lined 505 and closes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
eaitbrahim
commented
Oct 13, 2026
eaitbrahim
left a comment
Contributor
Author
There was a problem hiding this comment.
Round 1: no findings.
Checked against Python 3.14.4 BaseHTTPRequestHandler.parse_request/send_error/handle_one_request and the neighbouring docstrings (_declared_body_length, _drain_request_body, handle_one_request, do_POST, _refuse, log_message):
- Both 0.9 forms (
GET /xandGET /x HTTP/0.9) are caught: the stdlib setsrequest_versionto the exact stringHTTP/0.9for both, which is the same string test it uses to drop the status line, so nothing that would be answered unframed reaches a verb.HTTP/00.9/HTTP/0.8are not caught but already get headers parsed and full status-line framing (probed), so no desync. - Close is guaranteed:
send_erroremitsConnection: close(now honoured becauserequest_versionwas moved to 1.0), so an explicit 0.9 withConnection: keep-alivestill closes, the pipelined request behind it is never parsed, and thehandle_one_requestdrain is skipped. Consistent with #977 (close after answering). Expect: 100-continueon explicit 0.9: no 100 is sent (stdlib requires request_version >= 1.1), so refusal precedes any body read. HEAD 0.9 gets a bodiless 505 with Content-Length. Header-parse errors are refused by the stdlib before the override runs (pre-existing, connection closes).- Honest clients unaffected: the override returns
Trueimmediately for any version other thanHTTP/0.9; browsers/fetch, HTTP/1.0 and 1.1 (and the halt) take the unchanged path at the cost of one string compare.do_GETuntouched. log_errorroutes through the silencedlog_message, so nothing on stderr.- Tests are not vacuous: removing the version check makes all 4 #989 tests fail (AttributeError reset / unframed JSON body); file restored and byte-compared afterwards. All socket waits are bounded by a 5 s timeout and
_servingshuts the server down.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #989
What was wrong
GET /api/config(no version, HTTP/0.9) leavesself.headersa plaindict._admittedrefuses on Host,_refusedrains, and_declared_body_lengthraisedAttributeErroron.defects. The client got a reset with no answer, and a traceback went to stderr.GET /api/config HTTP/0.9does get its headers parsed. With a valid Host and session it was admitted and answered in 0.9 framing (body, no status line). WithConnection: keep-alivethe socket stayed open, so the unframed JSON sat in front of the nextHTTP/1.1 200on the same connection.Fix
KeelHandler.parse_requestruns the stdlib parse first. Ifrequest_version == "HTTP/0.9"(the stdlib's own test for "answer without a status line"), it refuses with a 505 that has a status line and closes the connection. That covers both forms, before any verb runs, so every request a verb sees now has a parsed header block.do_GET/do_POSTare untouched, so the CodeQL alerts ondo_GETare not re-attributed.fetchincluded. A versionlessPOSTwas already a stdlib 400.Tests (
tests/web/test_server.py)GET {path}\r\nfor/api/config,/,/api/gates/halt: the reply is a505status line, then a close, andcapfdstderr is empty.HTTP/0.9with session + keep-alive + a pipelined 1.1 GET: exactly one status (505), no"data"served, then a close._serving.Full suite: 8440 passed, 3 skipped. ruff, format and mypy are clean.
🤖 Generated with Claude Code