This is an automated email from the ASF dual-hosted git repository. FreeOnePlus pushed a commit to branch agent/sec-003-admin-token-header in repository https://gitbox.apache.org/repos/asf/doris-mcp-server.git
commit 9572cfb7758bfe90f4f61cd84477c2dd89288266 Author: FreeOnePlus <[email protected]> AuthorDate: Wed Jul 29 19:30:04 2026 +0800 fix: require headers for token management auth --- CHANGELOG.md | 2 + README.md | 13 +- doris_mcp_server/auth/token_handlers.py | 37 +++-- doris_mcp_server/auth/token_security_middleware.py | 8 +- test/security/test_token_management_auth.py | 160 +++++++++++++++++++++ 5 files changed, 190 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 653a9ee..fc211d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,8 @@ under **Unreleased** until a new version is selected and published. - Preserved service availability after malformed requests, unknown methods, header mismatches, unsupported versions, and missing capabilities. - Corrected resource and prompt error semantics and tool `isError` results. +- Removed admin-token query authentication and dashboard URL propagation; + management endpoints now accept admin credentials only in headers. - 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 b8546d2..a2574a1 100644 --- a/README.md +++ b/README.md @@ -143,11 +143,20 @@ Access the **Web-Based Token Management Dashboard** for enterprise-grade token a ``` #### **Interface Access** + +Management requests accept the admin token only in an HTTP header. Query-string +tokens are rejected and must not be placed in URLs, browser history, or access +logs. + ```bash -# Access the token management interface -http://localhost:3000/token/management?admin_token=your_secure_admin_token +curl -H "Authorization: Bearer your_secure_admin_token" \ + http://127.0.0.1:3000/token/stats ``` +The optional `/token/management` page must be opened through a client or local +proxy that supplies the same header. Its API requests use an in-page password +field and do not persist or propagate the token in URLs. + #### **Available Operations** - **📊 Token Statistics**: Real-time overview of active, expired, and total tokens - **➕ Create Tokens**: diff --git a/doris_mcp_server/auth/token_handlers.py b/doris_mcp_server/auth/token_handlers.py index cd16161..06a4743 100644 --- a/doris_mcp_server/auth/token_handlers.py +++ b/doris_mcp_server/auth/token_handlers.py @@ -410,6 +410,15 @@ class TokenHandlers: <body> <div class="container"> <h1>🔐 Doris MCP Server - Token Management</h1> + + <div class="section"> + <h2>🛡️ Admin Authentication</h2> + <div class="form-group"> + <label for="adminToken">Admin Token:</label> + <input type="password" id="adminToken" autocomplete="off" placeholder="Enter the configured admin token"> + <small style="color: #666; display: block; margin-top: 5px;">The token stays in this page and is sent only in the Authorization header.</small> + </div> + </div> <div class="section"> <h2>📊 Token Statistics</h2> @@ -517,12 +526,9 @@ class TokenHandlers: </div> <script> - // Get admin token from URL parameters - const urlParams = new URLSearchParams(window.location.search); - const adminToken = urlParams.get('admin_token'); - - // Create request headers with admin token + // Read the token only from the in-page password field. function getAuthHeaders() {{ + const adminToken = document.getElementById('adminToken').value; if (adminToken) {{ return {{ 'Content-Type': 'application/json', @@ -533,15 +539,6 @@ class TokenHandlers: }} }} - // Create URL with admin token parameter - function getAuthURL(baseUrl) {{ - if (adminToken) {{ - const separator = baseUrl.includes('?') ? '&' : '?'; - return `${{baseUrl}}${{separator}}admin_token=${{encodeURIComponent(adminToken)}}`; - }} - return baseUrl; - }} - function showResponse(elementId, data, isSuccess = true) {{ const element = document.getElementById(elementId); element.innerHTML = '<pre>' + JSON.stringify(data, null, 2) + '</pre>'; @@ -580,7 +577,7 @@ class TokenHandlers: delete data.db_fe_http_port; try {{ - const response = await fetch(getAuthURL('/token/create'), {{ + const response = await fetch('/token/create', {{ method: 'POST', headers: getAuthHeaders(), body: JSON.stringify(data) @@ -600,7 +597,7 @@ class TokenHandlers: // List tokens document.getElementById('listTokensBtn').addEventListener('click', async () => {{ try {{ - const response = await fetch(getAuthURL('/token/list'), {{ + const response = await fetch('/token/list', {{ headers: getAuthHeaders() }}); const result = await response.json(); @@ -613,7 +610,7 @@ class TokenHandlers: // Cleanup tokens document.getElementById('cleanupTokensBtn').addEventListener('click', async () => {{ try {{ - const response = await fetch(getAuthURL('/token/cleanup'), {{ + const response = await fetch('/token/cleanup', {{ method: 'POST', headers: getAuthHeaders() }}); @@ -633,7 +630,7 @@ class TokenHandlers: }} try {{ - const response = await fetch(getAuthURL(`/token/revoke?token_id=${{encodeURIComponent(tokenId)}}`), {{ + const response = await fetch(`/token/revoke?token_id=${{encodeURIComponent(tokenId)}}`, {{ method: 'DELETE', headers: getAuthHeaders() }}); @@ -650,8 +647,6 @@ class TokenHandlers: }} }}); - // Load token list on page load - document.getElementById('listTokensBtn').click(); </script> </body> </html> @@ -674,4 +669,4 @@ class TokenHandlers: </body> </html> """ - return HTMLResponse(error_html, status_code=500) \ No newline at end of file + return HTMLResponse(error_html, status_code=500) diff --git a/doris_mcp_server/auth/token_security_middleware.py b/doris_mcp_server/auth/token_security_middleware.py index d38f17d..d9dbc3b 100644 --- a/doris_mcp_server/auth/token_security_middleware.py +++ b/doris_mcp_server/auth/token_security_middleware.py @@ -125,12 +125,6 @@ class TokenSecurityMiddleware: if admin_token: return admin_token - # Try query parameter as fallback (not recommended for production) - admin_token = request.query_params.get('admin_token', '') - if admin_token: - self.logger.warning("Admin token passed via query parameter - this is insecure for production") - return admin_token - return None def _verify_admin_token(self, provided_token: str) -> bool: @@ -224,4 +218,4 @@ class TokenSecurityMiddleware: # Convenience function for middleware creation def create_token_security_middleware(config: DorisConfig) -> TokenSecurityMiddleware: """Create token security middleware with configuration""" - return TokenSecurityMiddleware(config) \ No newline at end of file + return TokenSecurityMiddleware(config) diff --git a/test/security/test_token_management_auth.py b/test/security/test_token_management_auth.py new file mode 100644 index 0000000..79ef623 --- /dev/null +++ b/test/security/test_token_management_auth.py @@ -0,0 +1,160 @@ +# 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. + +from urllib.parse import urlencode + +import httpx +import pytest +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.routing import Route + +from doris_mcp_server.auth.token_handlers import TokenHandlers +from doris_mcp_server.auth.token_security_middleware import TokenSecurityMiddleware +from doris_mcp_server.utils.config import DorisConfig + +ADMIN_TOKEN = "test-admin-token" + + +def _config() -> DorisConfig: + config = DorisConfig() + config.security.enable_http_token_management = True + config.security.require_admin_auth = True + config.security.token_management_admin_token = ADMIN_TOKEN + config.security.token_management_allowed_ips = ["127.0.0.1"] + return config + + +def _request( + path: str = "/token/stats", + *, + headers: dict[str, str] | None = None, + query: dict[str, str] | None = None, +) -> Request: + encoded_headers = [ + (name.lower().encode(), value.encode()) + for name, value in (headers or {}).items() + ] + return Request( + { + "type": "http", + "method": "GET", + "scheme": "http", + "server": ("127.0.0.1", 3000), + "client": ("127.0.0.1", 50000), + "path": path, + "raw_path": path.encode(), + "query_string": urlencode(query or {}).encode(), + "headers": encoded_headers, + } + ) + + [email protected] +async def test_admin_query_token_is_rejected_and_headers_are_accepted(): + middleware = TokenSecurityMiddleware(_config()) + + query_only = await middleware.check_token_management_access( + _request(query={"admin_token": ADMIN_TOKEN}) + ) + assert query_only is not None + assert query_only.status_code == 401 + + bad_header_with_query = await middleware.check_token_management_access( + _request( + headers={"Authorization": "Bearer wrong-token"}, + query={"admin_token": ADMIN_TOKEN}, + ) + ) + assert bad_header_with_query is not None + assert bad_header_with_query.status_code == 401 + + assert ( + await middleware.check_token_management_access( + _request(headers={"Authorization": f"Bearer {ADMIN_TOKEN}"}) + ) + is None + ) + assert ( + await middleware.check_token_management_access( + _request(headers={"X-Admin-Token": ADMIN_TOKEN}) + ) + is None + ) + + +class _AuthProvider: + token_manager = object() + + +class _SecurityManager: + auth_provider = _AuthProvider() + + @staticmethod + def get_token_stats(): + return { + "total_tokens": 0, + "active_tokens": 0, + "expired_tokens": 0, + "expiry_enabled": True, + "default_expiry_hours": 24, + } + + [email protected] +async def test_http_management_route_requires_admin_header(): + handlers = TokenHandlers(_SecurityManager(), _config()) + app = Starlette( + routes=[Route("/token/stats", handlers.handle_token_stats, methods=["GET"])] + ) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://127.0.0.1:3000", + ) as client: + denied = await client.get( + "/token/stats", + params={"admin_token": ADMIN_TOKEN}, + ) + accepted = await client.get( + "/token/stats", + headers={"Authorization": f"Bearer {ADMIN_TOKEN}"}, + ) + + assert denied.status_code == 401 + assert accepted.status_code == 200 + assert accepted.json()["success"] is True + + [email protected] +async def test_management_page_uses_header_only_and_never_builds_token_urls(): + handlers = TokenHandlers(_SecurityManager(), _config()) + + response = await handlers.handle_management_page( + _request( + path="/token/management", + headers={"Authorization": f"Bearer {ADMIN_TOKEN}"}, + ) + ) + body = response.body.decode() + + assert response.status_code == 200 + assert "admin_token" not in body + assert "URLSearchParams" not in body + assert "getAuthURL" not in body + assert "Authorization" in body + assert 'type="password" id="adminToken"' in body --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
