-
Notifications
You must be signed in to change notification settings - Fork 1
fix(security): reject ambiguous HTTP request framing #793
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: Fallback header branch is unreachable but silently narrower The non- 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Was this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
|
|
||
| @dataclass | ||
| class SecurityConfig: | ||
| """Runtime safety controls for the stdlib HTTP server.""" | ||
|
|
@@ -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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
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: | ||
|
|
||
| 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 | ||
|
|
| 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"})) |
There was a problem hiding this comment.
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_jsonthe 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 andclose_connectionis 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 defaultprotocol_versionis 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)
Was this helpful? React with 👍 or 👎 to provide feedback.