Coverage for src/local_deep_research/security/dns_pinning.py: 91%

195 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +0000

1"""Pin the validated IP for the actual outbound connection. 

2 

3Closes the resolve-vs-connect gap in the SSRF guard. ``validate_url`` 

4resolves a hostname via ``socket.getaddrinfo`` and checks the resulting 

5addresses against the private/internal/metadata block lists, but 

6``requests``/``urllib3`` then resolve the hostname *again, independently* 

7at connect time. An attacker who controls a DNS authority can answer with 

8a public address while the guard is looking and a private / cloud-metadata 

9address a moment later at connect — so the address that was validated is 

10not the address that gets connected to. 

11 

12This module makes the fetch connect to the SAME address that was 

13validated. A thread-local registry of pinned ``host -> addrinfo`` entries 

14is consulted by a process-wide ``socket.getaddrinfo`` shim installed once 

15at import. While a host is pinned (for the duration of a single request, 

16re-established at each redirect hop), the shim returns ONLY the 

17pre-validated addresses for that host; every other lookup falls through to 

18the real resolver untouched. 

19 

20Why a ``getaddrinfo`` shim rather than a custom ``HTTPAdapter``: 

21 

22* It covers BOTH sinks uniformly — the module-level ``requests.get`` / 

23 ``requests.post`` used by ``safe_get`` / ``safe_post`` AND the 

24 ``SafeSession.send`` path — without threading a mounted adapter through 

25 every call site or per-request session. 

26* TLS is preserved for free. The request URL keeps the original hostname, 

27 so ``urllib3`` still sets the TLS SNI and verifies the server 

28 certificate against that hostname. Only name resolution is redirected, 

29 so there is NO certificate-verification bypass and no 

30 ``server_hostname`` / ``assert_hostname`` plumbing to get wrong. 

31* It behaves identically for ``http`` and ``https`` and re-pins cleanly at 

32 every redirect hop. 

33* Thread safety: the pin registry is thread-local, so concurrent requests 

34 in LDR's thread pools never observe each other's pins, and every 

35 unpinned lookup is an untouched pass-through. This mirrors the existing 

36 ``security/egress`` ``socket.connect`` audit hook, which also installs a 

37 process-wide, thread-local-gated interposition. 

38 

39The outbound-notification path (Apprise) reuses the same shim through 

40:func:`pinned_notification_send`. Apprise fans out to arbitrary user URLs 

41via ``requests``/``urllib3`` and re-resolves at send time — the exact 

42resolve-vs-connect gap the shim closes — but it also follows redirects and 

43can be pointed at a *new*, unpinned host. So the notification window pins 

44every raw-webhook host in the batch AND activates a thread-local 

45"block-private" mode (:func:`block_private_resolution`): for its duration, 

46any UNPINNED lookup that resolves to a private/loopback/link-local/ 

47cloud-metadata address is refused at the ``getaddrinfo`` layer (a 

48``socket.gaierror``), so a redirect-to-internal is blocked at the socket 

49layer before a connection is made. Public unpinned hosts resolve normally. 

50The block is thread-local, so it only affects the sending thread and can 

51never wrongly block a legitimate private request running concurrently in 

52another thread. It is activated only after Apprise is forced into 

53synchronous, in-thread delivery (``AppriseAsset(async_mode=False)``); with 

54the default async fan-out the send would run in worker threads that carry 

55no thread-local pin/block and the guard would silently not apply. 

56 

57Import-order dependency: the pin only works while ``socket.getaddrinfo`` 

58is still THIS module's shim. If something replaces ``socket.getaddrinfo`` 

59wholesale AFTER this module is imported — e.g. ``gevent``/``eventlet`` 

60monkey-patching, or any other socket shim — that later patch silently 

61overwrites ours and the pin is dropped fail-open (validation still runs, 

62but the connect-time re-resolution it is meant to catch goes unguarded 

63again). LDR runs Socket.IO in threading ``async_mode`` and does not 

64monkeypatch sockets, so this does not currently apply — but if that ever 

65changes, any such monkeypatching must happen BEFORE this module (or 

66``security.safe_requests``, which imports it) is first imported. 

67""" 

68 

69import contextlib 

70import ipaddress 

71import socket 

72import threading 

73from typing import Iterator, List, Optional, Tuple 

74 

75import requests 

76from loguru import logger 

77from urllib3.exceptions import LocationParseError 

78from urllib3.util import parse_url 

79 

80from . import ssrf_validator 

81 

82# Attribute stamped on our shim (see below) so any later capture of the 

83# "real" resolver can recognize it and refuse to capture the shim as if it 

84# were the genuine resolver. 

85_SHIM_MARKER = "_ldr_dns_pin_shim" 

86 

87 

88def _capture_real_getaddrinfo(): 

