Coverage for src/local_deep_research/security/ssrf_validator.py: 98%

125 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-20 01:24 +0000

1""" 

2URL Validator for SSRF Prevention 

3 

4Validates URLs to prevent Server-Side Request Forgery (SSRF) attacks 

5by blocking requests to internal/private networks and enforcing safe schemes. 

6""" 

7 

8import ipaddress 

9import re 

10import socket 

11from urllib.parse import urlparse 

12from typing import Optional 

13from loguru import logger 

14from urllib3.exceptions import LocationParseError 

15from urllib3.util import parse_url 

16 

17from .ip_ranges import PRIVATE_IP_RANGES as BLOCKED_IP_RANGES 

18from .ip_ranges import NAT64_PREFIXES 

19 

20# Cloud-provider metadata endpoints — always blocked, even with 

21# allow_localhost=True or allow_private_ips=True. These IPs expose IAM / 

22# instance-role credentials and are never legitimate destinations. 

23# nosec B104 - Hardcoded IPs are intentional for SSRF prevention 

24ALWAYS_BLOCKED_METADATA_IPS = frozenset( 

25 { 

26 "169.254.169.254", # AWS IMDSv1/v2, Azure, OCI, DigitalOcean 

27 "169.254.170.2", # AWS ECS task metadata v3 

28 "169.254.170.23", # AWS ECS task metadata v4 

29 "169.254.0.23", # Tencent Cloud 

30 "100.100.100.200", # AlibabaCloud 

31 } 

32) 

33 

34# Allowed URL schemes 

35ALLOWED_SCHEMES = {"http", "https"} 

36 

37 

38def is_nat64_wrapped_metadata_ip(ip: ipaddress._BaseAddress) -> bool: 

39 """True iff ``ip`` is an IPv6 address inside a NAT64 prefix whose 

40 embedded IPv4 (low 32 bits) is in ``ALWAYS_BLOCKED_METADATA_IPS``. 

41 

42 Both ``is_ip_blocked`` and ``NotificationURLValidator._ip_matches_blocked_range`` 

43 consult this before honoring the ``security.allow_nat64`` operator 

44 opt-in, so cloud-metadata access cannot be re-opened through an 

45 IPv6-wrapped destination on a NAT64-equipped host. Keeping the 

46 extraction in one place prevents the two validators from drifting. 

47 """ 

48 if not isinstance(ip, ipaddress.IPv6Address): 

49 return False 

50 for nat64_prefix in NAT64_PREFIXES: 

51 if ip in nat64_prefix: 

52 embedded_v4 = ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF) 

53 return str(embedded_v4) in ALWAYS_BLOCKED_METADATA_IPS 

54 return False 

55 

56 

57# RFC 3986 forbids these characters in URLs; their presence in a URL signals 

58# a parser-differential attempt (GHSA-g23j-2vwm-5c25). \s covers space, \t, 

59# \n, \r, \v, \f. Backslash is the load-bearing payload — Python's urlparse 

60# treats it as a literal char while requests/urllib3 treat it as a path 

61# delimiter, so a crafted URL like ``http://127.0.0.1\@1.1.1.1`` would 

62# pass the urlparse-based hostname check but actually connect to 127.0.0.1. 

63RFC_FORBIDDEN_URL_CHARS_RE = re.compile(r"[\\\s\x00-\x1f\x7f]") 

64 

65 

66def is_ip_blocked( 

67 ip_str: str, 

68 allow_localhost: bool = False, 

69 allow_private_ips: bool = False, 

70 allow_nat64: Optional[bool] = None, 

71) -> bool: 

