mcpprobe-scanner 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcpprobe/__init__.py +21 -0
- mcpprobe/action.py +69 -0
- mcpprobe/authz.py +313 -0
- mcpprobe/catalogue.py +125 -0
- mcpprobe/checks/__init__.py +100 -0
- mcpprobe/checks/_util.py +102 -0
- mcpprobe/checks/active_stubs.py +212 -0
- mcpprobe/checks/auth.py +473 -0
- mcpprobe/checks/base.py +228 -0
- mcpprobe/checks/baseline.py +219 -0
- mcpprobe/checks/capability.py +171 -0
- mcpprobe/checks/metadata.py +341 -0
- mcpprobe/checks/poisoning.py +480 -0
- mcpprobe/checks/robustness.py +266 -0
- mcpprobe/checks/transport_conformance.py +429 -0
- mcpprobe/cli.py +295 -0
- mcpprobe/dnsutil.py +67 -0
- mcpprobe/governance.py +74 -0
- mcpprobe/models.py +264 -0
- mcpprobe/policy.py +140 -0
- mcpprobe/py.typed +0 -0
- mcpprobe/redact.py +101 -0
- mcpprobe/report/__init__.py +7 -0
- mcpprobe/report/json_report.py +33 -0
- mcpprobe/report/sarif.py +329 -0
- mcpprobe/report/terminal.py +172 -0
- mcpprobe/scanner.py +211 -0
- mcpprobe/transport/__init__.py +34 -0
- mcpprobe/transport/base.py +156 -0
- mcpprobe/transport/evidence.py +135 -0
- mcpprobe/transport/fingerprint.py +154 -0
- mcpprobe/transport/http.py +366 -0
- mcpprobe/transport/ratelimit.py +47 -0
- mcpprobe/transport/stdio.py +201 -0
- mcpprobe_scanner-0.1.0.dist-info/METADATA +240 -0
- mcpprobe_scanner-0.1.0.dist-info/RECORD +39 -0
- mcpprobe_scanner-0.1.0.dist-info/WHEEL +4 -0
- mcpprobe_scanner-0.1.0.dist-info/entry_points.txt +2 -0
- mcpprobe_scanner-0.1.0.dist-info/licenses/LICENSE +21 -0
mcpprobe/checks/auth.py
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""AUTH-01..04: authentication and OAuth 2.1 conformance checks (passive).
|
|
2
|
+
|
|
3
|
+
Spec references: MCP 2025-11-25 Authorization (RFC 9728 Protected Resource
|
|
4
|
+
Metadata, RFC 8414 AS metadata, PKCE S256, RFC 8707 audience binding, no
|
|
5
|
+
token passthrough, no tokens in query strings).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import secrets
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
14
|
+
|
|
15
|
+
import jwt
|
|
16
|
+
|
|
17
|
+
from mcpprobe.checks._util import (
|
|
18
|
+
host_of,
|
|
19
|
+
is_local_or_private,
|
|
20
|
+
origin_of,
|
|
21
|
+
parse_www_authenticate,
|
|
22
|
+
try_json,
|
|
23
|
+
well_known_urls,
|
|
24
|
+
)
|
|
25
|
+
from mcpprobe.checks.base import Check, ScanContext, evidence_of, evidence_summary
|
|
26
|
+
from mcpprobe.checks.capability import classify_tools
|
|
27
|
+
from mcpprobe.models import Confidence, Finding, Severity, Standards
|
|
28
|
+
from mcpprobe.policy import OptOutError, check_dns_opt_out
|
|
29
|
+
from mcpprobe.transport import PROTOCOL_2025_11_25, PROTOCOL_2026_07_28, RpcResponse
|
|
30
|
+
from mcpprobe.transport.base import CLIENT_INFO
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Auth01NoAuthentication(Check):
|
|
34
|
+
"""The endpoint served ``initialize``/``tools/list`` without any credentials."""
|
|
35
|
+
|
|
36
|
+
id = "AUTH-01"
|
|
37
|
+
title = "Remote MCP endpoint accepts requests without authentication"
|
|
38
|
+
severity = Severity.HIGH
|
|
39
|
+
applies_to = frozenset({"http"})
|
|
40
|
+
standards = Standards(
|
|
41
|
+
mcp=["MCP07:2025"],
|
|
42
|
+
asi=["ASI03"],
|
|
43
|
+
llm_2025=["LLM03:2025", "LLM06:2025"],
|
|
44
|
+
llm_2026=["LLM04:2026", "LLM03:2026"],
|
|
45
|
+
)
|
|
46
|
+
description = (
|
|
47
|
+
"POSTs initialize (or server/discover) and tools/list with no Authorization header. "
|
|
48
|
+
"If the server answers with a tool list, anyone who can reach the endpoint can drive "
|
|
49
|
+
"its tools. Missing authentication was the single most common issue in public MCP "
|
|
50
|
+
"server studies."
|
|
51
|
+
)
|
|
52
|
+
evidence_type = "The unauthenticated tools/list request and the 200 response."
|
|
53
|
+
false_positive_notes = (
|
|
54
|
+
"Servers protected by a network boundary (VPN, mTLS at a proxy, IP allow-list) will "
|
|
55
|
+
"still be flagged because the scanner only sees what the endpoint returns. Local "
|
|
56
|
+
"development servers bound to loopback are reported at reduced severity but are still "
|
|
57
|
+
"exposed to DNS-rebinding and browser-based attacks (see CVE-2025-49596 class)."
|
|
58
|
+
)
|
|
59
|
+
fix = (
|
|
60
|
+
"Put the endpoint behind OAuth 2.1 as described in the MCP authorization spec: return "
|
|
61
|
+
"401 with WWW-Authenticate pointing at RFC 9728 protected-resource metadata, validate "
|
|
62
|
+
"token audience, and reject requests without a valid bearer token. For local servers, "
|
|
63
|
+
"bind to 127.0.0.1 and still require a token."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
async def run(self, ctx: ScanContext) -> list[Finding]:
|
|
67
|
+
fp = ctx.fingerprint
|
|
68
|
+
if fp.auth_required:
|
|
69
|
+
return []
|
|
70
|
+
if fp.flavour == "unknown" and not ctx.inventory.tools:
|
|
71
|
+
return []
|
|
72
|
+
if not ctx.inventory.tools and ctx.inventory.tools_error:
|
|
73
|
+
return []
|
|
74
|
+
classes = classify_tools(ctx.inventory.tools)
|
|
75
|
+
dangerous = sorted(n for n, c in classes.items() if c.dangerous)
|
|
76
|
+
local = is_local_or_private(host_of(ctx.target))
|
|
77
|
+
if local:
|
|
78
|
+
severity = Severity.HIGH if dangerous else Severity.MEDIUM
|
|
79
|
+
else:
|
|
80
|
+
severity = Severity.CRITICAL if dangerous else Severity.HIGH
|
|
81
|
+
where = "a loopback/private address" if local else "a public address"
|
|
82
|
+
desc = (
|
|
83
|
+
f"The server at {where} answered {fp.flavour or 'MCP'} negotiation and tools/list "
|
|
84
|
+
f"without any credentials, exposing {len(ctx.inventory.tools)} tool(s)."
|
|
85
|
+
)
|
|
86
|
+
if dangerous:
|
|
87
|
+
desc += f" Tools with dangerous capability classes: {', '.join(dangerous)}."
|
|
88
|
+
ev = [e for e in [ctx.inventory.tools_evidence] if e]
|
|
89
|
+
names = ctx.tool_names()
|
|
90
|
+
shown = ", ".join(names[:8]) + (f" (+{len(names) - 8} more)" if len(names) > 8 else "")
|
|
91
|
+
return [
|
|
92
|
+
self.finding(
|
|
93
|
+
description=desc,
|
|
94
|
+
severity=severity,
|
|
95
|
+
confidence=Confidence.HIGH,
|
|
96
|
+
evidence=ev,
|
|
97
|
+
excerpt=evidence_summary(
|
|
98
|
+
ev, f"no Authorization header sent; {len(names)} tools listed: {shown}"
|
|
99
|
+
),
|
|
100
|
+
location=ctx.target,
|
|
101
|
+
details={"tools": ctx.tool_names(), "dangerous_tools": dangerous, "local": local},
|
|
102
|
+
)
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class Auth02MissingProtectedResourceMetadata(Check):
|
|
107
|
+
"""Auth-protected server without discoverable RFC 9728 metadata."""
|
|
108
|
+
|
|
109
|
+
id = "AUTH-02"
|
|
110
|
+
title = "Protected resource metadata (RFC 9728) missing or invalid"
|
|
111
|
+
severity = Severity.HIGH
|
|
112
|
+
applies_to = frozenset({"http"})
|
|
113
|
+
standards = Standards(mcp=["MCP07:2025"])
|
|
114
|
+
description = (
|
|
115
|
+
"On a 401 the scanner parses WWW-Authenticate for resource_metadata, then tries "
|
|
116
|
+
"/.well-known/oauth-protected-resource[/path] and the host root. The document must be "
|
|
117
|
+
"JSON with a non-empty authorization_servers list. Without it, conformant clients cannot "
|
|
118
|
+
"start the OAuth flow and users are pushed toward pasting long-lived static tokens."
|
|
119
|
+
)
|
|
120
|
+
evidence_type = "The 401 response headers and each metadata GET with its status/body."
|
|
121
|
+
false_positive_notes = (
|
|
122
|
+
"Servers that use a non-OAuth scheme (API keys at a gateway) will be flagged; the MCP "
|
|
123
|
+
"spec still expects RFC 9728 metadata for HTTP transports."
|
|
124
|
+
)
|
|
125
|
+
fix = (
|
|
126
|
+
"Serve /.well-known/oauth-protected-resource (path-aware variant preferred) with "
|
|
127
|
+
"'resource' and 'authorization_servers', and include "
|
|
128
|
+
'WWW-Authenticate: Bearer resource_metadata="<url>" on every 401.'
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
async def run(self, ctx: ScanContext) -> list[Finding]:
|
|
132
|
+
fp = ctx.fingerprint
|
|
133
|
+
http = ctx.http
|
|
134
|
+
if not fp.auth_required or http is None:
|
|
135
|
+
return []
|
|
136
|
+
findings: list[Finding] = []
|
|
137
|
+
params = parse_www_authenticate(fp.www_authenticate)
|
|
138
|
+
hint = params.get("resource_metadata")
|
|
139
|
+
responses: list[RpcResponse] = []
|
|
140
|
+
prm: dict[str, Any] | None = None
|
|
141
|
+
prm_url: str | None = None
|
|
142
|
+
candidates: list[str] = []
|
|
143
|
+
if hint:
|
|
144
|
+
candidates.append(hint)
|
|
145
|
+
candidates += [
|
|
146
|
+
u for u in well_known_urls(ctx.target, "oauth-protected-resource") if u not in candidates
|
|
147
|
+
]
|
|
148
|
+
# Only follow hints on the target's own origin (the spec requires that anyway).
|
|
149
|
+
target_origin = origin_of(ctx.target)
|
|
150
|
+
for url in candidates:
|
|
151
|
+
if origin_of(url) != target_origin:
|
|
152
|
+
ctx.notes.append(f"AUTH-02: ignored resource_metadata hint on another origin: {url}")
|
|
153
|
+
continue
|
|
154
|
+
resp = await http.get(url, note="AUTH-02 protected resource metadata")
|
|
155
|
+
responses.append(resp)
|
|
156
|
+
if resp.status == 200:
|
|
157
|
+
doc = try_json(resp.body)
|
|
158
|
+
if isinstance(doc, dict):
|
|
159
|
+
prm, prm_url = doc, url
|
|
160
|
+
break
|
|
161
|
+
if prm is None:
|
|
162
|
+
findings.append(
|
|
163
|
+
self.finding(
|
|
164
|
+
description=(
|
|
165
|
+
"The server requires authentication (401) but no RFC 9728 protected "
|
|
166
|
+
"resource metadata document could be found via WWW-Authenticate or the "
|
|
167
|
+
"well-known locations."
|
|
168
|
+
),
|
|
169
|
+
severity=Severity.HIGH,
|
|
170
|
+
confidence=Confidence.HIGH,
|
|
171
|
+
evidence=evidence_of(*responses),
|
|
172
|
+
location=ctx.target,
|
|
173
|
+
details={"www_authenticate": fp.www_authenticate, "tried": candidates},
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
return findings
|
|
177
|
+
ctx.shared["prm"] = prm
|
|
178
|
+
ctx.shared["prm_url"] = prm_url
|
|
179
|
+
servers = prm.get("authorization_servers")
|
|
180
|
+
problems: list[str] = []
|
|
181
|
+
if not isinstance(servers, list) or not servers:
|
|
182
|
+
problems.append("authorization_servers missing or empty")
|
|
183
|
+
elif not all(isinstance(s, str) and s.startswith(("https://", "http://")) for s in servers):
|
|
184
|
+
problems.append("authorization_servers contains non-URL entries")
|
|
185
|
+
if "resource" not in prm:
|
|
186
|
+
problems.append("'resource' field missing")
|
|
187
|
+
if problems:
|
|
188
|
+
findings.append(
|
|
189
|
+
self.finding(
|
|
190
|
+
description=f"Protected resource metadata at {prm_url} is invalid: "
|
|
191
|
+
+ "; ".join(problems)
|
|
192
|
+
+ ".",
|
|
193
|
+
severity=Severity.HIGH,
|
|
194
|
+
confidence=Confidence.HIGH,
|
|
195
|
+
evidence=evidence_of(*responses),
|
|
196
|
+
location=prm_url,
|
|
197
|
+
details={"problems": problems},
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
if not hint:
|
|
201
|
+
findings.append(
|
|
202
|
+
self.finding(
|
|
203
|
+
title="401 response lacks resource_metadata hint in WWW-Authenticate",
|
|
204
|
+
description=(
|
|
205
|
+
"The 401 response did not carry a WWW-Authenticate header with "
|
|
206
|
+
"resource_metadata. Metadata was found at the well-known location, but "
|
|
207
|
+
"clients that rely on the header hint will fail to discover it."
|
|
208
|
+
),
|
|
209
|
+
severity=Severity.LOW,
|
|
210
|
+
confidence=Confidence.MEDIUM,
|
|
211
|
+
evidence=evidence_of(*responses),
|
|
212
|
+
location=ctx.target,
|
|
213
|
+
details={"www_authenticate": fp.www_authenticate},
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
return findings
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class Auth03AuthorizationServerMetadata(Check):
|
|
220
|
+
"""Authorization server metadata: PKCE S256 and HTTPS endpoints."""
|
|
221
|
+
|
|
222
|
+
id = "AUTH-03"
|
|
223
|
+
title = "Authorization server metadata weak or missing (PKCE S256 / HTTPS)"
|
|
224
|
+
severity = Severity.HIGH
|
|
225
|
+
applies_to = frozenset({"http"})
|
|
226
|
+
standards = Standards(mcp=["MCP07:2025", "MCP01:2025"])
|
|
227
|
+
description = (
|
|
228
|
+
"For each authorization server listed in the protected resource metadata, fetches "
|
|
229
|
+
"/.well-known/oauth-authorization-server (RFC 8414, path-aware) then "
|
|
230
|
+
"/.well-known/openid-configuration. Flags a missing document, a "
|
|
231
|
+
"code_challenge_methods_supported list without S256 (MCP clients MUST refuse to proceed), "
|
|
232
|
+
"and non-HTTPS endpoints. This may make one GET to a third-party host; disable with "
|
|
233
|
+
"--no-follow-as."
|
|
234
|
+
)
|
|
235
|
+
evidence_type = "Each metadata GET with status and body."
|
|
236
|
+
false_positive_notes = (
|
|
237
|
+
"Some authorization servers support PKCE but omit code_challenge_methods_supported; "
|
|
238
|
+
"per the MCP spec that is still a conformance failure for clients."
|
|
239
|
+
)
|
|
240
|
+
fix = (
|
|
241
|
+
"Publish RFC 8414 metadata with code_challenge_methods_supported including S256 and "
|
|
242
|
+
"HTTPS-only endpoints. If you use a third-party identity provider, confirm it exposes "
|
|
243
|
+
"the metadata document at the issuer URL you advertise."
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
async def run(self, ctx: ScanContext) -> list[Finding]:
|
|
247
|
+
http = ctx.http
|
|
248
|
+
prm = ctx.shared.get("prm")
|
|
249
|
+
if http is None or not isinstance(prm, dict):
|
|
250
|
+
return []
|
|
251
|
+
servers = [s for s in prm.get("authorization_servers") or [] if isinstance(s, str)]
|
|
252
|
+
findings: list[Finding] = []
|
|
253
|
+
target_host = host_of(ctx.target)
|
|
254
|
+
for issuer in servers[:3]:
|
|
255
|
+
issuer_host = host_of(issuer)
|
|
256
|
+
if issuer_host != target_host:
|
|
257
|
+
if not ctx.options.follow_authorization_server:
|
|
258
|
+
ctx.notes.append(
|
|
259
|
+
f"AUTH-03: not fetching third-party AS metadata ({issuer}) (--no-follow-as)"
|
|
260
|
+
)
|
|
261
|
+
continue
|
|
262
|
+
# A different host gets the same courtesy as the target: honour its opt-out first.
|
|
263
|
+
try:
|
|
264
|
+
ctx.notes.extend(await check_dns_opt_out(issuer_host, ctx.options.txt_lookup))
|
|
265
|
+
except OptOutError:
|
|
266
|
+
ctx.notes.append(
|
|
267
|
+
f"AUTH-03: {issuer_host} has opted out of mcpprobe; AS metadata not fetched"
|
|
268
|
+
)
|
|
269
|
+
continue
|
|
270
|
+
responses: list[RpcResponse] = []
|
|
271
|
+
doc: dict[str, Any] | None = None
|
|
272
|
+
for url in _as_metadata_urls(issuer):
|
|
273
|
+
resp = await http.get(url, note="AUTH-03 authorization server metadata")
|
|
274
|
+
responses.append(resp)
|
|
275
|
+
if resp.status == 200:
|
|
276
|
+
candidate = try_json(resp.body)
|
|
277
|
+
if isinstance(candidate, dict):
|
|
278
|
+
doc = candidate
|
|
279
|
+
break
|
|
280
|
+
if doc is None:
|
|
281
|
+
findings.append(
|
|
282
|
+
self.finding(
|
|
283
|
+
title="Authorization server metadata not found",
|
|
284
|
+
description=f"No RFC 8414 / OpenID metadata document found for {issuer}.",
|
|
285
|
+
severity=Severity.MEDIUM,
|
|
286
|
+
confidence=Confidence.HIGH,
|
|
287
|
+
evidence=evidence_of(*responses),
|
|
288
|
+
location=issuer,
|
|
289
|
+
)
|
|
290
|
+
)
|
|
291
|
+
continue
|
|
292
|
+
methods = doc.get("code_challenge_methods_supported")
|
|
293
|
+
if not isinstance(methods, list) or "S256" not in methods:
|
|
294
|
+
findings.append(
|
|
295
|
+
self.finding(
|
|
296
|
+
title="PKCE S256 not advertised by authorization server",
|
|
297
|
+
description=(
|
|
298
|
+
f"{issuer} does not list S256 in code_challenge_methods_supported "
|
|
299
|
+
f"(got {methods!r}). MCP clients MUST refuse to proceed without it."
|
|
300
|
+
),
|
|
301
|
+
severity=Severity.HIGH,
|
|
302
|
+
confidence=Confidence.HIGH,
|
|
303
|
+
evidence=evidence_of(*responses),
|
|
304
|
+
location=issuer,
|
|
305
|
+
details={"code_challenge_methods_supported": methods},
|
|
306
|
+
)
|
|
307
|
+
)
|
|
308
|
+
insecure = [
|
|
309
|
+
f"{k}={v}"
|
|
310
|
+
for k, v in doc.items()
|
|
311
|
+
if k.endswith("_endpoint")
|
|
312
|
+
and isinstance(v, str)
|
|
313
|
+
and v.startswith("http://")
|
|
314
|
+
and not is_local_or_private(host_of(v))
|
|
315
|
+
]
|
|
316
|
+
if insecure:
|
|
317
|
+
findings.append(
|
|
318
|
+
self.finding(
|
|
319
|
+
title="Authorization server advertises plain-HTTP endpoints",
|
|
320
|
+
description=f"{issuer} metadata contains non-HTTPS endpoints: " + ", ".join(insecure),
|
|
321
|
+
severity=Severity.HIGH,
|
|
322
|
+
confidence=Confidence.HIGH,
|
|
323
|
+
evidence=evidence_of(*responses),
|
|
324
|
+
location=issuer,
|
|
325
|
+
details={"insecure_endpoints": insecure},
|
|
326
|
+
)
|
|
327
|
+
)
|
|
328
|
+
if isinstance(doc.get("issuer"), str) and doc["issuer"].rstrip("/") != issuer.rstrip("/"):
|
|
329
|
+
findings.append(
|
|
330
|
+
self.finding(
|
|
331
|
+
title="Authorization server issuer mismatch",
|
|
332
|
+
description=f"Metadata issuer {doc['issuer']!r} differs from advertised {issuer!r}.",
|
|
333
|
+
severity=Severity.LOW,
|
|
334
|
+
confidence=Confidence.MEDIUM,
|
|
335
|
+
evidence=evidence_of(*responses),
|
|
336
|
+
location=issuer,
|
|
337
|
+
)
|
|
338
|
+
)
|
|
339
|
+
return findings
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _as_metadata_urls(issuer: str) -> list[str]:
|
|
343
|
+
parts = urlsplit(issuer)
|
|
344
|
+
root = urlunsplit((parts.scheme, parts.netloc, "", "", ""))
|
|
345
|
+
path = parts.path.rstrip("/")
|
|
346
|
+
urls = []
|
|
347
|
+
if path:
|
|
348
|
+
urls.append(f"{root}/.well-known/oauth-authorization-server{path}")
|
|
349
|
+
urls.append(f"{root}{path}/.well-known/openid-configuration")
|
|
350
|
+
urls.append(f"{root}/.well-known/oauth-authorization-server")
|
|
351
|
+
urls.append(f"{root}/.well-known/openid-configuration")
|
|
352
|
+
return urls
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
class Auth04TokenAudienceAndPassthrough(Check):
|
|
356
|
+
"""Self-minted wrong-audience tokens must be rejected; tokens in query strings too."""
|
|
357
|
+
|
|
358
|
+
id = "AUTH-04"
|
|
359
|
+
title = "Server accepts a self-minted token with the wrong audience/issuer"
|
|
360
|
+
severity = Severity.CRITICAL
|
|
361
|
+
applies_to = frozenset({"http"})
|
|
362
|
+
standards = Standards(mcp=["MCP07:2025", "MCP01:2025"], asi=["ASI03"])
|
|
363
|
+
description = (
|
|
364
|
+
"Mints two syntactically valid JWTs the server cannot have issued (HS256 with a random "
|
|
365
|
+
"scanner key, and alg=none) with a foreign issuer and audience, and presents them as "
|
|
366
|
+
"Bearer tokens. Any non-401/403 answer means the server is not validating signature or "
|
|
367
|
+
"audience (RFC 8707). Also presents the token in the access_token query parameter, "
|
|
368
|
+
"which the spec forbids. Only self-minted tokens are used; no real credentials."
|
|
369
|
+
)
|
|
370
|
+
evidence_type = "Each probe request (token redacted) and the response status/body."
|
|
371
|
+
false_positive_notes = (
|
|
372
|
+
"A proxy that strips Authorization and answers 200 for health reasons could be flagged; "
|
|
373
|
+
"check the response body in the evidence actually contains an MCP result."
|
|
374
|
+
)
|
|
375
|
+
fix = (
|
|
376
|
+
"Validate the token signature against the authorization server's keys, require 'aud' to "
|
|
377
|
+
"equal this server's canonical URI (RFC 8707), reject alg=none, and never read tokens "
|
|
378
|
+
"from query strings."
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
async def run(self, ctx: ScanContext) -> list[Finding]:
|
|
382
|
+
http = ctx.http
|
|
383
|
+
fp = ctx.fingerprint
|
|
384
|
+
if http is None or not fp.auth_required:
|
|
385
|
+
return []
|
|
386
|
+
findings: list[Finding] = []
|
|
387
|
+
method, params, version = _probe_request(ctx)
|
|
388
|
+
now_claims = {
|
|
389
|
+
"iss": "https://mcpprobe.invalid/scanner",
|
|
390
|
+
"aud": "https://not-the-target.invalid",
|
|
391
|
+
"sub": "mcpprobe-probe",
|
|
392
|
+
"scope": "mcp:tools",
|
|
393
|
+
"jti": secrets.token_hex(8),
|
|
394
|
+
}
|
|
395
|
+
hs256 = jwt.encode(now_claims, secrets.token_hex(32), algorithm="HS256")
|
|
396
|
+
alg_none = jwt.encode(now_claims, "", algorithm="none")
|
|
397
|
+
header_accepted: list[RpcResponse] = []
|
|
398
|
+
for label, token in (("HS256/random-key", hs256), ("alg=none", alg_none)):
|
|
399
|
+
resp = await http.rpc(
|
|
400
|
+
method,
|
|
401
|
+
params,
|
|
402
|
+
protocol_version=version,
|
|
403
|
+
include_session=False,
|
|
404
|
+
headers_override={"Authorization": f"Bearer {token}"},
|
|
405
|
+
note=f"AUTH-04 self-minted token ({label})",
|
|
406
|
+
)
|
|
407
|
+
if _accepted(resp):
|
|
408
|
+
header_accepted.append(resp)
|
|
409
|
+
findings.append(
|
|
410
|
+
self.finding(
|
|
411
|
+
description=(
|
|
412
|
+
f"A self-minted JWT ({label}) with iss/aud pointing at scanner-controlled "
|
|
413
|
+
f"values was accepted: HTTP {resp.status} with "
|
|
414
|
+
f"{'an MCP result' if resp.result is not None else 'a non-401 body'}."
|
|
415
|
+
),
|
|
416
|
+
severity=Severity.CRITICAL,
|
|
417
|
+
confidence=Confidence.HIGH if resp.result is not None else Confidence.MEDIUM,
|
|
418
|
+
evidence=evidence_of(resp),
|
|
419
|
+
location=ctx.target,
|
|
420
|
+
details={"probe": label, "claims": now_claims},
|
|
421
|
+
)
|
|
422
|
+
)
|
|
423
|
+
# Token in query string (spec: MUST NOT be accepted).
|
|
424
|
+
qs_url = _with_query(ctx.target, "access_token", hs256)
|
|
425
|
+
resp = await http.raw_request(
|
|
426
|
+
"POST",
|
|
427
|
+
body=_body_for(http, method, params, version),
|
|
428
|
+
headers=http.default_headers(method, protocol_version=version, include_session=False),
|
|
429
|
+
url=qs_url,
|
|
430
|
+
label=method,
|
|
431
|
+
note="AUTH-04 token in query string",
|
|
432
|
+
)
|
|
433
|
+
if _accepted(resp) and not header_accepted:
|
|
434
|
+
findings.append(
|
|
435
|
+
self.finding(
|
|
436
|
+
title="Bearer token accepted via URI query string",
|
|
437
|
+
description=(
|
|
438
|
+
"The server answered a request carrying the token only in the access_token "
|
|
439
|
+
"query parameter. The MCP spec forbids tokens in URI query strings because "
|
|
440
|
+
"they leak into logs and referrers."
|
|
441
|
+
),
|
|
442
|
+
severity=Severity.HIGH,
|
|
443
|
+
confidence=Confidence.MEDIUM,
|
|
444
|
+
evidence=evidence_of(resp),
|
|
445
|
+
location=ctx.target,
|
|
446
|
+
)
|
|
447
|
+
)
|
|
448
|
+
return findings
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _probe_request(ctx: ScanContext) -> tuple[str, dict[str, Any], str]:
|
|
452
|
+
if ctx.fingerprint.flavour == "stateless-2026":
|
|
453
|
+
return "server/discover", {}, PROTOCOL_2026_07_28
|
|
454
|
+
return (
|
|
455
|
+
"initialize",
|
|
456
|
+
{"protocolVersion": PROTOCOL_2025_11_25, "capabilities": {}, "clientInfo": CLIENT_INFO},
|
|
457
|
+
PROTOCOL_2025_11_25,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _body_for(http: Any, method: str, params: dict[str, Any], version: str) -> str:
|
|
462
|
+
return json.dumps(http.build_message(method, params, protocol_version=version))
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _accepted(resp: RpcResponse) -> bool:
|
|
466
|
+
"""A 2xx answer means the credential was accepted (401/403 is the expected rejection)."""
|
|
467
|
+
return resp.status is not None and 200 <= resp.status < 300 and resp.error_text is None
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _with_query(url: str, key: str, value: str) -> str:
|
|
471
|
+
parts = urlsplit(url)
|
|
472
|
+
query = f"{parts.query}&{key}={value}" if parts.query else f"{key}={value}"
|
|
473
|
+
return urlunsplit((parts.scheme, parts.netloc, parts.path, query, parts.fragment))
|