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]

Reply via email to