From 012fd40821c3c7f07b9170874b19d5e33484d9e1 Mon Sep 17 00:00:00 2001 From: "duanjialing.777" Date: Tue, 8 Sep 2026 15:51:06 +0800 Subject: [PATCH] fix(http): reject non-finite JSON numbers Python's JSON decoder accepts non-standard constants and can overflow valid exponent syntax to infinity. Share one recursive finite-number guard across the Status and Chat request readers. Signed-off-by: duanjialing.777 --- loopx/chat_server.py | 6 +-- loopx/status_server.py | 35 +++++++++++++--- tests/test_chat_server_cors.py | 68 +++++++++++++++++++++++++++++++- tests/test_status_server_cors.py | 31 +++++++++++++++ 4 files changed, 130 insertions(+), 10 deletions(-) diff --git a/loopx/chat_server.py b/loopx/chat_server.py index 52e04a9281..7e76495233 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -76,6 +76,7 @@ cors_response_headers, is_loopback_host, is_loopback_origin, + parse_strict_json_object, ) @@ -492,10 +493,7 @@ def _read_json(self) -> dict[str, Any]: raise ValueError("request body is empty") if length > 64_000: raise ValueError("request body is too large") - payload = json.loads(self.rfile.read(length).decode("utf-8")) - if not isinstance(payload, dict): - raise ValueError("request body must be a JSON object") - return payload + return parse_strict_json_object(self.rfile.read(length)) def _require_loopback_origin(self) -> bool: if is_loopback_origin(self.headers.get("Origin")): diff --git a/loopx/status_server.py b/loopx/status_server.py index 4051e5a333..f33a887d5e 100644 --- a/loopx/status_server.py +++ b/loopx/status_server.py @@ -2,6 +2,7 @@ import hashlib import json +import math from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Any @@ -105,6 +106,34 @@ CONFIGURE_GOAL_APPLY_FIELDS = CONFIGURE_GOAL_REQUEST_FIELDS | {"preview_id"} +def _reject_non_standard_json_constant(value: str) -> None: + raise ValueError( + f"request body must be strict JSON; non-standard constant {value} is not allowed" + ) + + +def _require_finite_json_numbers(value: Any) -> None: + if isinstance(value, float) and not math.isfinite(value): + raise ValueError("request body must be strict JSON; non-finite numbers are not allowed") + if isinstance(value, dict): + for item in value.values(): + _require_finite_json_numbers(item) + elif isinstance(value, list): + for item in value: + _require_finite_json_numbers(item) + + +def parse_strict_json_object(raw: bytes) -> dict[str, Any]: + payload = json.loads( + raw.decode("utf-8"), + parse_constant=_reject_non_standard_json_constant, + ) + _require_finite_json_numbers(payload) + if not isinstance(payload, dict): + raise ValueError("request body must be a JSON object") + return payload + + def parse_goal_activation_filter(query: dict[str, list[str]]) -> str | None: """Parse the shared scoped-status query without accepting ambiguous input.""" @@ -223,11 +252,7 @@ def _read_json_body(self) -> dict[str, Any]: raise ValueError("request body is empty") if content_length > 64_000: raise ValueError("request body is too large") - raw = self.rfile.read(content_length) - payload = json.loads(raw.decode("utf-8")) - if not isinstance(payload, dict): - raise ValueError("request body must be a JSON object") - return payload + return parse_strict_json_object(self.rfile.read(content_length)) def _parse_reward_body(self, body: dict[str, Any], *, append: bool) -> tuple[str, str | None, dict[str, Any]]: allowed = REWARD_APPEND_FIELDS if append else REWARD_REQUEST_FIELDS diff --git a/tests/test_chat_server_cors.py b/tests/test_chat_server_cors.py index c41fb5b174..b546c1eb0c 100644 --- a/tests/test_chat_server_cors.py +++ b/tests/test_chat_server_cors.py @@ -5,6 +5,10 @@ import threading from pathlib import Path +import pytest + +from loopx.chat_action_store import ChatActionStore +from loopx.chat_actions import ChatActionService from loopx.chat_server import ChatHTTPServer, ChatRequestHandler from loopx.extensions.lark.cli_resolution import LarkCliResolution @@ -44,10 +48,11 @@ def _request( method: str, origin: str | None, path: str = "/api/chat/capabilities", + body: bytes | None = None, ) -> http.client.HTTPResponse: connection = http.client.HTTPConnection("127.0.0.1", port, timeout=5) headers = {"Origin": origin} if origin else {} - connection.request(method, path, headers=headers) + connection.request(method, path, body=body, headers=headers) return connection.getresponse() @@ -136,6 +141,67 @@ def test_chat_options_exposes_loopback_preflight_only() -> None: server.server_close() +@pytest.mark.parametrize("number", ["NaN", "Infinity", "-Infinity", "1e309"]) +def test_chat_post_rejects_non_finite_json_numbers(number: str) -> None: + server, thread = _start_server() + try: + response = _request( + server.server_address[1], + method="POST", + origin=None, + path="/api/ssh-source/ensure", + body=f'{{"host_alias":"","local_port":{number}}}'.encode(), + ) + payload = json.loads(response.read().decode("utf-8")) + + assert response.status == 400 + assert "request body must be strict JSON" in payload["error"] + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close() + + +def test_chat_action_context_cannot_persist_or_emit_overflowed_float( + tmp_path: Path, +) -> None: + registry_path = tmp_path / "registry.json" + registry_path.write_text( + json.dumps({"schema_version": "0.1", "goals": [{"id": "goal-one"}]}), + encoding="utf-8", + ) + action_store = ChatActionStore(tmp_path / "actions") + server, thread = _start_server() + server.action_store = action_store + server.action_service = ChatActionService( + store=action_store, + registry_path=registry_path, + ) + try: + response = _request( + server.server_address[1], + method="POST", + origin=None, + path="/api/actions/preview", + body=( + b'{"action_kind":"goal.lifecycle","summary":"Stop goal",' + b'"normalized_parameters":{"goal_id":"goal-one","operation":"stop"},' + b'"context":{"nested":{"overflow":1e309}},' + b'"idempotency_key":"stop-goal-one"}' + ), + ) + response_body = response.read() + + assert response.status == 400 + assert b"Infinity" not in response_body + assert json.loads(response_body)["error_code"] == "invalid_action_preview" + assert action_store.list() == [] + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close() + + def test_chat_status_forwards_valid_goal_activation_scope(monkeypatch) -> None: calls: list[dict[str, object]] = [] diff --git a/tests/test_status_server_cors.py b/tests/test_status_server_cors.py index 17ce42531f..6f7f8eed63 100644 --- a/tests/test_status_server_cors.py +++ b/tests/test_status_server_cors.py @@ -1,6 +1,7 @@ from __future__ import annotations import http.client +import json import threading import pytest @@ -9,6 +10,7 @@ StatusHTTPServer, StatusRequestHandler, cors_response_headers, + parse_strict_json_object, ) @@ -137,3 +139,32 @@ def test_options_preflight_with_loopback_origin_echoes_acao() -> None: finally: server.shutdown() thread.join(timeout=5) + + +@pytest.mark.parametrize("number", ["NaN", "Infinity", "-Infinity", "1e309"]) +def test_status_post_rejects_non_finite_json_numbers(number: str) -> None: + server, thread = _start_server() + server.reward_dry_run_path = "/reward/dry-run" + try: + connection = http.client.HTTPConnection( + "127.0.0.1", server.server_address[1], timeout=5 + ) + connection.request( + "POST", + server.reward_dry_run_path, + body=f'{{"unexpected":{number}}}'.encode(), + ) + response = connection.getresponse() + payload = json.loads(response.read().decode("utf-8")) + + assert response.status == 400 + assert "request body must be strict JSON" in payload["error"] + finally: + server.shutdown() + thread.join(timeout=5) + + +def test_strict_json_accepts_nested_finite_exponents() -> None: + assert parse_strict_json_object(b'{"values":[1e308,{"small":-1e-308}]}') == { + "values": [1e308, {"small": -1e-308}] + }