177 lines
6.4 KiB
Python
177 lines
6.4 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import threading
|
|
import urllib.error
|
|
import urllib.request
|
|
from email.message import Message
|
|
from http.server import ThreadingHTTPServer
|
|
|
|
from cognitive_agent.auth import AuthConfig, AuthRuntime
|
|
from cognitive_agent.server import SeatDemoHandler
|
|
|
|
|
|
def _auth(*, enabled: bool) -> AuthRuntime:
|
|
return AuthRuntime(
|
|
AuthConfig(
|
|
enabled=enabled,
|
|
username="test-user" if enabled else "",
|
|
password_hash="unused" if enabled else "",
|
|
session_secret=b"s" * 64 if enabled else b"",
|
|
session_ttl_seconds=3600,
|
|
login_attempts=5,
|
|
login_window_seconds=300,
|
|
requests_per_minute=120,
|
|
trust_proxy_headers=False,
|
|
)
|
|
)
|
|
|
|
|
|
class MutationSecurityHandler(SeatDemoHandler):
|
|
auth = _auth(enabled=False)
|
|
assistant_calls = 0
|
|
internal_calls = 0
|
|
last_hardware_bypass = True
|
|
|
|
def _handle_assistant_stream(self) -> None:
|
|
type(self).assistant_calls += 1
|
|
type(self).last_hardware_bypass = self._hardware_access_bypass_allowed()
|
|
self._read_json_body()
|
|
self._json({"handled": True})
|
|
|
|
def _handle_codex_vehicle_control(self) -> None:
|
|
type(self).internal_calls += 1
|
|
self._read_json_body()
|
|
self._json({"handled": True})
|
|
|
|
|
|
class TestMutationRequestSecurity:
|
|
@classmethod
|
|
def setup_class(cls) -> None:
|
|
cls.server = ThreadingHTTPServer(("127.0.0.1", 0), MutationSecurityHandler)
|
|
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
|
|
cls.thread.start()
|
|
cls.authority = f"127.0.0.1:{cls.server.server_port}"
|
|
cls.base_url = f"http://{cls.authority}"
|
|
|
|
@classmethod
|
|
def teardown_class(cls) -> None:
|
|
cls.server.shutdown()
|
|
cls.server.server_close()
|
|
cls.thread.join(timeout=3)
|
|
|
|
def setup_method(self) -> None:
|
|
MutationSecurityHandler.assistant_calls = 0
|
|
MutationSecurityHandler.internal_calls = 0
|
|
MutationSecurityHandler.last_hardware_bypass = True
|
|
|
|
def post(
|
|
self,
|
|
path: str,
|
|
*,
|
|
content_type: str | None = "application/json",
|
|
origin: str | None = None,
|
|
) -> tuple[int, dict[str, object]]:
|
|
headers = {}
|
|
if content_type is not None:
|
|
headers["Content-Type"] = content_type
|
|
if origin is not None:
|
|
headers["Origin"] = origin
|
|
request = urllib.request.Request(
|
|
f"{self.base_url}{path}",
|
|
data=b"{}",
|
|
headers=headers,
|
|
method="POST",
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=3) as response:
|
|
return response.status, json.loads(response.read().decode("utf-8"))
|
|
except urllib.error.HTTPError as exc:
|
|
return exc.code, json.loads(exc.read().decode("utf-8"))
|
|
|
|
def test_same_origin_json_assistant_request_is_accepted(self) -> None:
|
|
status, body = self.post(
|
|
"/api/assistant/stream",
|
|
content_type="application/json; charset=utf-8",
|
|
origin=self.base_url,
|
|
)
|
|
|
|
assert status == 200
|
|
assert body == {"handled": True}
|
|
assert MutationSecurityHandler.assistant_calls == 1
|
|
|
|
def test_cross_origin_alias_is_rejected_before_assistant_execution(self) -> None:
|
|
status, body = self.post(
|
|
"/api/assistant/stream",
|
|
origin=f"http://localhost:{self.server.server_port}",
|
|
)
|
|
|
|
assert status == 403
|
|
assert body["error"] == "cross_origin_request"
|
|
assert MutationSecurityHandler.assistant_calls == 0
|
|
|
|
def test_origin_scheme_and_port_must_match(self) -> None:
|
|
for origin in (
|
|
f"https://{self.authority}",
|
|
f"http://127.0.0.1:{self.server.server_port + 1}",
|
|
"null",
|
|
):
|
|
status, body = self.post("/api/assistant/stream", origin=origin)
|
|
assert status == 403
|
|
assert body["error"] == "cross_origin_request"
|
|
assert MutationSecurityHandler.assistant_calls == 0
|
|
|
|
def test_non_json_assistant_request_is_rejected_before_body_parsing(self) -> None:
|
|
for content_type in (None, "text/plain", "application/x-www-form-urlencoded"):
|
|
status, body = self.post(
|
|
"/api/assistant/stream",
|
|
content_type=content_type,
|
|
origin=self.base_url,
|
|
)
|
|
assert status == 415
|
|
assert body["error"] == "unsupported_media_type"
|
|
assert MutationSecurityHandler.assistant_calls == 0
|
|
|
|
def test_no_origin_local_json_requests_remain_compatible(self) -> None:
|
|
assistant_status, _ = self.post("/api/assistant/stream")
|
|
internal_status, _ = self.post("/api/internal/codex/vehicle-control")
|
|
|
|
assert assistant_status == 200
|
|
assert internal_status == 200
|
|
assert MutationSecurityHandler.assistant_calls == 1
|
|
assert MutationSecurityHandler.internal_calls == 1
|
|
|
|
def test_auth_disabled_never_grants_implicit_hardware_bypass(self) -> None:
|
|
status, _ = self.post("/api/assistant/stream")
|
|
|
|
assert status == 200
|
|
assert MutationSecurityHandler.last_hardware_bypass is False
|
|
|
|
def test_hardware_bypass_requires_auth_enabled_and_direct_loopback(self) -> None:
|
|
handler = object.__new__(SeatDemoHandler)
|
|
handler.client_address = ("127.0.0.1", 12345)
|
|
handler.headers = Message()
|
|
|
|
handler.auth = _auth(enabled=False)
|
|
assert handler._hardware_access_bypass_allowed() is False
|
|
|
|
handler.auth = _auth(enabled=True)
|
|
assert handler._hardware_access_bypass_allowed() is False
|
|
|
|
session = handler.auth.sessions.issue("test-user")
|
|
handler.headers["Cookie"] = f"{handler.auth.config.cookie_name}={session}"
|
|
assert handler._hardware_access_bypass_allowed() is True
|
|
|
|
handler.headers["X-Forwarded-For"] = "203.0.113.10"
|
|
assert handler._hardware_access_bypass_allowed() is False
|
|
|
|
def test_wireless_passive_state_bypass_does_not_widen_control_bypass(self) -> None:
|
|
handler = object.__new__(SeatDemoHandler)
|
|
handler.client_address = ("127.0.0.1", 12345)
|
|
handler.headers = Message()
|
|
handler.auth = _auth(enabled=False)
|
|
|
|
assert handler._passive_state_access_bypass_allowed("wireless_mqtt") is True
|
|
assert handler._passive_state_access_bypass_allowed("wired_can") is False
|
|
assert handler._hardware_access_bypass_allowed() is False
|