diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b5363d..06aa1e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,9 @@ under **Unreleased** until a new version is selected and published. ### Fixed +- Rejected non-finite Doris HTTP timeout configuration and restored safe + timeout defaults at the client boundary, preventing NaN values from + disabling the total request deadline. - Preserved fail-closed distribution provenance across version parsing, brandless FE/BE component fallback, and certification evidence validation, preventing unknown or mismatched brands from inheriting Apache Doris diff --git a/doris_mcp_server/utils/config.py b/doris_mcp_server/utils/config.py index 7646365..a37b0dd 100644 --- a/doris_mcp_server/utils/config.py +++ b/doris_mcp_server/utils/config.py @@ -22,6 +22,7 @@ import json import logging +import math import multiprocessing import os import secrets @@ -2252,14 +2253,23 @@ def validate(self) -> list[str]: 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 ( + not math.isfinite(self.database.http_connect_timeout_seconds) + or self.database.http_connect_timeout_seconds <= 0 + ): + errors.append("Doris HTTP connect timeout must be finite and greater than 0") - if self.database.http_read_timeout_seconds <= 0: - errors.append("Doris HTTP read timeout must be greater than 0") + if ( + not math.isfinite(self.database.http_read_timeout_seconds) + or self.database.http_read_timeout_seconds <= 0 + ): + errors.append("Doris HTTP read timeout must be finite and greater than 0") - if self.database.http_total_timeout_seconds <= 0: - errors.append("Doris HTTP total timeout must be greater than 0") + if ( + not math.isfinite(self.database.http_total_timeout_seconds) + or self.database.http_total_timeout_seconds <= 0 + ): + errors.append("Doris HTTP total timeout must be finite and greater than 0") if self.database.http_max_response_bytes <= 0: errors.append("Doris HTTP response byte limit must be greater than 0") diff --git a/doris_mcp_server/utils/doris_http_client.py b/doris_mcp_server/utils/doris_http_client.py index 41c0a26..2e89021 100644 --- a/doris_mcp_server/utils/doris_http_client.py +++ b/doris_mcp_server/utils/doris_http_client.py @@ -20,6 +20,7 @@ import asyncio import ipaddress +import math import socket from collections.abc import Mapping from dataclasses import dataclass @@ -168,7 +169,7 @@ def _bounded_float(value: Any, default: float) -> float: parsed = float(value) except (TypeError, ValueError): return default - if parsed <= 0: + if not math.isfinite(parsed) or parsed <= 0: return default return min(parsed, MAX_TIMEOUT_SECONDS) diff --git a/test/security/test_doris_http_ssrf.py b/test/security/test_doris_http_ssrf.py index 20977b0..7824570 100644 --- a/test/security/test_doris_http_ssrf.py +++ b/test/security/test_doris_http_ssrf.py @@ -300,6 +300,61 @@ async def test_read_and_total_timeouts_are_enforced() -> None: ) +@pytest.mark.parametrize( + ("setting", "label"), + [ + ("DORIS_HTTP_CONNECT_TIMEOUT_SECONDS", "connect"), + ("DORIS_HTTP_READ_TIMEOUT_SECONDS", "read"), + ("DORIS_HTTP_TOTAL_TIMEOUT_SECONDS", "total"), + ], +) +@pytest.mark.parametrize("value", ["nan", "inf", "-inf", "1e309"]) +def test_http_timeout_configuration_rejects_non_finite_values( + monkeypatch: pytest.MonkeyPatch, + setting: str, + label: str, + value: str, +) -> None: + monkeypatch.setenv(setting, value) + config = DorisConfig.from_env() + assert any( + error.startswith(f"Doris HTTP {label} timeout") + for error in config.validate() + ) + + +@pytest.mark.parametrize("value", [float("nan"), float("inf"), float("-inf")]) +def test_http_client_uses_safe_defaults_for_non_finite_timeouts(value: float) -> None: + client = _http_client( + "127.0.0.1", + 8030, + connect_timeout=value, + read_timeout=value, + total_timeout=value, + ) + assert client.connect_timeout_seconds == doris_http_client_module.DEFAULT_CONNECT_TIMEOUT_SECONDS + assert client.read_timeout_seconds == doris_http_client_module.DEFAULT_READ_TIMEOUT_SECONDS + assert client.total_timeout_seconds == doris_http_client_module.DEFAULT_TOTAL_TIMEOUT_SECONDS + + +async def test_nan_total_timeout_cannot_disable_the_http_deadline( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(doris_http_client_module, "DEFAULT_TOTAL_TIMEOUT_SECONDS", 0.05) + async with _http_server(body=b"ok", delay_seconds=0.2) as port: + client = _http_client("127.0.0.1", port, total_timeout=float("nan")) + with pytest.raises(DorisHTTPRequestError, match="timed out"): + await asyncio.wait_for( + client.get( + role="fe", + host="127.0.0.1", + port=port, + path="/metrics", + ), + timeout=1, + ) + + async def test_monitoring_does_not_discover_be_http_nodes_from_sql() -> None: manager = _manager( _database_config(