fix(http): decide reject-before-read closes from the framing, not the method

Two framing gaps the re-gate reproduced on the production handlers, both the
same class: something other than the wire was used to decide whether unread
body bytes are queued.

1. api/routes.py — the sidecar provenance 403 gated its close on
   `read_request_body`, a static per-method flag. That is wrong in both
   directions: a GET carrying `Content-Length`/`Transfer-Encoding` was answered
   with keep-alive and its body then parsed as the next request line
   (`501 Unsupported method ('{}GET')`), while a body-less DELETE/PUT/PATCH had
   a healthy pooled connection closed for nothing.

2. api/upload.py — all four multipart handlers read framing from
   `Content-Length` alone, so a chunked upload (which has none) was treated as
   length zero, matched no parts and was rejected 400 "No file field in
   request" with keep-alive intact. Wire trace before the fix:

       HTTP/1.1 400 Bad Request                  <- the upload rejection
       HTTP/1.1 400 Bad request syntax ('54')    <- our own chunk-size line

New primitive in api/helpers.py: `request_declares_body()` (Content-Length
present and non-zero, or any Transfer-Encoding other than identity; an
unparseable length counts as present, since it cannot prove absence) plus
`arm_connection_close_if_body_pending()` and `unsupported_transfer_encoding()`.

api/upload.py now routes all four handlers through one preflight,
`_read_multipart_or_reject()`, which refuses an undecodable transfer coding
with 411 before touching rfile — http.server never decodes chunked framing, so
that request can only be rejected — and keeps every previous status and message
(413 oversize, 400 invalid Content-Length/boundary) with its close.

Regressions: chunked reject that closes and a body-less reject that does not,
at all four upload handlers and at the sidecar 403, plus two pipelined
real-socket tests against the running server proving the follow-up request is
never answered off a poisoned socket.
This commit is contained in:
Rodrigo Gomes da Silva authored and n committed 2026-09-22 12:06:09 +00:00
1 parent 304815228a
commit b3a031a19d
5 files changed
+460 -68

No files matched your search

