mcp-server-taxcode 0.1.1__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.
taxcode_mcp/ssrf.py ADDED
@@ -0,0 +1,287 @@
1
+ """Shared SSRF checks for caller- or config-controlled outbound URLs.
2
+
3
+ Leaf servers that fetch a URL supplied by a tool argument or by configuration
4
+ should call :func:`validate_outbound_url` before the request. Rejection raises
5
+ :class:`SSRFValidationError`; it never returns the original URL unchanged after
6
+ a failed check.
7
+
8
+ HTTPS is required unless the host is listed in ``http_required_hosts``. Those
9
+ hosts are the only ones allowed to use ``http://``, and when ``allowed_hosts``
10
+ is also set the host must appear there too. Literal private, loopback,
11
+ link-local, multicast, reserved, and metadata addresses are blocked, including
12
+ obscured IPv4 forms (decimal, octal, hex, and short dotted forms).
13
+
14
+ DNS rebinding is a separate, request-time check. Use
15
+ :func:`validate_resolved_host` (off the event loop; see
16
+ ``SSRFSafeAsyncTransport``) so a public name cannot resolve to an internal
17
+ address at connect time.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import ipaddress
23
+ import re
24
+ import socket
25
+ from collections.abc import Collection
26
+ from typing import Literal
27
+ from urllib.parse import urlparse
28
+
29
+ SSRFReason = Literal[
30
+ "empty",
31
+ "invalid",
32
+ "scheme",
33
+ "missing_hostname",
34
+ "userinfo",
35
+ "blocked_hostname",
36
+ "blocked_ip",
37
+ "invalid_ip",
38
+ "allowlist",
39
+ "insecure_http",
40
+ "dns_failure",
41
+ ]
42
+
43
+ _BLOCKED_HOSTS = frozenset(
44
+ {
45
+ "localhost",
46
+ "localhost.localdomain",
47
+ "metadata.google.internal",
48
+ "metadata.goog",
49
+ }
50
+ )
51
+ _BLOCKED_SUFFIXES = (".localhost", ".local", ".internal", ".localdomain")
52
+
53
+ _OBSCURE_IPV4_RE = re.compile(
54
+ r"(?:0x[0-9a-f]+|\d+)(?:\.(?:0x[0-9a-f]+|\d+)){0,3}",
55
+ flags=re.IGNORECASE,
56
+ )
57
+ _IPV4_WIDTHS: dict[int, tuple[int, ...]] = {
58
+ 1: (32,),
59
+ 2: (8, 24),
60
+ 3: (8, 8, 16),
61
+ 4: (8, 8, 8, 8),
62
+ }
63
+
64
+
65
+ class SSRFValidationError(ValueError):
66
+ """Raised when an outbound URL or resolved address fails SSRF validation."""
67
+
68
+ def __init__(
69
+ self,
70
+ message: str,
71
+ *,
72
+ reason: SSRFReason,
73
+ hostname: str | None = None,
74
+ resolved_ip: str | None = None,
75
+ ) -> None:
76
+ super().__init__(message)
77
+ self.reason = reason
78
+ self.hostname = hostname
79
+ self.resolved_ip = resolved_ip
80
+
81
+
82
+ def validate_outbound_url(
83
+ url: str,
84
+ *,
85
+ allowed_hosts: Collection[str] | str | None = None,
86
+ http_required_hosts: Collection[str] | str | None = None,
87
+ ) -> str:
88
+ """Validate ``url`` and return it stripped of surrounding whitespace.
89
+
90
+ Args:
91
+ url: Absolute URL to fetch. Must be ``http`` or ``https``.
92
+ allowed_hosts: Optional leaf-specific host allowlist. ``None`` does not
93
+ restrict the host. A string is split on commas. An empty allowlist
94
+ matches nothing.
95
+ http_required_hosts: Hosts that must be contacted with ``http://``.
96
+ Every other host must use ``https://``. When ``allowed_hosts`` is
97
+ set, an HTTP host must be present in both lists.
98
+
99
+ Raises:
100
+ SSRFValidationError: The URL is unsafe. The exception message states
101
+ why; callers must not treat a raise as success.
102
+ """
103
+ candidate = url.strip()
104
+ if not candidate:
105
+ raise SSRFValidationError("Outbound URL is empty.", reason="empty")
106
+ if "\\" in candidate or any(ord(char) < 32 for char in candidate):
107
+ raise SSRFValidationError(
108
+ "Outbound URL contains illegal characters.",
109
+ reason="invalid",
110
+ )
111
+
112
+ parsed = urlparse(candidate)
113
+ scheme = parsed.scheme.lower()
114
+ if scheme not in {"http", "https"}:
115
+ shown = scheme or "(none)"
116
+ raise SSRFValidationError(
117
+ f"Outbound URL scheme {shown!r} is not permitted; only http and https are allowed.",
118
+ reason="scheme",
119
+ )
120
+ if parsed.username is not None or parsed.password is not None:
121
+ raise SSRFValidationError(
122
+ "Outbound URL must not include userinfo credentials.",
123
+ reason="userinfo",
124
+ )
125
+
126
+ raw_host = parsed.hostname
127
+ if not raw_host:
128
+ raise SSRFValidationError(
129
+ "Outbound URL is missing a hostname.",
130
+ reason="missing_hostname",
131
+ )
132
+ host = raw_host.rstrip(".").lower()
133
+ if host in _BLOCKED_HOSTS or host.endswith(_BLOCKED_SUFFIXES):
134
+ raise SSRFValidationError(
135
+ f"Blocked internal, loopback, or metadata hostname: {host}",
136
+ reason="blocked_hostname",
137
+ hostname=host,
138
+ )
139
+
140
+ literal = _literal_ip(host)
141
+ if literal is not None and _ip_is_blocked(literal):
142
+ raise SSRFValidationError(
143
+ f"Blocked private, link-local, loopback, or metadata address: {host}",
144
+ reason="blocked_ip",
145
+ hostname=host,
146
+ )
147
+
148
+ permitted = _host_set(allowed_hosts)
149
+ if permitted is not None and host not in permitted:
150
+ raise SSRFValidationError(
151
+ f"Hostname {host!r} is not in the outbound host allowlist.",
152
+ reason="allowlist",
153
+ hostname=host,
154
+ )
155
+
156
+ if scheme == "http":
157
+ http_hosts = _host_set(http_required_hosts)
158
+ if http_hosts is None or host not in http_hosts:
159
+ raise SSRFValidationError(
160
+ "Outbound URL must use HTTPS. HTTP is permitted only for an explicitly "
161
+ "configured allowlisted host.",
162
+ reason="insecure_http",
163
+ hostname=host,
164
+ )
165
+ return candidate
166
+
167
+
168
+ def validate_resolved_host(hostname: str) -> None:
169
+ """Reject a hostname that is, or resolves to, a non-global address.
170
+
171
+ ``example.com`` and names under it skip DNS so placeholder templates stay
172
+ usable offline. Run this in a worker thread when calling from an event loop.
173
+ """
174
+ host = hostname.rstrip(".").lower()
175
+ if host == "example.com" or host.endswith(".example.com"):
176
+ return
177
+
178
+ literal = _literal_ip(host)
179
+ if literal is not None:
180
+ if _ip_is_blocked(literal):
181
+ raise SSRFValidationError(
182
+ f"Blocked private, link-local, loopback, or metadata address: {host}",
183
+ reason="blocked_ip",
184
+ hostname=host,
185
+ )
186
+ return
187
+
188
+ try:
189
+ resolved = socket.getaddrinfo(host, None)
190
+ except socket.gaierror as exc:
191
+ raise SSRFValidationError(
192
+ f"Could not resolve hostname: {host}",
193
+ reason="dns_failure",
194
+ hostname=host,
195
+ ) from exc
196
+
197
+ found = False
198
+ for _, _, _, _, sockaddr in resolved:
199
+ found = True
200
+ resolved_ip = ipaddress.ip_address(sockaddr[0])
201
+ if _ip_is_blocked(resolved_ip):
202
+ raise SSRFValidationError(
203
+ f"Blocked hostname {host!r} resolving to a non-global address: {sockaddr[0]}",
204
+ reason="blocked_ip",
205
+ hostname=host,
206
+ resolved_ip=str(sockaddr[0]),
207
+ )
208
+ if not found:
209
+ raise SSRFValidationError(
210
+ f"Could not resolve hostname: {host}",
211
+ reason="dns_failure",
212
+ hostname=host,
213
+ )
214
+
215
+
216
+ def _host_set(hosts: Collection[str] | str | None) -> frozenset[str] | None:
217
+ """Normalize an allowlist. ``None`` means unrestricted; a string is comma-separated."""
218
+ if hosts is None:
219
+ return None
220
+ items = hosts.split(",") if isinstance(hosts, str) else hosts
221
+ return frozenset(item.strip().lower().rstrip(".") for item in items if item.strip())
222
+
223
+
224
+ def _ip_is_blocked(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
225
+ """Return True for addresses that must not be used as outbound destinations.
226
+
227
+ Multicast can report ``is_global`` in this Python, so it is checked explicitly.
228
+ Shared address space such as ``100.64.0.0/10`` is not marked private and is
229
+ rejected because it is not globally routable.
230
+ """
231
+ return bool(
232
+ ip.is_private
233
+ or ip.is_loopback
234
+ or ip.is_link_local
235
+ or ip.is_multicast
236
+ or ip.is_reserved
237
+ or ip.is_unspecified
238
+ or not ip.is_global
239
+ )
240
+
241
+
242
+ def _literal_ip(hostname: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
243
+ """Parse a hostname that is an IP literal, including obscured IPv4 forms."""
244
+ try:
245
+ return ipaddress.ip_address(hostname)
246
+ except ValueError:
247
+ return _obscure_ipv4(hostname)
248
+
249
+
250
+ def _obscure_ipv4(text: str) -> ipaddress.IPv4Address | None:
251
+ """Decode decimal, octal, hex, and short-form IPv4 literals.
252
+
253
+ Returns ``None`` when ``text`` is not shaped like an IPv4 literal. Raises
254
+ when it is shaped like one but the value is not a legal address, so those
255
+ forms cannot fall through as ordinary hostnames.
256
+ """
257
+ if _OBSCURE_IPV4_RE.fullmatch(text) is None:
258
+ return None
259
+ parts = text.split(".")
260
+ widths = _IPV4_WIDTHS[len(parts)]
261
+ packed = 0
262
+ for part, width in zip(parts, widths, strict=True):
263
+ try:
264
+ value = _parse_ipv4_component(part)
265
+ except ValueError as exc:
266
+ raise SSRFValidationError(
267
+ f"Outbound URL hostname is not a valid IP address: {text}",
268
+ reason="invalid_ip",
269
+ hostname=text,
270
+ ) from exc
271
+ if value >= (1 << width):
272
+ raise SSRFValidationError(
273
+ f"Outbound URL hostname is not a valid IP address: {text}",
274
+ reason="invalid_ip",
275
+ hostname=text,
276
+ )
277
+ packed = (packed << width) | value
278
+ return ipaddress.IPv4Address(packed)
279
+
280
+
281
+ def _parse_ipv4_component(part: str) -> int:
282
+ """Parse one IPv4 component, honoring ``0x`` hex and leading-zero octal."""
283
+ if part.lower().startswith("0x"):
284
+ return int(part, 16)
285
+ if len(part) > 1 and part.startswith("0"):
286
+ return int(part, 8)
287
+ return int(part, 10)
@@ -0,0 +1,41 @@
1
+ """Modular domain tools and sub-servers for FastMCP 4 Server Composition."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from taxcode_mcp.tools.compliance import (
6
+ calculate_built_in_gain_sourcing,
7
+ compliance_server,
8
+ cross_border_tax_analysis,
9
+ evaluate_georgia_residency,
10
+ evaluate_pr_bona_fide_residency,
11
+ evaluate_state_residency,
12
+ )
13
+ from taxcode_mcp.tools.statutes import (
14
+ get_statute_resource,
15
+ list_publications,
16
+ lookup_statute,
17
+ search_tax_code,
18
+ statutes_server,
19
+ )
20
+ from taxcode_mcp.tools.system import (
21
+ get_capabilities,
22
+ get_health_status,
23
+ system_server,
24
+ )
25
+
26
+ __all__ = [
27
+ "calculate_built_in_gain_sourcing",
28
+ "compliance_server",
29
+ "cross_border_tax_analysis",
30
+ "evaluate_georgia_residency",
31
+ "evaluate_pr_bona_fide_residency",
32
+ "evaluate_state_residency",
33
+ "get_capabilities",
34
+ "get_health_status",
35
+ "get_statute_resource",
36
+ "list_publications",
37
+ "lookup_statute",
38
+ "search_tax_code",
39
+ "statutes_server",
40
+ "system_server",
41
+ ]