72 """ 

73 Check if an IP address is in a blocked range. 

74 

75 Args: 

76 ip_str: IP address as string 

77 allow_localhost: Whether to allow localhost/loopback addresses 

78 allow_private_ips: Whether to allow all private/internal IPs plus localhost. 

79 This includes RFC1918 (10.x, 172.16-31.x, 192.168.x), CGNAT (100.64.x.x 

80 used by Podman/rootless containers), link-local (169.254.x.x), and IPv6 

81 private ranges (fc00::/7, fe80::/10). Use for trusted self-hosted services 

82 like SearXNG or Ollama in containerized environments. 

83 Note: cloud metadata endpoints in ``ALWAYS_BLOCKED_METADATA_IPS`` 

84 (AWS / Azure / OCI / DigitalOcean / AlibabaCloud / Tencent / ECS) 

85 are ALWAYS blocked regardless of these flags. 

86 allow_nat64: Override for the ``security.allow_nat64`` carve-out. 

87 ``None`` (default) reads the env setting — the behavior every 

88 existing caller relies on. An explicit ``bool`` answers a 

89 hypothetical ("would enabling NAT64 unblock this?") without 

90 mutating env; used by the notification "Test" admin hint to 

91 decide whether to surface ``LDR_SECURITY_ALLOW_NAT64``. The 

92 cloud-metadata always-block above fires first either way, so 

93 this can never reopen IMDS. 

94 

95 Returns: 

96 True if IP is blocked, False otherwise 

97 """ 

98 # Loopback ranges that can be allowed for trusted internal services 

99 # nosec B104 - These hardcoded IPs are intentional for SSRF allowlist 

100 LOOPBACK_RANGES = [ 

101 ipaddress.ip_network("127.0.0.0/8"), # IPv4 loopback 

102 ipaddress.ip_network("::1/128"), # IPv6 loopback 

103 ] 

104 

105 # Private/internal network ranges - allowed with allow_private_ips=True 

106 # nosec B104 - These hardcoded IPs are intentional for SSRF allowlist 

107 PRIVATE_RANGES = [ 

108 # RFC1918 Private Ranges 

109 ipaddress.ip_network("10.0.0.0/8"), # Class A private 

110 ipaddress.ip_network("172.16.0.0/12"), # Class B private 

111 ipaddress.ip_network("192.168.0.0/16"), # Class C private 

112 # Container/Virtual Network Ranges 

113 ipaddress.ip_network( 

114 "100.64.0.0/10" 

115 ), # CGNAT - used by Podman/rootless containers 

116 ipaddress.ip_network( 

117 "169.254.0.0/16" 

118 ), # Link-local (cloud metadata IPs blocked separately via ALWAYS_BLOCKED_METADATA_IPS) 

119 # IPv6 Private Ranges 

120 ipaddress.ip_network("fc00::/7"), # IPv6 Unique Local Addresses 

121 ipaddress.ip_network("fe80::/10"), # IPv6 Link-Local 

122 ] 

123 

124 try: 

125 ip = ipaddress.ip_address(ip_str) 

126 

127 # Unwrap IPv4-mapped IPv6 addresses (e.g. ::ffff:127.0.0.1 → 127.0.0.1) 

128 # These bypass IPv4 range checks if not converted. 

129 if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped: 

130 ip = ip.ipv4_mapped 

131 

132 # ALWAYS block cloud-metadata endpoints - critical SSRF target 

133 # for credential theft (AWS IMDS/ECS, Azure, OCI, DigitalOcean, 

134 # AlibabaCloud, Tencent Cloud). These are never legitimate 

135 # destinations regardless of allow_localhost / allow_private_ips. 

136 if str(ip) in ALWAYS_BLOCKED_METADATA_IPS: 

137 return True 

138 

139 # Also block metadata IPs reached via NAT64 wrap. NAT64 prefixes 

140 # embed the IPv4 destination in the low 32 bits; even when the 

141 # operator has set LDR_SECURITY_ALLOW_NAT64=true the metadata 

142 # block is "always" — an opt-in for IPv4 reachability does NOT 

143 # license IMDS exposure. 

144 if is_nat64_wrapped_metadata_ip(ip): 

145 return True 

146 

147 # Operator escape hatch for IPv6-only deployments using DNS64+NAT64. 