89 """Return the genuine resolver, never our own shim. 

90 

91 On a normal import ``socket.getaddrinfo`` is still the stdlib resolver 

92 and is captured as-is. But if this module is *reloaded* 

93 (``importlib.reload``) after :func:`install` has already swapped in our 

94 shim, ``socket.getaddrinfo`` is that shim — capturing it here would make 

95 the shim's pass-through call itself, recursing forever (``RecursionError``) 

96 on every unpinned lookup process-wide. In that case fall back to the real 

97 resolver captured by the previous load, which survives in this module's 

98 globals across a reload. 

99 """ 

100 current = socket.getaddrinfo 

101 if not getattr(current, _SHIM_MARKER, False): 

102 return current 

103 # Reload after install(): don't re-capture our own shim. Reuse the real 

104 # resolver the previous load already captured (guaranteed not to be a 

105 # shim, thanks to this very guard). 

106 previous = globals().get("_real_getaddrinfo") 

107 if previous is not None and not getattr(previous, _SHIM_MARKER, False): 107 ↛ 109line 107 didn't jump to line 109 because the condition on line 107 was always true

108 return previous 

109 return current 

110 

111 

112# The genuine resolver, captured at import BEFORE the shim is installed. 

113# Referenced by module-global name at call time (never closed over) so a 

114# test can patch this single seam to control what BOTH the validation-time 

115# resolution and the pin-time resolution observe. Captured via the guard 

116# above so a reload cannot make the shim call itself. 

117_real_getaddrinfo = _capture_real_getaddrinfo() 

118 

119# host (normalized) -> list of addrinfo 5-tuples that are safe to connect 

120# to. Thread-local so concurrent fetches in different worker threads cannot 

121# see one another's pins. 

122# 

123# The same thread-local also carries an optional ``block_private`` entry — 

124# a ``(allow_localhost, allow_private_ips, block_link_local)`` tuple set by 

125# :func:`block_private_resolution`. While present, every UNPINNED lookup on 

126# this thread is resolved and re-checked against the SSRF policy, and a 

127# result containing any blocked (private/loopback/link-local/metadata) 

128# address is refused. See :func:`_resolve_maybe_block`. 

129_thread_state = threading.local() 

130 

131# Schemes whose URL host is a real, resolvable connection target that the 

132# client will connect to verbatim — so pinning the validated address is both 

133# possible and worthwhile. These are the raw-webhook Apprise schemes 

134# (``json``/``xml``/``form`` and their TLS variants) plus plain 

135# ``http``/``https``. 

136# 

137# Every OTHER reachable Apprise scheme is deliberately excluded from pinning, 

138# for one of two reasons — but both remaining kinds are still resolve-time 

139# block-checked by the block-private window (:func:`block_private_resolution`), 

140# so excluding them from pinning never leaves a cloud-metadata hole: 

141# 

142# * Token-host schemes (``discord://``, ``slack://``, ``tgram://`` …) put an 

143# opaque credential/token in ``parse_url``'s host field, not a hostname. 

144# Pinning would try to resolve that token as a name and break the send; 

145# these plugins POST to their own hardcoded public API endpoints anyway. 

146# * Self-hosted plugin schemes (``gotify://``, ``ntfy(s)://``, ``mattermost://``, 

147# ``matrix://``, ``rocketchat://``, ``teams://`` …) DO carry a real, 

148# resolvable host — the "opaque token" description does not apply to them. 

149# They are left unpinned because their per-plugin URL→endpoint mapping is 

150# plugin-specific (path/port rewriting) rather than a plain connect to the 

151# URL host, and because their partition intentionally allows private/LAN 

152# targets: the block-private window (metadata-only for this partition) is 

153# the right guarantee for them, and pinning would add complexity without 

154# changing the metadata outcome. 

155# 

156# This block-window guarantee holds only for schemes whose network I/O resolves 

157# through the shimmed ``socket.getaddrinfo`` — which every scheme reachable 

158# today does (they send via ``requests``/``urllib3`` or ``smtplib``, both of 

159# which call ``getaddrinfo``). Which schemes can reach this send path at all is 

160# gated upstream by ``NotificationURLValidator.ALLOWED_SCHEMES``; a scheme that 

161# connects via a primitive the shim cannot see (e.g. a raw ``socket.sendto`` 

162# datagram, as Apprise's ``rsyslog://`` uses) would bypass BOTH the pin and the 

163# block window, so such a scheme must never be added to that allowlist. 

164_PINNABLE_SCHEMES = frozenset( 

165 { 

166 "http", 

167 "https", 

168 "json", 

169 "jsons", 

170 "xml", 

171 "xmls", 

172 "form", 

173 "forms", 

174 } 

175) 

176 

177_installed = False 

178 

179 

180def _normalize_host(host: Optional[str]) -> Optional[str]: 

181 """Normalize a host into the key form used by the pin registry. 

182 

183 Lower-cases (DNS is case-insensitive, and both ``urllib3.parse_url`` 

184 and ``urllib3``'s connection layer already lower-case the host before 

185 it reaches ``getaddrinfo``), strips IPv6 brackets, and drops the 

186 trailing root dot (``urllib3`` calls ``self._dns_host.rstrip(".")`` 

187 before resolving). Applying the same transform on both the pinning and 

188 the lookup side guarantees the key matches. 

189 """ 

