gabriel / musehub public
ssrf.py python
153 lines 6.2 KB
Raw
sha256:969ddc5e88776e70af33c016b8777bb721356508ae5f304b3fb46c451494ece7 test(mwp3): Phase 4 — fix adjacent suite regressions, lock … Sonnet 4.6 22 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
24 import asyncio
25 import ipaddress
26 import socket
27 from urllib.parse import urlparse
28
29 # ── Blocked IP ranges ─────────────────────────────────────────────────────────
30
31 _BLOCKED_NETWORKS: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = [
32 ipaddress.ip_network("127.0.0.0/8"), # Loopback IPv4
33 ipaddress.ip_network("::1/128"), # Loopback IPv6
34 ipaddress.ip_network("10.0.0.0/8"), # RFC-1918 class A
35 ipaddress.ip_network("172.16.0.0/12"), # RFC-1918 class B
36 ipaddress.ip_network("192.168.0.0/16"), # RFC-1918 class C
37 ipaddress.ip_network("169.254.0.0/16"), # Link-local IPv4 (AWS metadata)
38 ipaddress.ip_network("fe80::/10"), # Link-local IPv6
39 ipaddress.ip_network("fc00::/7"), # Unique-local IPv6
40 ipaddress.ip_network("100.64.0.0/10"), # Shared address space (RFC-6598)
41 ipaddress.ip_network("0.0.0.0/8"), # Unspecified IPv4
42 ipaddress.ip_network("240.0.0.0/4"), # Reserved IPv4
43 ipaddress.ip_network("::/128"), # Unspecified IPv6
44 ]
45
46 def _is_blocked_ip(addr: str) -> bool:
47 """Return True when *addr* falls within a blocked network range."""
48 try:
49 ip = ipaddress.ip_address(addr)
50 except ValueError:
51 return True # unparseable address — block it
52 return any(ip in net for net in _BLOCKED_NETWORKS)
53
54 # ── Sync check (scheme + bare IP literal) ─────────────────────────────────────
55
56 def check_url_safe(url: str) -> str:
57 """Fast synchronous SSRF pre-check — no DNS resolution.
58
59 Validates scheme (must be ``https``) and rejects bare IP literals that
60 fall in blocked ranges. Suitable for Pydantic field validators and other
61 synchronous call sites.
62
63 Args:
64 url: The candidate outbound URL.
65
66 Returns:
67 The original *url* string if safe.
68
69 Raises:
70 ValueError: When the URL is malformed, uses a non-HTTPS scheme, or
71 contains a bare IP literal in a blocked range.
72 """
73 try:
74 parsed = urlparse(url)
75 except Exception as exc: # pragma: no cover — urlparse is very forgiving
76 raise ValueError(f"Malformed URL: {exc}") from exc
77
78 if parsed.scheme != "https":
79 raise ValueError(
80 f"Outbound URL must use https:// — got {parsed.scheme!r}. "
81 "Non-HTTPS schemes are blocked to prevent credential leakage and SSRF."
82 )
83
84 hostname = parsed.hostname
85 if not hostname:
86 raise ValueError("URL has no hostname.")
87
88 # If the hostname is a literal IP address, check it immediately.
89 # DNS-based hostnames are deferred to the async validate_outbound_url check.
90 try:
91 ipaddress.ip_address(hostname)
92 # It parsed — it is a bare IP literal.
93 if _is_blocked_ip(hostname):
94 raise ValueError(
95 f"Outbound URL targets a private/reserved IP address ({hostname}). "
96 "Requests to RFC-1918, loopback, and link-local addresses are blocked."
97 )
98 except ValueError as exc:
99 if "private/reserved" in str(exc) or "blocked" in str(exc):
100 raise
101 # Not an IP literal — hostname will be resolved at delivery time.
102
103 return url
104
105 # ── Async check (scheme + IP literal + DNS resolution) ────────────────────────
106
107 async def validate_outbound_url(url: str) -> str:
108 """Full async SSRF check including DNS resolution.
109
110 Resolves the hostname and verifies that none of the resulting IP addresses
111 fall in a blocked range. Use this immediately before making an outbound
112 HTTP request to guard against DNS rebinding attacks.
113
114 Args:
115 url: The candidate outbound URL.
116
117 Returns:
118 The original *url* string if safe.
119
120 Raises:
121 ValueError: When the URL fails any SSRF check.
122 """
123 # Perform scheme + bare IP check first (cheap, no I/O).
124 check_url_safe(url)
125
126 parsed = urlparse(url)
127 hostname = parsed.hostname or ""
128
129 # If it's already a bare IP literal, it passed the sync check above.
130 # No DNS needed.
131 try:
132 ipaddress.ip_address(hostname)
133 return url # bare IP — already validated
134 except ValueError:
135 pass # not an IP literal — proceed to DNS resolution
136
137 # Resolve via getaddrinfo in a thread so the event loop is not blocked.
138 try:
139 infos: list[tuple[int, int, int, str, tuple[str, int]]] = await asyncio.to_thread(socket.getaddrinfo, hostname, None)
140 except socket.gaierror as exc:
141 raise ValueError(
142 f"Outbound URL hostname cannot be resolved: {hostname!r} — {exc}"
143 ) from exc
144
145 for _family, _type, _proto, _canonname, sockaddr in infos:
146 ip_str = sockaddr[0]
147 if _is_blocked_ip(ip_str):
148 raise ValueError(
149 f"Outbound URL {hostname!r} resolves to a private/reserved address "
150 f"({ip_str}). RFC-1918 / loopback / link-local targets are blocked."
151 )
152
153 return url
File History 2 commits
sha256:969ddc5e88776e70af33c016b8777bb721356508ae5f304b3fb46c451494ece7 test(mwp3): Phase 4 — fix adjacent suite regressions, lock … Sonnet 4.6 22 days ago
sha256:1749b9cc5cd2583c56d3261c4c00a342c27435f3946d4bc3e70f76aa2795458a docs(mwp-3): TDD implementation plan for job-enqueue idempo… Opus 4.8 22 days ago