"""HTTP client for Ghidra REST API communication. Provides safe request methods with error handling, HATEOAS compliance, and response simplification for MCP tool consumption. """ import time from typing import Any, Dict, Optional, Union from urllib.parse import urlparse import requests from ..config import get_config # Allowed origins for CORS-like validation ALLOWED_ORIGINS = { "http://localhost", "http://127.0.0.1", "https://localhost", "https://127.0.0.1", } def validate_origin(headers: Dict[str, str]) -> bool: """Validate request origin against allowed origins. Args: headers: Request headers dict Returns: True if origin is allowed or not present """ origin = headers.get("Origin") if not origin: # No origin header - allow (browser same-origin policy applies) return True try: parsed = urlparse(origin) origin_base = f"{parsed.scheme}://{parsed.hostname}" if parsed.port: origin_base += f":{parsed.port}" except Exception: return False return origin_base in ALLOWED_ORIGINS def get_instance_url(port: int, host: Optional[str] = None) -> str: """Get URL for a Ghidra instance by port. Args: port: Port number host: Optional host override (defaults to config) Returns: Full URL for the Ghidra instance """ if host is None: host = get_config().ghidra_host return f"http://{host}:{port}" def _make_request( method: str, port: int, endpoint: str, params: Optional[Dict[str, Any]] = None, json_data: Optional[Dict[str, Any]] = None, data: Optional[str] = None, headers: Optional[Dict[str, str]] = None, host: Optional[str] = None, ) -> Dict[str, Any]: """Make HTTP request to Ghidra instance with error handling. Args: method: HTTP method (GET, POST, PUT, PATCH, DELETE) port: Ghidra instance port endpoint: API endpoint path params: Query parameters json_data: JSON payload for POST/PUT/PATCH data: Raw text payload headers: Additional headers host: Optional host override Returns: Response dict with success flag and result or error """ config = get_config() url = f"{get_instance_url(port, host)}/{endpoint}" # Set up headers for HATEOAS API request_headers = { "Accept": "application/json", "X-Request-ID": f"mcp-bridge-{int(time.time() * 1000)}", } if headers: request_headers.update(headers) # Validate origin for state-changing requests is_state_changing = method.upper() in ["POST", "PUT", "PATCH", "DELETE"] if is_state_changing: check_headers = ( json_data.get("headers", {}) if isinstance(json_data, dict) else (headers or {}) ) if not validate_origin(check_headers): return { "success": False, "error": { "code": "ORIGIN_NOT_ALLOWED", "message": "Origin not allowed for state-changing request", }, "status_code": 403, "timestamp": int(time.time() * 1000), } if json_data is not None: request_headers["Content-Type"] = "application/json" elif data is not None: request_headers["Content-Type"] = "text/plain" try: response = requests.request( method, url, params=params, json=json_data, data=data, headers=request_headers, timeout=config.request_timeout, ) try: parsed_json = response.json() # Add timestamp if not present if isinstance(parsed_json, dict) and "timestamp" not in parsed_json: parsed_json["timestamp"] = int(time.time() * 1000) # Normalize error format if ( not response.ok and isinstance(parsed_json, dict) and "success" in parsed_json and not parsed_json["success"] ): if "error" in parsed_json and not isinstance( parsed_json["error"], dict ): error_message = parsed_json["error"] parsed_json["error"] = { "code": f"HTTP_{response.status_code}", "message": error_message, } return parsed_json except ValueError: if response.ok: return { "success": False, "error": { "code": "NON_JSON_RESPONSE", "message": "Received non-JSON success response", }, "status_code": response.status_code, "response_text": response.text[:500], "timestamp": int(time.time() * 1000), } else: return { "success": False, "error": { "code": f"HTTP_{response.status_code}", "message": f"Non-JSON error: {response.text[:100]}...", }, "status_code": response.status_code, "response_text": response.text[:500], "timestamp": int(time.time() * 1000), } except requests.exceptions.Timeout: return { "success": False, "error": {"code": "REQUEST_TIMEOUT", "message": "Request timed out"}, "status_code": 408, "timestamp": int(time.time() * 1000), } except requests.exceptions.ConnectionError: return { "success": False, "error": { "code": "CONNECTION_ERROR", "message": f"Failed to connect to Ghidra instance at {url}", }, "status_code": 503, "timestamp": int(time.time() * 1000), } except Exception as e: return { "success": False, "error": { "code": "UNEXPECTED_ERROR", "message": f"Unexpected error: {str(e)}", }, "exception": e.__class__.__name__, "timestamp": int(time.time() * 1000), } def safe_get( port: int, endpoint: str, params: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """Make GET request to Ghidra instance. Args: port: Ghidra instance port endpoint: API endpoint path params: Query parameters Returns: Response dict """ return _make_request("GET", port, endpoint, params=params) def safe_post( port: int, endpoint: str, data: Union[Dict[str, Any], str] ) -> Dict[str, Any]: """Make POST request to Ghidra instance. Args: port: Ghidra instance port endpoint: API endpoint path data: JSON dict or raw string payload Returns: Response dict """ headers = None json_payload = None text_payload = None if isinstance(data, dict): data = data.copy() # Don't mutate caller's dict headers = data.pop("headers", None) json_payload = data else: text_payload = data return _make_request( "POST", port, endpoint, json_data=json_payload, data=text_payload, headers=headers ) def safe_put(port: int, endpoint: str, data: Dict[str, Any]) -> Dict[str, Any]: """Make PUT request to Ghidra instance. Args: port: Ghidra instance port endpoint: API endpoint path data: JSON payload Returns: Response dict """ if isinstance(data, dict): data = data.copy() # Don't mutate caller's dict headers = data.pop("headers", None) else: headers = None return _make_request("PUT", port, endpoint, json_data=data, headers=headers) def safe_patch(port: int, endpoint: str, data: Dict[str, Any]) -> Dict[str, Any]: """Make PATCH request to Ghidra instance. Args: port: Ghidra instance port endpoint: API endpoint path data: JSON payload Returns: Response dict """ if isinstance(data, dict): data = data.copy() # Don't mutate caller's dict headers = data.pop("headers", None) else: headers = None return _make_request("PATCH", port, endpoint, json_data=data, headers=headers) def safe_delete(port: int, endpoint: str) -> Dict[str, Any]: """Make DELETE request to Ghidra instance. Args: port: Ghidra instance port endpoint: API endpoint path Returns: Response dict """ return _make_request("DELETE", port, endpoint) def simplify_response(response: Dict[str, Any]) -> Dict[str, Any]: """Simplify HATEOAS response for MCP tool consumption. - Removes _links from result entries - Flattens nested structures - Preserves important metadata - Converts structured data to text Args: response: Raw API response Returns: Simplified response dict """ if not isinstance(response, dict): return response result = response.copy() # Store API metadata api_metadata = {} for key in ["id", "instance", "timestamp", "size", "offset", "limit"]: if key in result: api_metadata[key] = result.get(key) # Simplify result data if "result" in result: if isinstance(result["result"], list): simplified_items = [] for item in result["result"]: if isinstance(item, dict): item_copy = item.copy() links = item_copy.pop("_links", None) if isinstance(links, dict): for link_name, link_data in links.items(): if isinstance(link_data, dict) and "href" in link_data: item_copy[f"{link_name}_url"] = link_data["href"] simplified_items.append(item_copy) else: simplified_items.append(item) result["result"] = simplified_items elif isinstance(result["result"], dict): result_copy = result["result"].copy() links = result_copy.pop("_links", None) if isinstance(links, dict): for link_name, link_data in links.items(): if isinstance(link_data, dict) and "href" in link_data: result_copy[f"{link_name}_url"] = link_data["href"] # Convert disassembly to text if "instructions" in result_copy and isinstance( result_copy["instructions"], list ): disasm_text = "" for instr in result_copy["instructions"]: if isinstance(instr, dict): addr = instr.get("address", "") mnemonic = instr.get("mnemonic", "") operands = instr.get("operands", "") bytes_str = instr.get("bytes", "") disasm_text += ( f"{addr}: {bytes_str.ljust(10)} {mnemonic} {operands}\n" ) result_copy["disassembly_text"] = disasm_text result_copy.pop("instructions", None) # Make decompiled code accessible if "ccode" in result_copy: result_copy["decompiled_text"] = result_copy["ccode"] elif "decompiled" in result_copy: result_copy["decompiled_text"] = result_copy["decompiled"] result["result"] = result_copy # Simplify top-level links links = result.pop("_links", None) if isinstance(links, dict): api_links = {} for link_name, link_data in links.items(): if isinstance(link_data, dict) and "href" in link_data: api_links[link_name] = link_data["href"] if api_links: result["api_links"] = api_links # Restore metadata for key, value in api_metadata.items(): if key not in result: result[key] = value return result