Skip to content

fix(web): refuse HTTP/0.9 before dispatch with a framed 505 (#989) - #999

Merged
eaitbrahim merged 1 commit into
mainfrom
fix/989-http09-request-line
Oct 13, 2026
Merged

eaitbrahim merged 1 commit into
mainfrom
fix/989-http09-request-line

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

Closes #989

What was wrong

  • GET /api/config (no version, HTTP/0.9) leaves self.headers a plain dict. _admitted refuses on Host, _refuse drains, and _declared_body_length raised AttributeError on .defects. The client got a reset with no answer, and a traceback went to stderr.
  • Found while writing the test: the explicit form GET /api/config HTTP/0.9 does get its headers parsed. With a valid Host and session it was admitted and answered in 0.9 framing (body, no status line). With Connection: keep-alive the socket stayed open, so the unframed JSON sat in front of the next HTTP/1.1 200 on the same connection.

Fix

KeelHandler.parse_request runs the stdlib parse first. If request_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_POST are untouched, so the CodeQL alerts on do_GET are not re-attributed.
  • The halt is unaffected. No browser sends 0.9, the console's fetch included. A versionless POST was already a stdlib 400.

Tests (tests/web/test_server.py)

  • Raw socket, GET {path}\r\n for /api/config, /, /api/gates/halt: the reply is a 505 status line, then a close, and capfd stderr is empty.
  • Explicit HTTP/0.9 with session + keep-alive + a pipelined 1.1 GET: exactly one status (505), no "data" served, then a close.
  • HTTP/1.0 and HTTP/1.1 GETs are still answered 200 with the document.
  • Every socket wait is bounded (5 s timeout). Servers are torn down by _serving.
  • Mutation-checked: no refusal, no status line, refusing 1.0 too, dispatching after the refusal, and raising in the handler (which proves the stderr pin works) each fail the tests.

Full suite: 8440 passed, 3 skipped. ruff, format and mypy are clean.

🤖 Generated with Claude Code

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 eaitbrahim self-assigned this Oct 13, 2026

@eaitbrahim eaitbrahim left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 /x and GET /x HTTP/0.9) are caught: the stdlib sets request_version to the exact string HTTP/0.9 for 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.8 are not caught but already get headers parsed and full status-line framing (probed), so no desync.
  • Close is guaranteed: send_error emits Connection: close (now honoured because request_version was moved to 1.0), so an explicit 0.9 with Connection: keep-alive still closes, the pipelined request behind it is never parsed, and the handle_one_request drain is skipped. Consistent with #977 (close after answering).
  • Expect: 100-continue on 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 True immediately for any version other than HTTP/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_GET untouched.
  • log_error routes through the silenced log_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 _serving shuts the server down.

@eaitbrahim
eaitbrahim merged commit 5820e38 into main Oct 13, 2026
4 checks passed
@eaitbrahim
eaitbrahim deleted the fix/989-http09-request-line branch October 13, 2026 21:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

keel serve: an HTTP/0.9 request line raises AttributeError in _declared_body_length

1 participant