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
« 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.
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.
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.
20Why a ``getaddrinfo`` shim rather than a custom ``HTTPAdapter``:
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.
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.
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"""
69import contextlib
70import ipaddress
71import socket
72import threading
73from typing import Iterator, List, Optional, Tuple
75import requests
76from loguru import logger
77from urllib3.exceptions import LocationParseError
78from urllib3.util import parse_url
80from . import ssrf_validator
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"
88def _capture_real_getaddrinfo():
89 """Return the genuine resolver, never our own shim.
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
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()
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()
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)
177_installed = False
180def _normalize_host(host: Optional[str]) -> Optional[str]:
181 """Normalize a host into the key form used by the pin registry.
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
199def _host_for_log(host) -> str:
200 """Best-effort text form of a host for a log message.
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)
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
223def reset_ssrf_block() -> None:
224 """Clear the per-thread "an SSRF block occurred" marker.
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
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.
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)
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
255def _getaddrinfo_bounded(host, port, family, type, proto, flags):
256 """Call the genuine resolver with a bounded timeout; fail closed.
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.
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 = {}
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
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"]
306def _pinned_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0):
307 """Process-wide ``socket.getaddrinfo`` replacement.
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)
363def _resolve_maybe_block(host, port, family, type, proto, flags):
364 """Real resolution for an unpinned host, honoring the block window.
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
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)
435def install() -> None:
436 """Install the process-wide ``getaddrinfo`` shim (idempotent).
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.
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
469def _shim_installed() -> bool:
470 """True iff THIS module's shim is still the active ``getaddrinfo``.
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
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``.
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
513def _extract_host(url: Optional[str]) -> Tuple[Optional[str], bool]:
514 """Return ``(normalized_host, is_ip_literal)`` for ``url``.
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
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.
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.
539 Returns the addrinfo list to pin (all entries validated safe).
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 )
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
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``.
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.
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.
602 Must wrap every outbound request AND every redirect hop, so each hop
603 re-resolves, re-validates, and re-pins.
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
615 entry = _resolve_and_validate(host, allow_localhost, allow_private_ips)
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)
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.
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.
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.
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.
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
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``.
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.
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).
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)
737class NotificationGuardUnavailableError(RuntimeError):
738 """The DNS-pin shim required for a guarded notification send is not
739 installed as the active ``socket.getaddrinfo`` resolver.
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.
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 """
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``.
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.
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).
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.
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
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()