190 if not host: 190 ↛ 191line 190 didn't jump to line 191 because the condition on line 190 was never true

191 return None 

192 host = host.strip().lower() 

193 if host.startswith("[") and host.endswith("]"): 193 ↛ 194line 193 didn't jump to line 194 because the condition on line 193 was never true

194 host = host[1:-1] 

195 host = host.rstrip(".") 

196 return host or None 

197 

198 

199def _host_for_log(host) -> str: 

200 """Best-effort text form of a host for a log message. 

201 

202 ``getaddrinfo`` accepts a ``bytes``/``bytearray`` host as well as a 

203 ``str``; decode one to text so the block-window warning reads cleanly. 

204 Decoding is for the log line only — the SSRF check runs on resolved IPs, 

205 never on the host string — so it must never raise: ``surrogateescape`` 

206 round-trips any byte sequence. (The ``idna`` codec is deliberately NOT 

207 used here: it does not support an error handler and raises on non-hostname 

208 bytes.) 

209 """ 

210 if isinstance(host, (bytes, bytearray)): 

211 return bytes(host).decode("utf-8", errors="surrogateescape") 

212 return str(host) 

213 

214 

215def _get_pins() -> dict: 

216 pins = getattr(_thread_state, "pins", None) 

217 if pins is None: 

218 pins = {} 

219 _thread_state.pins = pins 

220 return pins 

221 

222 

223def reset_ssrf_block() -> None: 

224 """Clear the per-thread "an SSRF block occurred" marker. 

225 

226 Called before a guarded ``notify()`` so :func:`consume_ssrf_block` 

227 reflects only that attempt. See :func:`_resolve_maybe_block`. 

228 """ 

229 _thread_state.ssrf_block_occurred = False 

230 

231 

232def consume_ssrf_block() -> bool: 

233 """Return whether the block-private window refused a lookup on this 

234 thread since the last :func:`reset_ssrf_block`, and clear the marker. 

235 

236 Apprise swallows the ``socket.gaierror`` the block raises inside its 

237 plugin, surfacing only a generic delivery failure. The sender 

238 (``notifications.service._send_with_retry``) consults this after a failed 

239 send to tell a confirmed security block apart from a transient error, so 

240 the block can fail fast instead of being retried 3x. 

241 """ 

242 occurred = getattr(_thread_state, "ssrf_block_occurred", False) 

243 _thread_state.ssrf_block_occurred = False 

244 return bool(occurred) 

245 

246 

247# Send-time DNS resolution is bounded so a slow / hostile DNS authority 

248# cannot hang the sending thread — which, on the notification path, is an 

249# HTTP request handler (test_service is reached straight from the "Send Test 

250# Notification" endpoint). Mirrors 

251# ``notification_validator._resolve_hostname_ips``. 

252_RESOLVE_TIMEOUT_SECONDS = 5 

253 

254 

255def _getaddrinfo_bounded(host, port, family, type, proto, flags): 

256 """Call the genuine resolver with a bounded timeout; fail closed. 

257 

258 Runs ``_real_getaddrinfo`` on a short-lived DAEMON thread (thread-safe 

259 timeout, no ``socket.setdefaulttimeout`` process-global mutation) so a 

260 hostile DNS authority that never answers cannot block the caller past 

261 ``_RESOLVE_TIMEOUT_SECONDS``. On timeout a ``socket.gaierror`` is raised 

262 — the lookup is REFUSED, never returned as a blank/partial answer a 

263 caller might treat as "resolved" — so the timeout can only ever fail 

264 closed. A resolution error raised by the resolver itself 

265 (``socket.gaierror`` / ``OSError``) propagates unchanged. 

266 

267 The worker is a DAEMON thread deliberately: a ``getaddrinfo`` call that 

268 hangs in C cannot be cancelled, and a non-daemon worker (as a 

269 ``concurrent.futures`` pool thread is) would be joined by the interpreter 

270 at shutdown — letting an in-flight hostile lookup block graceful process 

271 exit / restart. A daemon worker is abandoned on timeout and never blocks 

272 interpreter exit. 

273 """ 

274 result: dict = {} 

275 

276 def _worker() -> None: 

277 try: 

278 result["value"] = _real_getaddrinfo( 

279 host, port, family, type, proto, flags 

280 ) 

281 except BaseException as exc: # noqa: BLE001 - propagated to caller 

282 result["error"] = exc 

283 

284 worker = threading.Thread( 

285 target=_worker, 

286 name="ldr-dns-pin-resolve", 

287 daemon=True, 

288 ) 

289 worker.start() 

290 worker.join(_RESOLVE_TIMEOUT_SECONDS) 

291 if worker.is_alive(): 

