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:
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)
|
||||
Reference in new issue
Block a user