gabriel / musehub public
ssrf.py python
157 lines 6.2 KB
Raw
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ breaking 156 days ago
1 """SSRF (Server-Side Request Forgery) protection for outbound HTTP requests.
2
3 Any feature that makes outbound HTTP requests (webhooks, MCP callbacks, avatar
4 fetch) must validate the target URL through this module before sending.
5
6 What is blocked:
7 * Non-HTTPS schemes (http://, file://, ftp://, etc.)
8 * Loopback: 127.0.0.0/8, ::1
9 * RFC-1918 private ranges: 10.x, 172.16–31.x, 192.168.x
10 * Link-local: 169.254.0.0/16 (AWS metadata at 169.254.169.254), fe80::/10
11 * Unique-local IPv6: fc00::/7
12 * Shared address space: 100.64.0.0/10 (carrier-grade NAT, RFC-6598)
13 * Reserved / unspecified: 0.0.0.0/8, 240.0.0.0/4, ::/128
14
15 Two-layer defence:
16 1. ``check_url_safe(url)`` — fast sync check (scheme + bare IP literal).
17 Called from Pydantic field validators at request-parse time. No DNS
18 resolution; suitable for the async event loop.
19 2. ``validate_outbound_url(url)`` — full async check (scheme + IP literal +
20 DNS resolution via asyncio.to_thread). Called by webhook delivery before
21 each HTTP POST attempt. Blocks SSRF via DNS rebinding.
22 """
23 from __future__ import annotations
24
25 import asyncio
26 import ipaddress
27 import socket
28 from urllib.parse import urlparse
29
30 # ── Blocked IP ranges ─────────────────────────────────────────────────────────
31
32 _BLOCKED_NETWORKS: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = [
33 ipaddress.ip_network("127.0.0.0/8"), # Loopback IPv4
34 ipaddress.ip_network("::1/128"), # Loopback IPv6
35 ipaddress.ip_network("10.0.0.0/8"), # RFC-1918 class A
36 ipaddress.ip_network("172.16.0.0/12"), # RFC-1918 class B
37 ipaddress.ip_network("192.168.0.0/16"), # RFC-1918 class C
38 ipaddress.ip_network("169.254.0.0/16"), # Link-local IPv4 (AWS metadata)
39 ipaddress.ip_network("fe80::/10"), # Link-local IPv6
40 ipaddress.ip_network("fc00::/7"), # Unique-local IPv6
41 ipaddress.ip_network("100.64.0.0/10"), # Shared address space (RFC-6598)
42 ipaddress.ip_network("0.0.0.0/8"), # Unspecified IPv4
43 ipaddress.ip_network("240.0.0.0/4"), # Reserved IPv4
44 ipaddress.ip_network("::/128"), # Unspecified IPv6
45 ]
46
47
48 def _is_blocked_ip(addr: str) -> bool:
49 """Return True when *addr* falls within a blocked network range."""
50 try:
51 ip = ipaddress.ip_address(addr)
52 except ValueError:
53 return True # unparseable address — block it
54 return any(ip in net for net in _BLOCKED_NETWORKS)
55
56
57 # ── Sync check (scheme + bare IP literal) ─────────────────────────────────────
58
59 def check_url_safe(url: str) -> str:
60 """Fast synchronous SSRF pre-check — no DNS resolution.
61
62 Validates scheme (must be ``https``) and rejects bare IP literals that
63 fall in blocked ranges. Suitable for Pydantic field validators and other
64 synchronous call sites.
65
66 Args:
67 url: The candidate outbound URL.
68
69 Returns:
70 The original *url* string if safe.
71
72 Raises:
73 ValueError: When the URL is malformed, uses a non-HTTPS scheme, or
74 contains a bare IP literal in a blocked range.
75 """
76 try:
77 parsed = urlparse(url)
78 except Exception as exc: # pragma: no cover — urlparse is very forgiving
79 raise ValueError(f"Malformed URL: {exc}") from exc
80
81 if parsed.scheme != "https":
82 raise ValueError(
83 f"Outbound URL must use https:// — got {parsed.scheme!r}. "
84 "Non-HTTPS schemes are blocked to prevent credential leakage and SSRF."
85 )
86
87 hostname = parsed.hostname
88 if not hostname:
89 raise ValueError("URL has no hostname.")
90
91 # If the hostname is a literal IP address, check it immediately.
92 # DNS-based hostnames are deferred to the async validate_outbound_url check.
93 try:
94 ipaddress.ip_address(hostname)
95 # It parsed — it is a bare IP literal.
96 if _is_blocked_ip(hostname):
97 raise ValueError(
98 f"Outbound URL targets a private/reserved IP address ({hostname}). "
99 "Requests to RFC-1918, loopback, and link-local addresses are blocked."
100 )
101 except ValueError as exc:
102 if "private/reserved" in str(exc) or "blocked" in str(exc):
103 raise
104 # Not an IP literal — hostname will be resolved at delivery time.
105
106 return url
107
108
109 # ── Async check (scheme + IP literal + DNS resolution) ────────────────────────
110
111 async def validate_outbound_url(url: str) -> str:
112 """Full async SSRF check including DNS resolution.
113
114 Resolves the hostname and verifies that none of the resulting IP addresses
115 fall in a blocked range. Use this immediately before making an outbound
116 HTTP request to guard against DNS rebinding attacks.
117
118 Args:
119 url: The candidate outbound URL.
120
121 Returns:
122 The original *url* string if safe.
123
124 Raises:
125 ValueError: When the URL fails any SSRF check.
126 """
127 # Perform scheme + bare IP check first (cheap, no I/O).
128 check_url_safe(url)
129
130 parsed = urlparse(url)
131 hostname = parsed.hostname or ""
132
133 # If it's already a bare IP literal, it passed the sync check above.
134 # No DNS needed.
135 try:
136 ipaddress.ip_address(hostname)
137 return url # bare IP — already validated
138 except ValueError:
139 pass # not an IP literal — proceed to DNS resolution
140
141 # Resolve via getaddrinfo in a thread so the event loop is not blocked.
142 try:
143 infos: list[tuple[int, int, int, str, tuple[str, int]]] = await asyncio.to_thread(socket.getaddrinfo, hostname, None)
144 except socket.gaierror as exc:
145 raise ValueError(
146 f"Outbound URL hostname cannot be resolved: {hostname!r} — {exc}"
147 ) from exc
148
149 for _family, _type, _proto, _canonname, sockaddr in infos:
150 ip_str = sockaddr[0]
151 if _is_blocked_ip(ip_str):
152 raise ValueError(
153 f"Outbound URL {hostname!r} resolves to a private/reserved address "
154 f"({ip_str}). RFC-1918 / loopback / link-local targets are blocked."
155 )
156
157 return url
File History 1 commit
sha256:9590cee1e0ccd6c76528f005b95d634d80f5019f0dcb7c371e149adc31d1fb65 refactor: enforce gRPC framing on all MWP wire traffic Sonnet 4.6 minor ⚠ 156 days ago