blob: 41c0a2688852016b80f6b41d69d1cc408dcad134 [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.
"""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
MAX_CONFIGURED_HOSTS = 16
_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 int(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 and not candidate.is_loopback)
)
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)
def configured_fe_http_hosts(database_config: Any) -> tuple[str, ...]:
"""Return ordered FE HTTP candidates without widening the allowlist."""
configured_hosts = getattr(database_config, "fe_http_hosts", []) or []
if not isinstance(configured_hosts, list) or any(
not isinstance(host, str) for host in configured_hosts
):
raise DorisHTTPPolicyError("Configured Doris FE HTTP hosts are invalid")
if len(configured_hosts) > MAX_CONFIGURED_HOSTS:
raise DorisHTTPPolicyError("Too many Doris FE HTTP hosts are configured")
configured_host = getattr(database_config, "fe_http_host", "")
if configured_hosts:
candidates = (
[configured_host, *configured_hosts]
if configured_host
else configured_hosts
)
else:
if configured_host:
candidates = [configured_host]
else:
sql_hosts = getattr(database_config, "hosts", []) or []
if not isinstance(sql_hosts, list) or any(
not isinstance(host, str) for host in sql_hosts
):
raise DorisHTTPPolicyError("Configured Doris FE SQL hosts are invalid")
if len(sql_hosts) > MAX_CONFIGURED_HOSTS:
raise DorisHTTPPolicyError("Too many Doris FE SQL hosts are configured")
candidates = sql_hosts or [database_config.host]
return tuple(dict.fromkeys(_normalize_host(host) for host in candidates))
def database_config_for_request(connection_manager: Any) -> Any:
"""Resolve a token-bound endpoint config with legacy-manager fallback."""
from .security import get_current_auth_context
auth_context = get_current_auth_context()
if auth_context is not None and auth_context.auth_method == "doris_oauth":
raise DorisHTTPPolicyError(
"Doris OAuth credentials are unavailable for HTTP Basic requests"
)
resolver = getattr(
connection_manager,
"get_database_config_for_auth_context",
None,
)
if callable(resolver):
return resolver()
return connection_manager.config.database
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_hosts = configured_fe_http_hosts(database_config)
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": {(host, fe_port) for host in fe_hosts},
"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_first_available(
self,
*,
role: Literal["fe", "be"],
hosts: list[str] | tuple[str, ...],
port: int,
path: str,
params: Mapping[str, str] | None = None,
headers: Mapping[str, str] | None = None,
) -> DorisHTTPResponse:
"""Try configured endpoints in order on transport or gateway failure."""
candidates = tuple(dict.fromkeys(_normalize_host(host) for host in hosts))
if not candidates:
raise DorisHTTPPolicyError("No Doris HTTP endpoint is configured")
last_error: DorisHTTPRequestError | None = None
last_response: DorisHTTPResponse | None = None
for host in candidates:
try:
response = await self.get(
role=role,
host=host,
port=port,
path=path,
params=params,
headers=headers,
)
except DorisHTTPRequestError as exc:
last_error = exc
continue
if response.status not in {502, 503, 504}:
return response
last_response = response
if last_response is not None:
return last_response
if last_error is not None:
raise last_error
raise DorisHTTPRequestError("All configured Doris HTTP endpoints failed")
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, ""))
request_headers = dict(headers or {})
request_headers["Authorization"] = aiohttp.encode_basic_auth(
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,
allow_redirects=False,
headers=request_headers,
) 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, ...]:
addresses: 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)