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
189 changes: 189 additions & 0 deletions agents/cursor/_safe_http.py
Original file line number Diff line number Diff line change
@@ -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 "<unparseable URL>"
scheme = parsed.scheme or "<no scheme>"
if not host:
return f"{scheme}://<no host>"
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(

Check failure on line 130 in agents/cursor/_safe_http.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 19 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=Quantum-L9_Cognitive.Engine.Graphs&issues=AaAl-nRb2Bh3iwzuQxIv&open=AaAl-nRb2Bh3iwzuQxIv&pullRequest=225
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)
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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)
36 changes: 32 additions & 4 deletions agents/cursor/cursor_memory_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

# =============================================================================
Expand Down Expand Up @@ -198,10 +203,29 @@
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"})

Check warning on line 212 in agents/cursor/cursor_memory_client.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make sure using this hardcoded IP address "46.62.243.82" is safe here.

See more on https://sonarcloud.io/project/issues?id=Quantum-L9_Cognitive.Engine.Graphs&issues=AaAl-nSA2Bh3iwzuQxIw&open=AaAl-nSA2Bh3iwzuQxIw&pullRequest=225


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(
Comment thread
cryptoxdog marked this conversation as resolved.
req,
timeout=timeout,
context=context,
allowed_http_hosts=_ALLOWED_HTTP_HOSTS,
label="memory URL",
)


# =============================================================================
# MCP Client (Primary - MCP Server ONLY)
# =============================================================================
Expand Down Expand Up @@ -231,12 +255,14 @@
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}
Expand Down Expand Up @@ -276,8 +302,10 @@
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:
Expand Down Expand Up @@ -346,7 +374,7 @@
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",
Expand Down
29 changes: 26 additions & 3 deletions agents/cursor/cursor_neo4j_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)}]}

Expand Down
Loading
Loading