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
6 changes: 2 additions & 4 deletions loopx/chat_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
cors_response_headers,
is_loopback_host,
is_loopback_origin,
parse_strict_json_object,
)


Expand Down Expand Up @@ -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")):
Expand Down
35 changes: 30 additions & 5 deletions loopx/status_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import hashlib
import json
import math
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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
Expand Down
68 changes: 67 additions & 1 deletion tests/test_chat_server_cors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()


Expand Down Expand Up @@ -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]] = []

Expand Down
31 changes: 31 additions & 0 deletions tests/test_status_server_cors.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import http.client
import json
import threading

import pytest
Expand All @@ -9,6 +10,7 @@
StatusHTTPServer,
StatusRequestHandler,
cors_response_headers,
parse_strict_json_object,
)


Expand Down Expand Up @@ -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}]
}