The mirror image of the sidecar over-close already fixed here: the sibling reject-before-read sites still armed `arm_connection_close()` unconditionally, so a request with NO declared body was answered with `Connection: close` and the client's pipelined follow-up was dropped -- a healthy pooled connection killed for no framing reason. Reproduced on the wire, pipelined down one socket against the production handler (`responses seen: 1` at each site): POST /api/session/new off-session auth -> 401 + Connection: close POST /api/session/new bad Origin -> 403 + Connection: close POST /api/session/new no CSRF token -> 403 + Connection: close POST /api/csp-report limiter tripped -> 204 + Connection: close POST /api/health/restart success -> 200 + Connection: close POST /api/process-complete-ack -> 410 + Connection: close POST /api/upload no boundary -> 400 + Connection: close Each now routes through `arm_connection_close_if_body_pending()` (the helper the sidecar path already used), so the framing decides in both directions: body-bearing or unreadable framing still closes, an absent or agreeing-zero Content-Length keeps keep-alive. `check_auth_or_close()` cannot swap the helper in directly -- it has to arm before `check_auth()` writes its 401/302 and undo that on success -- so it gates the arming on `request_declares_body()` instead and keeps the success-path restore. `/api/health/restart` is armed once, ahead of the outcome, so all four results (completed / in_progress / busy / error) follow the framing rather than the result; the success path was closing on every call the WebUI makes. Only the boundary check in the upload preflight can reject a bodyless request: every other raise there (any Transfer-Encoding, an unreadable or conflicting length, an over-cap length) already declares a body and still closes. Five tests pinned the unconditional close on stubs that declared no body -- i.e. they pinned the bug. Each is re-grounded on real body-bearing framing (Content-Length, chunked, and an unreadable length) so the close half stays pinned, with a bodyless sibling asserting keep-alive. New real-socket regressions cover all seven sites in both directions.
1295 lines
52 KiB
Python
1295 lines
52 KiB
Python
"""Regression tests for HTTP/1.1 write rejection framing.
|
||
|
||
A rejected POST can happen before its request body is consumed (authentication
|
||
or CSRF failure). Keeping that connection alive leaves the body bytes in
|
||
``rfile``; BaseHTTPRequestHandler then parses them as the next request method.
|
||
The connection must close after those early rejections.
|
||
"""
|
||
from types import SimpleNamespace
|
||
from typing import Any, cast
|
||
|
||
import pytest
|
||
|
||
|
||
def _rejected_write(monkeypatch, headers):
|
||
"""Drive the production _handle_write through an auth rejection."""
|
||
import api.auth as auth
|
||
import server
|
||
|
||
handler = SimpleNamespace(
|
||
path="/api/session/new",
|
||
command="POST",
|
||
headers=headers,
|
||
close_connection=False,
|
||
)
|
||
monkeypatch.setattr(server, "reset_trusted_auth_request_state", lambda _handler: None)
|
||
monkeypatch.setattr(server, "get_profile_cookie", lambda _handler: None)
|
||
monkeypatch.setattr(server, "clear_request_profile", lambda: None)
|
||
monkeypatch.setattr(auth, "check_auth", lambda _handler, _parsed: False)
|
||
|
||
def route_must_not_run(_handler, _parsed):
|
||
raise AssertionError("route ran after auth rejection")
|
||
|
||
server.Handler._handle_write(cast(Any, handler), route_must_not_run)
|
||
return handler
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"headers",
|
||
[{"Content-Length": "15"}, {"Transfer-Encoding": "chunked"}, {"Content-Length": "banana"}],
|
||
ids=["content-length", "chunked", "unreadable-length"],
|
||
)
|
||
def test_auth_rejected_write_closes_connection(headers, monkeypatch):
|
||
"""A rejected write whose framing declares a body leaves it unread — close."""
|
||
assert _rejected_write(monkeypatch, headers).close_connection is True
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"headers", [{}, {"Content-Length": "0"}], ids=["no-content-length", "zero-content-length"]
|
||
)
|
||
def test_bodyless_auth_rejected_write_keeps_connection(headers, monkeypatch):
|
||
"""The over-close half at the auth gate: no declared body, nothing to protect.
|
||
|
||
`check_auth_or_close()` armed unconditionally, so a body-less POST that failed
|
||
auth answered 401 WITH `Connection: close` and dropped the client's pipelined
|
||
follow-up (reproduced on the wire — see the pooled-client file). Same rule as
|
||
the sidecar path: the framing decides, not the method or the site.
|
||
"""
|
||
assert _rejected_write(monkeypatch, headers).close_connection is False
|
||
|
||
|
||
def _csrf_origin_rejection(monkeypatch, headers):
|
||
import api.routes as routes
|
||
|
||
handler = SimpleNamespace(headers=headers, close_connection=False)
|
||
monkeypatch.setattr(routes, "_check_same_origin_browser_request", lambda _handler: False)
|
||
|
||
assert routes._check_csrf(handler) is False
|
||
return handler
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"headers",
|
||
[{"Content-Length": "15"}, {"Transfer-Encoding": "chunked"}, {"Content-Length": "banana"}],
|
||
ids=["content-length", "chunked", "unreadable-length"],
|
||
)
|
||
def test_origin_rejected_csrf_closes_connection(headers, monkeypatch):
|
||
assert _csrf_origin_rejection(monkeypatch, headers).close_connection is True
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"headers", [{}, {"Content-Length": "0"}], ids=["no-content-length", "zero-content-length"]
|
||
)
|
||
def test_bodyless_origin_rejected_csrf_keeps_connection(headers, monkeypatch):
|
||
"""A cross-origin rejection of a body-less write must not drop the socket."""
|
||
assert _csrf_origin_rejection(monkeypatch, headers).close_connection is False
|
||
|
||
|
||
def _csrf_token_rejection(monkeypatch, headers):
|
||
import api.auth as auth
|
||
import api.routes as routes
|
||
|
||
handler = SimpleNamespace(headers=headers, close_connection=False)
|
||
monkeypatch.setattr(routes, "_check_same_origin_browser_request", lambda _handler: True)
|
||
monkeypatch.setattr(routes, "_is_browser_unsafe_request", lambda _handler: True)
|
||
monkeypatch.setattr(auth, "is_auth_enabled", lambda: True)
|
||
monkeypatch.setattr(auth, "parse_cookie", lambda _handler: "session-cookie")
|
||
monkeypatch.setattr(auth, "verify_csrf_token", lambda _cookie, _token: False)
|
||
|
||
assert routes._check_csrf(handler) is False
|
||
return handler
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"headers",
|
||
[{"Content-Length": "15"}, {"Transfer-Encoding": "chunked"}, {"Content-Length": "banana"}],
|
||
ids=["content-length", "chunked", "unreadable-length"],
|
||
)
|
||
def test_token_rejected_csrf_closes_connection(headers, monkeypatch):
|
||
assert _csrf_token_rejection(monkeypatch, headers).close_connection is True
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"headers", [{}, {"Content-Length": "0"}], ids=["no-content-length", "zero-content-length"]
|
||
)
|
||
def test_bodyless_token_rejected_csrf_keeps_connection(headers, monkeypatch):
|
||
"""A token mismatch on a body-less write has nothing unread either."""
|
||
assert _csrf_token_rejection(monkeypatch, headers).close_connection is False
|
||
|
||
|
||
def test_sidecar_provenance_rejected_closes_connection(monkeypatch):
|
||
"""Sidecar proxy provenance rejection also runs before read_body()."""
|
||
import api.routes as routes
|
||
|
||
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"))
|
||
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(
|
||
handler, SimpleNamespace(path=handler.path, query=""), "POST", read_request_body=True
|
||
)
|
||
|
||
assert handler.close_connection is True
|
||
|
||
|
||
def test_sidecar_get_provenance_rejected_keeps_connection(monkeypatch):
|
||
"""A body-less GET rejection has nothing unread — keep-alive must survive.
|
||
|
||
Closing here would kill a pooled client's connection for no framing
|
||
reason (the deep-review gate's MUST-FIX 2).
|
||
"""
|
||
import api.routes as routes
|
||
|
||
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"))
|
||
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(handler, SimpleNamespace(path=handler.path, query=""), "GET")
|
||
|
||
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": "00"}, False), # 1*DIGIT, so an honest zero
|
||
({"Content-Length": " 0 "}, False),
|
||
({"Content-Length": "0\t"}, False),
|
||
({"Content-Length": ""}, True),
|
||
({"Content-Length": " "}, True),
|
||
({"Content-Length": "\t"}, True),
|
||
({"Content-Length": "12"}, True),
|
||
({"Content-Length": "-1"}, True),
|
||
({"Content-Length": "banana"}, True),
|
||
({"Content-Length": "0x5"}, True),
|
||
({"Content-Length": "+5"}, True),
|
||
# int() is looser than 1*DIGIT, and every one of these reads as ZERO --
|
||
# where the wrong parse is not merely wrong but silently keep-alive.
|
||
({"Content-Length": "+0"}, True),
|
||
({"Content-Length": "-0"}, True),
|
||
({"Content-Length": "0_0"}, True),
|
||
({"Content-Length": "+00"}, True),
|
||
({"Content-Length": "0_0_0"}, True),
|
||
({"Content-Length": "٠0"}, True), # ARABIC-INDIC ZERO ahead of an ASCII one
|
||
({"Content-Length": "٠"}, True), # ARABIC-INDIC DIGIT ZERO: int() -> 0
|
||
({"Content-Length": "0"}, True), # FULLWIDTH DIGIT ZERO: int() -> 0
|
||
({"Content-Length": "٥"}, True), # ARABIC-INDIC DIGIT FIVE: int() -> 5
|
||
({"Content-Length": "\xa00"}, True), # NBSP-padded zero: int() strips it
|
||
({"Content-Length": "0\x85"}, True), # NEL-padded zero: likewise
|
||
({"content-length": "7"}, True),
|
||
({"Transfer-Encoding": "chunked"}, True),
|
||
({"transfer-encoding": "Chunked"}, True),
|
||
# No coding is exempt: http.server decodes none of them (RFC 9112 6.3).
|
||
({"Transfer-Encoding": "identity"}, True),
|
||
({"Transfer-Encoding": "IDENTITY "}, True),
|
||
({"Transfer-Encoding": "Identity"}, True),
|
||
({"Transfer-Encoding": "\tidentity\t"}, True),
|
||
({"Transfer-Encoding": "identity, chunked"}, True),
|
||
({"Transfer-Encoding": "chunked, identity"}, True),
|
||
({"Transfer-Encoding": "identity,identity"}, True),
|
||
({"Transfer-Encoding": ","}, True),
|
||
({"Transfer-Encoding": "banana"}, True),
|
||
({"Transfer-Encoding": ""}, True),
|
||
({"Transfer-Encoding": " "}, True),
|
||
({"Transfer-Encoding": "\xa0identity"}, True), # not the token `identity`
|
||
# A coding beside a length: the coding decides, whatever the length says.
|
||
({"Transfer-Encoding": "identity", "Content-Length": "0"}, True),
|
||
({"Transfer-Encoding": "identity", "Content-Length": "15"}, True),
|
||
],
|
||
)
|
||
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
|
||
|
||
|
||
# ── A duplicated framing header hides the body from a first-value-only read ────
|
||
#
|
||
# `Message.get()` returns only the FIRST occurrence, so `Content-Length: 0`
|
||
# ahead of the real length reads back as an empty body: nothing is drained and
|
||
# the real bytes are parsed as the next request line. Verified live against the
|
||
# running server before the fix -- 403 then
|
||
# `400 Bad request syntax ('{"stale": true}GET /api/health/agent HTTP/1.1')`,
|
||
# and on /api/upload a 400 then `400 Bad request syntax ('--x')`.
|
||
|
||
|
||
def _message_with(raw_headers: str):
|
||
"""Build the email.message.Message a production handler really carries."""
|
||
from email.parser import Parser
|
||
|
||
return SimpleNamespace(headers=Parser().parsestr(raw_headers + "\r\n"))
|
||
|
||
|
||
def test_duplicate_conflicting_content_length_declares_a_body():
|
||
from api.helpers import request_declares_body
|
||
|
||
handler = _message_with("Host: x\r\nContent-Length: 0\r\nContent-Length: 15\r\n")
|
||
|
||
assert request_declares_body(cast(Any, handler)) is True
|
||
|
||
|
||
def test_duplicate_agreeing_zero_content_length_declares_no_body():
|
||
"""Repetition alone is not a conflict -- two honest zeroes still frame nothing."""
|
||
from api.helpers import request_declares_body
|
||
|
||
handler = _message_with("Host: x\r\nContent-Length: 0\r\nContent-Length: 0\r\n")
|
||
|
||
assert request_declares_body(cast(Any, handler)) is False
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"raw",
|
||
[
|
||
"Transfer-Encoding: identity\r\nTransfer-Encoding: chunked\r\n",
|
||
"Transfer-Encoding: chunked\r\nTransfer-Encoding: identity\r\n",
|
||
"Transfer-Encoding: identity\r\nTransfer-Encoding: identity\r\n",
|
||
"Transfer-Encoding: identity\r\ntransfer-encoding: identity\r\n",
|
||
],
|
||
ids=["identity-then-chunked", "chunked-then-identity", "twice", "twice-mixed-case"],
|
||
)
|
||
def test_repeated_transfer_encoding_refuses_whatever_the_order(raw):
|
||
"""A duplicated header cannot hide an undecodable coding behind an "unframed" one.
|
||
|
||
This used to have to look PAST a leading `identity` to find the chunked coding
|
||
(`Message.get()` returns only the first value, so it read as unframed and the
|
||
chunk bytes stayed queued). With no coding exempt the leading value already
|
||
refuses, so no ordering can mask anything -- which is what is pinned here,
|
||
rather than the specific coding a first-value read happened to miss.
|
||
"""
|
||
from api.helpers import request_declares_body, unsupported_transfer_encoding
|
||
|
||
handler = _message_with("Host: x\r\n" + raw)
|
||
|
||
assert unsupported_transfer_encoding(cast(Any, handler)) is not None
|
||
assert request_declares_body(cast(Any, handler)) is True
|
||
|
||
|
||
def test_unreadable_content_length_flags_conflict_and_garbage_only():
|
||
from api.helpers import unreadable_content_length
|
||
|
||
assert unreadable_content_length(cast(Any, _message_with("Content-Length: 5\r\n"))) is False
|
||
assert unreadable_content_length(cast(Any, _message_with("Host: x\r\n"))) is False
|
||
assert unreadable_content_length(
|
||
cast(Any, _message_with("Content-Length: 5\r\nContent-Length: 5\r\n"))
|
||
) is False
|
||
assert unreadable_content_length(
|
||
cast(Any, _message_with("Content-Length: 0\r\nContent-Length: 9\r\n"))
|
||
) is True
|
||
assert unreadable_content_length(cast(Any, _message_with("Content-Length: nope\r\n"))) is True
|
||
|
||
|
||
def test_sidecar_get_with_duplicate_content_length_closes_connection(monkeypatch):
|
||
"""The conflicting-framing GET reaches the production provenance rejection."""
|
||
import api.routes as routes
|
||
|
||
from email.parser import Parser
|
||
|
||
headers = Parser().parsestr("Host: x\r\nContent-Length: 0\r\nContent-Length: 15\r\n\r\n")
|
||
handler = SimpleNamespace(
|
||
path="/api/extensions/ext1/sidecar/proxy",
|
||
command="GET",
|
||
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=""), "GET"
|
||
)
|
||
|
||
assert handler.close_connection is True
|
||
|
||
|
||
# ── A BLANK framing value is unreadable, not absent ───────────────────────────
|
||
#
|
||
# `Content-Length:` with nothing after the colon reads back as '' from
|
||
# `get_all()`, and the empty value was SKIPPED — so `_declared_content_lengths()`
|
||
# reported "no length declared", `request_declares_body()` said no body was
|
||
# pending, and the provenance rejection answered 403 with keep-alive intact.
|
||
# Reproduced live against the running server on the otherwise-fixed head:
|
||
#
|
||
# GET /api/extensions/probe/sidecar/ping `Content-Length:` {"stale": true}
|
||
# -> HTTP/1.1 403 Forbidden (no Connection: close)
|
||
# -> HTTP/1.1 400 Bad request syntax
|
||
# ('{"stale": true}GET /api/health/agent HTTP/1.1')
|
||
#
|
||
# `Message` collapses `Content-Length:`, `Content-Length: ` and
|
||
# `Content-Length:\t` to the same '', so every blank spelling poisoned the socket
|
||
# identically — as did a blank value duplicated, or paired with a real length.
|
||
#
|
||
# A blank `Transfer-Encoding:` was the same hole one header over: '' reached the
|
||
# allowlist of codings that "framed nothing" (since deleted, along with the
|
||
# `identity` entry that was its last member), so a header naming no coding at all
|
||
# read as "no framing to worry about" and its payload poisoned the socket the same
|
||
# way (verified live: 403 then the same bad-syntax 400). Absence is something only
|
||
# the wire can say, and an absent header yields no values at all.
|
||
|
||
|
||
_BLANK_FRAMING_HEADERS = [
|
||
"Content-Length:",
|
||
"Content-Length: ",
|
||
"Content-Length:\t",
|
||
"Content-Length:\r\nContent-Length:", # duplicated blank
|
||
"Content-Length:\r\nContent-Length: 15", # blank ahead of a real length
|
||
"Content-Length: 15\r\nContent-Length:", # and behind one
|
||
"Content-Length: 0\r\nContent-Length:", # blank behind an honest zero
|
||
"Transfer-Encoding:",
|
||
"Transfer-Encoding: ",
|
||
"Transfer-Encoding:\r\nTransfer-Encoding: chunked",
|
||
]
|
||
|
||
|
||
@pytest.mark.parametrize("raw", _BLANK_FRAMING_HEADERS)
|
||
def test_blank_framing_value_declares_a_body(raw):
|
||
"""On a real email.message.Message, as the production handler carries."""
|
||
from api.helpers import request_declares_body
|
||
|
||
handler = _message_with("Host: x\r\n" + raw + "\r\n")
|
||
|
||
assert request_declares_body(cast(Any, handler)) is True
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"raw",
|
||
[
|
||
"Content-Length:",
|
||
"Content-Length: ",
|
||
"Content-Length:\r\nContent-Length:",
|
||
"Content-Length:\r\nContent-Length: 15",
|
||
"Content-Length: 0\r\nContent-Length:",
|
||
],
|
||
)
|
||
def test_blank_content_length_is_unreadable(raw):
|
||
"""The upload preflight gate must refuse it too — there is no length to drain."""
|
||
from api.helpers import unreadable_content_length
|
||
|
||
assert unreadable_content_length(cast(Any, _message_with("Host: x\r\n" + raw + "\r\n"))) is True
|
||
|
||
|
||
def test_blank_transfer_encoding_is_reported_as_an_undecodable_coding():
|
||
"""Named, non-empty and never None, so `is not None` callers and the 411 read right."""
|
||
from api.helpers import unsupported_transfer_encoding
|
||
|
||
coding = unsupported_transfer_encoding(cast(Any, _message_with("Host: x\r\nTransfer-Encoding:\r\n")))
|
||
|
||
assert coding is not None
|
||
assert coding, "an empty return value would be a falsy non-None trap for callers"
|
||
# Only an ABSENT header frames nothing; a present one always names a coding
|
||
# this server cannot decode, `identity` included.
|
||
assert unsupported_transfer_encoding(cast(Any, _message_with("Host: x\r\n"))) is None
|
||
assert unsupported_transfer_encoding(
|
||
cast(Any, _message_with("Host: x\r\nTransfer-Encoding: identity\r\n"))
|
||
) == "identity"
|
||
|
||
|
||
@pytest.mark.parametrize("raw", _BLANK_FRAMING_HEADERS)
|
||
def test_sidecar_get_with_blank_framing_value_closes_connection(raw, monkeypatch):
|
||
"""The reported case, through the production provenance rejection."""
|
||
import api.routes as routes
|
||
|
||
from email.parser import Parser
|
||
|
||
handler = SimpleNamespace(
|
||
path="/api/extensions/ext1/sidecar/proxy",
|
||
command="GET",
|
||
headers=Parser().parsestr("Host: x\r\n" + raw + "\r\n\r\n"),
|
||
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=""), "GET"
|
||
)
|
||
|
||
assert handler.close_connection is True
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"raw",
|
||
[
|
||
"",
|
||
"Content-Length: 0\r\n",
|
||
"Content-Length: 0 \r\n",
|
||
"Content-Length: 0\r\nContent-Length: 0\r\n",
|
||
],
|
||
)
|
||
def test_blank_rule_does_not_over_close_a_bodyless_request(raw):
|
||
"""The other half of the contract: framing that says "no body" keeps keep-alive.
|
||
|
||
No framing header at all, an OWS-padded zero and two AGREEING zeroes are
|
||
honest statements that nothing is queued, and none of them may be swept up by
|
||
the blank rule.
|
||
"""
|
||
from api.helpers import request_declares_body
|
||
|
||
assert request_declares_body(cast(Any, _message_with("Host: x\r\n" + raw))) is False
|
||
|
||
|
||
# ── A length that only `int()` calls zero is unreadable, not zero ──────────────
|
||
#
|
||
# The blank rule above closed the "no value" hole; this one closes the "value
|
||
# `int()` is too generous about" hole beside it. RFC 9110 is
|
||
# `Content-Length = 1*DIGIT` -- an unsigned run of ASCII digits -- but `int()`
|
||
# also accepts a leading sign, PEP 515 underscores and non-ASCII digits, and
|
||
# strips non-ASCII whitespace. Every spelling of ZERO below therefore parsed to
|
||
# an honest `0`, took the "every declared length agrees on zero" keep-alive
|
||
# branch, and left the payload queued. Reproduced live on the otherwise-fixed
|
||
# head, pipelined down one socket:
|
||
#
|
||
# GET /api/extensions/probe/sidecar/ping `Content-Length: +0` {"stale": true}
|
||
# -> HTTP/1.1 403 Forbidden (no Connection: close)
|
||
# -> HTTP/1.1 400 Bad request syntax
|
||
# ('{"stale": true}GET /api/health/agent HTTP/1.1')
|
||
#
|
||
# byte-for-byte the trace the blank value produced, and on /api/upload a 400
|
||
# then `400 Bad request syntax ('--x')`.
|
||
#
|
||
# A sign is only harmless on a NON-zero value (`+5` parses to 5, declares a body
|
||
# and closes anyway), which is exactly why the family looked safe: the mistake is
|
||
# invisible until the value is zero.
|
||
#
|
||
# Non-ASCII digits are unreadable by the same rule. An HTTP/1.1 request line
|
||
# decodes as latin-1, so U+0660 cannot cross the wire in a header value (only
|
||
# U+00B2/U+00B3/U+00B9 -- superscripts, which `int()` already rejects -- and the
|
||
# U+0085/U+00A0 whitespace `int()` strips can); these helpers take any headers
|
||
# mapping, though, so the grammar is enforced here rather than relying on the
|
||
# transport to filter.
|
||
|
||
_MALFORMED_ZERO_LENGTHS = [
|
||
"+0",
|
||
"-0",
|
||
"0_0",
|
||
"+00",
|
||
"0_0_0",
|
||
"\xa00", # NBSP-padded zero: str.strip()/int() erase U+00A0
|
||
"0\x85", # NEL-padded zero: likewise U+0085
|
||
"٠", # ARABIC-INDIC DIGIT ZERO -- isdigit() and int() both accept it
|
||
"0", # FULLWIDTH DIGIT ZERO
|
||
"𝟢", # MATHEMATICAL MONOSPACE DIGIT ZERO
|
||
]
|
||
|
||
|
||
@pytest.mark.parametrize("value", _MALFORMED_ZERO_LENGTHS)
|
||
def test_malformed_zero_content_length_declares_a_body(value):
|
||
"""On a real email.message.Message, as the production handler carries."""
|
||
from api.helpers import request_declares_body
|
||
|
||
handler = _message_with(f"Host: x\r\nContent-Length: {value}\r\n")
|
||
|
||
assert request_declares_body(cast(Any, handler)) is True
|
||
|
||
|
||
@pytest.mark.parametrize("value", _MALFORMED_ZERO_LENGTHS)
|
||
def test_malformed_zero_content_length_is_unreadable(value):
|
||
"""The upload preflight gate must refuse it too -- it is not a length."""
|
||
from api.helpers import unreadable_content_length
|
||
|
||
handler = _message_with(f"Host: x\r\nContent-Length: {value}\r\n")
|
||
|
||
assert unreadable_content_length(cast(Any, handler)) is True
|
||
|
||
|
||
@pytest.mark.parametrize("value", _MALFORMED_ZERO_LENGTHS)
|
||
def test_sidecar_get_with_a_malformed_zero_length_closes_connection(value, monkeypatch):
|
||
"""Through the production provenance rejection, not just the helper."""
|
||
handler = _rejected_sidecar_call(
|
||
monkeypatch, "GET", _message_with(f"Host: x\r\nContent-Length: {value}\r\n").headers
|
||
)
|
||
|
||
assert handler.close_connection is True
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"value",
|
||
["0", "00", "000", " 0 ", "0\t", "\t0", "0\r\nContent-Length: 0"],
|
||
ids=["zero", "double-zero", "triple-zero", "padded", "tab-behind", "tab-ahead", "two-agreeing"],
|
||
)
|
||
def test_digit_grammar_does_not_over_close_an_honest_zero(value):
|
||
"""The over-close half: `1*DIGIT` zeroes, SP/HTAB padding and all, stay alive.
|
||
|
||
`00` is as valid as `0` (`1*DIGIT` sets no leading-zero rule), and RFC 9110
|
||
OWS around a field value is SP/HTAB -- so trimming those must not become
|
||
trimming everything `str.strip()` would.
|
||
"""
|
||
from api.helpers import request_declares_body, unreadable_content_length
|
||
|
||
handler = _message_with(f"Host: x\r\nContent-Length: {value}\r\n")
|
||
|
||
assert request_declares_body(cast(Any, handler)) is False
|
||
assert unreadable_content_length(cast(Any, handler)) is False
|
||
|
||
|
||
def test_absurdly_long_digit_run_is_unreadable_and_never_raises():
|
||
"""`1*DIGIT` is necessary but not sufficient: `int()` caps the digit count.
|
||
|
||
A run longer than `sys.get_int_max_str_digits()` (4300) raises ValueError, and
|
||
this runs on rejection paths where an exception becomes a spurious 500 -- so
|
||
the parse stays guarded even though every character is now vetted first.
|
||
"""
|
||
from api.helpers import request_declares_body, unreadable_content_length
|
||
|
||
handler = _message_with("Host: x\r\nContent-Length: " + "0" * 5000 + "\r\n")
|
||
|
||
assert request_declares_body(cast(Any, handler)) is True
|
||
assert unreadable_content_length(cast(Any, handler)) is True
|
||
|
||
|
||
# ── The same audit one header over: NO transfer coding is decodable here ───────
|
||
#
|
||
# `identity` was exempt as a coding that "frames nothing", so `Transfer-Encoding:
|
||
# identity` plus payload bytes read as body-less: the sidecar rejection answered
|
||
# 403 with keep-alive and the payload became the next request line, and all four
|
||
# upload handlers fell through to a length-0 read. RFC 9112 6.3 is explicit --
|
||
# a request whose final transfer coding is not `chunked` has a length the
|
||
# recipient cannot determine, so it must be refused and the connection closed --
|
||
# and http.server decodes no coding at all, `chunked` included. Every spelling in
|
||
# the neighbourhood is swept here because three prior rounds each fixed one value
|
||
# of a framing rule and left an adjacent one poisoning the socket.
|
||
#
|
||
# The over-close half of this contract is pinned by the framing table above and by
|
||
# `test_blank_rule_does_not_over_close_a_bodyless_request` /
|
||
# `test_digit_grammar_does_not_over_close_an_honest_zero`: a request with NO
|
||
# framing header, or one whose declared lengths all agree on zero, keeps its
|
||
# keep-alive. An absent `Transfer-Encoding` is the only one that frames nothing.
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"value",
|
||
[
|
||
"identity",
|
||
"IDENTITY",
|
||
"Identity",
|
||
"identity ",
|
||
"\tidentity\t",
|
||
"\xa0identity",
|
||
"identity\xa0",
|
||
"\xa0identity\xa0",
|
||
"\x85identity",
|
||
"identity, chunked",
|
||
"chunked, identity",
|
||
"identity,identity",
|
||
",",
|
||
"banana",
|
||
],
|
||
ids=[
|
||
"identity",
|
||
"upper",
|
||
"title",
|
||
"sp-padded",
|
||
"htab-padded",
|
||
"nbsp-ahead",
|
||
"nbsp-behind",
|
||
"nbsp-both",
|
||
"nel-ahead",
|
||
"identity-then-chunked",
|
||
"chunked-then-identity",
|
||
"identity-list",
|
||
"lone-comma",
|
||
"unknown-coding",
|
||
],
|
||
)
|
||
def test_every_spelling_of_a_transfer_coding_declares_a_body(value):
|
||
"""Case, OWS, non-OWS padding, comma lists, a bare comma, an unknown coding.
|
||
|
||
The ASCII `identity` spellings are the newly-closed hole (they normalized to
|
||
the exempt token); the U+00A0/U+0085-padded ones were the previous round's
|
||
(`str.strip()` erased the padding, so `\\xa0identity` read back as `identity`)
|
||
and they reach the wire because a request line decodes as latin-1. The comma
|
||
forms never matched the exempt token and already refused -- they are pinned so
|
||
a future normalizer that splits the list cannot reopen the hole.
|
||
"""
|
||
from api.helpers import request_declares_body, unsupported_transfer_encoding
|
||
|
||
handler = _message_with(f"Host: x\r\nTransfer-Encoding: {value}\r\n")
|
||
|
||
assert unsupported_transfer_encoding(cast(Any, handler)) is not None
|
||
assert request_declares_body(cast(Any, handler)) is True
|
||
|
||
|
||
@pytest.mark.parametrize("length", ["0", "15"], ids=["zero-length", "real-length"])
|
||
def test_transfer_encoding_beside_a_content_length_still_declares_a_body(length):
|
||
"""A coding plus a length is unframeable however honest the length looks.
|
||
|
||
`Transfer-Encoding: identity` with `Content-Length: 0` was the worst of the
|
||
pair: both halves read as "no body", so the rejection kept the socket and the
|
||
payload behind them was parsed as the next request line. RFC 9112 6.3 refuses
|
||
the combination outright (it is the request-smuggling shape), and the coding
|
||
has to win -- a length cannot describe a body the server cannot decode.
|
||
"""
|
||
from api.helpers import request_declares_body
|
||
|
||
handler = _message_with(
|
||
f"Host: x\r\nTransfer-Encoding: identity\r\nContent-Length: {length}\r\n"
|
||
)
|
||
|
||
assert request_declares_body(cast(Any, handler)) is True
|
||
|
||
|
||
def _rate_limited_csp_report(monkeypatch, headers):
|
||
import api.routes as routes
|
||
|
||
handler = SimpleNamespace(headers=headers, close_connection=False)
|
||
monkeypatch.setattr(routes, "_csp_report_rate_limited", lambda _handler: True)
|
||
monkeypatch.setattr(routes, "_send_no_content", lambda _handler: True)
|
||
|
||
assert routes._handle_csp_report(handler) is True
|
||
return handler
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"headers",
|
||
[{"Content-Length": "15"}, {"Transfer-Encoding": "chunked"}, {"Content-Length": "banana"}],
|
||
ids=["content-length", "chunked", "unreadable-length"],
|
||
)
|
||
def test_csp_report_rate_limited_closes_connection(headers, monkeypatch):
|
||
"""A rate-limited CSP report is dropped before its body is read."""
|
||
assert _rate_limited_csp_report(monkeypatch, headers).close_connection is True
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"headers", [{}, {"Content-Length": "0"}], ids=["no-content-length", "zero-content-length"]
|
||
)
|
||
def test_bodyless_rate_limited_csp_report_keeps_connection(headers, monkeypatch):
|
||
"""A body-less report dropped by the limiter must not cost the browser its socket."""
|
||
assert _rate_limited_csp_report(monkeypatch, headers).close_connection is False
|
||
|
||
|
||
_RESTART_OUTCOMES = [
|
||
{"status": "completed"},
|
||
{"status": "in_progress"},
|
||
{"status": "busy"},
|
||
{"status": "error"},
|
||
]
|
||
|
||
|
||
@pytest.mark.parametrize("outcome", _RESTART_OUTCOMES, ids=lambda o: o["status"])
|
||
@pytest.mark.parametrize(
|
||
"headers",
|
||
[{"Content-Length": "15"}, {"Transfer-Encoding": "chunked"}, {"Content-Length": "banana"}],
|
||
ids=["content-length", "chunked", "unreadable-length"],
|
||
)
|
||
def test_health_restart_closes_connection(headers, outcome, monkeypatch):
|
||
"""health/restart never consumes its body on any outcome — so a declared one closes."""
|
||
import api.routes as routes
|
||
|
||
handler = SimpleNamespace(headers=headers, close_connection=False)
|
||
monkeypatch.setattr(routes, "restart_active_profile_gateway", lambda: outcome)
|
||
monkeypatch.setattr(routes, "j", lambda _handler, _payload, status=200: True)
|
||
|
||
routes._handle_health_restart(handler)
|
||
assert handler.close_connection is True
|
||
|
||
|
||
@pytest.mark.parametrize("outcome", _RESTART_OUTCOMES, ids=lambda o: o["status"])
|
||
@pytest.mark.parametrize(
|
||
"headers", [{}, {"Content-Length": "0"}], ids=["no-content-length", "zero-content-length"]
|
||
)
|
||
def test_bodyless_health_restart_keeps_connection(headers, outcome, monkeypatch):
|
||
"""Including the SUCCESS path, which closed a healthy socket on every call.
|
||
|
||
The WebUI's restart button sends no body, so the old unconditional arming made
|
||
a 200 "restarted successfully" hang up the connection every single time. The
|
||
framing decides for all four outcomes, not the result.
|
||
"""
|
||
import api.routes as routes
|
||
|
||
handler = SimpleNamespace(headers=headers, close_connection=False)
|
||
monkeypatch.setattr(routes, "restart_active_profile_gateway", lambda: outcome)
|
||
monkeypatch.setattr(routes, "j", lambda _handler, _payload, status=200: True)
|
||
|
||
routes._handle_health_restart(handler)
|
||
assert handler.close_connection is False
|
||
|
||
|
||
def _deprecated_ack_post(monkeypatch, headers):
|
||
"""Drive the deprecated /api/process-complete-ack 410, which runs pre-CSRF."""
|
||
import api.routes as routes
|
||
|
||
handler = SimpleNamespace(
|
||
path="/api/process-complete-ack",
|
||
command="POST",
|
||
headers=headers,
|
||
close_connection=False,
|
||
)
|
||
monkeypatch.setattr(
|
||
routes, "j", lambda _handler, _payload, status=200, extra_headers=None: True
|
||
)
|
||
|
||
assert routes.handle_post(
|
||
cast(Any, handler), SimpleNamespace(path=handler.path, query="")
|
||
) is True
|
||
return handler
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"headers",
|
||
[{"Content-Length": "15"}, {"Transfer-Encoding": "chunked"}, {"Content-Length": "banana"}],
|
||
ids=["content-length", "chunked", "unreadable-length"],
|
||
)
|
||
def test_deprecated_ack_with_a_body_closes_connection(headers, monkeypatch):
|
||
"""A stale tab's JSON ack is never read, so a declared body must close."""
|
||
assert _deprecated_ack_post(monkeypatch, headers).close_connection is True
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"headers", [{}, {"Content-Length": "0"}], ids=["no-content-length", "zero-content-length"]
|
||
)
|
||
def test_bodyless_deprecated_ack_keeps_connection(headers, monkeypatch):
|
||
"""A body-less ack has nothing queued — the 410 must not kill keep-alive."""
|
||
assert _deprecated_ack_post(monkeypatch, headers).close_connection is False
|
||
|
||
|
||
def test_upload_oversize_rejects_before_read(monkeypatch):
|
||
"""An oversized upload's 413 fires before rfile.read() — connection closes."""
|
||
import api.upload as upload
|
||
|
||
handler = SimpleNamespace(
|
||
headers={"Content-Type": "multipart/form-data; boundary=x", "Content-Length": "99999999999"},
|
||
close_connection=False,
|
||
)
|
||
monkeypatch.setattr(upload, "j", lambda _handler, _payload, status=200: None)
|
||
|
||
upload.handle_upload(handler)
|
||
|
||
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)
|
||
@pytest.mark.parametrize(
|
||
"headers",
|
||
[
|
||
{"Content-Type": "application/json"},
|
||
{"Content-Type": "application/json", "Content-Length": "0"},
|
||
{"Content-Type": "multipart/form-data"}, # multipart, but no boundary parameter
|
||
],
|
||
ids=["json-no-length", "json-zero-length", "multipart-without-boundary"],
|
||
)
|
||
def test_bodyless_boundary_rejection_keeps_connection(handler_name, headers, monkeypatch):
|
||
"""`_reject_before_read()` fires on a body-less request too — via the boundary check.
|
||
|
||
Every other rejection the preflight raises (any Transfer-Encoding, an unreadable
|
||
or conflicting length, an over-cap length) already declares a body. The missing
|
||
`boundary=` in Content-Type is the one that can fire with no body at all, and
|
||
the unconditional arming closed a healthy pooled socket for it: on the wire,
|
||
`POST /api/upload` with `Content-Type: application/json` and no `Content-Length`
|
||
answered 400 "No boundary in Content-Type" WITH `Connection: close` and the
|
||
pipelined GET was dropped. `_NeverRead` also proves the boundary check still
|
||
runs ahead of any `rfile.read()`.
|
||
"""
|
||
handler, answered = _run_multipart_handler(
|
||
monkeypatch, handler_name, headers, _NeverRead()
|
||
)
|
||
|
||
assert answered["status"] == 400, answered
|
||
assert answered["payload"]["error"] == "No boundary in Content-Type", answered
|
||
assert handler.close_connection is False
|
||
|
||
|
||
@pytest.mark.parametrize("handler_name", _MULTIPART_HANDLERS)
|
||
def test_boundary_rejection_with_a_declared_body_still_closes(handler_name, monkeypatch):
|
||
"""The close half of the same rejection: a declared body is still queued."""
|
||
handler, answered = _run_multipart_handler(
|
||
monkeypatch,
|
||
handler_name,
|
||
{"Content-Type": "application/json", "Content-Length": "15"},
|
||
_NeverRead(),
|
||
)
|
||
|
||
assert answered["status"] == 400, answered
|
||
assert answered["payload"]["error"] == "No boundary in Content-Type", answered
|
||
assert handler.close_connection is True
|
||
|
||
|
||
@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
|
||
|
||
|
||
@pytest.mark.parametrize("handler_name", _MULTIPART_HANDLERS)
|
||
def test_duplicate_content_length_multipart_rejects_before_read(handler_name, monkeypatch):
|
||
"""Disagreeing lengths give no honest byte count -- refuse without reading.
|
||
|
||
Reading the first value (0) made the body invisible: the handler answered
|
||
"No file field in request" with keep-alive and left the real multipart bytes
|
||
on the socket, where the server parsed them as the next request line.
|
||
"""
|
||
from email.parser import Parser
|
||
|
||
headers = Parser().parsestr(
|
||
"Content-Type: multipart/form-data; boundary=x\r\n"
|
||
"Content-Length: 0\r\n"
|
||
"Content-Length: 88\r\n\r\n"
|
||
)
|
||
handler, answered = _run_multipart_handler(monkeypatch, handler_name, headers, _NeverRead())
|
||
|
||
assert answered["status"] == 400, answered
|
||
assert answered["payload"]["error"] == "Invalid Content-Length", answered
|
||
assert handler.close_connection is True
|
||
|
||
|
||
@pytest.mark.parametrize("handler_name", _MULTIPART_HANDLERS)
|
||
@pytest.mark.parametrize(
|
||
"raw",
|
||
["Content-Length:", "Content-Length: ", "Content-Length:\r\nContent-Length: 63"],
|
||
ids=["blank", "whitespace-only", "blank-plus-real"],
|
||
)
|
||
def test_blank_content_length_multipart_rejects_before_read(handler_name, raw, monkeypatch):
|
||
"""A blank length reached `int('' or 0)` == 0 and read the body as empty.
|
||
|
||
All four handlers then answered 400 "No file field in request" with keep-alive
|
||
and left the whole multipart payload on the socket. Verified live on the
|
||
otherwise-fixed head: 400 then `400 Bad request syntax ('--x')`.
|
||
"""
|
||
from email.parser import Parser
|
||
|
||
headers = Parser().parsestr(
|
||
"Content-Type: multipart/form-data; boundary=x\r\n" + raw + "\r\n\r\n"
|
||
)
|
||
handler, answered = _run_multipart_handler(monkeypatch, handler_name, headers, _NeverRead())
|
||
|
||
assert answered["status"] == 400, answered
|
||
assert answered["payload"]["error"] == "Invalid Content-Length", answered
|
||
assert handler.close_connection is True
|
||
|
||
|
||
@pytest.mark.parametrize("handler_name", _MULTIPART_HANDLERS)
|
||
@pytest.mark.parametrize("value", ["+0", "0_0", "\xa00", "٠"], ids=["sign", "underscore", "nbsp", "arabic-indic"])
|
||
def test_malformed_zero_content_length_multipart_rejects_before_read(
|
||
handler_name, value, monkeypatch
|
||
):
|
||
"""A length only `int()` calls zero slipped the gate into `int(...)` == 0.
|
||
|
||
All four handlers then read an empty body, answered 400 "No file field in
|
||
request" with keep-alive, and left the whole payload on the socket. Live on
|
||
the otherwise-fixed head: 400 then `400 Bad request syntax ('--x')`.
|
||
"""
|
||
from email.parser import Parser
|
||
|
||
headers = Parser().parsestr(
|
||
f"Content-Type: multipart/form-data; boundary=x\r\nContent-Length: {value}\r\n\r\n"
|
||
)
|
||
handler, answered = _run_multipart_handler(monkeypatch, handler_name, headers, _NeverRead())
|
||
|
||
assert answered["status"] == 400, answered
|
||
assert answered["payload"]["error"] == "Invalid Content-Length", answered
|
||
assert handler.close_connection is True
|
||
|
||
|
||
@pytest.mark.parametrize("handler_name", _MULTIPART_HANDLERS)
|
||
@pytest.mark.parametrize(
|
||
"raw",
|
||
[
|
||
"Transfer-Encoding: identity",
|
||
"Transfer-Encoding: IDENTITY ",
|
||
"Transfer-Encoding: \xa0identity",
|
||
"Transfer-Encoding:",
|
||
"Transfer-Encoding: identity\r\nContent-Length: 0",
|
||
"Transfer-Encoding: identity\r\nContent-Length: 88",
|
||
"Transfer-Encoding: banana",
|
||
],
|
||
ids=[
|
||
"identity",
|
||
"identity-cased-and-padded",
|
||
"nbsp-padded-identity",
|
||
"blank",
|
||
"identity-plus-zero-length",
|
||
"identity-plus-real-length",
|
||
"unknown-coding",
|
||
],
|
||
)
|
||
def test_any_transfer_encoding_multipart_rejects_before_read(handler_name, raw, monkeypatch):
|
||
"""No coding is decodable by an upload handler, so none may reach `rfile`.
|
||
|
||
`identity` (any case, any OWS padding) was exempt as a coding that "frames
|
||
nothing", so each handler fell through to the length-0 read, answered 400 "No
|
||
file field in request" with keep-alive and left the whole multipart payload on
|
||
the socket (live: 400 then `400 Bad request syntax ('--x')`). The `\\xa0`-padded
|
||
spelling and the blank value are the previous round's holes, and the two
|
||
Content-Length pairings are the RFC 9112 6.3 smuggling shape -- the coding has
|
||
to win over any length beside it. `_NeverRead` proves the preflight answers
|
||
before a byte is consumed.
|
||
"""
|
||
from email.parser import Parser
|
||
|
||
headers = Parser().parsestr(
|
||
"Content-Type: multipart/form-data; boundary=x\r\n" + raw + "\r\n\r\n"
|
||
)
|
||
handler, answered = _run_multipart_handler(monkeypatch, handler_name, headers, _NeverRead())
|
||
|
||
assert answered["status"] == 411, answered
|
||
assert "Transfer-Encoding" in answered["payload"]["error"], answered
|
||
assert handler.close_connection is True
|
||
|
||
|
||
# ── Header serialization: exactly one Connection: close, never a crash ────────
|
||
#
|
||
# The advertisement lives in Handler.end_headers() via
|
||
# api.helpers.advertise_connection_close(). Two things went wrong there and
|
||
# both are pinned below:
|
||
#
|
||
# 1. The first cut keyed dedup off a private `_close_advertised` flag it set
|
||
# itself. Every SSE endpoint already sends its own `Connection: close`
|
||
# (which BaseHTTPRequestHandler.send_header turns into
|
||
# close_connection=True), so end_headers() appended a SECOND copy to every
|
||
# stream response.
|
||
# 2. It read `self.close_connection` unguarded. That attribute only exists
|
||
# once a request line has been parsed, so the many suite handlers built
|
||
# with `Handler.__new__(Handler)` raised AttributeError instead of
|
||
# emitting headers.
|
||
|
||
|
||
def _render_response_headers(prepare=None) -> str:
|
||
"""Drive the production Handler.end_headers() into a byte buffer."""
|
||
import io
|
||
|
||
from server import Handler
|
||
|
||
handler = Handler.__new__(Handler)
|
||
handler.request_version = "HTTP/1.1"
|
||
handler.close_connection = False
|
||
handler.wfile = io.BytesIO()
|
||
handler._headers_buffer = []
|
||
if prepare is not None:
|
||
prepare(handler)
|
||
Handler.end_headers(handler)
|
||
return handler.wfile.getvalue().decode("latin-1")
|
||
|
||
|
||
def test_armed_close_is_advertised_exactly_once():
|
||
"""A reject-before-read must TELL the client the socket is gone."""
|
||
from api.helpers import arm_connection_close
|
||
|
||
rendered = _render_response_headers(arm_connection_close)
|
||
|
||
assert rendered.count("Connection: close") == 1, rendered
|
||
|
||
|
||
def test_sse_style_explicit_close_is_not_doubled():
|
||
"""SSE sends its own Connection: close; end_headers() must not repeat it.
|
||
|
||
send_header('Connection', 'close') flips close_connection inside the
|
||
stdlib, so a self-keyed dedup flag would emit the header twice here.
|
||
"""
|
||
|
||
def prepare(handler):
|
||
handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
||
handler.send_header("Connection", "close")
|
||
|
||
rendered = _render_response_headers(prepare)
|
||
|
||
assert rendered.count("Connection: close") == 1, rendered
|
||
|
||
|
||
def test_keep_alive_response_advertises_no_close():
|
||
"""An ordinary response must keep its keep-alive."""
|
||
rendered = _render_response_headers(
|
||
lambda handler: handler.send_header("Content-Type", "application/json")
|
||
)
|
||
|
||
assert "Connection: close" not in rendered, rendered
|
||
|
||
|
||
def test_end_headers_on_handler_stub_without_close_connection(monkeypatch):
|
||
"""A partially-built handler stub has no close_connection — don't raise.
|
||
|
||
Much of the suite builds handlers with `Handler.__new__(Handler)` and never
|
||
parses a request, so the attribute is simply absent. Reading it unguarded
|
||
turned every such test into an AttributeError.
|
||
"""
|
||
from http.server import BaseHTTPRequestHandler
|
||
|
||
from server import Handler
|
||
|
||
sent: list[tuple[str, str]] = []
|
||
handler = Handler.__new__(Handler)
|
||
handler.send_header = lambda key, value: sent.append((key, value))
|
||
monkeypatch.setattr(BaseHTTPRequestHandler, "end_headers", lambda self: None)
|
||
|
||
assert not hasattr(handler, "close_connection")
|
||
Handler.end_headers(handler) # must not raise
|
||
|
||
assert "Content-Security-Policy-Report-Only" in dict(sent)
|
||
assert "Connection" not in dict(sent), "nothing was armed, so nothing to advertise"
|
||
|
||
|
||
def test_advertise_close_tolerates_a_non_iterable_header_buffer():
|
||
"""The dedup scan must be as defensive as the two attribute reads.
|
||
|
||
A bare ``Mock`` answers every attribute, so ``_headers_buffer`` comes back
|
||
truthy but non-iterable. Iterating it unguarded raised
|
||
``TypeError: 'Mock' object is not iterable`` from inside ``end_headers()``,
|
||
which ``_handle_write``'s generic ``except`` turns into a spurious 500 —
|
||
the same crash-inside-end_headers class as the missing
|
||
``close_connection`` guard above.
|
||
"""
|
||
from unittest.mock import Mock
|
||
|
||
from api.helpers import advertise_connection_close
|
||
|
||
handler = Mock()
|
||
advertise_connection_close(handler) # must not raise
|
||
|
||
handler.send_header.assert_called_once_with("Connection", "close")
|
||
|
||
|
||
def test_check_auth_or_close_on_handler_stub_without_close_connection(monkeypatch):
|
||
"""check_auth_or_close must not invent close_connection on a stub.
|
||
|
||
Arming a handler that never had the attribute would leave a bogus
|
||
keep-alive verdict behind, and reading it unguarded raised AttributeError
|
||
(which _handle_write then converted into a spurious 500).
|
||
"""
|
||
import api.auth as auth
|
||
from server import Handler
|
||
|
||
handler = Handler.__new__(Handler)
|
||
monkeypatch.setattr(auth, "check_auth", lambda _handler, _parsed: True)
|
||
|
||
assert auth.check_auth_or_close(handler, SimpleNamespace(path="/api/x")) is True
|
||
assert not hasattr(handler, "close_connection")
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"headers",
|
||
[{}, {"Content-Length": "0"}, {"Content-Length": "15"}, {"Transfer-Encoding": "chunked"}],
|
||
ids=["no-content-length", "zero-content-length", "content-length", "chunked"],
|
||
)
|
||
def test_check_auth_or_close_restores_keep_alive_on_success(headers, monkeypatch):
|
||
"""A successful auth must not inherit the armed close.
|
||
|
||
The two body-bearing rows are the ones that actually arm before `check_auth()`,
|
||
so they are what pins the restore; the body-less rows never arm at all.
|
||
"""
|
||
import api.auth as auth
|
||
|
||
handler = SimpleNamespace(headers=headers, close_connection=False)
|
||
monkeypatch.setattr(auth, "check_auth", lambda _handler, _parsed: True)
|
||
|
||
assert auth.check_auth_or_close(cast(Any, handler), SimpleNamespace(path="/api/x")) is True
|
||
assert handler.close_connection is False
|
||
|
||
|
||
def test_check_auth_or_close_restores_a_prior_close_on_success(monkeypatch):
|
||
"""Restore means restore: a connection already doomed stays doomed.
|
||
|
||
`Connection: close` sent by the client sets `close_connection` before routing,
|
||
and the restore must not overwrite that with keep-alive.
|
||
"""
|
||
import api.auth as auth
|
||
|
||
handler = SimpleNamespace(headers={"Content-Length": "15"}, close_connection=True)
|
||
monkeypatch.setattr(auth, "check_auth", lambda _handler, _parsed: True)
|
||
|
||
assert auth.check_auth_or_close(cast(Any, handler), SimpleNamespace(path="/api/x")) is True
|
||
assert handler.close_connection is True
|
||
|
||
|
||
def test_check_auth_or_close_tolerates_a_handler_without_headers(monkeypatch):
|
||
"""The framing read must never raise here: an exception becomes a spurious 500."""
|
||
import api.auth as auth
|
||
|
||
monkeypatch.setattr(auth, "check_auth", lambda _handler, _parsed: False)
|
||
|
||
for handler in (
|
||
SimpleNamespace(close_connection=False),
|
||
SimpleNamespace(headers=None, close_connection=False),
|
||
SimpleNamespace(headers=object(), close_connection=False),
|
||
):
|
||
assert auth.check_auth_or_close(cast(Any, handler), SimpleNamespace(path="/api/x")) is False
|
||
# No framing could be read, so nothing proves a body is pending.
|
||
assert handler.close_connection is False
|
||
|
||
|
||
def test_arm_connection_close_tolerates_a_read_only_handler():
|
||
"""Arming must never turn a 4xx into a 500, even on an odd handler."""
|
||
from api.helpers import arm_connection_close
|
||
|
||
class Frozen:
|
||
__slots__ = ()
|
||
|
||
arm_connection_close(Frozen()) # must not raise
|