292 # Timed out. The daemon worker is abandoned (it dies with the 

293 # process) and the lookup is refused — fail closed. 

294 raise socket.gaierror( 

295 getattr(socket, "EAI_AGAIN", socket.EAI_FAIL), 

296 f"DNS resolution for {host!r} timed out after " 

297 f"{_RESOLVE_TIMEOUT_SECONDS}s during a pinned send (fail closed)", 

298 ) 

299 if "error" in result: 

300 # Re-raise the resolver's own error unchanged (e.g. gaierror for 

301 # NXDOMAIN) so existing gaierror/OSError handling stays intact. 

302 raise result["error"] 

303 return result["value"] 

304 

305 

306def _pinned_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0): 

307 """Process-wide ``socket.getaddrinfo`` replacement. 

308 

309 Returns the pinned addresses when the calling thread has an active pin 

310 for ``host``; otherwise delegates unchanged to the real resolver, so 

311 this is a transparent pass-through for every lookup that is not part of 

312 a pinned request. 

313 """ 

314 key = _normalize_host(host) if isinstance(host, str) else None 

315 if key is not None: 

316 entry = _get_pins().get(key) 

317 if entry is not None: 

318 results = [] 

319 for fam, socktype, sockproto, canon, sockaddr in entry: 

320 # Honor a caller-requested address family (AF_UNSPEC == 0 

321 # means "any"). 

322 if family not in (0, socket.AF_UNSPEC, fam): 322 ↛ 323line 322 didn't jump to line 323 because the condition on line 322 was never true

323 continue 

324 # Honor a caller-requested socket type (0 == "any"). Every 

325 # pinned entry is resolved as SOCK_STREAM (see 

326 # _resolve_and_validate), which is also what urllib3's own 

327 # connection lookups always request explicitly — so this 

328 # never filters anything out of the normal HTTP(S) path. It 

329 # only guards a hypothetical caller-requested SOCK_DGRAM (or 

330 # other non-stream) lookup for a host that happens to be 

331 # mid-pin, which would otherwise get back a SOCK_STREAM / 

332 # IPPROTO_TCP entry mislabeled with the caller's requested 

333 # type. 

334 if type not in (0, socktype): 

335 continue 

336 # Rewrite the port from THIS lookup while preserving the 

337 # IPv6 flowinfo / scope_id captured at pin time. 

338 new_sockaddr = (sockaddr[0], port) + tuple(sockaddr[2:]) 

339 results.append( 

340 ( 

341 fam, 

342 type or socktype, 

343 proto or sockproto, 

344 canon, 

345 new_sockaddr, 

346 ) 

347 ) 

348 if results: 

349 return results 

350 # A pin exists but nothing matches the requested family/type. 

351 # Fail closed rather than silently falling through to a fresh 

352 # (attacker-controllable) resolution. 

353 raise socket.gaierror( 

354 getattr(socket, "EAI_ADDRFAMILY", socket.EAI_FAIL), 

355 "No pinned address for the requested family/socktype", 

356 ) 

357 # Not pinned. Delegate to the real resolver, but if a block-private 

358 # window is active on this thread, refuse any answer that resolves to a 

359 # blocked address (redirect-to-internal / rebind-to-metadata guard). 

360 return _resolve_maybe_block(host, port, family, type, proto, flags) 

361 

362 

363def _resolve_maybe_block(host, port, family, type, proto, flags): 

364 """Real resolution for an unpinned host, honoring the block window. 

365 

366 When no block-private window is active this is a transparent 

367 pass-through to the genuine resolver — with NO added timeout/thread 

368 overhead, so ordinary process-wide DNS is completely unaffected by the 

369 shim. When a window IS active (set by :func:`block_private_resolution`, 

370 i.e. during a notification send), the resolution is (a) bounded by a 

371 timeout so a slow / hostile DNS authority cannot hang the sending thread 

372 (fail closed — a timeout refuses the lookup, it never returns a blank 

373 answer), and (b) the resolved addresses are checked against the SSRF 

374 policy and the lookup is refused with ``socket.gaierror`` if ANY of them 

375 is blocked — closing a redirect-to-internal or rebind-to-metadata that 

376 targets a host that was never pinned. The check runs on the SAME address 

377 list the caller will connect to (this IS the ``getaddrinfo`` the client 

378 uses), so there is no second resolution to race against. 

379 """ 

380 block = getattr(_thread_state, "block_private", None) 

381 if block is None: 

382 # No window active: transparent, zero-overhead pass-through. 

383 return _real_getaddrinfo(host, port, family, type, proto, flags) 

384 # Window active — bound the resolution (send path) and fail closed on a 

385 # timeout by letting the gaierror propagate to the client. 

386 results = _getaddrinfo_bounded(host, port, family, type, proto, flags) 

387 # A ``None`` host is an AF_PASSIVE bind 

388 # (``getaddrinfo(None, port, ..., AI_PASSIVE)`` — a local listening-socket 

