diff --git a/agents/cursor/_safe_http.py b/agents/cursor/_safe_http.py new file mode 100644 index 0000000..99b442c --- /dev/null +++ b/agents/cursor/_safe_http.py @@ -0,0 +1,189 @@ +""" +--- L9_META --- +l9_schema: 1 +origin: engine-specific +engine: graph +layer: [agent] +tags: [cwe-939, http, urllib] +owner: platform +status: active +--- /L9_META --- + +Socket HTTP/1.0 client that never calls urllib.urlopen (CWE-939). +""" + +from __future__ import annotations + +import socket +import ssl +import urllib.error +import urllib.request +from email.message import Message +from io import BytesIO +from urllib.parse import urlparse + + +class HttpResponse: + def __init__(self, status: int, headers: Message, body: bytes) -> None: + self.status = status + self.headers = headers + self._body = body + + def read(self) -> bytes: + return self._body + + def __enter__(self) -> HttpResponse: + return self + + def __exit__(self, *_exc: object) -> None: + return None + + +DEFAULT_PORTS = {"http": 80, "https": 443} + + +def secure_ssl_context() -> ssl.SSLContext: + """Default context pinned to TLS 1.2+; TLSv1 and TLSv1.1 stay refused.""" + context = ssl.create_default_context() + context.minimum_version = ssl.TLSVersion.TLSv1_2 + return context + + +def format_authority(host: str, port: int | None) -> str: + """RFC 3986 authority. IPv6 literals keep their brackets.""" + literal = f"[{host}]" if ":" in host else host + return literal if port is None else f"{literal}:{port}" + + +def redact_url(url: str) -> str: + """Scheme and authority only — never echo userinfo, path, or query to a log.""" + try: + parsed = urlparse(url) + host = parsed.hostname + port = parsed.port + except ValueError: + return "" + scheme = parsed.scheme or "" + if not host: + return f"{scheme}://" + return f"{scheme}://{format_authority(host, port)}" + + +def require_http_url( + url: str, + *, + allowed_http_hosts: frozenset[str], + label: str = "URL", +) -> str: + """Refuse file://, userinfo, and unsigned remote http.""" + parsed = urlparse(url) + safe = redact_url(url) + if parsed.scheme not in {"http", "https"}: + msg = f"refusing non-http(s) {label} scheme {parsed.scheme!r}: {safe}" + raise ValueError(msg) + if parsed.username or parsed.password: + msg = f"refusing {label} with userinfo: {safe}" + raise ValueError(msg) + host = (parsed.hostname or "").lower() + if not host: + msg = f"refusing {label} without host: {safe}" + raise ValueError(msg) + if parsed.scheme == "http" and host not in allowed_http_hosts: + msg = f"refusing non-allowlisted http {label}: {safe}" + raise ValueError(msg) + return url + + +def parse_http_response(raw: bytes) -> tuple[int, str, Message, bytes]: + sep = raw.find(b"\r\n\r\n") + if sep < 0: + raise urllib.error.URLError("HTTP response missing header terminator") + head = raw[:sep] + body = raw[sep + 4 :] + lines = head.split(b"\r\n") + if not lines: + raise urllib.error.URLError("HTTP response missing status line") + status_line = lines[0].decode("latin-1", errors="replace") + parts = status_line.split(" ", 2) + if len(parts) < 2: + raise urllib.error.URLError("HTTP status line unparseable") + try: + status = int(parts[1]) + except ValueError as exc: + raise urllib.error.URLError("HTTP status is not an integer") from exc + reason = parts[2] if len(parts) > 2 else "" + headers = Message() + for line in lines[1:]: + if b":" not in line: + continue + key, value = line.split(b":", 1) + headers[key.decode("latin-1", errors="replace")] = value.decode("latin-1", errors="replace").strip() + length = headers.get("Content-Length") + if length is not None: + try: + body = body[: int(length)] + except ValueError as exc: + raise urllib.error.URLError("HTTP Content-Length is invalid") from exc + return status, reason, headers, body + + +def http_exchange( + req: urllib.request.Request, + *, + timeout: float, + context: ssl.SSLContext, + allowed_http_hosts: frozenset[str], + label: str = "URL", +) -> HttpResponse: + """HTTP/1.0 exchange over a raw socket. Never calls urllib.urlopen.""" + url = require_http_url(req.full_url, allowed_http_hosts=allowed_http_hosts, label=label) + parsed = urlparse(url) + host = parsed.hostname + if host is None: + msg = f"refusing {label} without host: {redact_url(url)}" + raise ValueError(msg) + path = parsed.path or "/" + if parsed.query: + path = f"{path}?{parsed.query}" + method = req.get_method() + payload = req.data if isinstance(req.data, (bytes, bytearray)) else b"" + try: + if parsed.scheme == "https": + port = parsed.port or 443 + raw_sock = socket.create_connection((host, port), timeout=timeout) + sock: socket.socket = context.wrap_socket(raw_sock, server_hostname=host) + else: + port = parsed.port or 80 + sock = socket.create_connection((host, port), timeout=timeout) + except OSError as exc: + raise urllib.error.URLError(exc) from exc + default_port = DEFAULT_PORTS[parsed.scheme] + header_host = format_authority(host, None if parsed.port in (None, default_port) else port) + header_lines = [ + f"{method} {path} HTTP/1.0", + f"Host: {header_host}", + "Connection: close", + ] + for key, value in req.header_items(): + if key.lower() == "host": + continue + header_lines.append(f"{key}: {value}") + if payload: + header_lines.append(f"Content-Length: {len(payload)}") + blob = ("\r\n".join(header_lines) + "\r\n\r\n").encode("latin-1") + bytes(payload) + try: + sock.sendall(blob) + chunks: list[bytes] = [] + while True: + piece = sock.recv(65536) + if not piece: + break + chunks.append(piece) + except OSError as exc: + raise urllib.error.URLError(exc) from exc + finally: + sock.close() + status, reason, headers, body = parse_http_response(b"".join(chunks)) + if status >= 400: + raise urllib.error.HTTPError(url, status, reason, headers, BytesIO(body)) + return HttpResponse(status, headers, body) diff --git a/agents/cursor/cursor_memory_client.py b/agents/cursor/cursor_memory_client.py index 8eebd98..6f3d87d 100644 --- a/agents/cursor/cursor_memory_client.py +++ b/agents/cursor/cursor_memory_client.py @@ -103,6 +103,11 @@ from datetime import UTC, datetime from pathlib import Path +try: + from ._safe_http import http_exchange, require_http_url, secure_ssl_context +except ImportError: # standalone `python cursor_memory_client.py` + from _safe_http import http_exchange, require_http_url, secure_ssl_context + import structlog # ============================================================================= @@ -198,10 +203,29 @@ def _load_dotenv_file(path: Path, *, override: bool) -> None: L9_EXECUTOR_API_KEY = os.getenv("MCP_API_KEY_C") or os.getenv("L9_EXECUTOR_API_KEY", "") # Skip SSL verification for self-signed certs -ssl_context = ssl.create_default_context() +ssl_context = secure_ssl_context() ssl_context.check_hostname = False ssl_context.verify_mode = ssl.CERT_NONE +# http is allowed only for the documented C1 IP and the local Docker/tunnel hosts. +# Anything else must be https. file:// and userinfo are refused (CWE-939). +_ALLOWED_HTTP_HOSTS = frozenset({"127.0.0.1", "localhost", "::1", "46.62.243.82"}) + + +def _require_http_url(url: str) -> str: + return require_http_url(url, allowed_http_hosts=_ALLOWED_HTTP_HOSTS, label="memory URL") + + +def _http_exchange(req: urllib.request.Request, *, timeout: float, context: ssl.SSLContext): + return http_exchange( + req, + timeout=timeout, + context=context, + allowed_http_hosts=_ALLOWED_HTTP_HOSTS, + label="memory URL", + ) + + # ============================================================================= # MCP Client (Primary - MCP Server ONLY) # ============================================================================= @@ -231,12 +255,14 @@ def mcp_call_tool(tool_name: str, arguments: dict) -> dict: req = urllib.request.Request(url, data=body, headers=headers, method="POST") try: - with urllib.request.urlopen(req, timeout=30, context=ssl_context) as response: + with _http_exchange(req, timeout=30, context=ssl_context) as response: result = json.loads(response.read().decode()) # MCP server returns {"status": "success", "result": {...}, "caller": "C"} if result.get("status") == "success": return result.get("result", {}) return {"error": result.get("detail", "MCP call failed")} + except ValueError as e: + return {"error": str(e)} except urllib.error.HTTPError as e: error_detail = e.read().decode() if e.fp else "" return {"error": f"HTTP {e.code}", "detail": error_detail} @@ -276,8 +302,10 @@ def api_request(method: str, path: str, data: dict | None = None) -> dict: req = urllib.request.Request(url, data=body, headers=headers, method=method) try: - with urllib.request.urlopen(req, timeout=30, context=ssl_context) as response: + with _http_exchange(req, timeout=30, context=ssl_context) as response: return json.loads(response.read().decode()) + except ValueError as e: + return {"error": str(e)} except urllib.error.HTTPError as e: return {"error": f"HTTP {e.code}", "detail": e.read().decode()} except urllib.error.URLError as e: @@ -346,7 +374,7 @@ def cmd_health(): req = urllib.request.Request(url, headers=headers, method="GET") try: - with urllib.request.urlopen(req, timeout=10, context=ssl_context) as response: + with _http_exchange(req, timeout=10, context=ssl_context) as response: api_result = json.loads(response.read().decode()) results["api_health"] = { "status": "healthy", diff --git a/agents/cursor/cursor_neo4j_query.py b/agents/cursor/cursor_neo4j_query.py index 09dade5..56be204 100644 --- a/agents/cursor/cursor_neo4j_query.py +++ b/agents/cursor/cursor_neo4j_query.py @@ -46,10 +46,17 @@ import json import os import sys +import urllib.error +import urllib.request from pathlib import Path import structlog +try: + from ._safe_http import http_exchange, require_http_url, secure_ssl_context +except ImportError: # standalone `python cursor_neo4j_query.py` + from _safe_http import http_exchange, require_http_url, secure_ssl_context + logger = structlog.get_logger(__name__) # Try to load from .env @@ -75,13 +82,16 @@ if not NEO4J_PASSWORD: logger.warning("NEO4J_PASSWORD env var not set — Neo4j queries will fail") +# HTTP Neo4j is the SSH-tunnel / local Docker path only. Remote hosts must be https. +_NEO4J_HTTP_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"}) +_SSL_CONTEXT = secure_ssl_context() + def query_neo4j(cypher: str) -> dict: """Execute a Cypher query against Neo4j.""" import base64 - import urllib.request - url = f"{NEO4J_URL}/db/neo4j/tx/commit" + url = f"{NEO4J_URL.rstrip('/')}/db/neo4j/tx/commit" data = json.dumps({"statements": [{"statement": cypher}]}).encode() # Create auth header @@ -94,11 +104,24 @@ def query_neo4j(cypher: str) -> dict: "Content-Type": "application/json", "Authorization": f"Basic {credentials}", }, + method="POST", ) try: - with urllib.request.urlopen(req, timeout=30) as response: + require_http_url(url, allowed_http_hosts=_NEO4J_HTTP_HOSTS, label="Neo4j URL") + with http_exchange( + req, + timeout=30, + context=_SSL_CONTEXT, + allowed_http_hosts=_NEO4J_HTTP_HOSTS, + label="Neo4j URL", + ) as response: return json.loads(response.read().decode()) + except ValueError as e: + return {"error": str(e), "errors": [{"message": str(e)}]} + except urllib.error.HTTPError as e: + detail = e.read().decode() if e.fp else str(e) + return {"error": f"HTTP {e.code}", "errors": [{"message": detail}]} except urllib.error.URLError as e: return {"error": str(e), "errors": [{"message": str(e)}]} diff --git a/tests/unit/test_cursor_memory_http.py b/tests/unit/test_cursor_memory_http.py new file mode 100644 index 0000000..632a65e --- /dev/null +++ b/tests/unit/test_cursor_memory_http.py @@ -0,0 +1,215 @@ +""" +--- L9_META --- +l9_schema: 1 +origin: engine-specific +engine: graph +layer: [test] +tags: [cwe-939, memory-client, http] +owner: platform +status: active +--- /L9_META --- + +CWE-939: memory and Neo4j CLIs never hand env URLs to urllib.urlopen. +""" + +from __future__ import annotations + +import socket +import ssl +import sys +import threading +import urllib.error +import urllib.request +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[2] +_AGENT_DIR = REPO / "agents" / "cursor" +_CLIENT = _AGENT_DIR / "cursor_memory_client.py" +_NEO4J = _AGENT_DIR / "cursor_neo4j_query.py" +_SAFE_HTTP = _AGENT_DIR / "_safe_http.py" +sys.path.insert(0, str(_AGENT_DIR)) + +import _safe_http # noqa: E402 +import cursor_memory_client as cmc # noqa: E402 +import cursor_neo4j_query as cnq # noqa: E402 + + +@pytest.mark.unit +def test_source_does_not_call_urllib_urlopen() -> None: + for path in (_CLIENT, _NEO4J, _SAFE_HTTP): + src = path.read_text(encoding="utf-8") + assert "urllib.request.urlopen" not in src + assert "from urllib.request import urlopen" not in src + + +@pytest.mark.unit +def test_require_http_url_rejects_file_and_userinfo() -> None: + with pytest.raises(ValueError, match="non-http"): + cmc._require_http_url("file:///etc/passwd") + with pytest.raises(ValueError, match="userinfo"): + cmc._require_http_url("http://user:pass@127.0.0.1/memory") + + +@pytest.mark.unit +def test_require_http_url_rejects_unsigned_remote_http() -> None: + with pytest.raises(ValueError, match="non-allowlisted"): + cmc._require_http_url("http://example.com/memory") + + +@pytest.mark.unit +def test_require_http_url_accepts_c1_and_loopback() -> None: + assert cmc._require_http_url("http://46.62.243.82/memory").startswith("http://") + assert cmc._require_http_url("http://127.0.0.1:8000/health").startswith("http://") + assert cmc._require_http_url("https://memory.example/mcp").startswith("https://") + + +@pytest.mark.unit +def test_mcp_call_tool_refuses_file_scheme(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(cmc, "L9_EXECUTOR_API_KEY", "test-key") + monkeypatch.setattr(cmc, "MCP_URL", "file:///etc/passwd") + result = cmc.mcp_call_tool("get_memory_stats", {"user_id": "l9-shared"}) + assert "error" in result + assert "non-http" in result["error"] + + +@pytest.mark.unit +def test_http_exchange_posts_over_loopback() -> None: + received: dict[str, bytes | str] = {} + + class Handler(BaseHTTPRequestHandler): + def do_POST(self) -> None: + length = int(self.headers.get("Content-Length", "0")) + received["path"] = self.path + received["body"] = self.rfile.read(length) + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"status":"success","result":{"ok":true}}') + + def log_message(self, *_args: object) -> None: + return None + + server = HTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + port = server.server_address[1] + req = urllib.request.Request( + f"http://127.0.0.1:{port}/mcp/call", + data=b'{"tool_name":"get_memory_stats"}', + headers={"Content-Type": "application/json"}, + method="POST", + ) + ctx = _safe_http.secure_ssl_context() + with cmc._http_exchange(req, timeout=2, context=ctx) as resp: + body = resp.read() + assert b'"ok":true' in body + assert received["path"] == "/mcp/call" + finally: + server.shutdown() + server.server_close() + + +@pytest.mark.unit +def test_http_exchange_maps_connect_error() -> None: + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + req = urllib.request.Request(f"http://127.0.0.1:{port}/health", method="GET") + ctx = _safe_http.secure_ssl_context() + with pytest.raises(urllib.error.URLError): + cmc._http_exchange(req, timeout=1, context=ctx) + + +@pytest.mark.unit +def test_neo4j_query_refuses_file_scheme(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(cnq, "NEO4J_URL", "file:///etc/passwd") + monkeypatch.setattr(cnq, "NEO4J_PASSWORD", "x") + result = cnq.query_neo4j("RETURN 1") + assert "error" in result + assert "non-http" in result["error"] + + +@pytest.mark.unit +def test_neo4j_query_refuses_remote_http(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(cnq, "NEO4J_URL", "http://example.com:7474") + monkeypatch.setattr(cnq, "NEO4J_PASSWORD", "x") + result = cnq.query_neo4j("RETURN 1") + assert "non-allowlisted" in result["error"] + + +@pytest.mark.unit +def test_secure_ssl_context_refuses_tls_below_1_2() -> None: + for context in ( + _safe_http.secure_ssl_context(), + cmc.ssl_context, + cnq._SSL_CONTEXT, + ): + assert context.minimum_version >= ssl.TLSVersion.TLSv1_2 + + +@pytest.mark.unit +def test_url_errors_never_echo_userinfo() -> None: + secret = "http://alice:hunter2@example.com/memory" + with pytest.raises(ValueError) as excinfo: + cmc._require_http_url(secret) + message = str(excinfo.value) + assert "hunter2" not in message + assert "alice" not in message + # Exact, not a substring probe: the whole message is the redacted form. + assert message == "refusing memory URL with userinfo: http://example.com" + + +@pytest.mark.unit +def test_redact_url_drops_path_query_and_userinfo() -> None: + assert _safe_http.redact_url("https://u:p@host.example:8443/x?token=abc") == ("https://host.example:8443") + assert _safe_http.redact_url("file:///etc/passwd") == "file://" + assert _safe_http.redact_url("http://[::1]:8000/health") == "http://[::1]:8000" + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("host", "port", "expected"), + [ + ("127.0.0.1", None, "127.0.0.1"), + ("127.0.0.1", 8000, "127.0.0.1:8000"), + ("::1", None, "[::1]"), + ("::1", 8000, "[::1]:8000"), + ], +) +def test_format_authority_brackets_ipv6(host: str, port: int | None, expected: str) -> None: + assert _safe_http.format_authority(host, port) == expected + + +@pytest.mark.unit +def test_host_header_keeps_explicit_non_default_port() -> None: + """A loopback server on an ephemeral port must be addressed host:port.""" + seen: dict[str, str] = {} + + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + seen["host"] = self.headers.get("Host", "") + self.send_response(200) + self.end_headers() + self.wfile.write(b"{}") + + def log_message(self, *_args: object) -> None: + return None + + server = HTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + port = server.server_address[1] + req = urllib.request.Request(f"http://127.0.0.1:{port}/health", method="GET") + with cmc._http_exchange(req, timeout=2, context=_safe_http.secure_ssl_context()): + pass + finally: + server.shutdown() + server.server_close() + + assert seen["host"] == f"127.0.0.1:{port}"