148 # Read lazily (not at import) so test monkeypatching works and so the 

149 # value is not cached across env mutations. Cloud-metadata IPs are 

150 # ALWAYS blocked above, so this carve-out cannot reopen IMDS via 

151 # the IPv6-wrapped form. 

152 # 

153 # allow_nat64 overrides the env read: None (default) preserves every 

154 # existing caller; an explicit bool lets the notification "Test" 

155 # admin hint ask "would LDR_SECURITY_ALLOW_NAT64=true unblock this?" 

156 # without touching process env. 

157 if allow_nat64 is None: 

158 from ..settings.env_registry import get_env_setting 

159 

160 nat64_allowed = bool(get_env_setting("security.allow_nat64", False)) 

161 else: 

162 nat64_allowed = allow_nat64 

163 

164 # Check if IP is in any blocked range 

165 for blocked_range in BLOCKED_IP_RANGES: 

166 if ip in blocked_range: 

167 # NAT64 carve-out: when the operator has opted in, the two 

168 # NAT64 prefixes don't block. 6to4 / Teredo / discard remain 

169 # blocked unconditionally. 

170 if nat64_allowed and blocked_range in NAT64_PREFIXES: 

171 continue 

172 # If allow_private_ips is True, skip blocking for private + loopback 

173 if allow_private_ips: 

174 is_loopback = any(ip in lr for lr in LOOPBACK_RANGES) 

175 is_private = any(ip in pr for pr in PRIVATE_RANGES) 

176 if is_loopback or is_private: 

177 continue 

178 # If allow_localhost is True, skip blocking for loopback only 

179 elif allow_localhost: 

180 is_loopback = any(ip in lr for lr in LOOPBACK_RANGES) 

181 if is_loopback: 

182 continue 

183 return True 

184 

185 return False 

186 

187 except ValueError: 

188 # Invalid IP address 

189 return False 

190 

191 

192def validate_url( 

193 url: str, 

194 allow_localhost: bool = False, 

195 allow_private_ips: bool = False, 

196) -> bool: 

197 """ 

198 Validate URL to prevent SSRF attacks. 

199 

200 Checks: 

201 1. URL scheme is allowed (http/https only) 

202 2. Hostname is not an internal/private IP address 

203 3. Hostname does not resolve to an internal/private IP 

204 

205 Args: 

206 url: URL to validate 

207 allow_localhost: Whether to allow localhost/loopback addresses. 

208 Set to True for trusted internal services like self-hosted 

209 search engines (e.g., searxng). Default False. 

210 allow_private_ips: Whether to allow all private/internal IPs plus localhost. 

211 This includes RFC1918 (10.x, 172.16-31.x, 192.168.x), CGNAT (100.64.x.x 

212 used by Podman/rootless containers), link-local (169.254.x.x), and IPv6 

213 private ranges (fc00::/7, fe80::/10). Use for trusted self-hosted services 

214 like SearXNG or Ollama in containerized environments. 

215 Note: cloud metadata endpoints in ``ALWAYS_BLOCKED_METADATA_IPS`` 

216 (AWS / Azure / OCI / DigitalOcean / AlibabaCloud / Tencent / ECS) 

217 are ALWAYS blocked regardless of these flags. 

218 

219 Returns: 

220 True if URL is safe, False otherwise 

221 """ 

222 if not isinstance(url, str): 

223 return False 

224 try: 

225 url = url.strip() 

226 # Layer 1: reject RFC-illegal characters that drive parser-differential 

227 # attacks (backslash, whitespace, control bytes). The URL is omitted 

228 # from this log line because userinfo (RFC 3986 §3.2.1) may contain 

229 # credentials and rejected URLs are by definition adversarial-shaped. 

230 if RFC_FORBIDDEN_URL_CHARS_RE.search(url): 

231 logger.warning("Blocked URL containing RFC-illegal characters") 

232 return False 

233 

234 parsed = urlparse(url) 

235 

236 # Check scheme 

