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 7d0ac3a fix: reject unauthenticated non-loopback binds (#114)
7d0ac3a is described below
commit 7d0ac3a0845e3e73b6f926faadc3716f6971404b
Author: Yijia Su <[email protected]>
AuthorDate: Wed Jul 29 20:11:41 2026 +0800
fix: reject unauthenticated non-loopback binds (#114)
---
.env.example | 11 +-
CHANGELOG.md | 2 +
README.md | 13 +-
docker-compose.yml | 1 +
doris_mcp_server/main.py | 20 ++-
doris_mcp_server/utils/config.py | 69 ++++++++-
start_server.sh | 2 +-
test/security/test_http_bind_auth_policy.py | 232 ++++++++++++++++++++++++++++
8 files changed, 338 insertions(+), 12 deletions(-)
diff --git a/.env.example b/.env.example
index 2b37f3d..dab0d15 100644
--- a/.env.example
+++ b/.env.example
@@ -55,7 +55,9 @@ DORIS_MAX_CONNECTION_AGE=3600
# Independent Authentication Switches - NEW DESIGN!
# Each authentication method can be enabled/disabled independently
# Any enabled method that succeeds will allow access
-# If all methods are disabled, anonymous access is allowed
+# If all methods are disabled, anonymous HTTP access is limited to loopback
+# Non-loopback HTTP without authentication is rejected before startup
+ALLOW_UNAUTHENTICATED_NON_LOOPBACK=false
# Legacy configuration - kept for backward compatibility
# AUTH_TYPE is now deprecated - use individual switches above
@@ -444,7 +446,7 @@ TEMP_FILES_DIR=tmp
# 2. Security Best Practices:
# - NEW: Enable individual authentication methods using ENABLE_TOKEN_AUTH,
ENABLE_JWT_AUTH, ENABLE_OAUTH_AUTH
-# - When all methods are disabled, ALL requests are allowed with anonymous
access
+# - When all methods are disabled, anonymous HTTP access is limited to
loopback
# - Authentication methods work independently - any one succeeding allows
access
# - Token Auth: Configure deployment-specific TOKEN_<ID> values before
enabling it
# - JWT Auth: Change JWT_SECRET_KEY and JWT_REFRESH_SECRET_KEY in production
@@ -485,9 +487,10 @@ TEMP_FILES_DIR=tmp
# - ENABLE_TOKEN_AUTH=false (default): Disable token authentication
# - ENABLE_JWT_AUTH=false (default): Disable JWT authentication
# - ENABLE_OAUTH_AUTH=false (default): Disable OAuth authentication
-# - When all methods are disabled, no authentication is required (anonymous
access)
+# - When all methods are disabled, only loopback HTTP may use anonymous
access
# - When multiple methods are enabled, any one succeeding allows access
-# - Recommended for development/testing: all false, production: enable
needed methods
+# - Recommended for local development/testing: all false with a loopback
bind
+# - Non-loopback HTTP requires authentication; do not enable the dangerous
override in production
#
# Token Authentication (ENABLE_TOKEN_AUTH=true) - Recommended for most use
cases:
# - Simple and secure token-based authentication
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6f50783..70f4daa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -52,6 +52,8 @@ under **Unreleased** until a new version is selected and
published.
management endpoints now accept admin credentials only in headers.
- Removed shipped static credentials and the legacy default secret; static and
management token modes now require explicit high-entropy credentials.
+- Refused unauthenticated HTTP startup on non-loopback bind addresses unless
+ the operator sets an explicit dangerous override.
- Released Doris connections on SQL profile, data freshness, and access
analysis paths.
- Improved Doris 4 role metadata compatibility and query recovery behavior.
diff --git a/README.md b/README.md
index 36fc9bc..6523ce4 100644
--- a/README.md
+++ b/README.md
@@ -483,9 +483,10 @@ transports, but does not create an HTTP protocol session.
* The current Host/Origin policy does not provide an operator-configured public
allowlist. Public hostnames and reverse proxies that rewrite Host or Origin
are therefore not a supported deployment shape yet.
-* Do not expose an unauthenticated listener on a non-loopback interface. The
- current release does not reject every unsafe non-loopback configuration at
- startup, so a successful startup is not proof that the deployment is safe.
+* HTTP startup fails when the bind host is not loopback and no authentication
+ method is enabled. `ALLOW_UNAUTHENTICATED_NON_LOOPBACK=true` is an explicit
+ dangerous override for isolated testing only; do not use it as a deployment
+ shortcut.
* Stateless MCP requests do not require sticky HTTP sessions and can use
multiple workers. Authentication modes can have stricter limits:
Doris-backed OAuth stores tokens and per-user pools in process memory and
@@ -1050,9 +1051,11 @@ Streamable HTTP mode requires you to run the MCP server
independently first, and
1. **Configure `.env`:** Ensure your database credentials and any other
necessary settings are correctly configured in the `.env` file within the
project directory.
2. **Start the Server:** Run the server from your terminal in the project's
root directory:
```bash
- MCP_HOST=127.0.0.1 ./start_server.sh
+ ./start_server.sh
```
- This script reads the `.env` file and starts the FastAPI server with
Streamable HTTP support. Keep the listener on loopback unless the non-loopback
deployment has been separately secured and validated.
+ This script reads the `.env` file and starts the Streamable HTTP server on
+ `127.0.0.1` by default. A non-loopback listener requires at least one
+ authentication method.
3. **Configure Cursor:** Add an entry like the following to your Cursor MCP
configuration, pointing to the running server's Streamable HTTP endpoint:
```json
diff --git a/docker-compose.yml b/docker-compose.yml
index 4127063..c9cbed6 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -40,6 +40,7 @@ services:
- DORIS_MAX_CONNECTIONS=20
# Security configuration
+ - MCP_HOST=0.0.0.0
- ENABLE_TOKEN_AUTH=true
- TOKEN_ADMIN=${MCP_STATIC_TOKEN:?Set MCP_STATIC_TOKEN to a securely
generated value of at least 32 characters}
- MAX_RESULT_ROWS=10000
diff --git a/doris_mcp_server/main.py b/doris_mcp_server/main.py
index f5faae3..2d8beaf 100644
--- a/doris_mcp_server/main.py
+++ b/doris_mcp_server/main.py
@@ -38,6 +38,7 @@ from .utils.config import (
_mark_source,
get_effective_auth_config,
normalize_effective_auth_config,
+ validate_http_bind_auth_policy,
)
from .utils.db import DorisConnectionManager
from .utils.security import DorisSecurityManager
@@ -68,6 +69,9 @@ def _multiworker_environment(
"SERVER_NAME": config.server_name,
"TRANSPORT": "http",
"WORKERS": str(workers),
+ "ALLOW_UNAUTHENTICATED_NON_LOOPBACK": str(
+ config.security.allow_unauthenticated_non_loopback
+ ).lower(),
}
@@ -162,8 +166,22 @@ class DorisServer:
- async def start_http(self, host: str = os.getenv("SERVER_HOST",
_default_config.database.host), port: int = os.getenv("SERVER_PORT",
_default_config.server_port), workers: int = 1):
+ async def start_http(self, host: str = os.getenv("SERVER_HOST",
_default_config.server_host), port: int = os.getenv("SERVER_PORT",
_default_config.server_port), workers: int = 1):
"""Start Streamable HTTP transport mode with workers support"""
+ effective_auth = get_effective_auth_config(self.config)
+ bind_warning = validate_http_bind_auth_policy(
+ transport="http",
+ host=host,
+ auth_methods=effective_auth.auth_methods,
+ allow_unauthenticated_non_loopback=(
+ self.config.security.allow_unauthenticated_non_loopback
+ ),
+ )
+ if (
+ bind_warning
+ and bind_warning not in effective_auth.auth_config_warnings
+ ):
+ self.logger.warning(bind_warning)
self.logger.info(f"Starting Doris MCP Server (Streamable HTTP mode) -
{host}:{port}, workers: {workers}")
try:
diff --git a/doris_mcp_server/utils/config.py b/doris_mcp_server/utils/config.py
index d2af765..806b2cf 100644
--- a/doris_mcp_server/utils/config.py
+++ b/doris_mcp_server/utils/config.py
@@ -25,6 +25,7 @@ import logging
import multiprocessing
import os
from dataclasses import dataclass, field
+from ipaddress import ip_address
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
@@ -174,6 +175,46 @@ def _is_loopback_url(url: str) -> bool:
return (parsed.hostname or "").lower() in {"127.0.0.1", "localhost", "::1"}
+def _is_loopback_bind_host(host: str) -> bool:
+ """Return whether a bind host is explicitly confined to loopback."""
+ normalized = str(host or "").strip().lower()
+ if normalized in {"localhost", "localhost."}:
+ return True
+ if normalized.startswith("[") and normalized.endswith("]"):
+ normalized = normalized[1:-1]
+ try:
+ return ip_address(normalized).is_loopback
+ except ValueError:
+ return False
+
+
+def validate_http_bind_auth_policy(
+ *,
+ transport: str,
+ host: str,
+ auth_methods: tuple[str, ...],
+ allow_unauthenticated_non_loopback: bool,
+) -> str | None:
+ """Reject unauthenticated HTTP exposure outside explicit loopback binds."""
+ if str(transport).strip().lower() != "http":
+ return None
+ if auth_methods or _is_loopback_bind_host(host):
+ return None
+
+ bind_host = str(host or "").strip() or "<empty>"
+ if allow_unauthenticated_non_loopback:
+ return (
+ "DANGEROUS: ALLOW_UNAUTHENTICATED_NON_LOOPBACK=true permits "
+ f"unauthenticated HTTP exposure on non-loopback host '{bind_host}'"
+ )
+
+ raise AuthConfigError(
+ f"Refusing unauthenticated HTTP bind to non-loopback host
'{bind_host}'. "
+ "Enable an authentication method or bind to 127.0.0.1, localhost, or
::1. "
+ "ALLOW_UNAUTHENTICATED_NON_LOOPBACK=true is an explicit dangerous
override."
+ )
+
+
def _ensure_source_maps(config: Any) -> None:
if not hasattr(config, "_explicit_sources"):
config._explicit_sources = {}
@@ -237,6 +278,7 @@ class SecurityConfig:
enable_jwt_auth: bool = False # Enable JWT authentication (default:
disabled)
enable_oauth_auth: bool = False # Enable OAuth 2.0/OIDC authentication
(default: disabled)
enable_doris_oauth_auth: bool = False # Enable Doris-backed OAuth
(default: disabled)
+ allow_unauthenticated_non_loopback: bool = False
doris_oauth_base_url: str = "" # Public base URL for future Doris OAuth
metadata
doris_oauth_db_tools_enabled: bool = False
doris_oauth_db_tool_allowlist: list[str] = field(
@@ -643,6 +685,11 @@ class DorisConfig:
if "ENABLE_DORIS_OAUTH_AUTH" in os.environ:
config.security.enable_doris_oauth_auth =
_str_to_bool(os.getenv("ENABLE_DORIS_OAUTH_AUTH"))
_mark_source(config, "enable_doris_oauth_auth", "env")
+ if "ALLOW_UNAUTHENTICATED_NON_LOOPBACK" in os.environ:
+ config.security.allow_unauthenticated_non_loopback = _str_to_bool(
+ os.getenv("ALLOW_UNAUTHENTICATED_NON_LOOPBACK")
+ )
+ _mark_source(config, "allow_unauthenticated_non_loopback", "env")
if "DORIS_OAUTH_BASE_URL" in os.environ:
config.security.doris_oauth_base_url =
os.getenv("DORIS_OAUTH_BASE_URL", "").strip()
_mark_source(config, "doris_oauth_base_url", "env")
@@ -946,7 +993,14 @@ class DorisConfig:
config = cls()
# Update basic configuration
- for key in ["server_name", "server_port", "temp_files_dir",
"transport", "workers"]:
+ for key in [
+ "server_name",
+ "server_host",
+ "server_port",
+ "temp_files_dir",
+ "transport",
+ "workers",
+ ]:
if key in config_data:
setattr(config, key, config_data[key])
_mark_source(config, key, "config_file")
@@ -1012,6 +1066,7 @@ class DorisConfig:
return {
"server_name": self.server_name,
"server_version": self.server_version,
+ "server_host": self.server_host,
"server_port": self.server_port,
"temp_files_dir": self.temp_files_dir,
"database": {
@@ -1039,6 +1094,7 @@ class DorisConfig:
"enable_oauth_auth": self.security.enable_oauth_auth,
"oauth_enabled": self.security.oauth_enabled,
"enable_doris_oauth_auth":
self.security.enable_doris_oauth_auth,
+ "allow_unauthenticated_non_loopback":
self.security.allow_unauthenticated_non_loopback,
"doris_oauth_base_url": self.security.doris_oauth_base_url,
"doris_oauth_db_tools_enabled":
self.security.doris_oauth_db_tools_enabled,
"doris_oauth_db_tool_allowlist":
self.security.doris_oauth_db_tool_allowlist,
@@ -1532,6 +1588,17 @@ def normalize_effective_auth_config(
if enable_external_oauth_auth:
methods.append("external_oauth")
+ bind_warning = validate_http_bind_auth_policy(
+ transport=transport,
+ host=config.server_host,
+ auth_methods=tuple(methods),
+ allow_unauthenticated_non_loopback=(
+ config.security.allow_unauthenticated_non_loopback
+ ),
+ )
+ if bind_warning:
+ warnings.append(bind_warning)
+
if enable_doris_oauth_auth:
discovery_mode = "doris_oauth"
elif enable_external_oauth_auth:
diff --git a/start_server.sh b/start_server.sh
index f6bcb87..0282650 100755
--- a/start_server.sh
+++ b/start_server.sh
@@ -66,7 +66,7 @@ fi
# Set HTTP-specific environment variables
# FIX for Issue #62 Bug 4: Use SERVER_PORT instead of MCP_PORT for consistency
with code
export MCP_TRANSPORT_TYPE="http"
-export MCP_HOST="${MCP_HOST:-0.0.0.0}"
+export MCP_HOST="${MCP_HOST:-127.0.0.1}"
export SERVER_PORT="${SERVER_PORT:-3000}" # Changed from MCP_PORT to
SERVER_PORT
export WORKERS="${WORKERS:-1}"
export ALLOWED_ORIGINS="${ALLOWED_ORIGINS:-*}"
diff --git a/test/security/test_http_bind_auth_policy.py
b/test/security/test_http_bind_auth_policy.py
new file mode 100644
index 0000000..5fe2b4a
--- /dev/null
+++ b/test/security/test_http_bind_auth_policy.py
@@ -0,0 +1,232 @@
+# 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.
+
+import json
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+from doris_mcp_server.main import DorisServer, _multiworker_environment
+from doris_mcp_server.utils.config import (
+ AuthConfigError,
+ DorisConfig,
+ _mark_source,
+ normalize_effective_auth_config,
+)
+from doris_mcp_server.utils.secret_policy import (
+ is_static_token_environment_variable,
+)
+
+STATIC_TOKEN = "V4nK8qR2mT7xP5cL9sD3hF6jY1uB0eG4iW8aN2zQ"
+
+
+def _http_config(host: str) -> DorisConfig:
+ config = DorisConfig()
+ config.transport = "http"
+ config.server_host = host
+ _mark_source(config, "transport", "test")
+ return config
+
+
+def _clear_auth_environment(monkeypatch: pytest.MonkeyPatch) -> None:
+ for name in list(os.environ):
+ if is_static_token_environment_variable(name) or name in {
+ "ALLOW_UNAUTHENTICATED_NON_LOOPBACK",
+ "AUTH_TYPE",
+ "ENABLE_DORIS_OAUTH_AUTH",
+ "ENABLE_JWT_AUTH",
+ "ENABLE_OAUTH_AUTH",
+ "ENABLE_TOKEN_AUTH",
+ "OAUTH_ENABLED",
+ }:
+ monkeypatch.delenv(name, raising=False)
+
+
[email protected](
+ "host",
+ [
+ "localhost",
+ "LOCALHOST.",
+ "127.0.0.1",
+ "127.42.0.1",
+ "::1",
+ "[::1]",
+ ],
+)
+def test_unauthenticated_http_accepts_explicit_loopback_hosts(host):
+ effective = normalize_effective_auth_config(_http_config(host))
+
+ assert effective.auth_methods == ()
+ assert effective.auth_config_warnings == ()
+
+
[email protected](
+ "host",
+ [
+ "",
+ "0.0.0.0",
+ "::",
+ "192.168.1.20",
+ "mcp.example.test",
+ ],
+)
+def test_unauthenticated_http_rejects_non_loopback_hosts(host):
+ with pytest.raises(
+ AuthConfigError,
+ match="Refusing unauthenticated HTTP bind to non-loopback",
+ ):
+ normalize_effective_auth_config(_http_config(host))
+
+
+def test_stdio_does_not_apply_http_bind_policy():
+ config = DorisConfig()
+ config.server_host = "0.0.0.0"
+
+ effective = normalize_effective_auth_config(config)
+
+ assert effective.transport == "stdio"
+ assert effective.auth_methods == ()
+
+
+def test_authenticated_http_accepts_non_loopback_host(tmp_path, monkeypatch):
+ _clear_auth_environment(monkeypatch)
+ config = _http_config("0.0.0.0")
+ config.security.token_file_path = str(tmp_path / "tokens.json")
+ config.security.enable_token_auth = True
+ _mark_source(config, "enable_token_auth", "test")
+ monkeypatch.setenv("TOKEN_ADMIN", STATIC_TOKEN)
+
+ effective = normalize_effective_auth_config(config)
+
+ assert effective.auth_methods == ("token",)
+ assert effective.auth_config_warnings == ()
+
+
+def test_explicit_dangerous_override_allows_startup_with_warning():
+ config = _http_config("0.0.0.0")
+ config.security.allow_unauthenticated_non_loopback = True
+
+ effective = normalize_effective_auth_config(config)
+
+ assert effective.auth_methods == ()
+ assert any(
+ "DANGEROUS: ALLOW_UNAUTHENTICATED_NON_LOOPBACK=true" in warning
+ for warning in effective.auth_config_warnings
+ )
+
+
+def test_environment_loads_explicit_dangerous_override(tmp_path, monkeypatch):
+ _clear_auth_environment(monkeypatch)
+ monkeypatch.setenv("TRANSPORT", "http")
+ monkeypatch.setenv("SERVER_HOST", "0.0.0.0")
+ monkeypatch.setenv("ALLOW_UNAUTHENTICATED_NON_LOOPBACK", "true")
+
+ config = DorisConfig.from_env(str(tmp_path / "missing.env"))
+ effective = normalize_effective_auth_config(config)
+
+ assert config.security.allow_unauthenticated_non_loopback is True
+ assert effective.auth_config_warnings
+
+
+def test_config_file_preserves_server_host_and_dangerous_override(tmp_path):
+ config_file = tmp_path / "config.json"
+ config_file.write_text(
+ json.dumps(
+ {
+ "transport": "http",
+ "server_host": "0.0.0.0",
+ "security": {
+ "allow_unauthenticated_non_loopback": True,
+ },
+ }
+ ),
+ encoding="utf-8",
+ )
+
+ config = DorisConfig.from_file(str(config_file))
+ effective = normalize_effective_auth_config(config)
+
+ assert config.server_host == "0.0.0.0"
+ assert effective.auth_config_warnings
+
+
[email protected]
+async def test_start_http_rechecks_the_actual_bind_host():
+ config = _http_config("127.0.0.1")
+ normalize_effective_auth_config(config)
+ server = object.__new__(DorisServer)
+ server.config = config
+
+ with pytest.raises(
+ AuthConfigError,
+ match="Refusing unauthenticated HTTP bind to non-loopback",
+ ):
+ await server.start_http(host="0.0.0.0", port=0, workers=1)
+
+
+def test_multiworker_environment_preserves_dangerous_override():
+ config = _http_config("0.0.0.0")
+ config.security.allow_unauthenticated_non_loopback = True
+
+ environment = _multiworker_environment(
+ config,
+ host="0.0.0.0",
+ port=3000,
+ workers=2,
+ )
+
+ assert environment["ALLOW_UNAUTHENTICATED_NON_LOOPBACK"] == "true"
+
+
+def test_cli_process_fails_before_unauthenticated_non_loopback_startup():
+ environment = os.environ.copy()
+ for name in list(environment):
+ if is_static_token_environment_variable(name) or name in {
+ "ALLOW_UNAUTHENTICATED_NON_LOOPBACK",
+ "AUTH_TYPE",
+ "ENABLE_DORIS_OAUTH_AUTH",
+ "ENABLE_JWT_AUTH",
+ "ENABLE_OAUTH_AUTH",
+ "ENABLE_TOKEN_AUTH",
+ "OAUTH_ENABLED",
+ }:
+ environment.pop(name, None)
+
+ result = subprocess.run(
+ [
+ sys.executable,
+ "-m",
+ "doris_mcp_server",
+ "--transport",
+ "http",
+ "--host",
+ "0.0.0.0",
+ "--port",
+ "0",
+ ],
+ cwd=Path(__file__).parents[2],
+ env=environment,
+ capture_output=True,
+ text=True,
+ timeout=15,
+ check=False,
+ )
+
+ assert result.returncode == 1
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]