blob: 20977b08a9a2f671643d83dc959ebdccf4518938 [file]
# 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,
database_config_for_request,
)
from doris_mcp_server.utils.monitoring_tools import DorisMonitoringTools
from doris_mcp_server.utils.security import (
AuthContext,
reset_auth_context,
set_current_auth_context,
)
def _database_config(
*,
host: str,
fe_http_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_host=fe_http_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,
)
def test_http_config_fails_closed_for_doris_oauth_route() -> None:
manager = _manager(
_database_config(
host="127.0.0.1",
fe_http_port=8030,
)
)
context_token = set_current_auth_context(
AuthContext(
auth_method="doris_oauth",
doris_user="analyst",
)
)
try:
with pytest.raises(
DorisHTTPPolicyError,
match="credentials are unavailable",
):
database_config_for_request(manager)
finally:
reset_auth_context(context_token)
@pytest.mark.parametrize(
"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_hostname_resolving_to_ipv4_and_ipv6_loopback_is_allowed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_loop = SimpleNamespace(
getaddrinfo=AsyncMock(
return_value=[
(10, 1, 6, "", ("::1", 8030, 0, 0)),
(2, 1, 6, "", ("127.0.0.1", 8030)),
]
)
)
monkeypatch.setattr(
doris_http_client_module.asyncio,
"get_running_loop",
lambda: fake_loop,
)
client = _http_client("localhost", 8030)
assert await client._resolve_addresses("localhost", 8030) == (
"::1",
"127.0.0.1",
)
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="sql.invalid",
fe_http_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"]["node_info"]["host"] == "127.0.0.1"
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="sql.invalid",
fe_http_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")
monkeypatch.setenv("DORIS_HOST", "sql.internal")
monkeypatch.setenv("DORIS_FE_HTTP_HOST", "fe-http.internal")
config = DorisConfig.from_env()
assert config.database.host == "sql.internal"
assert config.database.fe_http_host == "fe-http.internal"
assert config.to_dict()["database"]["fe_http_host"] == "fe-http.internal"
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="sql.internal",
fe_http_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
assert ("127.0.0.1", 8030) in client.allowed_endpoints["fe"]
assert ("sql.internal", 8030) not in client.allowed_endpoints["fe"]
fallback_config = _database_config(
host="127.0.0.1",
fe_http_port=8030,
)
fallback_client = DorisHTTPClient.from_database_config(fallback_config)
assert ("127.0.0.1", 8030) in fallback_client.allowed_endpoints["fe"]