from typing import Any from urllib.parse import urljoin, urlparse, urlunparse import httpx from ..utils.url_safety import resolve_and_validate as _resolve_and_validate _MAX_REDIRECTS = 5 _REDIRECT_CODES = {301, 302, 303, 307, 308} def _blocked_response(url: str, reason: str) -> dict[str, Any]: return { "success": False, "status_code": 0, "headers": {}, "content": f"Request blocked: {reason}", "url": url, } def _pinned_url(url: str, ip: str) -> str: """Rewrite ``url`` so the connection targets ``ip`` while keeping the path/query. The original hostname is preserved separately for the ``Host`` header and TLS SNI/cert verification (via httpx's ``sni_hostname`` request extension). """ parsed = urlparse(url) host_literal = f"[{ip}]" if ":" in ip else ip netloc = f"{host_literal}:{parsed.port}" if parsed.port else host_literal return urlunparse(parsed._replace(netloc=netloc)) async def _request_with_safe_redirects( client: httpx.AsyncClient, method: str, url: str, **kwargs: Any, ) -> tuple[httpx.Response | None, dict[str, Any] | None]: """Issue a request while validating every redirect target before following it. The hostname is resolved once per hop and the connection is pinned to the validated IP, closing the DNS-rebinding race where a controlled resolver returns a public IP at validation time and a private IP at connect time. """ current_method = method.upper() current_url = url request_kwargs = dict(kwargs) # Pop caller headers/extensions ONCE so they're reused on every redirect hop # (the per-hop Host + SNI are layered on top each time). Popping inside the # loop dropped the caller's Authorization/Accept/etc. on the first redirect. caller_headers = dict(request_kwargs.pop("headers", None) or {}) caller_extensions = dict(request_kwargs.pop("extensions", None) or {}) for redirect_count in range(_MAX_REDIRECTS + 1): is_safe, reason, hostname, addr_infos = _resolve_and_validate(current_url) if not is_safe or hostname is None or addr_infos is None: return None, _blocked_response(current_url, reason) pinned_ip = addr_infos[0][4][0] parsed = urlparse(current_url) headers = {**caller_headers, "Host": parsed.netloc} extensions = {**caller_extensions, "sni_hostname": hostname} response = await client.request( current_method, _pinned_url(current_url, pinned_ip), follow_redirects=False, headers=headers, extensions=extensions, **request_kwargs, ) if response.status_code not in _REDIRECT_CODES: return response, None location = response.headers.get("Location") if not location: return response, None if redirect_count == _MAX_REDIRECTS: return None, _blocked_response(current_url, "Too many redirects") current_url = urljoin(current_url, location) if response.status_code == 303 or ( response.status_code in {301, 302} and current_method not in {"GET", "HEAD"} ): current_method = "GET" request_kwargs.pop("data", None) request_kwargs.pop("content", None) request_kwargs.pop("json", None) return None, _blocked_response(current_url, "Too many redirects") async def http_request( url: str, method: str = "GET", headers: dict[str, str] | None = None, data: str | dict | None = None, params: dict[str, str] | None = None, timeout: int = 30, ) -> dict[str, Any]: """Make HTTP requests to APIs and web services. Do not use this tool for GitHub API calls. Use `GH_TOKEN=dummy gh` in the sandbox so GitHub authentication is handled by the sandbox proxy. Args: url: Target URL method: HTTP method (GET, POST, PUT, DELETE, etc.) headers: HTTP headers to include data: Request body data (string or dict) params: URL query parameters timeout: Request timeout in seconds Returns: Dictionary with response data including status, headers, and content """ try: kwargs: dict[str, Any] = {} if headers: kwargs["headers"] = headers if params: kwargs["params"] = params if data: if isinstance(data, dict): kwargs["json"] = data else: kwargs["content"] = data async with httpx.AsyncClient(timeout=timeout) as client: response, blocked = await _request_with_safe_redirects( client, method, url, **kwargs, ) if blocked: return blocked try: content = response.json() except ValueError: content = response.text return { "success": response.status_code < 400, "status_code": response.status_code, "headers": dict(response.headers), "content": content, "url": str(response.url), } except httpx.TimeoutException: return { "success": False, "status_code": 0, "headers": {}, "content": f"Request timed out after {timeout} seconds", "url": url, } except httpx.HTTPError as e: return { "success": False, "status_code": 0, "headers": {}, "content": f"Request error: {e!s}", "url": url, }