237 if parsed.scheme.lower() not in ALLOWED_SCHEMES: 

238 logger.warning( 

239 f"Blocked URL with invalid scheme: {parsed.scheme} - {redact_url_for_log(url)}" 

240 ) 

241 return False 

242 

243 # Layer 2: extract host using urllib3, the same parser ``requests`` 

244 # uses internally. ``urlparse`` and urllib3 disagree on URLs like 

245 # ``http://127.0.0.1\@1.1.1.1`` — urlparse says ``1.1.1.1``, 

246 # urllib3 says ``127.0.0.1``. Validating against urllib3 means the 

247 # validator and the HTTP client cannot disagree on destination. 

248 try: 

249 u3 = parse_url(url) 

250 except LocationParseError: 

251 logger.warning("Blocked URL: urllib3 parser rejected it") 

252 return False 

253 hostname = u3.host 

254 # Authority must be ASCII printable. urllib3 currently rejects 

255 # non-ASCII via LocationParseError, but this guard keeps us 

256 # independent of that staying constant — CVE-2019-9636 showed 

257 # Python's stdlib loosened a similar restriction previously. 

258 # Brackets/colon used in IPv6 hosts are within 0x20-0x7e, so this 

259 # runs cleanly before bracket-strip. 

260 if hostname and any(ord(c) < 0x20 or ord(c) > 0x7E for c in hostname): 260 ↛ 261line 260 didn't jump to line 261 because the condition on line 260 was never true

261 logger.warning("Blocked URL with non-ASCII / control bytes in host") 

262 return False 

263 # Strip IPv6 brackets so ipaddress.ip_address can parse the host. 

264 if hostname and hostname.startswith("[") and hostname.endswith("]"): 

265 hostname = hostname[1:-1] 

266 # rstrip(".") matches getaddrinfo behaviour — trailing dots are 

267 # ignored at resolution time. 

268 if hostname: 

269 hostname = hostname.rstrip(".") 

270 if not hostname: 

271 logger.warning( 

272 f"Blocked URL with no hostname: {redact_url_for_log(url)}" 

273 ) 

274 return False 

275 

276 # Check if hostname is an IP address 

277 try: 

278 ip = ipaddress.ip_address(hostname) 

279 if is_ip_blocked( 

280 str(ip), 

281 allow_localhost=allow_localhost, 

282 allow_private_ips=allow_private_ips, 

283 ): 

284 logger.warning( 

285 f"Blocked URL with internal/private IP: {hostname} - {redact_url_for_log(url)}" 

286 ) 

287 return False 

288 except ValueError: 

289 # Not an IP address, it's a hostname - need to resolve it 

290 pass 

291 

292 # Resolve hostname to IP and check. 

293 # 

294 # NOTE: This is a best-effort, validation-time check. The caller 

295 # (typically safe_requests) hands the URL to requests/urllib3 

296 # afterwards, which resolves the hostname AGAIN at connect time -- 

297 # a DNS rebinding TOCTOU window. Closing it would require pinning 

298 # the resolved IP into the outbound connection (HTTPAdapter shim 

299 # with server_hostname for SNI), which is HTTPS-only and doesn't 

300 # follow redirects cleanly. See SECURITY.md "Notification Webhook 

301 # SSRF" subsection for the accepted-risk rationale (the same 

302 # caveat applies here). 

303 try: 

304 # Get all IP addresses for hostname 

305 # nosec B104 - DNS resolution is intentional for SSRF prevention (checking if hostname resolves to private IP) 

306 addr_info = socket.getaddrinfo( 

307 hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM 

308 ) 

309 

310 for info in addr_info: 

311 ip_str = str( 

312 info[4][0] 

313 ) # Extract IP address from addr_info tuple 

314 

315 if is_ip_blocked( 

316 ip_str, 

317 allow_localhost=allow_localhost, 

318 allow_private_ips=allow_private_ips, 

319 ): 