389 # setup, not an outbound name to rebind), so it carries no attacker- 

390 # controllable destination and is exempt from the block check. EVERY other 

391 # host is checked, INCLUDING a ``bytes``/``bytearray`` host: ``getaddrinfo`` 

392 # accepts a bytes name and a bytes host (e.g. ``b"169.254.169.254"``) 

393 # resolves to metadata/link-local exactly as a ``str`` host would, so 

394 # returning it unchecked would be the one guard that fails OPEN. The check 

395 # itself runs on the resolved IPs regardless of the host type — only the 

396 # log line needs a text host, so a bytes host is decoded for display. 

397 if host is None: 

398 return results 

399 host_display = _host_for_log(host) 

400 allow_localhost, allow_private_ips, block_link_local = block 

401 for info in results: 

402 ip_str = str(info[4][0]) 

403 if ssrf_validator.is_ip_blocked( 

404 ip_str, 

405 allow_localhost=allow_localhost, 

406 allow_private_ips=allow_private_ips, 

407 block_link_local=block_link_local, 

408 ): 

409 logger.warning( 

410 "Blocked send-time resolution of {} to internal/private/" 

411 "metadata IP {} during the pinned notification window " 

412 "(possible SSRF / redirect-to-internal)", 

413 host_display, 

414 ip_str, 

415 ) 

416 # Mark the thread so the sender can tell this confirmed security 

417 # block apart from a transient failure once Apprise has swallowed 

418 # the gaierror below — a confirmed block must fail fast, not be 

419 # retried. Consumed by ``consume_ssrf_block``. 

420 _thread_state.ssrf_block_occurred = True 

421 raise socket.gaierror( 

422 getattr(socket, "EAI_FAIL", -1), 

423 "Blocked resolution to an internal/private/metadata IP " 

424 "during the pinned notification send window", 

425 ) 

426 return results 

427 

428 

429# Stamp our shim so a later capture (e.g. after ``importlib.reload``) can tell 

430# it apart from the genuine resolver and refuse to capture it as the "real" 

431# one — see :func:`_capture_real_getaddrinfo`. 

432setattr(_pinned_getaddrinfo, _SHIM_MARKER, True) 

433 

434 

435def install() -> None: 

436 """Install the process-wide ``getaddrinfo`` shim (idempotent). 

437 

438 A no-op after the first call. The shim is transparent until a thread 

439 activates a pin via :func:`pinned_request`, so importing this module 

440 has no behavioral effect on code that does not fetch through the safe 

441 request helpers. 

442 

443 Idempotent under module reload too: if a shim (this load's or a prior 

444 load's) is already the active resolver, it is not stacked on top of 

445 itself. 

446 """ 

447 global _installed 

448 if _installed: 448 ↛ 449line 448 didn't jump to line 449 because the condition on line 448 was never true

449 return 

450 # Install our shim unless it is ALREADY EXACTLY this module's shim 

451 # (identity check, not just the marker). A reload resets ``_installed`` to 

452 # False and rebuilds ``_pinned_getaddrinfo`` as a fresh function object, 

453 # while ``socket.getaddrinfo`` still points at the PREVIOUS load's shim — 

454 # a stale, marker-stamped resolver whose thread-local reads live in the 

455 # old module. Reinstalling here keeps ``socket.getaddrinfo`` in lockstep 

456 # with the reloaded module so ``_shim_installed()`` — the invariant the 

457 # notification send fails closed on — holds after a reload, instead of 

458 # silently reporting the shim as missing forever. 

459 # 

460 # This cannot chain shim-onto-shim: ``_real_getaddrinfo`` is captured via 

461 # ``_capture_real_getaddrinfo``, which never returns a shim, so the newly 

462 # installed shim's pass-through calls the genuine resolver, not the old 

463 # shim. 

464 if socket.getaddrinfo is not _pinned_getaddrinfo: 464 ↛ 466line 464 didn't jump to line 466 because the condition on line 464 was always true

465 socket.getaddrinfo = _pinned_getaddrinfo 

466 _installed = True 

467 

468 

469def _shim_installed() -> bool: 

470 """True iff THIS module's shim is still the active ``getaddrinfo``. 

471 

472 The pin and block-private guarantees only hold while 

473 ``socket.getaddrinfo`` is exactly this module's ``_pinned_getaddrinfo``: 

474 the thread-local pin/block state lives here, so a wholesale replacement 

475 after import (``gevent``/``eventlet`` monkeypatch, another socket shim, 

476 or a stale post-reload shim) would consult different state and silently 

477 drop the guard. The check is strict identity — a *different* shim, even 

478 one stamped with our marker, does not satisfy it because it would read a 

479 different ``_thread_state``. Callers on the send path treat a False here 

480 as fail-closed rather than sending unguarded. 

481 """ 

482 return socket.getaddrinfo is _pinned_getaddrinfo 

483 

484 

