Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 62 additions & 3 deletions contextual_orchestrator/server.py

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 415 content-type rejection does not drain body or close connection

In _read_json the content-type check (server.py) raises 415 before _request_body_size, so on a wrong content-type the declared body is neither read nor drained and close_connection is not set. On an HTTP keep-alive connection those unread body bytes could be interpreted as the start of a subsequent request. This is pre-existing behavior (the original code also checked content-type first) and the default protocol_version is HTTP/1.0 (connections close by default unless the client opts into keep-alive), so impact is limited — but it is a residual gap not covered by this framing hardening PR.

(Refers to this code)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,55 @@ 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]
Comment on lines +158 to +162

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📝 Info: Fallback header branch is unreachable but silently narrower

The non-get_all fallback at server.py uses headers.get(), which for email.message.Message-style objects returns only the first occurrence of a duplicated header. If a non-Message header container were ever passed here, duplicate/comma-joined content-length detection would be weaker than the primary path. In production self.headers is an http.client.HTTPMessage (has get_all), so this branch is only exercised by hypothetical callers; not a bug today, but worth noting the divergence in defense strength.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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
Comment on lines +171 to +172

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📝 Info: Missing Content-Length with a present body still returns 0

When neither Transfer-Encoding nor Content-Length is present, _request_body_size returns 0 (server.py), so any body bytes actually sent are left unread. This matches the prior behavior (int(get("content-length", "0"))) and is consistent with the documented boundary, but a body sent without Content-Length on a keep-alive connection would go unconsumed rather than being explicitly rejected.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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",
)
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
Comment on lines +186 to +193

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📝 Info: Oversized-length check avoids int conversion limit

The check at server.py strips leading zeros, then compares digit-length and lexicographic order against str(max_body_bytes) before int(normalized). Any over-length value is rejected first, so int() never sees a 5000-digit string and cannot hit Python's 4300-digit conversion limit. Lexicographic comparison is valid because both operands are equal-length zero-stripped decimals.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.



@dataclass
class SecurityConfig:
"""Runtime safety controls for the stdlib HTTP server."""
Expand Down Expand Up @@ -5677,10 +5726,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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📝 Info: Blocking read when declared body never arrives on a kept-open connection

self.rfile.read(body_size) at server.py will block until body_size bytes arrive or the peer closes. The short-body regression test relies on SHUT_WR to force EOF. A client that declares a large Content-Length but sends fewer bytes without closing will hold the worker thread until socket timeout (Slowloris-style). This is pre-existing behavior (the old code read content-length the same way) and not introduced by this PR, but the framing hardening does not address it.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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:
Expand Down
33 changes: 33 additions & 0 deletions docs/doctoring/request-framing-boundary.md
Original file line number Diff line number Diff line change
@@ -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

168 changes: 168 additions & 0 deletions tests/test_request_framing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
"""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"
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:
"""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_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()
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"}))
Loading