gabriel / musehub public
ssrf.py python
153 lines 6.2 KB
Raw
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2 chore: bump version to 0.2.0rc15 for musehub#113 fix release Sonnet 4.6 patch 15 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 11 commits
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2 chore: bump version to 0.2.0rc15 for musehub#113 fix release Sonnet 4.6 patch 15 days ago
sha256:0032d6cfa33bc3c8367436ad768e7dd0e339b4332153160247da8266cb5fa352 Merge branch 'task/version-tags-phase3-server' into dev Human 17 days ago
sha256:4669620efda9ff41c55bdefd1f7bfe1c239d468428744c84ead9957e5a003a53 merge: rescue snapshot-recovery hardening (c00aa21d) into d… Opus 4.8 minor 30 days ago
sha256:a59da49c4611b970fc4b6ae48678ce4943261c213a07ddbd73ce9201df869b4a fix: remove false-positive proposal_comments index drop fro… Sonnet 4.6 patch 34 days ago
sha256:0a240d6dbff234f07d98a28a4a9a68db702f3f9ff9260196f24219bdb1c0b6f3 feat: render markdown mists as HTML with heading anchor links Sonnet 4.6 patch 35 days ago
sha256:24a7d47486ebc4ebd1832830580e177ec6f877b48dced8c000e198cdec4ce9d6 Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 36 days ago
sha256:b9ff931d147e0114a1f17060f415b89ed551c170a91ff226c70437aa5c85f9ee Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 36 days ago
sha256:d1122d21e73471879b460037b22c0b50fded7c423444a176f248428f75dac39c Merge 'task/fix-issue-pagination-cursor' into 'dev' — propo… Human 36 days ago
sha256:01e18975e73d2b3cd5b6db7929c895bef9aa6e0d4391dc5b2adfc548b41318dd Merge 'feat/adding-debug-logs-to-staging' into 'dev' — prop… Human 36 days ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f fix: use wire_bytes not mpack_bytes_raw in compute_object_b… Sonnet 4.6 patch 48 days ago
sha256:b99f2455dc346966d040133f5203297e6e3ef5803a93728a2c30568d0a0f7583 rename: delta_add → delta_upsert across wire format, models… Sonnet 4.6 patch 51 days ago