485def _extract_scheme_host( 

486 url: Optional[str], 

487) -> Tuple[Optional[str], Optional[str], bool]: 

488 """Return ``(scheme, normalized_host, is_ip_literal)`` for ``url``. 

489 

490 ``scheme`` is lower-cased (``None`` when absent). ``host`` is ``None`` 

491 when the URL is empty or the parser rejects it. Uses 

492 ``urllib3.util.parse_url`` — the same parser ``requests`` uses 

493 internally and the same one ``ssrf_validator.validate_url`` validates 

494 against — so the host we pin is the host the client will resolve. 

495 """ 

496 if not url: 

497 return None, None, False 

498 try: 

499 parsed = parse_url(url) 

500 except (LocationParseError, ValueError): 

501 return None, None, False 

502 scheme = (parsed.scheme or "").lower() or None 

503 host = _normalize_host(parsed.host) 

504 if host is None: 504 ↛ 505line 504 didn't jump to line 505 because the condition on line 504 was never true

505 return scheme, None, False 

506 try: 

507 ipaddress.ip_address(host) 

508 return scheme, host, True # IP literal — no DNS to rebind 

509 except ValueError: 

510 return scheme, host, False 

511 

512 

513def _extract_host(url: Optional[str]) -> Tuple[Optional[str], bool]: 

514 """Return ``(normalized_host, is_ip_literal)`` for ``url``. 

515 

516 Thin wrapper over :func:`_extract_scheme_host` for callers that don't 

517 need the scheme (e.g. :func:`pinned_request`). 

518 """ 

519 _scheme, host, is_literal = _extract_scheme_host(url) 

520 return host, is_literal 

521 

522 

523def _resolve_and_validate( 

524 host: str, 

525 allow_localhost: bool, 

526 allow_private_ips: bool, 

527 block_link_local: bool = False, 

528) -> List[tuple]: 

529 """Resolve ``host`` once and validate every returned address. 

530 

531 Uses the real resolver (never the shim, to avoid recursion and to 

532 ignore any pin) and applies exactly the ``is_ip_blocked`` policy that 

533 ``validate_url`` applies — honoring ``allow_localhost`` / 

534 ``allow_private_ips`` (and ``block_link_local`` for the notification 

535 path) while the ``ALWAYS_BLOCKED_METADATA_IPS`` set keeps firing 

536 regardless. This is the connect-time re-validation that the pin adds: an 

537 answer that changed since ``validate_url`` ran is caught here. 

538 

539 Returns the addrinfo list to pin (all entries validated safe). 

540 

541 Raises: 

542 ValueError: if any resolved address is blocked. SSRF-style message 

543 so existing callers/tests catch it uniformly. 

544 requests.ConnectionError: if the host no longer resolves at pin 

545 time, OR if resolution times out (a slow / hostile DNS authority 

546 cannot hang the send) — fail closed as an ordinary transport 

547 error so the existing retry / ``RequestException`` handling stays 

548 intact. 

549 """ 

550 try: 

551 addr_info = _getaddrinfo_bounded( 

552 host, None, socket.AF_UNSPEC, socket.SOCK_STREAM, 0, 0 

553 ) 

554 except socket.gaierror as exc: 

555 raise requests.ConnectionError( 

556 f"DNS resolution failed while pinning host {host}" 

557 ) from exc 

558 if not addr_info: 558 ↛ 559line 558 didn't jump to line 559 because the condition on line 558 was never true

559 raise requests.ConnectionError( 

560 f"No addresses resolved while pinning host {host}" 

561 ) 

562 

563 for info in addr_info: 

564 ip_str = str(info[4][0]) 

565 if ssrf_validator.is_ip_blocked( 

566 ip_str, 

567 allow_localhost=allow_localhost, 

568 allow_private_ips=allow_private_ips, 

569 block_link_local=block_link_local, 

570 ): 

571 logger.warning( 

572 "Blocked connect-time address for host {}: resolves to " 

573 "internal/private/metadata IP {} " 

574 "(resolve-vs-connect guard)", 

575 host, 

576 ip_str, 

577 ) 

578 raise ValueError( 

579 "URL failed security validation (possible SSRF): " 

580 f"host {host} resolved to blocked IP {ip_str} at connect time" 

581 ) 

582 return addr_info 

583 

584 

585@contextlib.contextmanager 

586def pinned_request( 

587 url: str, 

588 allow_localhost: bool = False, 

589 allow_private_ips: bool = False, 

590) -> Iterator[None]: 

591 """Pin DNS for the duration of a single outbound request to ``url``. 

592 

593 Resolves the URL host ONCE, validates every returned address against 

594 the SSRF policy, and pins those addresses so the connection made by 

595 ``requests`` / ``urllib3`` inside this context cannot be steered to a 

596 rebind target: the address validated here is the address connected to. 

597 

598 Yields without pinning when the host is an IP literal (no DNS to 

599 rebind — the caller's ``validate_url`` already vetted the literal) or 

600 when the URL host cannot be extracted. 

601 

602 Must wrap every outbound request AND every redirect hop, so each hop 

603 re-resolves, re-validates, and re-pins. 

604 

605 Raises: 

606 ValueError: if the host resolves to a blocked address at connect 

607 time. 

608 requests.ConnectionError: if the host fails to resolve at pin time. 

609 """ 

