This is an automated email from the ASF dual-hosted git repository.

FreeOnePlus pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris-mcp-server.git


The following commit(s) were added to refs/heads/master by this push:
     new e080b06  fix: constrain Doris monitoring HTTP endpoints (#129)
e080b06 is described below

commit e080b0631b248dff4e97be2056a3aa92abf895f8
Author: Yijia Su <[email protected]>
AuthorDate: Thu Jul 30 01:15:23 2026 +0800

    fix: constrain Doris monitoring HTTP endpoints (#129)
---
 .env.example                                   |  11 +-
 README.md                                      |  25 +-
 doris_mcp_server/utils/analysis_tools.py       | 392 ++++++++++++------------
 doris_mcp_server/utils/config.py               |  58 +++-
 doris_mcp_server/utils/doris_http_client.py    | 394 +++++++++++++++++++++++++
 doris_mcp_server/utils/monitoring_tools.py     | 185 +++++++-----
 test/integration/test_real_doris_transports.py |  79 ++++-
 test/protocol/stdio_capability_server.py       |  45 +++
 test/protocol/test_mcp_v2_protocol.py          |  70 +++++
 test/security/test_doris_http_ssrf.py          | 350 ++++++++++++++++++++++
 10 files changed, 1307 insertions(+), 302 deletions(-)

diff --git a/.env.example b/.env.example
index 61e2508..6c0b1de 100644
--- a/.env.example
+++ b/.env.example
@@ -33,11 +33,18 @@ DORIS_DATABASE=information_schema
 # Doris FE HTTP API port (for Profile and other HTTP APIs)
 DORIS_FE_HTTP_PORT=8030
 
-# Doris BE (Backend) nodes configuration (optional, for external access)
-# Format: host1,host2,host3 (if empty, will use "show backends" to get BE 
nodes)
+# Doris BE (Backend) HTTP allowlist for monitoring metrics.
+# Format: host1,host2,host3. If empty, BE HTTP metrics are disabled.
 DORIS_BE_HOSTS=
 DORIS_BE_WEBSERVER_PORT=8040
 
+# FE/BE HTTP safety limits. Redirects are always disabled and resolved
+# metadata/link-local addresses are always rejected.
+DORIS_HTTP_CONNECT_TIMEOUT_SECONDS=3
+DORIS_HTTP_READ_TIMEOUT_SECONDS=15
+DORIS_HTTP_TOTAL_TIMEOUT_SECONDS=30
+DORIS_HTTP_MAX_RESPONSE_BYTES=4194304
+
 # Connection pool configuration
 DORIS_MAX_CONNECTIONS=20
 DORIS_CONNECTION_TIMEOUT=30
diff --git a/README.md b/README.md
index d06e8a0..a57c57c 100644
--- a/README.md
+++ b/README.md
@@ -264,8 +264,13 @@ cp .env.example .env
     *   `DORIS_DATABASE`: Default database name (default: information_schema)
     *   `DORIS_MIN_CONNECTIONS`: Minimum connection pool size (default: 5)
     *   `DORIS_MAX_CONNECTIONS`: Maximum connection pool size (default: 20)
-    *   `DORIS_BE_HOSTS`: BE nodes for monitoring (comma-separated, optional - 
auto-discovery via SHOW BACKENDS if empty)
+    *   `DORIS_FE_HTTP_PORT`: FE HTTP API port for profile, table-size, and 
monitoring tools (default: 8030)
+    *   `DORIS_BE_HOSTS`: Explicit BE HTTP allowlist for monitoring 
(comma-separated; BE HTTP metrics are disabled when empty)
     *   `DORIS_BE_WEBSERVER_PORT`: BE webserver port for monitoring tools 
(default: 8040)
+    *   `DORIS_HTTP_CONNECT_TIMEOUT_SECONDS`: FE/BE HTTP connection timeout 
(default: 3)
+    *   `DORIS_HTTP_READ_TIMEOUT_SECONDS`: FE/BE HTTP socket read timeout 
(default: 15)
+    *   `DORIS_HTTP_TOTAL_TIMEOUT_SECONDS`: FE/BE HTTP total timeout (default: 
30; hard maximum: 60)
+    *   `DORIS_HTTP_MAX_RESPONSE_BYTES`: FE/BE HTTP response limit (default: 4 
MiB; hard maximum: 16 MiB)
     *   `FE_ARROW_FLIGHT_SQL_PORT`: Frontend Arrow Flight SQL port for ADBC 
(New in v0.5.0)
     *   `BE_ARROW_FLIGHT_SQL_PORT`: Backend Arrow Flight SQL port for ADBC 
(New in v0.5.0)
 *   **Authentication Configuration (Enhanced in v0.6.0)**:
@@ -1475,21 +1480,21 @@ If you have further requirements for the returned 
results, you can describe the
 
 ### Q: How to configure BE nodes for monitoring tools?
 
-**A:** Choose the appropriate configuration based on your deployment scenario:
+**A:** Configure every BE HTTP node that the MCP server may contact:
 
-**External Network (Manual Configuration):**
 ```bash
-# Manually specify BE node addresses
+# Explicitly allow these configured BE nodes
 DORIS_BE_HOSTS=10.1.1.100,10.1.1.101,10.1.1.102
 DORIS_BE_WEBSERVER_PORT=8040
 ```
 
-**Internal Network (Automatic Discovery):**
-```bash
-# Leave BE_HOSTS empty for auto-discovery
-# DORIS_BE_HOSTS=  # Not set or empty
-# System will use 'SHOW BACKENDS' command to get internal IPs
-```
+BE HTTP endpoints are never inferred from `SHOW BACKENDS`: SQL metadata is not
+an outbound HTTP allowlist. If `DORIS_BE_HOSTS` is empty, BE HTTP metrics are
+disabled. FE and BE requests use only configured hosts and ports, pin validated
+DNS results for each request, reject metadata/link-local destinations, disable
+redirects, and enforce connection/read/total timeouts plus a response byte
+limit. Private and loopback addresses remain available for normal internal
+Doris deployments and SSH tunnels.
 
 ### Q: How to use SQL Explain/Profile files with LLM for optimization?
 
diff --git a/doris_mcp_server/utils/analysis_tools.py 
b/doris_mcp_server/utils/analysis_tools.py
index 070e861..90ae515 100644
--- a/doris_mcp_server/utils/analysis_tools.py
+++ b/doris_mcp_server/utils/analysis_tools.py
@@ -23,11 +23,12 @@ import time
 from datetime import datetime
 from typing import Any, Dict, List
 import uuid
-import aiohttp
 import hashlib
 from pathlib import Path
+from urllib.parse import quote
 
 from .db import DorisConnectionManager
+from .doris_http_client import DorisHTTPClient
 from .logger import get_logger
 from .sql_security_utils import (
     SQLSecurityError,
@@ -879,72 +880,64 @@ class SQLAnalyzer:
             Query ID string or None if not found
         """
         try:
-            # Get database config
             db_config = self.connection_manager.config.database
-            
-            # Build HTTP API URL according to official documentation
-            # Reference: 
https://doris.apache.org/zh-CN/docs/admin-manual/open-api/fe-http/query-profile-action#通过-trace-id-获取-query-id
-            url = 
f"http://{db_config.host}:{db_config.fe_http_port}/rest/v2/manager/query/trace_id/{trace_id}";
-            
-            # HTTP Basic Auth
-            auth = aiohttp.BasicAuth(db_config.user, db_config.password)
-            
-            logger.info(f"Requesting query ID from: {url}")
-            
-            async with aiohttp.ClientSession() as session:
-                async with session.get(url, auth=auth, timeout=10) as response:
-                    if response.status == 200:
-                        # Check content type first
-                        content_type = response.headers.get('content-type', '')
-                        response_text = await response.text()
-                        logger.info(f"Response content type: {content_type}")
-                        logger.info(
-                            "Query ID response body length: %s",
-                            len(response_text),
-                        )
-                        
-                        # Parse JSON response (regardless of content-type)
-                        if response_text.strip():
-                            try:
-                                import json
-                                result = json.loads(response_text)
-                                logger.info("Query ID API returned JSON")
-                                
-                                # Parse response according to Doris API format
-                                if result.get("code") == 0 and 
result.get("data"):
-                                    data = result["data"]
-                                    # Data can be either a string (query_id) 
or object with query_ids
-                                    if isinstance(data, str):
-                                        logger.info(f"Found query ID: {data}")
-                                        return data
-                                    elif isinstance(data, dict) and 
"query_ids" in data:
-                                        query_ids = data["query_ids"]
-                                        if query_ids:
-                                            query_id = query_ids[0]  # Take 
the first query ID
-                                            logger.info(f"Found query ID: 
{query_id}")
-                                            return query_id
-                                        else:
-                                            logger.warning("No query IDs found 
in response")
-                                else:
-                                    logger.error(f"API returned error: 
{result}")
-                                    
-                            except json.JSONDecodeError as e:
-                                logger.error(f"Failed to parse JSON response: 
{e}")
-                                # Fallback: try to extract query ID using regex
-                                import re
-                                query_id_pattern = 
r'[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}'
-                                matches = re.findall(query_id_pattern, 
response_text)
-                                if matches:
-                                    query_id = matches[0]
-                                    logger.info(f"Extracted query ID from 
text: {query_id}")
+            http_client = DorisHTTPClient.from_database_config(db_config)
+            response = await http_client.get(
+                role="fe",
+                host=db_config.host,
+                port=db_config.fe_http_port,
+                path=(
+                    "/rest/v2/manager/query/trace_id/"
+                    f"{quote(trace_id, safe='')}"
+                ),
+                headers={"Accept": "application/json"},
+            )
+            if response.status == 200:
+                content_type = response.headers.get("content-type", "")
+                response_text = response.text()
+                logger.info(f"Response content type: {content_type}")
+                logger.info(
+                    "Query ID response body length: %s",
+                    len(response_text),
+                )
+                if response_text.strip():
+                    try:
+                        import json
+
+                        result = json.loads(response_text)
+                        logger.info("Query ID API returned JSON")
+                        if result.get("code") == 0 and result.get("data"):
+                            data = result["data"]
+                            if isinstance(data, str):
+                                logger.info(f"Found query ID: {data}")
+                                return data
+                            if isinstance(data, dict) and "query_ids" in data:
+                                query_ids = data["query_ids"]
+                                if query_ids:
+                                    query_id = query_ids[0]
+                                    logger.info(f"Found query ID: {query_id}")
                                     return query_id
-                    else:
-                        logger.error(f"HTTP request failed with status 
{response.status}")
-                        response_text = await response.text()
-                        logger.error(
-                            "Query ID response body omitted (length=%s)",
-                            len(response_text),
+                                logger.warning("No query IDs found in 
response")
+                        else:
+                            logger.error(f"API returned error: {result}")
+                    except json.JSONDecodeError as e:
+                        logger.error(f"Failed to parse JSON response: {e}")
+                        import re
+
+                        query_id_pattern = (
+                            r"[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-"
+                            r"[a-f0-9]{4}-[a-f0-9]{12}"
                         )
+                        matches = re.findall(query_id_pattern, response_text)
+                        if matches:
+                            query_id = matches[0]
+                            logger.info(f"Extracted query ID from text: 
{query_id}")
+                            return query_id
+            else:
+                logger.error(
+                    "Query ID HTTP request failed with status %s",
+                    response.status,
+                )
             
             return None
             
@@ -963,79 +956,71 @@ class SQLAnalyzer:
             Profile data dict or None if failed
         """
         try:
-            # Get database config
             db_config = self.connection_manager.config.database
-            
-            # Try both API endpoints according to official documentation
-            urls = [
-                
f"http://{db_config.host}:{db_config.fe_http_port}/rest/v2/manager/query/profile/text/{query_id}";,
-                
f"http://{db_config.host}:{db_config.fe_http_port}/api/profile/text?query_id={query_id}";
+            endpoints = [
+                (
+                    "/rest/v2/manager/query/profile/text/"
+                    f"{quote(query_id, safe='')}",
+                    None,
+                ),
+                ("/api/profile/text", {"query_id": query_id}),
             ]
-            
-            # HTTP Basic Auth
-            auth = aiohttp.BasicAuth(db_config.user, db_config.password)
-            
-            for i, url in enumerate(urls):
-                logger.info(f"Requesting profile from URL {i+1}: {url}")
-                
-                async with aiohttp.ClientSession() as session:
-                    async with session.get(url, auth=auth, timeout=60) as 
response:
-                        if response.status == 200:
-                            content_type = 
response.headers.get('content-type', '')
-                            response_text = await response.text()
-                            logger.info(f"Profile response content type: 
{content_type}")
-                            logger.info(f"Profile response length: 
{len(response_text)}")
-                            
-                            # Handle JSON response
-                            if 'application/json' in content_type:
-                                try:
-                                    result = await response.json()
-                                    logger.info("Profile API returned JSON")
-                                    
-                                    if result.get("code") == 0 and 
result.get("data"):
-                                        profile_text = 
result["data"].get("profile", "")
-                                        return {
-                                            "query_id": query_id,
-                                            "profile_text": profile_text,
-                                            "profile_size": len(profile_text),
-                                            "retrieved_at": 
datetime.now().isoformat(),
-                                            "api_endpoint": url
-                                        }
-                                    else:
-                                        logger.warning(f"Profile API returned 
error: {result}")
-                                        continue  # Try next URL
-                                        
-                                except Exception as e:
-                                    logger.error(f"Failed to parse profile 
JSON: {e}")
-                                    continue
-                            
-                            # Handle plain text response
-                            else:
-                                if response_text.strip() and "not found" not 
in response_text.lower():
-                                    return {
-                                        "query_id": query_id,
-                                        "profile_text": response_text,
-                                        "profile_size": len(response_text),
-                                        "retrieved_at": 
datetime.now().isoformat(),
-                                        "api_endpoint": url
-                                    }
-                                else:
-                                    logger.warning(
-                                        "Profile not found or empty"
-                                    )
-                                    continue  # Try next URL
-                        
-                        elif response.status == 404:
-                            logger.warning(f"Profile not found (404) at {url}")
-                            continue  # Try next URL
-                        else:
-                            logger.error(f"Profile HTTP request failed with 
status {response.status} at {url}")
-                            response_text = await response.text()
-                            logger.error(
-                                "Profile response body omitted (length=%s)",
-                                len(response_text),
-                            )
-                            continue  # Try next URL
+            http_client = DorisHTTPClient.from_database_config(db_config)
+            for i, (path, params) in enumerate(endpoints):
+                logger.info("Requesting profile from configured FE endpoint 
%s", i + 1)
+                response = await http_client.get(
+                    role="fe",
+                    host=db_config.host,
+                    port=db_config.fe_http_port,
+                    path=path,
+                    params=params,
+                    headers={"Accept": "application/json, text/plain"},
+                )
+                if response.status == 200:
+                    content_type = response.headers.get("content-type", "")
+                    response_text = response.text()
+                    logger.info(f"Profile response content type: 
{content_type}")
+                    logger.info(f"Profile response length: 
{len(response_text)}")
+                    if "application/json" in content_type:
+                        try:
+                            import json
+
+                            result = json.loads(response_text)
+                            logger.info("Profile API returned JSON")
+                            if result.get("code") == 0 and result.get("data"):
+                                profile_text = result["data"].get("profile", 
"")
+                                return {
+                                    "query_id": query_id,
+                                    "profile_text": profile_text,
+                                    "profile_size": len(profile_text),
+                                    "retrieved_at": datetime.now().isoformat(),
+                                    "api_endpoint": response.url,
+                                }
+                            logger.warning(f"Profile API returned error: 
{result}")
+                            continue
+                        except Exception as e:
+                            logger.error(f"Failed to parse profile JSON: {e}")
+                            continue
+                    if (
+                        response_text.strip()
+                        and "not found" not in response_text.lower()
+                    ):
+                        return {
+                            "query_id": query_id,
+                            "profile_text": response_text,
+                            "profile_size": len(response_text),
+                            "retrieved_at": datetime.now().isoformat(),
+                            "api_endpoint": response.url,
+                        }
+                    logger.warning("Profile not found or empty")
+                    continue
+                if response.status == 404:
+                    logger.warning("Profile not found (404)")
+                    continue
+                logger.error(
+                    "Profile HTTP request failed with status %s",
+                    response.status,
+                )
             
             return None
             
@@ -1061,14 +1046,7 @@ class SQLAnalyzer:
             Dict containing table data size information
         """
         try:
-            # Get database config
             db_config = self.connection_manager.config.database
-            
-            # Build HTTP API URL according to official documentation
-            # Reference: 
https://doris.apache.org/zh-CN/docs/admin-manual/open-api/fe-http/show-table-data-action
-            url = 
f"http://{db_config.host}:{db_config.fe_http_port}/api/show_table_data";
-            
-            # Build query parameters
             params = {}
             if db_name:
                 params["db"] = db_name
@@ -1076,72 +1054,76 @@ class SQLAnalyzer:
                 params["table"] = table_name
             if single_replica:
                 params["single_replica"] = "true"
-            
-            # HTTP Basic Auth
-            auth = aiohttp.BasicAuth(db_config.user, db_config.password)
-            
-            logger.info(f"Requesting table data size from: {url} with params: 
{params}")
-            
-            async with aiohttp.ClientSession() as session:
-                async with session.get(url, auth=auth, params=params, 
timeout=30) as response:
-                    if response.status == 200:
-                        response_text = await response.text()
-                        logger.info(f"Table data size response length: 
{len(response_text)}")
-                        
-                        try:
-                            # Parse JSON response
-                            import json
-                            result = json.loads(response_text)
-                            
-                            if result.get("code") == 0 and result.get("data"):
-                                data = result["data"]
-                                
-                                # Process and format the data
-                                formatted_data = 
self._format_table_data_size(data, db_name, table_name, single_replica)
-                                
-                                return {
-                                    "success": True,
-                                    "db_name": db_name,
-                                    "table_name": table_name,
-                                    "single_replica": single_replica,
-                                    "timestamp": datetime.now().isoformat(),
-                                    "data": formatted_data,
-                                    "url": url,
-                                    "note": "Table data size information from 
Doris FE HTTP API"
-                                }
-                            else:
-                                return {
-                                    "success": False,
-                                    "error": f"API returned error: {result}",
-                                    "db_name": db_name,
-                                    "table_name": table_name,
-                                    "url": url,
-                                    "timestamp": datetime.now().isoformat()
-                                }
-                                
-                        except json.JSONDecodeError as e:
-                            logger.error(f"Failed to parse JSON response: {e}")
-                            return {
-                                "success": False,
-                                "error": f"Failed to parse JSON response: {e}",
-                                "response_text": response_text[:500],  # First 
500 chars for debugging
-                                "url": url,
-                                "timestamp": datetime.now().isoformat()
-                            }
-                    else:
-                        logger.error(f"HTTP request failed with status 
{response.status}")
-                        response_text = await response.text()
-                        logger.error(
-                            "Table size response body omitted (length=%s)",
-                            len(response_text),
+            logger.info(
+                "Requesting table data size from configured FE endpoint with 
params: %s",
+                params,
+            )
+            http_client = DorisHTTPClient.from_database_config(db_config)
+            response = await http_client.get(
+                role="fe",
+                host=db_config.host,
+                port=db_config.fe_http_port,
+                path="/api/show_table_data",
+                params=params,
+                headers={"Accept": "application/json"},
+            )
+            if response.status == 200:
+                response_text = response.text()
+                logger.info(
+                    "Table data size response length: %s",
+                    len(response_text),
+                )
+                try:
+                    import json
+
+                    result = json.loads(response_text)
+                    if result.get("code") == 0 and result.get("data"):
+                        data = result["data"]
+                        formatted_data = self._format_table_data_size(
+                            data,
+                            db_name,
+                            table_name,
+                            single_replica,
                         )
                         return {
-                            "success": False,
-                            "error": f"HTTP request failed with status 
{response.status}",
-                            "response_text": response_text[:500],  # First 500 
chars for debugging
-                            "url": url,
-                            "timestamp": datetime.now().isoformat()
+                            "success": True,
+                            "db_name": db_name,
+                            "table_name": table_name,
+                            "single_replica": single_replica,
+                            "timestamp": datetime.now().isoformat(),
+                            "data": formatted_data,
+                            "url": response.url,
+                            "note": (
+                                "Table data size information from Doris FE "
+                                "HTTP API"
+                            ),
                         }
+                    return {
+                        "success": False,
+                        "error": f"API returned error: {result}",
+                        "db_name": db_name,
+                        "table_name": table_name,
+                        "url": response.url,
+                        "timestamp": datetime.now().isoformat(),
+                    }
+                except json.JSONDecodeError as e:
+                    logger.error(f"Failed to parse JSON response: {e}")
+                    return {
+                        "success": False,
+                        "error": f"Failed to parse JSON response: {e}",
+                        "url": response.url,
+                        "timestamp": datetime.now().isoformat(),
+                    }
+            logger.error(
+                "Table data size HTTP request failed with status %s",
+                response.status,
+            )
+            return {
+                "success": False,
+                "error": f"HTTP request failed with status {response.status}",
+                "url": response.url,
+                "timestamp": datetime.now().isoformat(),
+            }
                         
         except Exception as e:
             logger.error(f"Table data size request failed: {str(e)}")
diff --git a/doris_mcp_server/utils/config.py b/doris_mcp_server/utils/config.py
index 001f6b5..c59e963 100644
--- a/doris_mcp_server/utils/config.py
+++ b/doris_mcp_server/utils/config.py
@@ -285,11 +285,18 @@ class DatabaseConfig:
     # FE HTTP API port for profile and other HTTP APIs
     fe_http_port: int = 8030
     
-    # BE nodes configuration for external access
-    # If be_hosts is empty, will use "show backends" to get BE nodes
+    # BE HTTP nodes must be configured explicitly. SQL metadata is not trusted
+    # as an outbound HTTP allowlist.
     be_hosts: list[str] = field(default_factory=list)
     be_webserver_port: int = 8040
 
+    # Shared FE/BE HTTP safety limits. Runtime enforcement also applies hard
+    # caps so unsafe config values cannot remove the boundary.
+    http_connect_timeout_seconds: float = 3.0
+    http_read_timeout_seconds: float = 15.0
+    http_total_timeout_seconds: float = 30.0
+    http_max_response_bytes: int = 4 * 1024 * 1024
+
     # Arrow Flight SQL Configuration (Required for ADBC tools)
     fe_arrow_flight_sql_port: int | None = None
     be_arrow_flight_sql_port: int | None = None
@@ -696,6 +703,31 @@ class DorisConfig:
         be_webserver_port = os.getenv("DORIS_BE_WEBSERVER_PORT", "").strip()
         if be_webserver_port and be_webserver_port.isdigit():
             config.database.be_webserver_port = int(be_webserver_port)
+
+        config.database.http_connect_timeout_seconds = float(
+            os.getenv(
+                "DORIS_HTTP_CONNECT_TIMEOUT_SECONDS",
+                str(config.database.http_connect_timeout_seconds),
+            )
+        )
+        config.database.http_read_timeout_seconds = float(
+            os.getenv(
+                "DORIS_HTTP_READ_TIMEOUT_SECONDS",
+                str(config.database.http_read_timeout_seconds),
+            )
+        )
+        config.database.http_total_timeout_seconds = float(
+            os.getenv(
+                "DORIS_HTTP_TOTAL_TIMEOUT_SECONDS",
+                str(config.database.http_total_timeout_seconds),
+            )
+        )
+        config.database.http_max_response_bytes = int(
+            os.getenv(
+                "DORIS_HTTP_MAX_RESPONSE_BYTES",
+                str(config.database.http_max_response_bytes),
+            )
+        )
         
         # Arrow Flight SQL Configuration
         fe_arrow_port_env = os.getenv("FE_ARROW_FLIGHT_SQL_PORT")
@@ -1181,6 +1213,10 @@ class DorisConfig:
                 "fe_http_port": self.database.fe_http_port,
                 "be_hosts": self.database.be_hosts,
                 "be_webserver_port": self.database.be_webserver_port,
+                "http_connect_timeout_seconds": 
self.database.http_connect_timeout_seconds,
+                "http_read_timeout_seconds": 
self.database.http_read_timeout_seconds,
+                "http_total_timeout_seconds": 
self.database.http_total_timeout_seconds,
+                "http_max_response_bytes": 
self.database.http_max_response_bytes,
                 "fe_arrow_flight_sql_port": 
self.database.fe_arrow_flight_sql_port,
                 "be_arrow_flight_sql_port": 
self.database.be_arrow_flight_sql_port,
                 "min_connections": self.database.min_connections,  # Always 0, 
shown for reference
@@ -1328,6 +1364,24 @@ class DorisConfig:
         if self.database.max_connections <= 0:
             errors.append("Maximum connections must be greater than 0")
 
+        if not (1 <= self.database.fe_http_port <= 65535):
+            errors.append("Doris FE HTTP port must be in the range 1-65535")
+
+        if not (1 <= self.database.be_webserver_port <= 65535):
+            errors.append("Doris BE HTTP port must be in the range 1-65535")
+
+        if self.database.http_connect_timeout_seconds <= 0:
+            errors.append("Doris HTTP connect timeout must be greater than 0")
+
+        if self.database.http_read_timeout_seconds <= 0:
+            errors.append("Doris HTTP read timeout must be greater than 0")
+
+        if self.database.http_total_timeout_seconds <= 0:
+            errors.append("Doris HTTP total timeout must be greater than 0")
+
+        if self.database.http_max_response_bytes <= 0:
+            errors.append("Doris HTTP response byte limit must be greater than 
0")
+
         # Validate security configuration
         if self.security.auth_type not in ["token", "basic", "oauth", "jwt"]:
             errors.append("Authentication type must be one of token, basic, 
oauth, or jwt")
diff --git a/doris_mcp_server/utils/doris_http_client.py 
b/doris_mcp_server/utils/doris_http_client.py
new file mode 100644
index 0000000..7406e9f
--- /dev/null
+++ b/doris_mcp_server/utils/doris_http_client.py
@@ -0,0 +1,394 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Bounded HTTP access to explicitly configured Doris FE and BE nodes."""
+
+from __future__ import annotations
+
+import asyncio
+import ipaddress
+import socket
+from collections.abc import Mapping
+from dataclasses import dataclass
+from typing import Any, Literal
+from urllib.parse import urlencode, urlunsplit
+
+import aiohttp
+
+DEFAULT_CONNECT_TIMEOUT_SECONDS = 3.0
+DEFAULT_READ_TIMEOUT_SECONDS = 15.0
+DEFAULT_TOTAL_TIMEOUT_SECONDS = 30.0
+DEFAULT_MAX_RESPONSE_BYTES = 4 * 1024 * 1024
+MAX_TIMEOUT_SECONDS = 60.0
+MAX_RESPONSE_BYTES = 16 * 1024 * 1024
+
+_METADATA_HOSTS = frozenset(
+    {
+        "metadata",
+        "metadata.google.internal",
+        "metadata.goog",
+        "instance-data",
+    }
+)
+_METADATA_ADDRESSES = frozenset(
+    {
+        ipaddress.ip_address("169.254.169.254"),
+        ipaddress.ip_address("169.254.170.2"),
+        ipaddress.ip_address("100.100.100.200"),
+        ipaddress.ip_address("fd00:ec2::254"),
+    }
+)
+
+
+class DorisHTTPError(RuntimeError):
+    """Base error for a Doris HTTP request."""
+
+
+class DorisHTTPPolicyError(DorisHTTPError):
+    """The requested endpoint violates the configured SSRF boundary."""
+
+
+class DorisHTTPResponseTooLarge(DorisHTTPError):
+    """The Doris HTTP response exceeded the configured byte limit."""
+
+
+class DorisHTTPRequestError(DorisHTTPError):
+    """The configured Doris HTTP endpoint could not be reached."""
+
+
+@dataclass(frozen=True)
+class DorisHTTPResponse:
+    """A bounded in-memory Doris HTTP response."""
+
+    status: int
+    headers: Mapping[str, str]
+    body: bytes
+    url: str
+
+    def text(self) -> str:
+        return self.body.decode("utf-8", errors="replace")
+
+
+class _PinnedResolver(aiohttp.abc.AbstractResolver):
+    """Resolve one configured hostname only to preflighted addresses."""
+
+    def __init__(self, hostname: str, addresses: tuple[str, ...]):
+        self.hostname = hostname
+        self.addresses = addresses
+
+    async def resolve(
+        self,
+        host: str,
+        port: int = 0,
+        family: socket.AddressFamily = socket.AF_INET,
+    ) -> list[aiohttp.abc.ResolveResult]:
+        if _normalize_host(host) != self.hostname:
+            raise OSError("Unexpected Doris HTTP hostname")
+        results: list[aiohttp.abc.ResolveResult] = []
+        for address in self.addresses:
+            parsed = ipaddress.ip_address(address)
+            address_family = socket.AF_INET6 if parsed.version == 6 else 
socket.AF_INET
+            if family not in {socket.AF_UNSPEC, address_family}:
+                continue
+            results.append(
+                {
+                    "hostname": host,
+                    "host": address,
+                    "port": port,
+                    "family": address_family,
+                    "proto": socket.IPPROTO_TCP,
+                    "flags": socket.AI_NUMERICHOST,
+                }
+            )
+        if not results:
+            raise OSError("No validated Doris HTTP address")
+        return results
+
+    async def close(self) -> None:
+        return None
+
+
+def _normalize_host(host: Any) -> str:
+    if not isinstance(host, str):
+        raise DorisHTTPPolicyError("Doris HTTP host must be a string")
+    normalized = host.strip()
+    if normalized.startswith("[") and normalized.endswith("]"):
+        normalized = normalized[1:-1]
+    normalized = normalized.rstrip(".")
+    if (
+        not normalized
+        or "://" in normalized
+        or any(character in normalized for character in "/\\?#@%")
+    ):
+        raise DorisHTTPPolicyError("Doris HTTP host is malformed")
+    try:
+        return normalized.encode("idna").decode("ascii").lower()
+    except UnicodeError as exc:
+        raise DorisHTTPPolicyError("Doris HTTP host is malformed") from exc
+
+
+def _validate_port(port: Any) -> int:
+    if isinstance(port, bool) or not isinstance(port, int) or not (1 <= port 
<= 65535):
+        raise DorisHTTPPolicyError("Doris HTTP port must be between 1 and 
65535")
+    return port
+
+
+def _address_allowed(address: str) -> bool:
+    parsed = ipaddress.ip_address(address)
+    candidates = (parsed, getattr(parsed, "ipv4_mapped", None))
+    return not any(
+        candidate is not None
+        and (
+            candidate in _METADATA_ADDRESSES
+            or candidate.is_link_local
+            or candidate.is_multicast
+            or candidate.is_unspecified
+            or candidate.is_reserved
+        )
+        for candidate in candidates
+    )
+
+
+def _bounded_float(value: Any, default: float) -> float:
+    try:
+        parsed = float(value)
+    except (TypeError, ValueError):
+        return default
+    if parsed <= 0:
+        return default
+    return min(parsed, MAX_TIMEOUT_SECONDS)
+
+
+def _bounded_response_limit(value: Any) -> int:
+    try:
+        parsed = int(value)
+    except (TypeError, ValueError):
+        return DEFAULT_MAX_RESPONSE_BYTES
+    if parsed <= 0:
+        return DEFAULT_MAX_RESPONSE_BYTES
+    return min(parsed, MAX_RESPONSE_BYTES)
+
+
+class DorisHTTPClient:
+    """Fetch only configured Doris HTTP endpoints with SSRF controls."""
+
+    def __init__(
+        self,
+        *,
+        user: str,
+        password: str,
+        allowed_endpoints: Mapping[str, set[tuple[str, int]]],
+        connect_timeout_seconds: float = DEFAULT_CONNECT_TIMEOUT_SECONDS,
+        read_timeout_seconds: float = DEFAULT_READ_TIMEOUT_SECONDS,
+        total_timeout_seconds: float = DEFAULT_TOTAL_TIMEOUT_SECONDS,
+        max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES,
+    ):
+        self.user = user
+        self.password = password
+        self.allowed_endpoints = {
+            role: {
+                (_normalize_host(host), _validate_port(port))
+                for host, port in endpoints
+            }
+            for role, endpoints in allowed_endpoints.items()
+        }
+        self.connect_timeout_seconds = _bounded_float(
+            connect_timeout_seconds,
+            DEFAULT_CONNECT_TIMEOUT_SECONDS,
+        )
+        self.read_timeout_seconds = _bounded_float(
+            read_timeout_seconds,
+            DEFAULT_READ_TIMEOUT_SECONDS,
+        )
+        self.total_timeout_seconds = _bounded_float(
+            total_timeout_seconds,
+            DEFAULT_TOTAL_TIMEOUT_SECONDS,
+        )
+        self.max_response_bytes = _bounded_response_limit(max_response_bytes)
+
+    @classmethod
+    def from_database_config(cls, database_config: Any) -> DorisHTTPClient:
+        fe_host = _normalize_host(database_config.host)
+        fe_port = _validate_port(database_config.fe_http_port)
+        be_port = _validate_port(getattr(database_config, "be_webserver_port", 
8040))
+        be_hosts = getattr(database_config, "be_hosts", []) or []
+        if not isinstance(be_hosts, list) or any(
+            not isinstance(host, str) for host in be_hosts
+        ):
+            raise DorisHTTPPolicyError("Configured Doris BE hosts are invalid")
+        return cls(
+            user=str(database_config.user),
+            password=str(database_config.password),
+            allowed_endpoints={
+                "fe": {(fe_host, fe_port)},
+                "be": {(_normalize_host(host), be_port) for host in be_hosts},
+            },
+            connect_timeout_seconds=getattr(
+                database_config,
+                "http_connect_timeout_seconds",
+                DEFAULT_CONNECT_TIMEOUT_SECONDS,
+            ),
+            read_timeout_seconds=getattr(
+                database_config,
+                "http_read_timeout_seconds",
+                DEFAULT_READ_TIMEOUT_SECONDS,
+            ),
+            total_timeout_seconds=getattr(
+                database_config,
+                "http_total_timeout_seconds",
+                DEFAULT_TOTAL_TIMEOUT_SECONDS,
+            ),
+            max_response_bytes=getattr(
+                database_config,
+                "http_max_response_bytes",
+                DEFAULT_MAX_RESPONSE_BYTES,
+            ),
+        )
+
+    async def get(
+        self,
+        *,
+        role: Literal["fe", "be"],
+        host: str,
+        port: int,
+        path: str,
+        params: Mapping[str, str] | None = None,
+        headers: Mapping[str, str] | None = None,
+    ) -> DorisHTTPResponse:
+        normalized_host = _normalize_host(host)
+        validated_port = _validate_port(port)
+        if (normalized_host, validated_port) not in self.allowed_endpoints.get(
+            role,
+            set(),
+        ):
+            raise DorisHTTPPolicyError(
+                "Doris HTTP request is not an explicitly configured endpoint"
+            )
+        if normalized_host in _METADATA_HOSTS:
+            raise DorisHTTPPolicyError(
+                "Doris HTTP endpoint is prohibited by SSRF policy"
+            )
+        if (
+            not isinstance(path, str)
+            or not path.startswith("/")
+            or path.startswith("//")
+            or any(character in path for character in "\\?#\r\n")
+        ):
+            raise DorisHTTPPolicyError("Doris HTTP path is malformed")
+
+        addresses = await self._resolve_addresses(normalized_host, 
validated_port)
+        resolver = _PinnedResolver(normalized_host, addresses)
+        connector = aiohttp.TCPConnector(
+            resolver=resolver,
+            use_dns_cache=False,
+            ttl_dns_cache=0,
+            force_close=True,
+            limit=1,
+        )
+        timeout = aiohttp.ClientTimeout(
+            total=self.total_timeout_seconds,
+            connect=self.connect_timeout_seconds,
+            sock_connect=self.connect_timeout_seconds,
+            sock_read=self.read_timeout_seconds,
+        )
+        host_for_url = (
+            f"[{normalized_host}]" if ":" in normalized_host else 
normalized_host
+        )
+        query = urlencode(params or {}, doseq=False)
+        url = urlunsplit(("http", f"{host_for_url}:{validated_port}", path, 
query, ""))
+        auth = aiohttp.BasicAuth(self.user, self.password)
+        try:
+            async with aiohttp.ClientSession(
+                connector=connector,
+                timeout=timeout,
+                raise_for_status=False,
+                trust_env=False,
+            ) as session:
+                async with session.get(
+                    url,
+                    auth=auth,
+                    allow_redirects=False,
+                    headers=dict(headers or {}),
+                ) as response:
+                    body = await self._read_bounded_body(response)
+                    return DorisHTTPResponse(
+                        status=response.status,
+                        headers=dict(response.headers),
+                        body=body,
+                        url=str(response.url),
+                    )
+        except DorisHTTPError:
+            raise
+        except TimeoutError as exc:
+            raise DorisHTTPRequestError("Doris HTTP request timed out") from 
exc
+        except (aiohttp.ClientError, OSError) as exc:
+            raise DorisHTTPRequestError("Doris HTTP request failed") from exc
+
+    async def _resolve_addresses(self, host: str, port: int) -> tuple[str, 
...]:
+        try:
+            literal = ipaddress.ip_address(host)
+        except ValueError:
+            literal = None
+        if literal is not None:
+            addresses = (str(literal),)
+        else:
+            loop = asyncio.get_running_loop()
+            try:
+                results = await asyncio.wait_for(
+                    loop.getaddrinfo(
+                        host,
+                        port,
+                        family=socket.AF_UNSPEC,
+                        type=socket.SOCK_STREAM,
+                    ),
+                    timeout=self.connect_timeout_seconds,
+                )
+            except (OSError, TimeoutError) as exc:
+                raise DorisHTTPRequestError(
+                    "Doris HTTP hostname resolution failed"
+                ) from exc
+            addresses = tuple(
+                dict.fromkeys(result[4][0].split("%", 1)[0] for result in 
results)
+            )
+        if not addresses or any(not _address_allowed(address) for address in 
addresses):
+            raise DorisHTTPPolicyError(
+                "Doris HTTP endpoint resolves to a prohibited address"
+            )
+        return addresses
+
+    async def _read_bounded_body(
+        self,
+        response: aiohttp.ClientResponse,
+    ) -> bytes:
+        content_length = response.headers.get("Content-Length")
+        if content_length:
+            try:
+                if int(content_length) > self.max_response_bytes:
+                    raise DorisHTTPResponseTooLarge(
+                        "Doris HTTP response exceeds the configured byte limit"
+                    )
+            except ValueError as exc:
+                raise DorisHTTPRequestError(
+                    "Doris HTTP response has an invalid Content-Length"
+                ) from exc
+        body = bytearray()
+        async for chunk in response.content.iter_chunked(64 * 1024):
+            body.extend(chunk)
+            if len(body) > self.max_response_bytes:
+                raise DorisHTTPResponseTooLarge(
+                    "Doris HTTP response exceeds the configured byte limit"
+                )
+        return bytes(body)
diff --git a/doris_mcp_server/utils/monitoring_tools.py 
b/doris_mcp_server/utils/monitoring_tools.py
index d9055c2..ba32b94 100644
--- a/doris_mcp_server/utils/monitoring_tools.py
+++ b/doris_mcp_server/utils/monitoring_tools.py
@@ -20,15 +20,18 @@ Provides monitoring and metrics collection functions for FE 
and BE nodes
 """
 
 import re
-import aiohttp
-import asyncio
 from enum import Enum
-from typing import Any, Dict, List, Optional, Tuple
+from typing import Any, Dict, List, Literal, Optional, Tuple
 from datetime import datetime
 
 from .db import DorisConnectionManager
+from .doris_http_client import (
+    DorisHTTPClient,
+    DorisHTTPPolicyError,
+    DorisHTTPRequestError,
+    DorisHTTPResponseTooLarge,
+)
 from .logger import get_logger
-from .sql_security_utils import get_auth_context
 
 logger = get_logger(__name__)
 
@@ -683,16 +686,14 @@ class DorisMonitoringTools:
         self.connection_manager = connection_manager
     
     async def get_be_nodes(self) -> List[Dict[str, Any]]:
-        """Get BE node information, prioritize configured be_hosts, fallback 
to SHOW BACKENDS"""
+        """Return only BE HTTP nodes from the explicit configuration 
allowlist."""
         try:
-            # Get database configuration
             db_config = self.connection_manager.config.database
-            
-            # Check if BE hosts are configured
-            if db_config.be_hosts:
-                logger.info(f"Using configured BE hosts: {db_config.be_hosts}")
+            be_hosts = getattr(db_config, "be_hosts", []) or []
+            if be_hosts:
+                logger.info(f"Using configured BE hosts: {be_hosts}")
                 be_nodes = []
-                for i, host in enumerate(db_config.be_hosts):
+                for i, host in enumerate(be_hosts):
                     be_info = {
                         "backend_id": f"configured_{i}",
                         "host": host,
@@ -710,80 +711,91 @@ class DorisMonitoringTools:
                 
                 logger.info(f"Found {len(be_nodes)} configured BE nodes")
                 return be_nodes
-            
-            # Fallback to SHOW BACKENDS if no BE hosts configured
-            logger.info("No BE hosts configured, using SHOW BACKENDS to 
discover BE nodes")
-            connection = await self.connection_manager.get_connection("query")
-            auth_context = get_auth_context()
-            result = await connection.execute("SHOW BACKENDS", 
auth_context=auth_context)
-            
-            be_nodes = []
-            for row in result.data:
-                # SHOW BACKENDS returns columns including: BackendId, Host, 
HeartbeatPort, BePort, HttpPort, BrpcPort, etc.
-                be_info = {
-                    "backend_id": row.get("BackendId"),
-                    "host": row.get("Host"),
-                    "heartbeat_port": row.get("HeartbeatPort"),
-                    "be_port": row.get("BePort"),
-                    "http_port": row.get("HttpPort"),  # This is webserver_port
-                    "brpc_port": row.get("BrpcPort"),
-                    "alive": row.get("Alive"),
-                    "system_decommissioned": row.get("SystemDecommissioned"),
-                    "cluster_id": row.get("ClusterId"),
-                    "version": row.get("Version"),
-                    "source": "show_backends"
-                }
-                be_nodes.append(be_info)
-            
-            logger.info(f"Found {len(be_nodes)} BE nodes from SHOW BACKENDS")
-            return be_nodes
+            logger.warning(
+                "BE HTTP metrics disabled because DORIS_BE_HOSTS is empty"
+            )
+            return []
             
         except Exception as e:
             logger.error(f"Failed to get BE nodes: {str(e)}")
             return []
     
-    async def fetch_metrics_from_url(self, url: str, node_type: str, 
node_info: Dict[str, Any]) -> Dict[str, Any]:
-        """Fetch monitoring metrics from specified URL"""
+    async def fetch_metrics_from_node(
+        self,
+        node_type: Literal["fe", "be"],
+        node_info: Dict[str, Any],
+    ) -> Dict[str, Any]:
+        """Fetch metrics from an explicitly configured FE or BE node."""
+        endpoint = f"{node_info.get('host')}:{node_info.get('port') or 
node_info.get('http_port')}"
         try:
-            # Get database configuration for authentication
             db_config = self.connection_manager.config.database
-            auth = aiohttp.BasicAuth(db_config.user, db_config.password)
-            
-            logger.info(f"Fetching metrics from {node_type} node: {url}")
-            
-            async with aiohttp.ClientSession() as session:
-                async with session.get(url, auth=auth, timeout=30) as response:
-                    if response.status == 200:
-                        # Parse Prometheus format
-                        metrics_text = await response.text()
-                        metrics_data = 
self._parse_prometheus_metrics(metrics_text)
-                        
-                        return {
-                            "success": True,
-                            "node_type": node_type,
-                            "node_info": node_info,
-                            "metrics": metrics_data,
-                            "url": url,
-                            "timestamp": datetime.now().isoformat()
-                        }
-                    else:
-                        logger.error(f"HTTP request failed with status 
{response.status} for {url}")
-                        return {
-                            "success": False,
-                            "error": f"HTTP {response.status}",
-                            "node_type": node_type,
-                            "node_info": node_info,
-                            "url": url
-                        }
-                        
+            port_key = "port" if node_type == "fe" else "http_port"
+            http_client = DorisHTTPClient.from_database_config(db_config)
+            response = await http_client.get(
+                role=node_type,
+                host=node_info["host"],
+                port=int(node_info[port_key]),
+                path="/metrics",
+                headers={"Accept": "text/plain"},
+            )
+            if response.status == 200:
+                metrics_data = self._parse_prometheus_metrics(response.text())
+                return {
+                    "success": True,
+                    "node_type": node_type,
+                    "node_info": node_info,
+                    "metrics": metrics_data,
+                    "url": response.url,
+                    "timestamp": datetime.now().isoformat(),
+                }
+            logger.error(
+                "HTTP metrics request failed with status %s for %s",
+                response.status,
+                endpoint,
+            )
+            return {
+                "success": False,
+                "error": f"HTTP {response.status}",
+                "error_type": "http_status",
+                "node_type": node_type,
+                "node_info": node_info,
+                "url": response.url,
+            }
+        except DorisHTTPPolicyError as e:
+            logger.error("Blocked Doris metrics endpoint %s: %s", endpoint, e)
+            return {
+                "success": False,
+                "error": str(e),
+                "error_type": "prohibited_endpoint",
+                "node_type": node_type,
+                "node_info": node_info,
+            }
+        except DorisHTTPResponseTooLarge as e:
+            logger.error("Doris metrics response too large for %s", endpoint)
+            return {
+                "success": False,
+                "error": str(e),
+                "error_type": "response_too_large",
+                "node_type": node_type,
+                "node_info": node_info,
+            }
+        except DorisHTTPRequestError as e:
+            logger.error("Doris metrics request failed for %s: %s", endpoint, 
e)
+            return {
+                "success": False,
+                "error": str(e),
+                "error_type": "request_failed",
+                "node_type": node_type,
+                "node_info": node_info,
+            }
         except Exception as e:
-            logger.error(f"Failed to fetch metrics from {url}: {str(e)}")
+            logger.error("Failed to fetch metrics from %s: %s", endpoint, e)
             return {
                 "success": False,
                 "error": str(e),
+                "error_type": "internal_error",
                 "node_type": node_type,
                 "node_info": node_info,
-                "url": url
             }
     
     def _parse_prometheus_metrics(self, metrics_text: str) -> Dict[str, Any]:
@@ -955,13 +967,12 @@ class DorisMonitoringTools:
         """Get FE monitoring metrics"""
         try:
             db_config = self.connection_manager.config.database
-            fe_url = 
f"http://{db_config.host}:{db_config.fe_http_port}/metrics";
-            
-            fe_result = await self.fetch_metrics_from_url(
-                fe_url, 
-                "fe", 
+            fe_result = await self.fetch_metrics_from_node(
+                "fe",
                 {"host": db_config.host, "port": db_config.fe_http_port}
             )
+            if not fe_result.get("success"):
+                return fe_result
             
             if fe_result.get("success") and priority == "p0":
                 fe_p0_metrics = self._get_metrics_by_type("fe", monitor_type)
@@ -999,13 +1010,25 @@ class DorisMonitoringTools:
         """Get BE monitoring metrics from all BE nodes"""
         try:
             be_nodes = await self.get_be_nodes()
+            if not be_nodes:
+                return [
+                    {
+                        "success": False,
+                        "error": (
+                            "BE HTTP metrics require explicit DORIS_BE_HOSTS "
+                            "configuration"
+                        ),
+                        "error_type": "unconfigured_endpoint",
+                    }
+                ]
             be_results = []
             
             for be_node in be_nodes:
                 if be_node.get("alive") == "true":  # Only get alive BE nodes
-                    be_url = 
f"http://{be_node['host']}:{be_node['http_port']}/metrics"
-                    
-                    be_result = await self.fetch_metrics_from_url(be_url, 
"be", be_node)
+                    be_result = await self.fetch_metrics_from_node("be", 
be_node)
+                    if not be_result.get("success"):
+                        be_results.append(be_result)
+                        continue
                     
                     if be_result.get("success") and priority == "p0":
                         be_p0_metrics = self._get_metrics_by_type("be", 
monitor_type)
@@ -1608,4 +1631,4 @@ class DorisMonitoringTools:
             
             return total_bytes
         except (ValueError, TypeError):
-            return None
\ No newline at end of file
+            return None
diff --git a/test/integration/test_real_doris_transports.py 
b/test/integration/test_real_doris_transports.py
index 2f703fd..0e1f128 100644
--- a/test/integration/test_real_doris_transports.py
+++ b/test/integration/test_real_doris_transports.py
@@ -19,7 +19,10 @@
 
 Set ``DORIS_REAL_INTEGRATION=1`` and provide ``DORIS_REAL_HOST``,
 ``DORIS_REAL_USER``, and ``DORIS_REAL_DATABASE``. ``DORIS_REAL_PORT`` defaults
-to 9030 and ``DORIS_REAL_PASSWORD`` may be empty.
+to 9030 and ``DORIS_REAL_PASSWORD`` may be empty. Set
+``DORIS_REAL_HTTP_INTEGRATION=1`` with ``DORIS_REAL_FE_HTTP_PORT``,
+``DORIS_REAL_BE_HOSTS``, and ``DORIS_REAL_BE_WEBSERVER_PORT`` to exercise the
+configured FE/BE HTTP monitoring boundary through both transports.
 """
 
 from __future__ import annotations
@@ -172,7 +175,7 @@ def _server_environment(
     user: str,
     password: str,
 ) -> dict[str, str]:
-    return {
+    environment = {
         **os.environ,
         "DORIS_HOST": settings.host,
         "DORIS_PORT": str(settings.port),
@@ -190,6 +193,16 @@ def _server_environment(
         "LOG_LEVEL": "ERROR",
         "PYTHONUNBUFFERED": "1",
     }
+    fe_http_port = os.getenv("DORIS_REAL_FE_HTTP_PORT", "").strip()
+    if fe_http_port:
+        environment["DORIS_FE_HTTP_PORT"] = fe_http_port
+    be_hosts = os.getenv("DORIS_REAL_BE_HOSTS", "").strip()
+    if be_hosts:
+        environment["DORIS_BE_HOSTS"] = be_hosts
+    be_http_port = os.getenv("DORIS_REAL_BE_WEBSERVER_PORT", "").strip()
+    if be_http_port:
+        environment["DORIS_BE_WEBSERVER_PORT"] = be_http_port
+    return environment
 
 
 def _free_tcp_port() -> int:
@@ -572,3 +585,65 @@ async def test_real_doris_tool_regression_paths(
         assert recovered_result.is_error is False
         assert recovered_payload["success"] is True
         assert recovered_payload["data"][0]["recovered"] == 1
+
+
[email protected](
+    os.getenv("DORIS_REAL_HTTP_INTEGRATION") != "1",
+    reason="set DORIS_REAL_HTTP_INTEGRATION=1 with FE/BE HTTP endpoints",
+)
[email protected]("transport", ["http", "stdio"])
+async def test_real_doris_configured_monitoring_http_endpoints_and_recovery(
+    transport: str,
+    doris_sandbox: DorisSandbox,
+) -> None:
+    environment = _server_environment(
+        doris_sandbox.settings,
+        user=doris_sandbox.settings.user,
+        password=doris_sandbox.settings.password,
+    )
+    assert environment.get("DORIS_FE_HTTP_PORT")
+    assert environment.get("DORIS_BE_HOSTS")
+    assert environment.get("DORIS_BE_WEBSERVER_PORT")
+
+    async with _transport_client(transport, environment) as client:
+        fe_result = await client.call_tool(
+            "get_monitoring_metrics",
+            {
+                "content_type": "data",
+                "role": "fe",
+                "monitor_type": "all",
+                "priority": "all",
+                "include_raw_metrics": False,
+            },
+        )
+        assert fe_result.is_error is False
+        assert isinstance(fe_result.structured_content, dict)
+        fe_payload = fe_result.structured_content["data"]["fe"]
+        assert fe_payload["success"] is True
+        assert fe_payload["node_type"] == "fe"
+        assert fe_payload["node_info"]["host"] == doris_sandbox.settings.host
+
+        be_result = await client.call_tool(
+            "get_monitoring_metrics",
+            {
+                "content_type": "data",
+                "role": "be",
+                "monitor_type": "all",
+                "priority": "all",
+                "include_raw_metrics": False,
+            },
+        )
+        assert be_result.is_error is False
+        assert isinstance(be_result.structured_content, dict)
+        be_payload = be_result.structured_content["data"]["be"]
+        assert be_payload
+        assert all(node["success"] is True for node in be_payload)
+        assert all(node["node_type"] == "be" for node in be_payload)
+
+        recovered_result, recovered_payload = await _exec_query(
+            client,
+            "SELECT 1 AS recovered",
+        )
+        assert recovered_result.is_error is False
+        assert recovered_payload["success"] is True
+        assert recovered_payload["data"][0]["recovered"] == 1
diff --git a/test/protocol/stdio_capability_server.py 
b/test/protocol/stdio_capability_server.py
index 14adf19..f510a9c 100644
--- a/test/protocol/stdio_capability_server.py
+++ b/test/protocol/stdio_capability_server.py
@@ -41,6 +41,7 @@ from doris_mcp_server.utils.analysis_tools import SQLAnalyzer
 from doris_mcp_server.utils.data_governance_tools import DataGovernanceTools
 from doris_mcp_server.utils.data_quality_tools import DataQualityTools
 from doris_mcp_server.utils.db import QueryResult
+from doris_mcp_server.utils.monitoring_tools import DorisMonitoringTools
 from doris_mcp_server.utils.security_analytics_tools import 
SecurityAnalyticsTools
 
 REQUIRED_EXTENSION = "io.apache.doris/read"
@@ -148,6 +149,27 @@ class RoleConnectionManager:
         return self.connection
 
 
+class SSRFMonitoringConnectionManager:
+    def __init__(self) -> None:
+        self.config = SimpleNamespace(
+            database=SimpleNamespace(
+                host="169.254.169.254",
+                fe_http_port=80,
+                be_hosts=[],
+                be_webserver_port=8040,
+                user="root",
+                password="",
+                http_connect_timeout_seconds=0.2,
+                http_read_timeout_seconds=0.2,
+                http_total_timeout_seconds=0.2,
+                http_max_response_bytes=1024,
+            )
+        )
+
+    async def get_connection(self, session_id: str):
+        raise AssertionError("SSRF policy must reject before database access")
+
+
 class RoleSecurityAnalyticsTools(SecurityAnalyticsTools):
     async def _get_audit_log_data(
         self,
@@ -204,6 +226,9 @@ class OneToolManager:
             ),
         )
         self.role_analyzer = 
RoleSecurityAnalyticsTools(RoleConnectionManager())
+        self.monitoring_tools = DorisMonitoringTools(
+            SSRFMonitoringConnectionManager()
+        )
 
     async def list_tools(self) -> list[Tool]:
         return [
@@ -259,6 +284,17 @@ class OneToolManager:
                     "required": ["table_name", "columns"],
                 },
             ),
+            Tool(
+                name="get_monitoring_metrics",
+                description="Exercise the production Doris HTTP SSRF 
boundary.",
+                input_schema={
+                    "type": "object",
+                    "properties": {
+                        "content_type": {"type": "string"},
+                        "role": {"type": "string"},
+                    },
+                },
+            ),
         ]
 
     async def call_tool(self, name: str, arguments: dict) -> str:
@@ -292,6 +328,15 @@ class OneToolManager:
             return json.dumps(
                 await self.freshness_router._analyze_columns_tool(arguments)
             )
+        if name == "get_monitoring_metrics":
+            return json.dumps(
+                await self.monitoring_tools.get_monitoring_metrics(
+                    role=arguments.get("role", "fe"),
+                    priority="all",
+                    info_only=arguments.get("content_type") == "definitions",
+                    include_raw_metrics=False,
+                )
+            )
         return "{}"
 
 
diff --git a/test/protocol/test_mcp_v2_protocol.py 
b/test/protocol/test_mcp_v2_protocol.py
index 1bc3002..e476f0a 100644
--- a/test/protocol/test_mcp_v2_protocol.py
+++ b/test/protocol/test_mcp_v2_protocol.py
@@ -882,6 +882,49 @@ async def 
test_http_rejects_injected_sql_identifier_and_recovers():
         assert recovered.json()["result"]["isError"] is False
 
 
[email protected]
+async def test_http_monitoring_rejects_metadata_endpoint_and_recovers():
+    app = create_test_server(
+        tools_manager=ProfileToolManager(),
+    ).streamable_http_app(
+        json_response=True,
+        stateless_http=True,
+        host="127.0.0.1",
+        transport_security=create_transport_security("127.0.0.1"),
+    )
+
+    async with (
+        app.router.lifespan_context(app),
+        httpx2.ASGITransport(app) as transport,
+        httpx2.AsyncClient(
+            transport=transport,
+            base_url="http://127.0.0.1:3000";,
+        ) as client,
+    ):
+        rejected = await client.post(
+            "/mcp",
+            json=modern_tool_request(
+                1,
+                "get_monitoring_metrics",
+                {"content_type": "data", "role": "fe"},
+            ),
+            headers=modern_tool_headers("get_monitoring_metrics"),
+        )
+        assert rejected.status_code == 200
+        assert rejected.json()["result"]["isError"] is False
+        fe_result = 
rejected.json()["result"]["structuredContent"]["data"]["fe"]
+        assert fe_result["success"] is False
+        assert fe_result["error_type"] == "prohibited_endpoint"
+
+        recovered = await client.post(
+            "/mcp",
+            json=modern_tool_request(2, "echo", {}),
+            headers=modern_tool_headers("echo"),
+        )
+        assert recovered.status_code == 200
+        assert recovered.json()["result"]["isError"] is False
+
+
 @pytest.mark.asyncio
 async def test_stdio_validates_capabilities_versions_and_process_survival():
     server_script = Path(__file__).with_name("stdio_capability_server.py")
@@ -934,6 +977,7 @@ async def 
test_stdio_validates_capabilities_versions_and_process_survival():
             "monitor_data_freshness",
             "analyze_data_access_patterns",
             "analyze_columns",
+            "get_monitoring_metrics",
         ]
         secret = "stdio-secret-sec-016"
         error_result = await capable.call_tool(
@@ -961,6 +1005,7 @@ async def 
test_stdio_validates_capabilities_versions_and_process_survival():
             "monitor_data_freshness",
             "analyze_data_access_patterns",
             "analyze_columns",
+            "get_monitoring_metrics",
         ]
         legacy_error = await legacy.read_resource("doris://table/missing")
         assert (
@@ -1123,6 +1168,31 @@ async def 
test_stdio_rejects_injected_sql_identifier_and_recovers():
         assert recovered.is_error is False
 
 
[email protected]
+async def test_stdio_monitoring_rejects_metadata_endpoint_and_recovers():
+    server_script = Path(__file__).with_name("stdio_capability_server.py")
+    server_params = StdioServerParameters(
+        command=sys.executable,
+        args=[str(server_script)],
+    )
+
+    async with Client(
+        stdio_client(server_params),
+        extensions=[advertise(REQUIRED_EXTENSION)],
+    ) as modern:
+        rejected = await modern.call_tool(
+            "get_monitoring_metrics",
+            {"content_type": "data", "role": "fe"},
+        )
+        assert rejected.is_error is False
+        fe_result = rejected.structured_content["data"]["fe"]
+        assert fe_result["success"] is False
+        assert fe_result["error_type"] == "prohibited_endpoint"
+
+        recovered = await modern.call_tool("echo", {})
+        assert recovered.is_error is False
+
+
 @pytest.mark.asyncio
 async def test_stdio_prompt_errors_are_typed_and_process_survives():
     server_script = Path(__file__).with_name("stdio_capability_server.py")
diff --git a/test/security/test_doris_http_ssrf.py 
b/test/security/test_doris_http_ssrf.py
new file mode 100644
index 0000000..13978f1
--- /dev/null
+++ b/test/security/test_doris_http_ssrf.py
@@ -0,0 +1,350 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""SSRF, timeout, and response-boundary tests for Doris FE/BE HTTP."""
+
+from __future__ import annotations
+
+import asyncio
+from contextlib import asynccontextmanager, suppress
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+
+from doris_mcp_server.utils import doris_http_client as 
doris_http_client_module
+from doris_mcp_server.utils.analysis_tools import SQLAnalyzer
+from doris_mcp_server.utils.config import DorisConfig
+from doris_mcp_server.utils.doris_http_client import (
+    MAX_RESPONSE_BYTES,
+    MAX_TIMEOUT_SECONDS,
+    DorisHTTPClient,
+    DorisHTTPPolicyError,
+    DorisHTTPRequestError,
+    DorisHTTPResponseTooLarge,
+)
+from doris_mcp_server.utils.monitoring_tools import DorisMonitoringTools
+
+
+def _database_config(
+    *,
+    host: str,
+    fe_http_port: int,
+    be_hosts: list[str] | None = None,
+    be_webserver_port: int = 8040,
+    max_response_bytes: int = 4096,
+    connect_timeout: float = 1.0,
+    read_timeout: float = 1.0,
+    total_timeout: float = 1.0,
+) -> SimpleNamespace:
+    return SimpleNamespace(
+        host=host,
+        fe_http_port=fe_http_port,
+        be_hosts=be_hosts or [],
+        be_webserver_port=be_webserver_port,
+        user="root",
+        password="",
+        http_connect_timeout_seconds=connect_timeout,
+        http_read_timeout_seconds=read_timeout,
+        http_total_timeout_seconds=total_timeout,
+        http_max_response_bytes=max_response_bytes,
+    )
+
+
+def _manager(database_config: SimpleNamespace) -> SimpleNamespace:
+    return SimpleNamespace(
+        config=SimpleNamespace(database=database_config),
+        get_connection=AsyncMock(
+            side_effect=AssertionError("HTTP monitoring must not discover SQL 
nodes")
+        ),
+    )
+
+
+@asynccontextmanager
+async def _http_server(
+    *,
+    status: int = 200,
+    body: bytes = b"",
+    headers: dict[str, str] | None = None,
+    delay_seconds: float = 0,
+):
+    response_headers = {
+        "Content-Length": str(len(body)),
+        "Connection": "close",
+        **(headers or {}),
+    }
+
+    async def handler(
+        reader: asyncio.StreamReader,
+        writer: asyncio.StreamWriter,
+    ) -> None:
+        try:
+            await reader.readuntil(b"\r\n\r\n")
+            if delay_seconds:
+                await asyncio.sleep(delay_seconds)
+            reason = "OK" if status == 200 else "Found"
+            head = (
+                f"HTTP/1.1 {status} {reason}\r\n"
+                + "".join(
+                    f"{key}: {value}\r\n" for key, value in 
response_headers.items()
+                )
+                + "\r\n"
+            ).encode()
+            writer.write(head + body)
+            await writer.drain()
+        except (BrokenPipeError, ConnectionResetError, 
asyncio.IncompleteReadError):
+            pass
+        finally:
+            writer.close()
+            with suppress(Exception):
+                await writer.wait_closed()
+
+    server = await asyncio.start_server(handler, "127.0.0.1", 0)
+    try:
+        port = int(server.sockets[0].getsockname()[1])
+        yield port
+    finally:
+        server.close()
+        await server.wait_closed()
+
+
+def _http_client(
+    host: str,
+    port: int,
+    *,
+    max_response_bytes: int = 4096,
+    connect_timeout: float = 1,
+    read_timeout: float = 1,
+    total_timeout: float = 1,
+) -> DorisHTTPClient:
+    return DorisHTTPClient(
+        user="root",
+        password="",
+        allowed_endpoints={"fe": {(host, port)}, "be": set()},
+        connect_timeout_seconds=connect_timeout,
+        read_timeout_seconds=read_timeout,
+        total_timeout_seconds=total_timeout,
+        max_response_bytes=max_response_bytes,
+    )
+
+
[email protected](
+    "host",
+    [
+        "169.254.169.254",
+        "169.254.170.2",
+        "100.100.100.200",
+        "fd00:ec2::254",
+        "::ffff:100.100.100.200",
+        "metadata.google.internal",
+    ],
+)
+async def test_configured_metadata_and_link_local_endpoints_are_rejected(
+    host: str,
+) -> None:
+    client = _http_client(host, 80)
+    with pytest.raises(DorisHTTPPolicyError, match="prohibited"):
+        await client.get(role="fe", host=host, port=80, path="/metrics")
+
+
+async def test_unconfigured_endpoint_is_rejected_before_network_access() -> 
None:
+    client = _http_client("127.0.0.1", 8030)
+    with pytest.raises(DorisHTTPPolicyError, match="explicitly configured"):
+        await client.get(
+            role="fe",
+            host="127.0.0.2",
+            port=8030,
+            path="/metrics",
+        )
+
+
+async def test_hostname_resolving_to_link_local_is_rejected(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    fake_loop = SimpleNamespace(
+        getaddrinfo=AsyncMock(
+            return_value=[
+                (
+                    2,
+                    1,
+                    6,
+                    "",
+                    ("169.254.169.254", 8030),
+                )
+            ]
+        )
+    )
+    monkeypatch.setattr(
+        doris_http_client_module.asyncio,
+        "get_running_loop",
+        lambda: fake_loop,
+    )
+    client = _http_client("doris.internal", 8030)
+    with pytest.raises(DorisHTTPPolicyError, match="prohibited"):
+        await client._resolve_addresses("doris.internal", 8030)
+    fake_loop.getaddrinfo.assert_awaited_once()
+
+
+async def test_redirects_are_not_followed() -> None:
+    async with _http_server(
+        status=302,
+        headers={"Location": "http://169.254.169.254/latest/meta-data"},
+    ) as port:
+        response = await _http_client("127.0.0.1", port).get(
+            role="fe",
+            host="127.0.0.1",
+            port=port,
+            path="/metrics",
+        )
+    assert response.status == 302
+    assert response.body == b""
+
+
+async def test_response_content_length_is_bounded_before_body_read() -> None:
+    async with _http_server(body=b"x" * 128) as port:
+        with pytest.raises(DorisHTTPResponseTooLarge):
+            await _http_client(
+                "127.0.0.1",
+                port,
+                max_response_bytes=32,
+            ).get(
+                role="fe",
+                host="127.0.0.1",
+                port=port,
+                path="/metrics",
+            )
+
+
+async def test_read_and_total_timeouts_are_enforced() -> None:
+    async with _http_server(body=b"ok", delay_seconds=0.2) as port:
+        with pytest.raises(DorisHTTPRequestError, match="timed out"):
+            await _http_client(
+                "127.0.0.1",
+                port,
+                connect_timeout=0.05,
+                read_timeout=0.05,
+                total_timeout=0.05,
+            ).get(
+                role="fe",
+                host="127.0.0.1",
+                port=port,
+                path="/metrics",
+            )
+
+
+async def test_monitoring_does_not_discover_be_http_nodes_from_sql() -> None:
+    manager = _manager(
+        _database_config(
+            host="127.0.0.1",
+            fe_http_port=8030,
+            be_hosts=[],
+        )
+    )
+    tools = DorisMonitoringTools(manager)
+    assert await tools.get_be_nodes() == []
+    manager.get_connection.assert_not_awaited()
+
+    results = await tools._get_be_metrics("all", "p0", "prometheus", False)
+    assert results == [
+        {
+            "success": False,
+            "error": ("BE HTTP metrics require explicit DORIS_BE_HOSTS 
configuration"),
+            "error_type": "unconfigured_endpoint",
+        }
+    ]
+    manager.get_connection.assert_not_awaited()
+
+
+async def test_monitoring_rejects_configured_metadata_without_network() -> 
None:
+    manager = _manager(
+        _database_config(
+            host="169.254.169.254",
+            fe_http_port=80,
+        )
+    )
+    result = await DorisMonitoringTools(manager)._get_fe_metrics(
+        "all",
+        "p0",
+        "prometheus",
+        False,
+    )
+    assert result["success"] is False
+    assert result["error_type"] == "prohibited_endpoint"
+
+
+async def test_monitoring_fetches_configured_loopback_metrics() -> None:
+    metrics = b"doris_fe_query_total 7\n"
+    async with _http_server(
+        body=metrics,
+        headers={"Content-Type": "text/plain"},
+    ) as port:
+        manager = _manager(
+            _database_config(
+                host="127.0.0.1",
+                fe_http_port=port,
+            )
+        )
+        result = await DorisMonitoringTools(manager).get_monitoring_metrics(
+            role="fe",
+            priority="all",
+            include_raw_metrics=True,
+        )
+    assert result["success"] is True
+    assert result["data"]["fe"]["success"] is True
+    assert result["data"]["fe"]["metrics"]["doris_fe_query_total"] == 7
+
+
+async def test_analysis_fe_http_path_uses_same_policy() -> None:
+    manager = _manager(
+        _database_config(
+            host="metadata.google.internal",
+            fe_http_port=80,
+        )
+    )
+    analyzer = SQLAnalyzer(manager)
+    assert await analyzer._get_query_id_by_trace_id("trace-id") is None
+    result = await analyzer.get_table_data_size(db_name="db", 
table_name="table")
+    assert result["success"] is False
+    assert "prohibited" in result["error"].lower()
+
+
+def test_http_safety_configuration_loads_and_has_runtime_hard_caps(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    monkeypatch.setenv("DORIS_HTTP_CONNECT_TIMEOUT_SECONDS", "2")
+    monkeypatch.setenv("DORIS_HTTP_READ_TIMEOUT_SECONDS", "4")
+    monkeypatch.setenv("DORIS_HTTP_TOTAL_TIMEOUT_SECONDS", "8")
+    monkeypatch.setenv("DORIS_HTTP_MAX_RESPONSE_BYTES", "8192")
+    config = DorisConfig.from_env()
+    assert config.database.http_connect_timeout_seconds == 2
+    assert config.database.http_read_timeout_seconds == 4
+    assert config.database.http_total_timeout_seconds == 8
+    assert config.database.http_max_response_bytes == 8192
+    assert not [error for error in config.validate() if 
error.startswith("Doris HTTP")]
+
+    database_config = _database_config(
+        host="127.0.0.1",
+        fe_http_port=8030,
+        connect_timeout=999,
+        read_timeout=999,
+        total_timeout=999,
+        max_response_bytes=MAX_RESPONSE_BYTES * 2,
+    )
+    client = DorisHTTPClient.from_database_config(database_config)
+    assert client.connect_timeout_seconds == MAX_TIMEOUT_SECONDS
+    assert client.read_timeout_seconds == MAX_TIMEOUT_SECONDS
+    assert client.total_timeout_seconds == MAX_TIMEOUT_SECONDS
+    assert client.max_response_bytes == MAX_RESPONSE_BYTES


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to