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.
@@ -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))