@@ -8,6 +8,8 @@ The connection must close after those early rejections.
from types import SimpleNamespace
from typing import Any, cast
import pytest
def test_auth_rejected_write_closes_connection(monkeypatch):
import api.auth as auth
@@ -63,6 +65,7 @@ def test_sidecar_provenance_rejected_closes_connection(monkeypatch):
handler = SimpleNamespace(
path="/api/extensions/ext1/sidecar/proxy",
command="POST",
headers={"Content-Length": "15"},
close_connection=False,
)
monkeypatch.setattr(routes, "_match_extension_sidecar_proxy_path", lambda _path: ("ext1", "/proxy"))
@@ -87,6 +90,7 @@ def test_sidecar_get_provenance_rejected_keeps_connection(monkeypatch):
handler = SimpleNamespace(
path="/api/extensions/ext1/sidecar/proxy",
command="GET",
headers={},
close_connection=False,
)
monkeypatch.setattr(routes, "_match_extension_sidecar_proxy_path", lambda _path: ("ext1", "/proxy"))
@@ -98,6 +102,116 @@ def test_sidecar_get_provenance_rejected_keeps_connection(monkeypatch):
assert handler.close_connection is False
# ── Framing, not the method, decides whether a rejection must close ──────────
#
# The re-gate found both halves of the same mistake: `read_request_body` is a
# static per-method flag, so it neither proves a body is pending (a body-less
# DELETE closed a healthy connection) nor proves one is absent (a GET carrying a
# declared body kept the connection and poisoned it). Both directions below run
# against the production handler.
def _rejected_sidecar_call(monkeypatch, method, headers, **kwargs):
"""Drive a provenance-rejected sidecar proxy call; return the handler."""
import api.routes as routes
handler = SimpleNamespace(
path="/api/extensions/ext1/sidecar/proxy",
command=method,
headers=headers,
close_connection=False,
)
monkeypatch.setattr(routes, "_match_extension_sidecar_proxy_path", lambda _path: ("ext1", "/proxy"))
monkeypatch.setattr(routes, "_check_same_origin_browser_request", lambda _handler, **_: False)
monkeypatch.setattr(routes, "j", lambda _handler, _payload, status=200: None)
routes._handle_extension_sidecar_proxy(
cast(Any, handler), SimpleNamespace(path=handler.path, query=""), method, **kwargs
)
return handler
@pytest.mark.parametrize(
"headers",
[
{"Content-Length": "12"},
{"Transfer-Encoding": "chunked"},
{"Content-Length": "banana"}, # unreadable framing proves nothing is absent
],
ids=["content-length", "chunked", "garbage-content-length"],
)
def test_sidecar_get_with_a_declared_body_closes_connection(headers, monkeypatch):
"""A GET may still declare a body — those bytes are unread, so close.
Reproduced by the gate as a 403 WITHOUT `Connection: close`, followed by
`501 Unsupported method ('{}GET')` on the same socket.
"""
handler = _rejected_sidecar_call(monkeypatch, "GET", headers)
assert handler.close_connection is True
@pytest.mark.parametrize("method", ["POST", "PUT", "PATCH", "DELETE"])
@pytest.mark.parametrize(
"headers", [{}, {"Content-Length": "0"}], ids=["no-content-length", "zero-content-length"]
)
def test_bodyless_write_method_rejection_keeps_connection(method, headers, monkeypatch):
"""A write method with no declared body has nothing unread — keep-alive.
`read_request_body=True` is passed for every one of these (that is what the
real unsafe-method call site does), so this is exactly the case the old
per-method gate got wrong in the over-close direction.
"""
handler = _rejected_sidecar_call(monkeypatch, method, headers, read_request_body=True)
assert handler.close_connection is False
@pytest.mark.parametrize(
("headers", "expected"),
[
({}, False),
({"Content-Length": "0"}, False),
({"Content-Length": " 0 "}, False),
({"Content-Length": ""}, False),
({"Content-Length": "12"}, True),
({"Content-Length": "-1"}, True),
({"Content-Length": "banana"}, True),
({"content-length": "7"}, True),
({"Transfer-Encoding": "chunked"}, True),
({"transfer-encoding": "Chunked"}, True),
({"Transfer-Encoding": "identity"}, False),
({"Transfer-Encoding": ""}, False),
],
)
def test_request_declares_body_reads_the_framing_headers(headers, expected):
from api.helpers import request_declares_body
handler = SimpleNamespace(headers=headers)
assert request_declares_body(cast(Any, handler)) is expected
def test_request_declares_body_is_case_insensitive_on_a_real_message():
"""Production handlers carry an email.message.Message, not a dict."""
from email.parser import Parser
from api.helpers import request_declares_body
message = Parser().parsestr("Host: x\r\ntransfer-encoding: Chunked\r\n\r\n")
assert request_declares_body(cast(Any, SimpleNamespace(headers=message))) is True
def test_request_declares_body_tolerates_a_handler_without_headers():
"""Never raise on a rejection path: an exception there becomes a 500."""
from api.helpers import request_declares_body
assert request_declares_body(cast(Any, SimpleNamespace())) is False
assert request_declares_body(cast(Any, SimpleNamespace(headers=None))) is False
assert request_declares_body(cast(Any, SimpleNamespace(headers=object()))) is False
def test_csp_report_rate_limited_closes_connection(monkeypatch):
"""A rate-limited CSP report is dropped before its body is read."""
import api.routes as routes
@@ -137,6 +251,90 @@ def test_upload_oversize_rejects_before_read(monkeypatch):
assert handler.close_connection is True
# ── Multipart framing preflight: chunked uploads must close, empty ones must not ─
#
# All four multipart handlers keyed on Content-Length alone, so a chunked upload
# (which has none) was read as length 0, matched no parts, and was rejected with
# "No file field in request" and keep-alive intact — leaving every chunk byte on
# the socket, which is the exact poisoning this PR exists to prevent.
# http.server never decodes chunked framing, so the only safe answer is 411 with
# the connection armed for close.
_MULTIPART_HANDLERS = [
"handle_upload",
"handle_upload_extract",
"handle_transcribe",
"handle_workspace_upload",
]
class _NeverRead:
"""An rfile that fails the test if a reject-before-read path reads the body."""
def read(self, *_args):
raise AssertionError("the body must not be read on a reject-before-read path")
def _run_multipart_handler(monkeypatch, handler_name, headers, rfile):
import api.upload as upload
answered: dict[str, Any] = {}
def _record(_handler, payload, status=200):
answered["payload"] = payload
answered["status"] = status
return True
monkeypatch.setattr(upload, "j", _record)
handler = SimpleNamespace(headers=headers, rfile=rfile, close_connection=False)
getattr(upload, handler_name)(cast(Any, handler))
return handler, answered
@pytest.mark.parametrize("handler_name", _MULTIPART_HANDLERS)
def test_chunked_multipart_rejected_before_read_closes_connection(handler_name, monkeypatch):
handler, answered = _run_multipart_handler(
monkeypatch,
handler_name,
{"Content-Type": "multipart/form-data; boundary=x", "Transfer-Encoding": "chunked"},
_NeverRead(),
)
assert answered["status"] == 411, answered
assert "Transfer-Encoding" in answered["payload"]["error"], answered
assert handler.close_connection is True
@pytest.mark.parametrize("handler_name", _MULTIPART_HANDLERS)
def test_bodyless_multipart_rejection_keeps_connection(handler_name, monkeypatch):
"""A well-framed upload with no body is rejected with nothing left unread."""
handler, answered = _run_multipart_handler(
monkeypatch,
handler_name,
{"Content-Type": "multipart/form-data; boundary=x", "Content-Length": "0"},
SimpleNamespace(read=lambda *_args: b""),
)
assert answered["status"] == 400, answered
assert handler.close_connection is False
@pytest.mark.parametrize("handler_name", _MULTIPART_HANDLERS)
def test_non_numeric_content_length_still_closes(handler_name, monkeypatch):
"""The pre-existing invalid-Content-Length reject keeps its 400 and its close."""
handler, answered = _run_multipart_handler(
monkeypatch,
handler_name,
{"Content-Type": "multipart/form-data; boundary=x", "Content-Length": "banana"},
_NeverRead(),
)
assert answered["status"] == 400, answered
assert answered["payload"]["error"] == "Invalid Content-Length", answered
assert handler.close_connection is True
# ── Header serialization: exactly one Connection: close, never a crash ────────
#
# The advertisement lives in Handler.end_headers() via
@@ -19,8 +19,11 @@ stale tab gets 410, not 403).
"""
import http.client
import socket
import urllib.parse
import pytest
from tests._pytest_port import BASE
_PORT = urllib.parse.urlparse(BASE).port
@@ -61,3 +64,96 @@ def test_rejected_write_advertises_close_and_pooled_followup_succeeds():
assert follow_status == 200, (
f"pooled follow-up after a reject-before-read must succeed; got {follow_status}"
)
# ── Framing the Content-Length gate could not see ─────────────────────────────
#
# Both cases below are pipelined down ONE socket against the real server: the
# reject-before-read request first, an ordinary GET immediately after it in the
# same write. If the rejection closes, the server answers once and EOFs and the
# GET is never seen. If it does not, the leftover body/chunk bytes are parsed as
# the next request line and a second response comes back — a 400 bad-syntax or
# `501 Unsupported method` naming bytes the client never sent as a request.
_FOLLOWING_GET = b"GET /api/health/agent HTTP/1.1\r\nHost: 127.0.0.1\r\nAccept: */*\r\n\r\n"
_MULTIPART_UPLOAD_PATHS = (
"/api/upload",
"/api/upload/extract",
"/api/workspace/upload",
"/api/transcribe",
)
_MULTIPART_PAYLOAD = (
b'--x\r\nContent-Disposition: form-data; name="file"; filename="a.txt"\r\n'
b"\r\nhello\r\n--x--\r\n"
)
_CHUNKED_MULTIPART_BODY = (
f"{len(_MULTIPART_PAYLOAD):X}\r\n".encode() + _MULTIPART_PAYLOAD + b"\r\n0\r\n\r\n"
)
def _pipelined_after(request: bytes) -> bytes:
"""Send *request* and a plain GET in one write; return every byte answered."""
sock = socket.create_connection(("127.0.0.1", _PORT), timeout=10)
try:
sock.sendall(request + _FOLLOWING_GET)
received = b""
while True:
try:
chunk = sock.recv(65536)
except (TimeoutError, socket.timeout, ConnectionError):
break
if not chunk:
break
received += chunk
return received
finally:
sock.close()
def _assert_single_closed_response(answered: bytes, status: bytes, leftover: bytes) -> None:
text = answered.decode("latin-1", errors="replace")
assert answered.startswith(b"HTTP/1.1 " + status), text
assert b"Connection: close" in answered, text
assert answered.count(b"HTTP/1.1 ") == 1, (
f"the pipelined GET was answered, so the socket stayed open: {text}"
)
assert leftover not in answered, f"the unread body reached the request parser: {text}"
assert b"Bad request syntax" not in answered, text
assert b"Unsupported method" not in answered, text
@pytest.mark.parametrize("path", _MULTIPART_UPLOAD_PATHS)
def test_chunked_upload_rejection_closes_and_cannot_poison_the_socket(path):
"""A chunked upload has no Content-Length — the old gate read it as empty.
All four multipart handlers then answered 400 "No file field in request"
with keep-alive intact, leaving the chunk bytes on the socket.
"""
answered = _pipelined_after(
f"POST {path} HTTP/1.1\r\n"
"Host: 127.0.0.1\r\n"
"Content-Type: multipart/form-data; boundary=x\r\n"
"Transfer-Encoding: chunked\r\n"
"\r\n".encode() + _CHUNKED_MULTIPART_BODY
)
_assert_single_closed_response(answered, b"411", _MULTIPART_PAYLOAD)
def test_sidecar_get_with_a_body_closes_and_cannot_poison_the_socket():
"""A provenance-rejected GET that carries a declared body.
`read_request_body=False` said "no body" and the 403 went out with
keep-alive; the gate's repro then got `501 Unsupported method ('{}GET')` on
the same socket. No Origin/Referer/Sec-Fetch-Site here, so provenance fails
before the body would ever be read.
"""
answered = _pipelined_after(
"GET /api/extensions/probe/sidecar/ping HTTP/1.1\r\n"
"Host: 127.0.0.1\r\n"
"Content-Type: application/json\r\n"
f"Content-Length: {len(_BODY)}\r\n"
"\r\n".encode() + _BODY
)
_assert_single_closed_response(answered, b"403", _BODY)