From 82a9f66f7ae2327a3087410222faad5d9eaccd97 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 05:18:11 +0900 Subject: [PATCH 1/2] fix: reject ambiguous HTTP request framing --- contextual_orchestrator/server.py | 61 +++++++- docs/doctoring/request-framing-boundary.md | 33 +++++ tests/test_request_framing.py | 154 +++++++++++++++++++++ 3 files changed, 245 insertions(+), 3 deletions(-) create mode 100644 docs/doctoring/request-framing-boundary.md create mode 100644 tests/test_request_framing.py diff --git a/contextual_orchestrator/server.py b/contextual_orchestrator/server.py index d75870530..667e8e45c 100644 --- a/contextual_orchestrator/server.py +++ b/contextual_orchestrator/server.py @@ -143,6 +143,51 @@ def __init__(self, status: int, code: str, message: str, detail: dict[str, Any] self.detail = detail or {} +def _request_body_size(headers: Any, max_body_bytes: int) -> int: + """Return a safe JSON body length or reject ambiguous HTTP framing. + + The stdlib handler does not decode transfer codings for this API. A single + ASCII decimal ``Content-Length`` is therefore the only accepted framing + signal; duplicate, comma-joined, negative, malformed, oversized, or + transfer-coded requests fail closed before any body read. + """ + get_all = getattr(headers, "get_all", None) + if callable(get_all): + transfer_values = get_all("transfer-encoding") + length_values = get_all("content-length") + else: # pragma: no cover - production uses email.message.Message headers + transfer_value = headers.get("transfer-encoding") + length_value = headers.get("content-length") + transfer_values = None if transfer_value is None else [transfer_value] + length_values = None if length_value is None else [length_value] + + if transfer_values is not None: + raise RequestError( + 400, + "invalid_request_framing", + "transfer-encoding request framing is not supported", + ) + if length_values is None: + return 0 + if len(length_values) != 1 or "," in length_values[0]: + raise RequestError( + 400, + "invalid_request_framing", + "content-length must appear exactly once", + ) + value = length_values[0].strip() + if not value or not value.isascii() or not value.isdecimal(): + raise RequestError( + 400, + "invalid_request_framing", + "content-length must be a non-negative decimal value", + ) + body_size = int(value) + if body_size > max_body_bytes: + raise RequestError(413, "request_too_large", "request body exceeds configured limit") + return body_size + + @dataclass class SecurityConfig: """Runtime safety controls for the stdlib HTTP server.""" @@ -5648,10 +5693,20 @@ def _parse_optional_int(self, query: dict[str, list[str]], field_name: str) -> i def _read_json(self) -> dict[str, Any]: if self.headers.get("content-type", "").split(";", 1)[0].strip().lower() != "application/json": raise RequestError(415, "unsupported_media_type", "content-type must be application/json") - body_size = int(self.headers.get("content-length", "0")) - if body_size > security.max_body_bytes: - raise RequestError(413, "request_too_large", "request body exceeds configured limit") + try: + body_size = _request_body_size(self.headers, security.max_body_bytes) + except RequestError: + # Do not let a peer reuse a connection after an ambiguous frame. + self.close_connection = True + raise raw = self.rfile.read(body_size) + if len(raw) != body_size: + self.close_connection = True + raise RequestError( + 400, + "invalid_request_framing", + "request body ended before content-length", + ) return _coerce_json(raw) if raw else {} def log_message(self, format: str, *args: object) -> None: diff --git a/docs/doctoring/request-framing-boundary.md b/docs/doctoring/request-framing-boundary.md new file mode 100644 index 000000000..2d21d77e0 --- /dev/null +++ b/docs/doctoring/request-framing-boundary.md @@ -0,0 +1,33 @@ +# Request framing boundary + +## Customer action + +Send JSON requests with one non-negative decimal `Content-Length` and no +`Transfer-Encoding`. Clients that use chunked transfer encoding must route +through a front proxy that terminates and validates HTTP framing before +forwarding a length-delimited request to this stdlib gateway. + +## Decision + +The gateway rejects duplicate or comma-joined `Content-Length` values, +negative/signed/non-decimal values, unsupported transfer codings, bodies above +the configured byte limit, and bodies shorter than their declared length. It +closes the connection after a framing error so unread bytes cannot be parsed as +the next request. The body limit is checked before `read()`, preventing a +negative length from becoming `read(-1)` and reading until peer close. + +This is deliberately narrower than implementing a chunked decoder in the +stdlib handler. A proxy may decode chunked HTTP, but the application boundary +has one framing implementation and one maximum-body policy. + +## Verification + +`tests/test_request_framing.py` covers absent/zero/trimmed lengths, duplicate +and comma-joined lengths, malformed and oversized values, transfer encoding, +negative-length raw sockets, chunked raw sockets, and a short declared body. + +## APA 7 reference + +Internet Engineering Task Force. (2022). *HTTP/1.1* (RFC 9112). RFC Editor. +https://www.rfc-editor.org/rfc/rfc9112.html + diff --git a/tests/test_request_framing.py b/tests/test_request_framing.py new file mode 100644 index 000000000..7d15d1d62 --- /dev/null +++ b/tests/test_request_framing.py @@ -0,0 +1,154 @@ +"""HTTP request-framing tests for the JSON API trust boundary.""" + +from __future__ import annotations + +from email.message import Message +import json +from pathlib import Path +import socket +import sys +import threading + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from contextual_orchestrator import ModelAgent, TaskOrchestrator # noqa: E402 +from contextual_orchestrator.server import ( # noqa: E402 + RequestError, + SecurityConfig, + _request_body_size, + build_server, +) + + +def _headers(**values: str) -> Message: + """Build a case-insensitive header collection with optional duplicates.""" + message = Message() + for name, value in values.items(): + for item in value.split("|", 1): + message[name.replace("_", "-")] = item + return message + + +def _framing_error(headers: Message, maximum: int = 1024) -> RequestError: + """Return the expected framing error for a synthetic header collection.""" + try: + _request_body_size(headers, maximum) + except RequestError as exc: + return exc + raise AssertionError("expected invalid request framing") + + +def test_request_body_size_accepts_absent_zero_and_trimmed_lengths() -> None: + """Requests without a body and ordinary JSON lengths remain compatible.""" + assert _request_body_size(_headers(), 1024) == 0 + assert _request_body_size(_headers(content_length=" 7 "), 1024) == 7 + assert _request_body_size(_headers(content_length="0"), 1024) == 0 + + +def test_request_body_size_rejects_duplicate_and_comma_joined_lengths() -> None: + """Equivalent duplicate values are rejected rather than normalized.""" + duplicate = _headers(content_length="7|7") + comma_joined = _headers(content_length="7, 7") + assert _framing_error(duplicate).code == "invalid_request_framing" + assert _framing_error(comma_joined).code == "invalid_request_framing" + + +def test_request_body_size_rejects_negative_malformed_and_oversized_lengths() -> None: + """Negative, signed, Unicode, and over-limit lengths never reach read().""" + for value in ("-1", "+1", "1.0", "12", "not-a-length"): + error = _framing_error(_headers(content_length=value)) + assert error.code == "invalid_request_framing" + assert _framing_error(_headers(content_length="1025"), 1024).code == "request_too_large" + + +def test_request_body_size_rejects_transfer_encoding_even_without_content_length() -> None: + """The server does not decode chunked or any other transfer coding.""" + error = _framing_error(_headers(transfer_encoding="chunked")) + assert error.code == "invalid_request_framing" + + +def _start_server() -> tuple[object, threading.Thread, int]: + """Start a small authenticated mock server for raw socket framing tests.""" + server = build_server( + TaskOrchestrator([ModelAgent("framing_agent", "mock-framing")]), + port=0, + security=SecurityConfig(auth_token="framing-token"), + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server, thread, server.server_address[1] + + +def _raw_request(port: int, headers: bytes, body: bytes = b"", *, close_write: bool = False) -> bytes: + """Send one raw request and return the complete response bytes.""" + request = ( + b"POST /v1/chat/completions HTTP/1.1\r\n" + b"Host: 127.0.0.1\r\n" + b"Authorization: Bearer framing-token\r\n" + b"Content-Type: application/json\r\n" + + headers + + b"Connection: close\r\n\r\n" + + body + ) + with socket.create_connection(("127.0.0.1", port), timeout=2) as connection: + connection.sendall(request) + if close_write: + connection.shutdown(socket.SHUT_WR) + chunks: list[bytes] = [] + while True: + chunk = connection.recv(4096) + if not chunk: + return b"".join(chunks) + chunks.append(chunk) + + +def test_negative_content_length_is_rejected_without_reading_until_peer_close() -> None: + """A negative length returns promptly instead of invoking read(-1).""" + server, thread, port = _start_server() + try: + response = _raw_request(port, b"Content-Length: -1\r\n", b"{}") + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close() + assert b"400 Bad Request" in response + assert b"invalid_request_framing" in response + + +def test_transfer_encoding_is_rejected_before_chunked_bytes_are_interpreted() -> None: + """Chunked framing is rejected explicitly because the handler has no decoder.""" + server, thread, port = _start_server() + try: + response = _raw_request(port, b"Transfer-Encoding: chunked\r\n", b"2\r\n{}\r\n0\r\n\r\n") + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close() + assert b"400 Bad Request" in response + assert b"invalid_request_framing" in response + + +def test_short_body_is_rejected_after_peer_closes() -> None: + """A valid prefix cannot satisfy a larger declared body length.""" + server, thread, port = _start_server() + try: + response = _raw_request(port, b"Content-Length: 20\r\n", b"{}", close_write=True) + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close() + assert b"400 Bad Request" in response + assert b"invalid_request_framing" in response + + +if __name__ == "__main__": # pragma: no cover + test_request_body_size_accepts_absent_zero_and_trimmed_lengths() + test_request_body_size_rejects_duplicate_and_comma_joined_lengths() + test_request_body_size_rejects_negative_malformed_and_oversized_lengths() + test_request_body_size_rejects_transfer_encoding_even_without_content_length() + test_negative_content_length_is_rejected_without_reading_until_peer_close() + test_transfer_encoding_is_rejected_before_chunked_bytes_are_interpreted() + test_short_body_is_rejected_after_peer_closes() + print(json.dumps({"status": "ok"})) From 3651a8181d0844a8daa196a73aff401fd34e78da Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 05:28:41 +0900 Subject: [PATCH 2/2] fix: bound oversized content lengths before conversion --- contextual_orchestrator/server.py | 8 ++++++-- tests/test_request_framing.py | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/contextual_orchestrator/server.py b/contextual_orchestrator/server.py index 667e8e45c..7fb624588 100644 --- a/contextual_orchestrator/server.py +++ b/contextual_orchestrator/server.py @@ -182,9 +182,13 @@ def _request_body_size(headers: Any, max_body_bytes: int) -> int: "invalid_request_framing", "content-length must be a non-negative decimal value", ) - body_size = int(value) - if body_size > max_body_bytes: + normalized = value.lstrip("0") or "0" + maximum = str(max_body_bytes) + if len(normalized) > len(maximum) or ( + len(normalized) == len(maximum) and normalized > maximum + ): raise RequestError(413, "request_too_large", "request body exceeds configured limit") + body_size = int(normalized) return body_size diff --git a/tests/test_request_framing.py b/tests/test_request_framing.py index 7d15d1d62..5d25cc064 100644 --- a/tests/test_request_framing.py +++ b/tests/test_request_framing.py @@ -61,6 +61,7 @@ def test_request_body_size_rejects_negative_malformed_and_oversized_lengths() -> error = _framing_error(_headers(content_length=value)) assert error.code == "invalid_request_framing" assert _framing_error(_headers(content_length="1025"), 1024).code == "request_too_large" + assert _framing_error(_headers(content_length="9" * 5000), 1024).code == "request_too_large" def test_request_body_size_rejects_transfer_encoding_even_without_content_length() -> None: @@ -130,6 +131,19 @@ def test_transfer_encoding_is_rejected_before_chunked_bytes_are_interpreted() -> assert b"invalid_request_framing" in response +def test_unbounded_digit_content_length_is_rejected_and_connection_is_closed() -> None: + """Huge decimal headers cannot escape before the connection-close guard.""" + server, thread, port = _start_server() + try: + response = _raw_request(port, b"Content-Length: " + (b"9" * 5000) + b"\r\n") + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close() + assert b"HTTP/1.0 413 " in response + assert b"request_too_large" in response + + def test_short_body_is_rejected_after_peer_closes() -> None: """A valid prefix cannot satisfy a larger declared body length.""" server, thread, port = _start_server()