610 host, is_literal = _extract_host(url) 

611 if host is None or is_literal: 

612 yield 

613 return 

614 

615 entry = _resolve_and_validate(host, allow_localhost, allow_private_ips) 

616 

617 pins = _get_pins() 

618 # Save/restore any prior pin for this host so nested contexts (e.g. a 

619 # redirect chain that loops back to the same host) are safe. 

620 had_previous = host in pins 

621 previous = pins.get(host) 

622 pins[host] = entry 

623 try: 

624 yield 

625 finally: 

626 if had_previous: 626 ↛ 627line 626 didn't jump to line 627 because the condition on line 626 was never true

627 pins[host] = previous 

628 else: 

629 pins.pop(host, None) 

630 

631 

632@contextlib.contextmanager 

633def block_private_resolution( 

634 allow_localhost: bool = False, 

635 allow_private_ips: bool = False, 

636 block_link_local: bool = False, 

637) -> Iterator[None]: 

638 """Refuse UNPINNED lookups that resolve to a blocked address. 

639 

640 While this context is active on the calling thread, any host that is 

641 NOT covered by an active pin and resolves to a private / loopback / 

642 link-local / cloud-metadata address (per ``ssrf_validator.is_ip_blocked`` 

643 with the supplied flags) is refused at the ``getaddrinfo`` layer with a 

644 ``socket.gaierror``. Pinned hosts are unaffected (they short-circuit to 

645 their validated addresses), and public unpinned hosts resolve normally. 

646 

647 ``block_link_local`` (notification lenient partition): when True, the 

648 whole link-local range stays refused even under ``allow_private_ips=True`` 

649 — closing metadata reachable in link-local beyond the always-blocked 

650 literals (e.g. Scaleway ``169.254.42.42``) without over-blocking RFC1918 / 

651 loopback / non-link-local ULA self-hosted notifiers. 

652 

653 This closes redirect-to-internal / rebind-to-metadata for hosts that 

654 were never pinned (e.g. a webhook that 302-redirects to 

655 ``169.254.169.254`` or ``127.0.0.1``): the block runs on the very 

656 resolution the client is about to connect to, so there is no window to 

657 race. 

658 

659 Thread-local: only the calling thread is affected, so a legitimate 

660 private request in another thread is never disturbed. Windows nest 

661 (inner replaces outer, restored on exit). 

662 """ 

663 prev = getattr(_thread_state, "block_private", None) 

664 _thread_state.block_private = ( 

665 allow_localhost, 

666 allow_private_ips, 

667 block_link_local, 

668 ) 

669 try: 

670 yield 

671 finally: 

672 _thread_state.block_private = prev 

673 

674 

675@contextlib.contextmanager 

676def pin_hosts( 

677 urls, 

678 allow_localhost: bool = False, 

679 allow_private_ips: bool = False, 

680 block_link_local: bool = False, 

681) -> Iterator[None]: 

682 """Pin the validated address of every pinnable host in ``urls``. 

683 

684 For each URL whose scheme is in :data:`_PINNABLE_SCHEMES` and whose host 

685 is a real (non-literal) name, resolves once, validates every returned 

686 address against the SSRF policy, and pins those addresses for the 

687 duration of the context. IP literals, token-host plugin schemes, and 

688 unparseable URLs are skipped (nothing to rebind, or the host is not a 

689 resolvable name). A host that fails to resolve right now is also skipped 

690 rather than aborting the whole batch — it is left unpinned so the 

691 accompanying block-private window still governs its send-time lookup and 

692 Apprise fails only that one URL. 

693 

694 ``block_link_local`` is forwarded to the connect-time validation so a 

695 raw-webhook host that resolves into link-local (metadata territory) is 

696 refused even under ``allow_private_ips=True`` (notification lenient 

697 partition). 

698 

699 Raises: 

700 ValueError: if a pinnable host resolves to a blocked address (the 

701 batch is refused — fail closed). 

702 """ 

703 pins = _get_pins() 

704 saved: List[Tuple[str, bool, Optional[list]]] = [] 

705 pinned_now = set() 

706 try: 

707 for url in urls: 

708 scheme, host, is_literal = _extract_scheme_host(url) 

709 if scheme not in _PINNABLE_SCHEMES: 

710 continue 

711 if host is None or is_literal or host in pinned_now: 711 ↛ 712line 711 didn't jump to line 712 because the condition on line 711 was never true

712 continue 

713 try: 

714 entry = _resolve_and_validate( 

715 host, 

716 allow_localhost, 

717 allow_private_ips, 

718 block_link_local=block_link_local, 

719 ) 

