mcp-web-engine 1.0.4 → 1.2.0

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,339 @@
1
+ """
2
+ Hardened Security Module: SSRF Protection, IP/DNS Validation, Multi-Key Auth (Master & Beta Keys) & Rate Limiting
3
+ """
4
+
5
+ import hashlib
6
+ import hmac
7
+ import ipaddress
8
+ import json
9
+ import re
10
+ import socket
11
+ import threading
12
+ import time
13
+ import urllib.parse
14
+ from dataclasses import dataclass
15
+ from datetime import datetime, timezone
16
+
17
+ from fastapi import HTTPException, Security
18
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
19
+
20
+ from . import state_store
21
+ from .config import settings
22
+
23
+ security_bearer = HTTPBearer(auto_error=False)
24
+ _security_credentials = Security(security_bearer)
25
+
26
+ # Compatibility/debug mirror only; clearing it NEVER resets durable usage.
27
+ rate_limit_records = {}
28
+ _rate_lock = threading.RLock()
29
+ MAX_RATE_BUCKETS = 4096
30
+ BETA_KEYS_FILE = str(state_store.beta_keys_path())
31
+
32
+
33
+ def _secret_matches(token, expected):
34
+ return (
35
+ isinstance(token, str)
36
+ and isinstance(expected, str)
37
+ and bool(token)
38
+ and bool(expected)
39
+ and len(token) <= 1024
40
+ and hmac.compare_digest(token.encode("utf-8"), expected.encode("utf-8"))
41
+ )
42
+
43
+
44
+ def _is_master_key(token):
45
+ return _secret_matches(token, settings.API_KEY)
46
+
47
+
48
+ def _key_id(token):
49
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
50
+
51
+
52
+ def _beta_record(token):
53
+ """Re-read operator state on every admission; malformed state fails closed."""
54
+ if not isinstance(token, str) or not token or len(token) > 1024:
55
+ return None
56
+ try:
57
+ with open(BETA_KEYS_FILE, "r", encoding="utf-8") as stream:
58
+ raw = stream.read(1024 * 1024 + 1)
59
+ if len(raw) > 1024 * 1024:
60
+ return None
61
+ keys = json.loads(raw)
62
+ if not isinstance(keys, dict):
63
+ return None
64
+ record = next(
65
+ (value for key, value in keys.items() if _secret_matches(token, key)), None
66
+ )
67
+ if not isinstance(record, dict) or record.get("status") != "active":
68
+ return None
69
+ limit = record.get("limit")
70
+ rate = record.get("rate_limit", settings.RATE_LIMIT_PER_MINUTE)
71
+ telemetry = record.get("telemetry", {})
72
+ if not isinstance(telemetry, dict):
73
+ return None
74
+ requests = telemetry.get("requests", 0)
75
+ if any(
76
+ type(value) is not int or value < 0 for value in (limit, rate, requests)
77
+ ):
78
+ return None
79
+ if rate == 0:
80
+ return None
81
+ expires = record.get("expires_at")
82
+ if expires is not None:
83
+ if not isinstance(expires, str):
84
+ return None
85
+ expiry = datetime.fromisoformat(expires.replace("Z", "+00:00"))
86
+ if expiry.tzinfo is None or expiry <= datetime.now(timezone.utc):
87
+ return None
88
+ return record
89
+ except (OSError, ValueError, TypeError, AttributeError):
90
+ return None
91
+
92
+
93
+ def is_beta_key_valid(token: str) -> bool:
94
+ record = _beta_record(token)
95
+ if record is None:
96
+ return False
97
+ try:
98
+ usage = state_store.read_usage(_key_id(token))
99
+ except state_store.StateUnavailable:
100
+ return False
101
+ return max(record.get("telemetry", {}).get("requests", 0),
102
+ usage.get("admissions", 0)) < record["limit"]
103
+
104
+
105
+ # Stable special-purpose exclusions supplement ipaddress's versioned registry.
106
+ # Conservative policy also excludes globally reachable anycast exceptions here.
107
+ PRIVATE_NETWORKS = tuple(
108
+ ipaddress.ip_network(net)
109
+ for net in (
110
+ "0.0.0.0/8",
111
+ "10.0.0.0/8",
112
+ "100.64.0.0/10",
113
+ "127.0.0.0/8",
114
+ "169.254.0.0/16",
115
+ "172.16.0.0/12",
116
+ "192.0.0.0/24",
117
+ "192.0.2.0/24",
118
+ "192.88.99.0/24",
119
+ "192.168.0.0/16",
120
+ "198.18.0.0/15",
121
+ "198.51.100.0/24",
122
+ "203.0.113.0/24",
123
+ "224.0.0.0/4",
124
+ "240.0.0.0/4",
125
+ "::/128",
126
+ "::1/128",
127
+ "fc00::/7",
128
+ "fe80::/10",
129
+ "ff00::/8",
130
+ "2001::/23",
131
+ "2001:db8::/32",
132
+ "3fff::/20",
133
+ )
134
+ )
135
+ # IPv6 transition mechanisms can route a global-looking address into IPv4.
136
+ TRANSITION_NETWORKS = tuple(
137
+ ipaddress.ip_network(net)
138
+ for net in (
139
+ "64:ff9b::/96",
140
+ "64:ff9b:1::/48",
141
+ "2001::/32",
142
+ "2002::/16",
143
+ )
144
+ )
145
+ FORBIDDEN_HOSTNAMES = {"localhost", "loopback", "broadcasthost", "local"}
146
+
147
+
148
+ def is_ip_private(ip_str: str) -> bool:
149
+ """Historical API name: True means *not a public unicast destination*."""
150
+ try:
151
+ ip = ipaddress.ip_address(int(ip_str) if ip_str.isdigit() else ip_str)
152
+ if isinstance(ip, ipaddress.IPv6Address):
153
+ if ip.ipv4_mapped is not None:
154
+ return is_ip_private(str(ip.ipv4_mapped))
155
+ if any(ip in net for net in TRANSITION_NETWORKS):
156
+ return True
157
+ if ip not in ipaddress.ip_network("2000::/3"):
158
+ return True
159
+ if any(ip.version == net.version and ip in net for net in PRIVATE_NETWORKS):
160
+ return True
161
+ return (
162
+ not ip.is_global or ip.is_multicast or ip.is_reserved or ip.is_unspecified
163
+ )
164
+ except ValueError:
165
+ return False
166
+
167
+
168
+ def _url_error(
169
+ code="INVALID_URL", message="Malformed or ambiguous HTTP URL.", status_code=400
170
+ ):
171
+ return HTTPException(
172
+ status_code=status_code, detail={"error": code, "message": message}
173
+ )
174
+
175
+
176
+ @dataclass(frozen=True)
177
+ class ValidatedTarget:
178
+ url: str
179
+ hostname: str
180
+ port: int
181
+ addresses: tuple
182
+
183
+ @property
184
+ def resolve_entry(self):
185
+ # One validated address, no DNS fallback. URL retains Host and TLS SNI.
186
+ address = self.addresses[0]
187
+ if ":" in address:
188
+ address = f"[{address}]"
189
+ host = f"[{self.hostname}]" if ":" in self.hostname else self.hostname
190
+ return f"{host}:{self.port}:{address}"
191
+
192
+
193
+ def _parse_http_url(url_str):
194
+ if not isinstance(url_str, str) or not url_str or len(url_str) > 16384:
195
+ raise _url_error()
196
+ # urlsplit silently removes controls/leading whitespace; libcurl need not.
197
+ if "\\" in url_str or any(
198
+ ord(c) <= 32 or ord(c) == 127 or c.isspace() for c in url_str
199
+ ):
200
+ raise _url_error()
201
+ try:
202
+ parsed = urllib.parse.urlsplit(url_str)
203
+ if parsed.scheme not in ("http", "https"):
204
+ raise _url_error("INVALID_SCHEME", "Only http/https are supported.")
205
+ # Never decode authorities or accept userinfo/IPv6 zone identifiers.
206
+ if not parsed.netloc or any(c in parsed.netloc for c in ("%", "@")):
207
+ raise _url_error()
208
+ hostname = parsed.hostname
209
+ port = parsed.port
210
+ if not hostname or port == 0 or parsed.netloc.endswith(":"):
211
+ raise _url_error()
212
+ port = port or (443 if parsed.scheme == "https" else 80)
213
+ try:
214
+ ip = ipaddress.ip_address(hostname)
215
+ except ValueError:
216
+ hostname = hostname.encode("idna").decode("ascii").lower().rstrip(".")
217
+ labels = hostname.split(".")
218
+ if (
219
+ not hostname
220
+ or len(hostname) > 253
221
+ or any(
222
+ not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label)
223
+ for label in labels
224
+ )
225
+ ):
226
+ raise _url_error()
227
+ # libcurl interprets legacy numeric IPv4 as literals, ignoring DNS.
228
+ try:
229
+ socket.inet_aton(hostname)
230
+ except OSError:
231
+ pass
232
+ else:
233
+ raise _url_error()
234
+ else:
235
+ hostname = str(ip)
236
+ authority = f"[{hostname}]" if ":" in hostname else hostname
237
+ if parsed.port is not None:
238
+ authority += f":{port}"
239
+ # Encode Unicode path/query once. Never unquote URL data or authority.
240
+ path = urllib.parse.quote(parsed.path or "/", safe="/%:@!$&'()*+,;=-._~")
241
+ query = urllib.parse.quote(parsed.query, safe="/%?:@!$&'()*+,;=-._~[]")
242
+ canonical = urllib.parse.urlunsplit((parsed.scheme, authority, path, query, ""))
243
+ return canonical, hostname, port
244
+ except (ValueError, UnicodeError):
245
+ raise _url_error() from None
246
+
247
+
248
+ def resolve_ssrf_target(url_str: str) -> ValidatedTarget:
249
+ """Resolve once; fetchers MUST pin these exact addresses into the transport."""
250
+ canonical, hostname, port = _parse_http_url(url_str)
251
+ if hostname in FORBIDDEN_HOSTNAMES or hostname.endswith((".local", ".localhost")):
252
+ raise _url_error("SSRF_BLOCKED", "Local hostname is blocked.", 403)
253
+ try:
254
+ literal = ipaddress.ip_address(hostname)
255
+ except ValueError:
256
+ try:
257
+ records = socket.getaddrinfo(hostname, port, type=socket.SOCK_STREAM)
258
+ except OSError:
259
+ raise _url_error(
260
+ "DNS_RESOLUTION_FAILED", "Could not resolve target hostname."
261
+ ) from None
262
+ addresses = tuple(dict.fromkeys(record[4][0] for record in records))
263
+ else:
264
+ addresses = (str(literal),)
265
+ if not addresses:
266
+ raise _url_error("DNS_RESOLUTION_FAILED", "No destination addresses returned.")
267
+ for address in addresses:
268
+ try:
269
+ ipaddress.ip_address(address)
270
+ except ValueError:
271
+ raise _url_error(
272
+ "SSRF_BLOCKED", "Invalid destination address.", 403
273
+ ) from None
274
+ if is_ip_private(address):
275
+ raise _url_error(
276
+ "SSRF_BLOCKED", "Non-public destination address is blocked.", 403
277
+ )
278
+ return ValidatedTarget(canonical, hostname, port, addresses)
279
+
280
+
281
+ def validate_ssrf_url(url_str: str) -> str:
282
+ """Compatibility API; fetching requires resolve_ssrf_target, not this alone."""
283
+ return resolve_ssrf_target(url_str).url
284
+
285
+
286
+ def verify_api_key(auth: HTTPAuthorizationCredentials = _security_credentials):
287
+ if (
288
+ not auth
289
+ or not auth.credentials
290
+ or not isinstance(auth.scheme, str)
291
+ or auth.scheme.lower() != "bearer"
292
+ ):
293
+ raise HTTPException(
294
+ 401,
295
+ detail={"error": "UNAUTHORIZED", "message": "Invalid or missing API key."},
296
+ )
297
+ token = auth.credentials
298
+ if _is_master_key(token) or is_beta_key_valid(token):
299
+ return token
300
+ raise HTTPException(
301
+ 401, detail={"error": "UNAUTHORIZED", "message": "Invalid or revoked API key."}
302
+ )
303
+
304
+
305
+ def verify_master_api_key(auth: HTTPAuthorizationCredentials = _security_credentials):
306
+ """Dependency for operator-only telemetry/metrics. Wire at the route layer."""
307
+ token = verify_api_key(auth)
308
+ if not _is_master_key(token):
309
+ raise HTTPException(403, detail={"error": "ADMIN_REQUIRED"})
310
+ return token
311
+
312
+
313
+ def check_rate_limit(api_key: str):
314
+ """Common durable admission for HTTP transports; one call per request.
315
+
316
+ Quota counts admitted requests, not tools within a batch. No authorization
317
+ JSON is ever written. All local workers must share MCP_WEB_ENGINE_STATE_DB.
318
+ """
319
+ master = _is_master_key(api_key)
320
+ record = None if master else _beta_record(api_key)
321
+ if not master and record is None:
322
+ raise HTTPException(401, detail={"error": "UNAUTHORIZED"})
323
+ key = _key_id(api_key)
324
+ rate = settings.RATE_LIMIT_PER_MINUTE
325
+ if record is not None:
326
+ rate = min(rate, record.get("rate_limit", rate))
327
+ try:
328
+ state_store.admit(
329
+ key, rate, quota=record["limit"] if record is not None else None,
330
+ baseline=record.get("telemetry", {}).get("requests", 0) if record is not None else 0,
331
+ capacity=MAX_RATE_BUCKETS, now=time.time(),
332
+ )
333
+ except state_store.AdmissionDenied as exc:
334
+ raise HTTPException(429, detail={"error": exc.code}) from None
335
+ except state_store.StateUnavailable:
336
+ raise HTTPException(503, detail={"error": "USAGE_STATE_UNAVAILABLE"}) from None
337
+ with _rate_lock:
338
+ if len(rate_limit_records) < MAX_RATE_BUCKETS or key in rate_limit_records:
339
+ rate_limit_records[key] = True
@@ -0,0 +1,133 @@
1
+ """Local-host durable admission/telemetry. Never reads or writes credentials.
2
+
3
+ Every operation owns a connection; BEGIN IMMEDIATE serializes worker admissions.
4
+ Use a local filesystem shared by workers, not a distributed-host SQLite mount.
5
+ Admission totals and completed tool telemetry are deliberately separate counters.
6
+ """
7
+ import math
8
+ import os
9
+ import sqlite3
10
+ import time
11
+ from contextlib import contextmanager
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+
16
+ class AdmissionDenied(Exception):
17
+ def __init__(self, code):
18
+ self.code = code
19
+ super().__init__(code)
20
+
21
+
22
+ class StateUnavailable(Exception):
23
+ """Storage failed; callers must fail closed without exposing paths/errors."""
24
+
25
+
26
+ def _state_dir():
27
+ return Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local/state") / "mcp-web-engine"
28
+
29
+
30
+ def state_db_path():
31
+ return Path(os.environ.get("MCP_WEB_ENGINE_STATE_DB") or _state_dir() / "usage.sqlite3")
32
+
33
+
34
+ def beta_keys_path():
35
+ return Path(os.environ.get("MCP_WEB_ENGINE_BETA_KEYS_FILE") or _state_dir() / "beta_keys.json")
36
+
37
+
38
+ @contextmanager
39
+ def _connection(path=None):
40
+ db = None
41
+ try:
42
+ target = Path(path) if path is not None else state_db_path()
43
+ target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
44
+ # Create with private permissions without changing process-global umask.
45
+ try:
46
+ fd = os.open(target, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
47
+ except FileExistsError:
48
+ pass
49
+ else:
50
+ os.close(fd)
51
+ db = sqlite3.connect(str(target), timeout=10, isolation_level=None)
52
+ db.row_factory = sqlite3.Row
53
+ db.execute("PRAGMA busy_timeout=10000")
54
+ db.execute("BEGIN IMMEDIATE")
55
+ db.execute("""CREATE TABLE IF NOT EXISTS usage (
56
+ key_id TEXT PRIMARY KEY,
57
+ window_started REAL NOT NULL DEFAULT 0,
58
+ window_count INTEGER NOT NULL DEFAULT 0 CHECK(window_count >= 0),
59
+ admissions INTEGER NOT NULL DEFAULT 0 CHECK(admissions >= 0),
60
+ requests INTEGER NOT NULL DEFAULT 0 CHECK(requests >= 0),
61
+ errors INTEGER NOT NULL DEFAULT 0 CHECK(errors >= 0),
62
+ units INTEGER NOT NULL DEFAULT 0 CHECK(units >= 0),
63
+ latency_total REAL NOT NULL DEFAULT 0,
64
+ web_search INTEGER NOT NULL DEFAULT 0,
65
+ fetch_url INTEGER NOT NULL DEFAULT 0,
66
+ extract_markdown INTEGER NOT NULL DEFAULT 0,
67
+ last_seen REAL
68
+ )""")
69
+ yield db
70
+ db.commit()
71
+ except (OSError, sqlite3.Error, OverflowError) as exc:
72
+ if db is not None:
73
+ db.rollback()
74
+ raise StateUnavailable("Usage state unavailable") from exc
75
+ finally:
76
+ if db is not None:
77
+ db.close()
78
+
79
+
80
+ def _ensure_row(db, key_id, capacity):
81
+ if db.execute("SELECT 1 FROM usage WHERE key_id=?", (key_id,)).fetchone():
82
+ return
83
+ if db.execute("SELECT COUNT(*) FROM usage").fetchone()[0] >= capacity:
84
+ raise AdmissionDenied("RATE_LIMIT_CAPACITY")
85
+ db.execute("INSERT INTO usage(key_id) VALUES (?)", (key_id,))
86
+
87
+
88
+ def admit(key_id, rate, *, quota=None, baseline=0, capacity=4096, now=None, path=None):
89
+ """Atomically consume one HTTP admission or raise AdmissionDenied.
90
+
91
+ Baseline imports legacy JSON requests via max(), never adding tool counts to
92
+ admission quota. Wall-clock windows survive restart; clock rollback does not
93
+ reset a live window. Operators must rotate tokens rather than delete DB rows.
94
+ """
95
+ now = time.time() if now is None else now
96
+ with _connection(path) as db:
97
+ _ensure_row(db, key_id, capacity)
98
+ row = db.execute("SELECT * FROM usage WHERE key_id=?", (key_id,)).fetchone()
99
+ used = max(row["admissions"], baseline)
100
+ if quota is not None and used >= quota:
101
+ raise AdmissionDenied("BETA_QUOTA_EXCEEDED")
102
+ start, count = row["window_started"], row["window_count"]
103
+ if now - start >= 60:
104
+ start, count = now, 0
105
+ if count >= rate:
106
+ raise AdmissionDenied("RATE_LIMIT_EXCEEDED")
107
+ db.execute("UPDATE usage SET window_started=?, window_count=?, admissions=? WHERE key_id=?",
108
+ (start, count + 1, used + 1, key_id))
109
+
110
+
111
+ def read_usage(key_id=None, *, path=None) -> dict[str, Any]:
112
+ """Return hashed-id counters, never credentials; None returns all rows."""
113
+ with _connection(path) as db:
114
+ if key_id is not None:
115
+ row = db.execute("SELECT * FROM usage WHERE key_id=?", (key_id,)).fetchone()
116
+ return dict(row) if row else {}
117
+ return {row["key_id"]: dict(row) for row in db.execute("SELECT * FROM usage")}
118
+
119
+
120
+ def record_telemetry(key_id, tool_name, latency_ms, success, units=1, *, path=None):
121
+ """Completed tool counters only; cannot authorize or restore a revoked key."""
122
+ if not isinstance(latency_ms, (int, float)) or not math.isfinite(latency_ms) or latency_ms < 0:
123
+ raise ValueError("Invalid latency")
124
+ if type(units) is not int or units < 0:
125
+ raise ValueError("Invalid units")
126
+ with _connection(path) as db:
127
+ _ensure_row(db, key_id, 4096)
128
+ db.execute("""UPDATE usage SET requests=requests+1, errors=errors+?,
129
+ units=units+?, latency_total=latency_total+?, last_seen=?,
130
+ web_search=web_search+?, fetch_url=fetch_url+?, extract_markdown=extract_markdown+?
131
+ WHERE key_id=?""",
132
+ (int(not success), units, latency_ms, time.time(), int(tool_name == "web_search"),
133
+ int(tool_name == "fetch_url"), int(tool_name == "extract_markdown"), key_id))