320 logger.warning( 

321 f"Blocked URL - hostname {hostname} resolves to " 

322 f"internal/private IP: {ip_str} - {redact_url_for_log(url)}" 

323 ) 

324 return False 

325 

326 except socket.gaierror: 

327 logger.warning(f"Failed to resolve hostname {hostname}") 

328 return False 

329 except Exception: 

330 logger.exception("Error during hostname resolution") 

331 return False 

332 

333 # URL passes all checks 

334 return True 

335 

336 except Exception: 

337 logger.exception(f"Error validating URL {redact_url_for_log(url)}") 

338 return False 

339 

340 

341def assert_base_url_safe(base_url: str, *, setting_key: str) -> str: 

342 """Validate an LLM provider base_url. Raises ValueError on SSRF. 

343 

344 Args: 

345 base_url: The URL to validate. 

346 setting_key: The settings dot-path that produced this URL 

347 (e.g. ``"llm.ollama.url"``). Embedded into the error message 

348 so operators know which setting to fix. Pass ``cls.url_setting`` 

349 from the OpenAI-compat parent or ``"llm.ollama.url"`` from 

350 the Ollama provider — NEVER ``cls.provider_name`` which is a 

351 display string ("xAI Grok", "llama.cpp") not a settings key. 

352 

353 Uses ``allow_localhost=True, allow_private_ips=True`` because the 

354 legitimate destinations for LLM SDKs are localhost (Ollama, LM Studio, 

355 llama.cpp) and RFC1918 (Docker / private network deployments). The 

356 ``ALWAYS_BLOCKED_METADATA_IPS`` set still fires under those flags and 

357 prevents the auth-gated SSRF that would otherwise reach cloud-credential 

358 endpoints (AWS IMDS / ECS, Azure, OCI, DigitalOcean, AlibabaCloud, 

359 Tencent). 

360 

361 Caveat: this guard validates once at provider construction. The SDK 

362 re-resolves the hostname on every inference call, so a hostile DNS 

363 authority can rebind between guard and connect — same TOCTOU as 

364 ``validate_url``. See SECURITY.md for the accepted-risk rationale. 

365 """ 

366 if not validate_url(base_url, allow_localhost=True, allow_private_ips=True): 

367 raise ValueError( 

368 f"base_url failed SSRF validation: refusing to send " 

369 f"inference traffic. Check {setting_key} config." 

370 ) 

371 return base_url 

372 

373 

374def get_safe_url( 

375 url: Optional[str], default: Optional[str] = None 

376) -> Optional[str]: 

377 """ 

378 Get URL if it's safe, otherwise return default. 

379 

380 Args: 

381 url: URL to validate 

382 default: Default value if URL is unsafe 

383 

384 Returns: 

385 URL if safe, default otherwise 

386 """ 

387 if not url: 

388 return default 

389 

390 if validate_url(url): 

391 return url 

392 

393 logger.warning(f"Unsafe URL rejected: {redact_url_for_log(url)}") 

394 return default 

395 

396 

397def redact_url_for_log(url: str) -> str: 

398 """Return ``scheme://host:port`` (no userinfo, path, query, fragment). 

399 

400 For log output only. Drops everything except scheme + authority host 

401 + port to minimise the chance of leaking credentials, tokens, or 

402 sensitive paths into logs while still giving operators enough to 

403 distinguish ``http://10.0.0.1:80`` from ``https://10.0.0.1:443``. 

404 

405 RFC 3986 §3.2.1 allows credentials in URL userinfo 

406 (``http://user:pass@host/``). A rejected URL is by definition 

407 adversarial-shaped, but it may still carry the operator's real 

408 credentials if a misconfiguration produced it. 

409 """ 

410 try: 

411 u = parse_url(url) 

412 scheme = u.scheme or "?" 

413 host = u.host or "<no-host>" 

414 host_port = f"{host}:{u.port}" if u.port else host 

415 return f"{scheme}://{host_port}" 

416 except (LocationParseError, ValueError): 

417 return "<unparseable>"