720 except requests.ConnectionError: 

721 logger.debug( 

722 "Skipping pin for currently-unresolvable host {}", host 

723 ) 

724 continue 

725 saved.append((host, host in pins, pins.get(host))) 

726 pins[host] = entry 

727 pinned_now.add(host) 

728 yield 

729 finally: 

730 for host, had_previous, previous in reversed(saved): 

731 if had_previous: 731 ↛ 732line 731 didn't jump to line 732 because the condition on line 731 was never true

732 pins[host] = previous 

733 else: 

734 pins.pop(host, None) 

735 

736 

737class NotificationGuardUnavailableError(RuntimeError): 

738 """The DNS-pin shim required for a guarded notification send is not 

739 installed as the active ``socket.getaddrinfo`` resolver. 

740 

741 Raised ONLY by :func:`pinned_notification_send` (never by 

742 :func:`pinned_request`, the general-purpose safe_get/safe_post guard, 

743 which does not depend on this pre-check). This is a deliberate 

744 fail-closed SECURITY REFUSAL, not an incidental runtime error: the 

745 pin/block-private window cannot be guaranteed without the shim, so the 

746 send is refused outright. 

747 

748 Subclasses ``RuntimeError`` (not just ``Exception``) so any code that 

749 still generically catches ``RuntimeError`` around a guarded send 

750 continues to behave the same. It is a dedicated subclass — rather than 

751 a bare ``RuntimeError`` — so ``notifications.service`` can catch this 

752 specific refusal and re-raise it as its own ``SecurityBlockError`` 

753 (non-retryable, INVALID_URL classification) without this general 

754 security module importing exception types from the higher-level 

755 ``notifications`` package (which would invert the module layering and 

756 risk a circular import — ``notifications.service`` imports this 

757 module). 

758 """ 

759 

760 

761@contextlib.contextmanager 

762def pinned_notification_send( 

763 urls, 

764 allow_localhost: bool = False, 

765 allow_private_ips: bool = False, 

766 block_link_local: bool = False, 

767) -> Iterator[None]: 

768 """Guard a single in-thread Apprise ``notify()`` over ``urls``. 

769 

770 Combines :func:`pin_hosts` (pin every raw-webhook host in the batch to 

771 its validated address) with :func:`block_private_resolution` (refuse any 

772 unpinned lookup — a redirect target or a token-scheme endpoint — that 

773 resolves to a blocked address). Both are thread-local, so the caller 

774 MUST run the ``notify()`` synchronously in this thread 

775 (``AppriseAsset(async_mode=False)``); an async fan-out would resolve in 

776 worker threads that carry neither the pin nor the block. 

777 

778 ``allow_private_ips`` mirrors the notification validator's per-scheme 

779 policy: pass the operator flag for ``http``/``https`` batches (private 

780 blocked unless opted in) and ``True`` for the plugin/raw-webhook batch 

781 (private allowed, cloud-metadata still always blocked). 

782 

783 ``block_link_local`` should be ``True`` for the plugin/raw-webhook batch 

784 (which runs with ``allow_private_ips=True``) so the whole link-local 

785 range stays blocked there — cloud-provider metadata lives in link-local 

786 beyond the always-blocked literals, and no legitimate self-hosted 

787 notifier does. It is forwarded to both the pin's connect-time validation 

788 and the block-private window. 

789 

790 Raises: 

791 NotificationGuardUnavailableError: if the ``getaddrinfo`` shim is not 

792 the active resolver (see :func:`_shim_installed`). The 

793 pin/block cannot be guaranteed in that state, so the send is 

794 REFUSED (fail closed) rather than proceeding unguarded. A 

795 ``RuntimeError`` subclass — see its docstring for why it is 

796 not a bare ``RuntimeError``. 

797 """ 

798 # Fail closed if our shim is not installed: without it the pin registry 

799 # and the block-private window are never consulted, so proceeding would 

800 # send completely unguarded (silent fail-open). Refuse instead. 

801 if not _shim_installed(): 

802 raise NotificationGuardUnavailableError( 

803 "DNS pin shim is not the active socket.getaddrinfo; refusing the " 

804 "notification send (fail closed). The resolve-vs-connect pin and " 

805 "block-private window cannot be guaranteed — see " 

806 "security.dns_pinning." 

807 ) 

808 with contextlib.ExitStack() as stack: 

809 stack.enter_context( 

810 pin_hosts( 

811 urls, 

812 allow_localhost, 

813 allow_private_ips, 

814 block_link_local=block_link_local, 

815 ) 

816 ) 

817 stack.enter_context( 

818 block_private_resolution( 

819 allow_localhost, 

820 allow_private_ips, 

821 block_link_local=block_link_local, 

822 ) 

823 ) 

824 yield 

825 

826 

827# Install on import: safe_requests imports this module, so the shim is in 

828# place before any fetch. Transparent until a pin is active. 

829install()