Coverage for src/local_deep_research/security/notification_validator.py: 97%
391 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""
2Security validation for notification service URLs.
4This module provides validation for user-configured notification service URLs
5to prevent Server-Side Request Forgery (SSRF) attacks and other security issues.
6"""
8import ipaddress
9import re
10import socket
11from concurrent.futures import ThreadPoolExecutor, TimeoutError
12from typing import ClassVar, List, Optional, Tuple, Union
13from urllib.parse import unquote, urlparse
15from loguru import logger
16from urllib3.exceptions import LocationParseError
17from urllib3.util import parse_url
19from .notification_destination import (
20 NotificationDestinationError,
21 NotificationDestinationKind,
22 normalize_host_for_transport,
23 parse_notification_destination,
24)
25from .ip_ranges import PRIVATE_IP_RANGES as _PRIVATE_IP_RANGES
26from .legacy_ipv4 import (
27 AMBIGUOUS_NUMERIC_IPV4_HOST_ERROR,
28 ENCODED_NUMERIC_IPV4_HOST_ERROR,
29 is_ambiguous_numeric_ipv4_host,
30 is_percent_encoded_numeric_ipv4_host,
31)
32from .log_sanitizer import redact_and_bound_for_log
33from .ssrf_validator import RFC_FORBIDDEN_URL_CHARS_RE, redact_url_for_log
35# (#6938) Cap for a request-supplied hostname or scheme in a log line, and
36# in the rejection messages this module returns, which callers log
37# (``NotificationService.test_service``) — a scoped IPv6 literal such as
38# ``[fe80::1%25<100k chars>]`` is accepted by the parser and rejected here
39# with its full host. 256 keeps every name within the 253-char DNS limit
40# intact. Each value is redacted before it is cut (``redact_and_bound_for_log``).
41_LOG_HOST_MAX_CHARS = 256
43# Type alias for resolved IP addresses (avoids referencing the private
44# ipaddress._BaseAddress API).
45ResolvedIP = Union[ipaddress.IPv4Address, ipaddress.IPv6Address]
47# ``localhost`` carries an inline case-insensitive group rather than a
48# pattern-wide re.IGNORECASE: hostnames are case-insensitive, but the other two
49# alternatives are already case-agnostic (``\d`` and explicit ``A-Za-z``
50# classes), and a blanket flag would additionally fold Unicode look-alikes
51# (U+017F, U+212A) into those ``A-Za-z`` classes. Scoping the flag keeps the
52# widening to the one alternative that needed it.
53# ``(?://)?`` mirrors the IPv6 arm below: a scheme-relative fragment names the
54# same host as its bare spelling, so ``//localhost/x`` has to fail closed
55# exactly like ``localhost/x``.
56_SCHEMELESS_URL_FRAGMENT_RE = re.compile(
57 r"^(?://)?(?:(?i:localhost)|(?:\d{1,3}\.){3}\d{1,3}|"
58 r"(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,})"
59 r"(?::\d+)?(?:[/?#]|$)"
60)
61# A userinfo prefix hides the host from the anchored pattern above, so
62# ``user@127.0.0.1/x`` would otherwise read as opaque data. Two deliberate
63# narrowings keep this from swallowing ordinary URL data:
64# * only the loopback-name and IPv4-literal alternatives are carried over.
65# This alternation, not the trailer below, is what keeps
66# ``recipient@example.com`` in a ``?to=`` list an e-mail address:
67# ``more:secret:parts@example.com/webhook`` is credentials in front of an
68# ordinary domain — exactly the shape an Apprise service URL uses — and a
69# public domain is not a blocked shortcut in the first place.
70# * a ``[/?#]`` trailer is required rather than also accepting
71# end-of-string. That is what keeps a bare ``admin@localhost`` or
72# ``recipient@127.0.0.1`` — a recipient whose domain part happens to be
73# spelled like a loopback host — an address rather than an authority.
74_USERINFO_URL_FRAGMENT_RE = re.compile(
75 r"^(?://)?[^/?#@\s]*@(?:(?i:localhost)|(?:\d{1,3}\.){3}\d{1,3})"
76 r"(?::\d+)?[/?#]"
77)
78# Legacy inet_aton spellings (decimal, hexadecimal, octal, shortened) name the
79# same host as their dotted-quad form; ``legacy_ipv4`` already owns that
80# grammar for whole URLs. The ``[/?#]`` trailer is NOT the safeguard here —
81# it is the common case. Apprise's comma-separated target lists put a ``/``
82# or a ``?`` directly after the last id (``tgram://…/111111111,222222222?
83# format=markdown``, ``twilio://…/2125551234,4085551234?batch=yes``), and the
84# one-component inet_aton form accepts every decimal below 2**32, so a bare
85# id would read as a host. ``_names_legacy_numeric_host`` therefore demands
86# an authority marker as well: two or more dot-separated components, a
87# ``//`` prefix, or a ``userinfo@`` prefix. ``%`` is in the host class so the
88# percent-encoded spellings reach the same decision.
89_NUMERIC_HOST_FRAGMENT_RE = re.compile(
90 r"^(?P<prefix>(?://)?(?:[^/?#@\s]*@)?)"
91 r"(?P<host>[0-9A-Fa-fXx.%]+)(?::\d+)?[/?#]"
92)
93# Keep candidate detection deliberately broad. IPv6Address below is the sole
94# authority for validity, including dotted-quad and scoped IPv6 forms.
95# Brackets delimit the authority instead of belonging to the address, so the
96# bracketed arm takes a repeated or unclosed pair and any trailing run that
97# is not a separator: ``[::1``, ``[[::1]]`` and ``[::1]x`` each name ``::1``.
98_IPV6_FRAGMENT_CANDIDATE_RE = re.compile(
99 r"^(?://)?(?:\[+(?P<bracketed>[^\s,\[\]/?#]+)"
100 r"[^\s,/?#]*|"
101 r"(?P<bare>(?=[^\s,/?#]*:[^\s,/?#]*:)[^\s,/?#]+))"
102 r"(?:[/?#]|$)"
103)
104_PERCENT_ENCODED_BRACKETS = {"%5b": "[", "%5d": "]"}
105_PERCENT_ENCODED_BRACKET_RE = re.compile(
106 "|".join(_PERCENT_ENCODED_BRACKETS), re.IGNORECASE
107)
108# ``%2e`` spells the label separator, so it decides both where a host's
109# trailing root dot ends and how many components the host has.
110_PERCENT_ENCODED_DOTS = ("%2e", "%2E")
111# One scan finds the end of a fragment's authority for every helper below.
112_AUTHORITY_END_RE = re.compile(r"[/?#]")
113_URL_SCHEME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]{0,31}://")
114# Keep this boundary zero-width to avoid quadratic backtracking on
115# separator runs.
116_URL_BOUNDARY_RE = re.compile(r"(?<=[,\s])(?=[A-Za-z][A-Za-z0-9+.-]{0,31}://)")
117_APPRISE_TELEGRAM_URL_RE = re.compile(
118 r"^tgram://(?:bot)?[0-9]+(?::|%3A)[a-z0-9_-]+(?:/|\?|$)",
119 re.IGNORECASE,
120)
123def _strip_notification_url_delimiters(entry: str) -> str:
124 start = 0
125 end = len(entry)
126 while start < end and (entry[start] == "," or entry[start].isspace()):
127 start += 1
128 while end > start and (entry[end - 1] == "," or entry[end - 1].isspace()):
129 end -= 1
130 return entry[start:end]
133def _is_ipv6_literal(host: str) -> bool:
134 try:
135 ipaddress.IPv6Address(host)
136 except ipaddress.AddressValueError:
137 return False
138 return True
141def _decode_bracket_escapes(fragment: str) -> str:
142 """Decode ``%5b``/``%5d`` so an encoded IPv6 literal stays recognizable."""
143 return _PERCENT_ENCODED_BRACKET_RE.sub(
144 lambda match: _PERCENT_ENCODED_BRACKETS[match.group(0).lower()],
145 fragment,
146 )
149def _has_label_separator(host: str) -> bool:
150 """Return whether a host carries a dot in either spelling."""
151 return "." in host or any(dot in host for dot in _PERCENT_ENCODED_DOTS)
154def _strip_trailing_host_dots(host: str) -> str:
155 r"""Drop a host's trailing root dots, ``%2e`` included.
157 A fragment's authority is attacker-supplied and unbounded, so the
158 literal dots come off with one C-level ``rstrip`` and the encoded ones
159 walk back by index. Repeated slicing and a ``(?:\.|%2e)+$`` regex are
160 both quadratic on a long run of dots.
161 """
162 stripped = host.rstrip(".")
163 if not stripped.endswith(_PERCENT_ENCODED_DOTS):
164 return stripped
165 end = len(stripped)
166 while True:
167 if end >= 3 and stripped[end - 3 : end].lower() == "%2e":
168 end -= 3
169 elif end > 0 and stripped[end - 1] == ".":
170 end -= 1
171 else:
172 return stripped[:end]
175def _authority_end(rest: str) -> int:
176 """Return the offset of a fragment's first ``/``, ``?`` or ``#``."""
177 boundary = _AUTHORITY_END_RE.search(rest)
178 return len(rest) if boundary is None else boundary.start()
181def _strip_fragment_host_dots(fragment: str) -> str:
182 """Drop a host's trailing dots the way ``_normalize_host`` does.
184 ``localhost.`` and ``localhost`` are one host to every resolver, so the
185 fully-qualified spelling must not read as opaque data here when the bare
186 one fails closed. ``%2e`` spells the same dot, so ``localhost%2e``
187 normalizes onto the same host as ``localhost.`` does.
188 """
189 prefix, rest = "", fragment
190 if rest.startswith("//"):
191 prefix, rest = "//", rest[2:]
192 boundary = _authority_end(rest)
193 host, tail = rest[:boundary], rest[boundary:]
194 port = ""
195 colon = host.rfind(":")
196 # Split a port off a colon-free host, or off a bracketed one: a BARE
197 # IPv6 literal's own colons are part of the address rather than a port
198 # delimiter, but a bracketed literal has already closed, so ``[::1].:80``
199 # has to split the way ``[::1]:80`` does.
200 if (
201 colon > 0
202 and (":" not in host[:colon] or "]" in host[:colon])
203 and host[colon + 1 :].isdigit()
204 ):
205 host, port = host[:colon], host[colon:]
206 stripped = _strip_trailing_host_dots(host)
207 if stripped == host:
208 return fragment
209 return prefix + stripped + port + tail
212def _strip_fragment_userinfo(fragment: str) -> str:
213 """Drop a ``userinfo@`` prefix from a fragment's authority component."""
214 prefix, rest = "", fragment
215 if rest.startswith("//"):
216 prefix, rest = "//", rest[2:]
217 authority_end = _authority_end(rest)
218 # The LAST ``@`` inside the authority delimits userinfo; any ``@`` in the
219 # path or query belongs to the data, not to the host.
220 at_sign = rest.rfind("@", 0, authority_end)
221 return fragment if at_sign < 0 else prefix + rest[at_sign + 1 :]
224def _is_ipv6_url_fragment(fragment: str) -> bool:
225 candidate = _IPV6_FRAGMENT_CANDIDATE_RE.match(fragment)
226 if candidate is None:
227 return False
229 bracketed = candidate.group("bracketed")
230 host = bracketed or candidate.group("bare")
231 if _is_ipv6_literal(host):
232 return True
233 if bracketed is not None:
234 return False
235 # The bare alternative has no port group, so its greedy run swallows a
236 # trailing ``:<port>``. Without this retry ``::1:99999`` -- a valid host
237 # with an out-of-range port -- fails ``IPv6Address`` as one literal and is
238 # silently accepted as URL data, while the bracketed ``[::1]:99999`` is
239 # refused. Retry once with the final colon-segment removed so both forms
240 # fail closed on the same input.
241 head, sep, _tail = host.rpartition(":")
242 return bool(sep) and _is_ipv6_literal(head)
245def _names_legacy_numeric_host(prefix: str, host: str) -> bool:
246 """Return whether a fragment's leading token is an inet_aton authority.
248 A one-component inet_aton host is any integer below 2**32, which is also
249 the shape of every Telegram chat id, Twilio number and Unix timestamp
250 that an Apprise comma-separated target list leaves in front of a ``/``
251 or ``?``. Such a token only names a host once the fragment carries an
252 authority marker of its own: a second dot-separated component (a
253 trailing root dot does not count), a ``//`` prefix, or a ``userinfo@``
254 prefix.
255 """
256 # A trailing root dot (``.`` or ``%2e``) is not a second component:
257 # ``222222222%2e`` is still a one-component numeric token.
258 bare_host = re.sub(r"(?:\.|%2[eE])+$", "", host)
259 if not prefix and not _has_label_separator(bare_host):
260 return False
261 return is_ambiguous_numeric_ipv4_host(
262 host
263 ) or is_percent_encoded_numeric_ipv4_host(host)
266def _matches_scheme_less_url_shape(fragment: str) -> bool:
267 if _SCHEMELESS_URL_FRAGMENT_RE.match(fragment):
268 return True
269 if _USERINFO_URL_FRAGMENT_RE.match(fragment):
270 return True
272 numeric = _NUMERIC_HOST_FRAGMENT_RE.match(fragment)
273 if numeric is not None and _names_legacy_numeric_host(
274 numeric.group("prefix"), numeric.group("host")
275 ):
276 return True
278 return _is_ipv6_url_fragment(fragment)
281def _is_scheme_less_url_fragment(fragment: str) -> bool:
282 """Recognize a comma-separated fragment that names a host without a scheme.
284 Every check is additive: an alternate spelling is only ever consulted
285 after the literal fragment has been cleared, so normalization can widen
286 the refusal set but can never readmit a fragment that already fails
287 closed.
288 """
289 if _matches_scheme_less_url_shape(fragment):
290 return True
291 # Spellings that normalize onto a host the checks above already refuse:
292 # percent-encoded brackets and a trailing root dot, encoded or literal.
293 normalized = _strip_fragment_host_dots(_decode_bracket_escapes(fragment))
294 if normalized != fragment and _matches_scheme_less_url_shape(normalized):
295 return True
296 # A userinfo prefix hides an IPv6 authority from the anchored patterns
297 # above. Only the IPv6 arm is retried on the stripped form: stripping
298 # userinfo from ``recipient@example.com`` would read an e-mail address as
299 # a hostname, which is why the hostname arm demands the ``[/?#]`` trailer
300 # that proves an authority instead.
301 without_userinfo = _strip_fragment_userinfo(normalized)
302 return without_userinfo != normalized and _is_ipv6_url_fragment(
303 without_userinfo
304 )
307def parse_notification_url_list(
308 urls: str, separator: str = ","
309) -> tuple[list[str], Optional[str]]:
310 r"""Partition notification URLs without dropping malformed fragments.
312 A boundary is a comma or whitespace sequence followed by a URL scheme.
313 This preserves commas inside one Apprise service URL while keeping
314 whitespace a hard separator. Commas and surrounding whitespace — any
315 Unicode whitespace, matching the bare ``str.strip()`` every other
316 consumer of an entry applies — are trimmed from each entry's ENDS
317 before it is inspected, so a value pasted with a trailing NBSP is not
318 mistaken for a URL with an illegal character in it. An entry whose
319 head carries a scheme but which contains characters that are illegal
320 unencoded in a URI (whitespace, control bytes, backslash —
321 ``RFC_FORBIDDEN_URL_CHARS_RE``, the same class ``validate_service_url``
322 rejects) is malformed no matter what trails it: the token after the first illegal character is returned
323 as the invalid fragment, so ``discord://x garbage`` fails closed exactly
324 like ``discord://x example.com/hook`` instead of being dispatched as a
325 single entry. When the illegal character only TRAILS the entry
326 (``slack://t/x/y\``) there is no token after it, so the whole entry is
327 returned as the fragment. URL-like fragments without a scheme are
328 returned separately so callers can fail closed instead of dispatching
329 only the valid subset.
331 ``invalid_fragment`` is a REFUSAL SIGNAL, not a substitute URL, and it
332 carries three warnings for callers:
334 * It may CARRY A SCHEME. ``RFC_FORBIDDEN_URL_CHARS_RE`` covers
335 backslash and the non-``\s`` control bytes, which the
336 scheme-boundary regex does not split on, so
337 ``a://h1/p\https://h2/q`` stays ONE entry whose fragment is
338 ``https://h2/q``. A caller that validates the fragment *in place of*
339 the entry validates a decoy while the raw original string — pointing
340 at ``h1`` — is what gets dispatched.
341 * It may be VALID on its own. Never assume validating it yields an
342 error message.
343 * It may CONTAIN CREDENTIALS. When the illegal character trails the
344 entry the fragment IS the entry, and for token-in-authority Apprise
345 schemes even a ``scheme://host`` redaction of it is the secret. Log
346 its length, never its content.
348 The only correct response to a non-``None`` fragment is to refuse the
349 whole input.
351 Custom separators are explicit boundaries: commas and whitespace inside
352 each resulting URL remain data, subject to service URL validation. Every
353 parsed entry still requires a top-level URL scheme. The scheme-less
354 fragment check below is the one guard a custom separator does NOT get:
355 its entries take ``validate_service_url`` but are never scanned for
356 comma-separated scheme-less fragments, because with an explicit separator
357 a comma is ordinary data inside one URL.
359 Returns ``(parsed_urls, invalid_fragment)``. Custom separators retain the
360 historical explicit-split behavior.
361 """
362 if separator != ",":
363 parsed_urls = [
364 entry.strip() for entry in urls.split(separator) if entry.strip()
365 ]
366 else:
367 parsed_urls = []
368 for entry in _URL_BOUNDARY_RE.split(urls):
369 normalized_entry = _strip_notification_url_delimiters(entry)
370 if normalized_entry:
371 parsed_urls.append(normalized_entry)
372 for entry in parsed_urls:
373 if not _URL_SCHEME_RE.match(entry):
374 return parsed_urls, entry
375 if separator != ",":
376 continue
377 # A URL entry containing characters illegal unencoded in a URI is
378 # malformed regardless of what trails it: ``discord://x garbage``
379 # is not one URL with "garbage" in it, it is a URL followed by
380 # junk. Any token after the first illegal character surfaces as
381 # the invalid fragment so every caller fails closed on the shape.
382 if RFC_FORBIDDEN_URL_CHARS_RE.search(entry):
383 fragments = [
384 fragment
385 for fragment in RFC_FORBIDDEN_URL_CHARS_RE.split(entry)
386 if fragment
387 ]
388 # fragments[0] is the prefix before the illegal character; the
389 # next non-empty token is the smuggled fragment. When the
390 # illegal character only trails the entry, the entry itself
391 # is the malformed fragment.
392 return parsed_urls, fragments[1] if len(fragments) > 1 else entry
393 # Commas are legal inside one Apprise URL (e.g. a multi-target
394 # ``?to=id1,id2``), so a comma-separated fragment only fails closed
395 # when it is itself a scheme-less URL that Apprise could repair
396 # away or absorb into a neighbouring entry.
397 for fragment in re.split(r",+", entry)[1:]:
398 if _is_scheme_less_url_fragment(fragment):
399 return parsed_urls, fragment
400 return parsed_urls, None
403class NotificationURLValidationError(ValueError):
404 """Raised when a notification service URL fails security validation."""
406 pass
409class NotificationURLValidator:
410 """Validates notification service URLs to prevent SSRF and other attacks."""
412 # Dangerous protocols that should never be used for notifications
413 BLOCKED_SCHEMES = (
414 "file", # Local file access
415 "ftp", # FTP can be abused for SSRF
416 "ftps", # Secure FTP can be abused for SSRF
417 "data", # Data URIs can leak sensitive data
418 "javascript", # XSS/code execution
419 "vbscript", # XSS/code execution
420 "about", # Browser internal
421 "blob", # Browser internal
422 )
424 # Allowed protocols for notification services
425 ALLOWED_SCHEMES = (
426 "http", # Webhook services
427 "https", # Webhook services (preferred)
428 "mailto", # Email notifications
429 "discord", # Discord webhooks
430 "slack", # Slack webhooks
431 "tgram", # Apprise Telegram scheme
432 "gotify", # Gotify notifications
433 "pushover", # Pushover notifications
434 "ntfy", # ntfy.sh notifications (http)
435 "ntfys", # ntfy.sh notifications (https)
436 "signal", # Signal via signal-api-rest container
437 "matrix", # Matrix protocol
438 "mattermost", # Mattermost webhooks
439 "rocketchat", # Rocket.Chat webhooks
440 "teams", # Microsoft Teams
441 "json", # Generic JSON webhooks
442 "xml", # Generic XML webhooks
443 "form", # Form-encoded webhooks
444 )
446 # Apprise query parameters that can select a second outbound destination
447 # or resource independently of the URL authority screened below.
448 #
449 # ``template`` is consumed by Discord/Slack/Workflows (including native
450 # HTTPS webhook URLs that Apprise converts to those plugins) as an
451 # unrestricted AppriseAttachment, which can read local files or fetch
452 # remote URLs. ``redirect`` overrides the hardened AppriseAsset setting
453 # used by the notification service and would re-enable redirect-based SSRF.
454 BLOCKED_APPRISE_QUERY_KEYS = frozenset(("redirect", "template"))
456 # mailto-specific secondary egress/resource selectors. ``smtp`` replaces
457 # the actual SMTP host; PGP key paths are unrestricted attachments; and
458 # WKD derives HTTPS fetches from recipient-controlled domains.
459 BLOCKED_MAILTO_QUERY_KEYS = frozenset(
460 ("smtp", "pgppub", "pgpkey", "pgpprv", "wkd")
461 )
463 # Schemes whose authority identifies a network destination. Token-style
464 # schemes such as discord and slack deliberately do not belong here.
465 HOST_BEARING_PLUGIN_SCHEMES: ClassVar[frozenset[str]] = frozenset(
466 {
467 "signal",
468 "gotify",
469 "ntfy",
470 "ntfys",
471 "mattermost",
472 "rocketchat",
473 "matrix",
474 "json",
475 "xml",
476 "form",
477 "mailto",
478 }
479 )
480 ADDRESS_BEARING_SCHEMES: ClassVar[frozenset[str]] = (
481 frozenset({"http", "https"}) | HOST_BEARING_PLUGIN_SCHEMES
482 )
484 # Reuse shared private IP range definitions
485 PRIVATE_IP_RANGES = _PRIVATE_IP_RANGES
487 # Prefix emitted by validate_service_url when an http(s) URL targets a
488 # private/internal IP. Pinned as a class-level constant so the
489 # validator, the hint logic, and the call site (service.py) share a
490 # single source of truth.
491 PRIVATE_IP_REJECTION_PREFIX = "Blocked private/internal IP address:"
493 @staticmethod
494 def _ip_matches_blocked_range(
495 ip,
496 allow_private_ips: bool = False,
497 allow_nat64: Optional[bool] = None,
498 block_link_local: bool = False,
499 ) -> bool:
500 """Block-decision for a parsed IP, delegating to
501 ``ssrf_validator.is_ip_blocked`` so the two validators share a
502 single source of truth.
504 Honors:
505 - ALWAYS_BLOCKED_METADATA_IPS (cloud metadata, absolute)
506 - is_nat64_wrapped_metadata_ip (NAT64-wrapped IMDS, absolute)
507 - security.allow_nat64 env carve-out for the two NAT64 prefixes
508 (overridable via ``allow_nat64``: None reads env, an explicit
509 bool answers the "would NAT64 unblock this?" hint probe)
510 - allow_private_ips: when True, RFC1918 / CGNAT / loopback /
511 link-local / IPv6 ULA are allowed BUT the two absolute checks
512 above still fire. This closes the historical bypass where
513 ``allow_private_ips=True`` skipped the host check entirely
514 and let metadata IPs through the notification path.
515 - block_link_local: when True, the whole link-local range
516 (169.254.0.0/16, fe80::/10) stays blocked even under
517 allow_private_ips=True. The notification validator and send
518 guards pass it for every host-bearing destination (http(s)
519 included) so metadata reachable in link-local beyond the
520 always-blocked literals cannot slip through the opt-in.
521 """
522 from .ssrf_validator import is_ip_blocked
524 return is_ip_blocked(
525 str(ip),
526 allow_private_ips=allow_private_ips,
527 allow_nat64=allow_nat64,
528 block_link_local=block_link_local,
529 )
531 @staticmethod
532 def _resolve_hostname_ips(
533 hostname: str,
534 ) -> Optional[List[ResolvedIP]]:
535 """Resolve hostname to a list of ipaddress objects, with a 5s
536 timeout. Returns None on resolution failure or timeout.
538 Extracted from ``_is_private_ip`` so multiple policy decisions
539 can share a single resolution pass — see
540 ``validate_service_url_with_hint`` for the single-pass call
541 site that closes a DNS-rebinding TOCTOU window between the
542 default-level and elevated-level decisions.
543 """
544 # NOTE: best-effort, validation-time check. Apprise re-resolves
545 # the hostname when it actually sends the request (via
546 # requests/urllib3), so a DNS-rebinding attacker could serve a
547 # public IP here and a private/metadata IP at send time — the
548 # classic resolve-vs-connect TOCTOU window.
549 #
550 # SEND-TIME GUARD (this window is now closed in code for the
551 # delivery path): ``notifications.service`` forces Apprise into
552 # synchronous, in-thread delivery (``AppriseAsset(async_mode=False)``),
553 # disables HTTP redirect-following for the send (asset-level
554 # ``http_redirects=False`` re-forced per plugin, so a user-supplied
555 # ``?redirect=yes`` cannot re-enable it), and wraps every ``notify()``
556 # in ``dns_pinning.pinned_notification_send`` (see
557 # ``security.dns_pinning``). Together, for the duration of the send,
558 # that (a) closes the redirect attack class outright — a webhook can
559 # no longer 30x-redirect the send to a private/loopback host or to an
560 # arbitrary public host for data exfiltration, because the send is
561 # simply not redirected; (b) pins every raw-webhook host in the batch
562 # to the address validated here, so the ORIGINAL host's own DNS
563 # rebind cannot steer the connection to a different address; and
564 # (c) activates a thread-local block-private mode so any UNPINNED
565 # lookup (a plugin scheme's endpoint) that resolves to a blocked
566 # address is refused at the ``getaddrinfo`` layer — on a
567 # timeout-bounded resolution — before a socket is opened. The block
568 # runs on the very resolution the client connects to, so there is no
569 # residual race. Every partition requires the operator's private-IP
570 # opt-in for private addresses (LAN targets or a private forward
571 # proxy); cloud metadata remains blocked. Because the pin/block are thread-local,
572 # they never disturb a legitimate private request on another thread.
573 #
574 # DEFENSE IN DEPTH: the whole outbound-notification path is still
575 # gated behind an env-only master switch
576 # (LDR_NOTIFICATIONS_ALLOW_OUTBOUND, default off); enabling it is the
577 # operator's explicit decision. See SECURITY.md "Notification Webhook
578 # SSRF". Operators can further avoid raw webhooks by preferring
579 # plugin schemes (discord://, slack://, ntfy://, ntfys://, gotify://,
580 # tgram://, mattermost://, etc.) that hardcode their endpoints.
581 #
582 # Plugin schemes are not categorically exempt from authority
583 # validation: some have hardcoded destinations (Discord, Slack,
584 # Teams Workflows), some send against the URL authority (Signal,
585 # Gotify, generic webhooks), and ntfy/Matrix are mode-dependent.
586 #
587 # concurrent.futures for thread-safe timeout instead of
588 # socket.setdefaulttimeout() which is process-global.
589 try:
590 executor = ThreadPoolExecutor(max_workers=1)
591 try:
592 future = executor.submit(
593 socket.getaddrinfo,
594 hostname,
595 None,
596 socket.AF_UNSPEC,
597 socket.SOCK_STREAM,
598 )
599 resolved_ips = future.result(timeout=5)
600 finally:
601 executor.shutdown(wait=False, cancel_futures=True)
602 return [
603 ipaddress.ip_address(sockaddr[0])
604 for _family, _, _, _, sockaddr in resolved_ips
605 ]
606 except (socket.gaierror, OSError, TimeoutError, ValueError):
607 logger.warning(
608 "DNS resolution failed for hostname {} — "
609 "allowing request (unable to determine if private)",
610 redact_and_bound_for_log(hostname, _LOG_HOST_MAX_CHARS),
611 )
612 return None
614 @staticmethod
615 def _host_blocks_at_level(
616 hostname: str,
617 allow_private_ips: bool = False,
618 allow_nat64: Optional[bool] = None,
619 resolved_ips: Optional[List[ResolvedIP]] = None,
620 block_link_local: bool = False,
621 ) -> bool:
622 """Decide whether ``hostname`` is blocked at the given
623 ``allow_private_ips`` / ``allow_nat64`` level, optionally reusing
624 a prior DNS resolution.
626 Pre-resolved IPs can be passed via ``resolved_ips`` to share a
627 single resolution across multiple policy decisions, closing
628 DNS-rebinding TOCTOU windows between calls. If ``resolved_ips``
629 is None and ``hostname`` is not an IP literal, DNS is resolved
630 via ``_resolve_hostname_ips``.
632 ``block_link_local`` forces the whole link-local range blocked even
633 under ``allow_private_ips=True`` (plugin-scheme IMDS guard).
634 """
635 # Localhost-string shortcuts only apply when the operator hasn't
636 # opted into private-IP reachability. With allow_private_ips=True
637 # we let the IP path (DNS-resolved or literal) make the decision
638 # so metadata-IP literals like "169.254.169.254" still block.
639 if not allow_private_ips and hostname.lower() in (
640 "localhost",
641 "127.0.0.1",
642 "::1",
643 # Recognize and validate a local address; no socket is bound here.
644 "0.0.0.0", # nosec B104
645 "::",
646 ):
647 return True
649 # Try to parse as IP address first.
650 try:
651 ip = ipaddress.ip_address(hostname)
652 return NotificationURLValidator._ip_matches_blocked_range(
653 ip,
654 allow_private_ips=allow_private_ips,
655 allow_nat64=allow_nat64,
656 block_link_local=block_link_local,
657 )
658 except ValueError:
659 pass
661 # Hostname — use pre-resolved IPs if provided, otherwise resolve.
662 if resolved_ips is None:
663 resolved_ips = NotificationURLValidator._resolve_hostname_ips(
664 hostname
665 )
666 if resolved_ips is None:
667 # DNS failed — allow (matches the historical behaviour).
668 return False
669 for ip in resolved_ips:
670 if NotificationURLValidator._ip_matches_blocked_range(
671 ip,
672 allow_private_ips=allow_private_ips,
673 allow_nat64=allow_nat64,
674 block_link_local=block_link_local,
675 ):
676 return True
677 return False
679 @staticmethod
680 def _extract_host(url: str) -> Optional[str]:
681 """Extract the raw hostname from ``url`` using urllib3's
682 ``parse_url`` (the same parser requests uses internally).
683 Returns None if urllib3 rejects the URL.
684 """
685 try:
686 u3 = parse_url(url)
687 except LocationParseError:
688 return None
689 return u3.host
691 @staticmethod
692 def _normalize_host(hostname: Optional[str]) -> str:
693 """Normalize a raw hostname from ``parse_url``: strip IPv6
694 bracket notation and trailing dots. Shared by
695 ``validate_service_url`` and ``validate_service_url_with_hint``
696 so the two call sites cannot drift.
697 """
698 if hostname and hostname.startswith("[") and hostname.endswith("]"):
699 hostname = hostname[1:-1]
700 if hostname:
701 hostname = hostname.rstrip(".")
702 return hostname or ""
704 @staticmethod
705 def _apprise_query_keys(url: str) -> Tuple[List[str], bool]:
706 """Return canonical query keys using Apprise 1.12 semantics.
708 Apprise splits pairs on both ``&`` and ``;``, preserves a leading
709 ``+`` key namespace marker, converts later ``+`` characters to spaces,
710 percent-decodes once, then lowercases and strips the key. Its URL
711 parser also treats everything after the first ``?`` as query data,
712 including text that ``urllib.parse`` would classify as a fragment.
714 The boolean reports malformed percent escapes in a key. Values are
715 intentionally neither decoded nor returned: they can contain secrets,
716 file paths, and alternate destinations and are irrelevant to policy.
717 """
718 query_at = url.find("?")
719 if query_at < 0:
720 return [], False
722 raw_query = url[query_at + 1 :]
723 keys = []
724 hex_digits = "0123456789abcdefABCDEF"
726 for amp_pair in raw_query.split("&"):
727 for pair in amp_pair.split(";"):
728 raw_key = pair.split("=", 1)[0]
730 index = 0
731 while index < len(raw_key):
732 if raw_key[index] != "%":
733 index += 1
734 continue
735 if (
736 index + 2 >= len(raw_key)
737 or raw_key[index + 1] not in hex_digits
738 or raw_key[index + 2] not in hex_digits
739 ):
740 return keys, True
741 index += 3
743 apprise_key = (
744 raw_key[:1] + raw_key[1:].replace("+", " ")
745 if raw_key
746 else ""
747 )
748 keys.append(unquote(apprise_key).lower().strip())
750 return keys, False
752 @staticmethod
753 def _validate_apprise_query_policy(url: str, scheme: str) -> Optional[str]:
754 """Reject Apprise query options that bypass destination validation."""
755 keys, malformed = NotificationURLValidator._apprise_query_keys(url)
756 if malformed:
757 logger.warning(
758 "Blocked notification URL with malformed percent-encoding "
759 "in a query parameter name"
760 )
761 return "Malformed percent-encoding in notification parameter name"
763 blocked_keys = NotificationURLValidator.BLOCKED_APPRISE_QUERY_KEYS
764 if scheme == "mailto":
765 blocked_keys = (
766 blocked_keys
767 | NotificationURLValidator.BLOCKED_MAILTO_QUERY_KEYS
768 )
770 for key in keys:
771 if key in blocked_keys:
772 # Never log the value: it may contain a token, local path, or
773 # alternate destination selected by an attacker.
774 logger.warning(
775 "Blocked unsafe notification parameter {} for {} URL",
776 key,
777 scheme,
778 )
779 return f"Blocked unsafe notification parameter: {key}"
781 return None
783 @staticmethod
784 def _is_private_ip(
785 hostname: str,
786 allow_private_ips: bool = False,
787 allow_nat64: Optional[bool] = None,
788 _resolved_ips: Optional[List[ResolvedIP]] = None,
789 block_link_local: bool = False,
790 ) -> bool:
791 """
792 Check if hostname resolves to a private IP address.
794 Args:
795 hostname: Hostname to check
796 allow_private_ips: When True, RFC1918 / CGNAT / loopback /
797 link-local / IPv6 ULA are NOT considered private. Cloud
798 metadata IPs and NAT64-wrapped metadata IPs are blocked
799 regardless — the operator opt-in cannot license IMDS
800 exposure.
801 allow_nat64: Override for the ``security.allow_nat64`` carve-out.
802 None (default) reads the env setting; an explicit bool
803 answers the hint probe.
804 block_link_local: When True, the whole link-local range stays
805 blocked even under allow_private_ips=True. Passed for every
806 host-bearing notification destination, http(s) included
807 (metadata lives in link-local beyond the always-blocked
808 literals).
810 Returns:
811 True if hostname is a private IP or localhost (subject to
812 allow_private_ips), or wraps a metadata IP unconditionally
813 """
814 return NotificationURLValidator._host_blocks_at_level(
815 hostname,
816 allow_private_ips=allow_private_ips,
817 allow_nat64=allow_nat64,
818 resolved_ips=_resolved_ips,
819 block_link_local=block_link_local,
820 )
822 @staticmethod
823 def validate_service_url(
824 url: str,
825 allow_private_ips: bool = False,
826 allow_nat64: Optional[bool] = None,
827 ) -> Tuple[bool, Optional[str]]:
828 """Validate a notification service URL for security issues.
830 Thin public wrapper around ``_validate_service_url_impl``.
831 Always performs its own DNS resolution (no caller-supplied
832 resolution state).
834 Args:
835 url: Service URL to validate (e.g., "discord://webhook_id/token")
836 allow_private_ips: Whether to allow private IPs (default: False)
837 Set to True for development/testing environments
838 allow_nat64: Override for the ``security.allow_nat64``
839 carve-out. None (default) reads the env setting;
840 an explicit bool overrides it.
842 Returns:
843 Tuple of (is_valid, error_message)
844 """
845 return NotificationURLValidator._validate_service_url_impl(
846 url,
847 allow_private_ips=allow_private_ips,
848 allow_nat64=allow_nat64,
849 )
851 @staticmethod
852 def _validate_service_url_impl(
853 url: str,
854 allow_private_ips: bool = False,
855 allow_nat64: Optional[bool] = None,
856 resolved_ips: Optional[List[ResolvedIP]] = None,
857 ) -> Tuple[bool, Optional[str]]:
858 """Internal validation with optional pre-resolved DNS state.
860 ``resolved_ips`` is trusted internal state used by
861 ``validate_service_url_with_hint`` to share a single DNS
862 resolution across multiple policy decisions. Must NEVER be
863 exposed on the public API — callers could pass ``[]`` to
864 bypass hostname resolution and private-IP checks.
866 Returns:
867 Tuple of (is_valid, error_message)
868 - is_valid: True if URL passes security checks
869 - error_message: None if valid, error description if invalid
871 Examples:
872 >>> validate_service_url("discord://webhook_id/token")
873 (True, None)
875 >>> validate_service_url("file:///etc/passwd")
876 (False, "Blocked unsafe protocol: file")
878 >>> validate_service_url("http://localhost:5000/webhook")
879 (False, "Blocked private/internal IP address: localhost")
881 Caller contract:
882 ``notifications.service.NotificationService.test_service``
883 calls ``validate_service_url_with_hint`` (single-pass sibling
884 below) to obtain the validation result AND, when the URL is
885 rejected, a ``hint_would_help`` flag indicating whether the
886 rejection targets a recoverable destination — one that
887 ``LDR_NOTIFICATIONS_ALLOW_PRIVATE_IPS=true`` (for RFC1918 /
888 CGNAT / loopback / IPv6 ULA, plus NAT64-wrapped
889 non-metadata destinations via the NAT64 carve-out) would
890 actually unblock. If ``hint_would_help`` is False the URL
891 targets an always-blocked category (link-local, which every
892 host-bearing notification destination blocks, cloud-metadata IPs, 6to4,
893 Teredo, discard prefix, IPv4-mapped IPv6 of metadata,
894 IPv4-Compatible IPv6, IPv4-Translated/SIIT, NAT64-wrapped
895 metadata) and the hint is suppressed because
896 naming the env var would mislead. The parametrized
897 integration test ``test_test_service_ip_rejection_matrix``
898 in tests/web/services/test_notification_coverage.py locks
899 this contract end-to-end across every IP category — if the
900 wording here changes, that test fails and the call site
901 needs updating.
902 """
903 if not url or not isinstance(url, str):
904 return False, "Service URL must be a non-empty string"
906 # Strip whitespace (must run before the RFC-illegal char check
907 # so legitimate URLs with surrounding whitespace are not rejected).
908 url = url.strip()
910 # Reject URLs containing characters that drive parser-differential
911 # SSRF bypasses (backslash, whitespace, control bytes) — see
912 # GHSA-g23j-2vwm-5c25. The URL is omitted from the log line because
913 # userinfo (RFC 3986 §3.2.1) may contain credentials and rejected
914 # URLs are by definition adversarial-shaped.
915 if RFC_FORBIDDEN_URL_CHARS_RE.search(url):
916 logger.warning(
917 "Blocked notification URL containing RFC-illegal characters"
918 )
919 return (
920 False,
921 "URL contains characters that are not allowed (whitespace, backslash, or control bytes)",
922 )
924 # Parse URL
925 try:
926 parsed = urlparse(url)
927 except Exception:
928 # Never echo the parser exception back to the caller: this error
929 # string is surfaced to the user by the test-URL endpoint, and the
930 # exception text can carry parser internals / stack-trace fragments
931 # (CWE-209, py/stack-trace-exposure). Log at WARNING without a
932 # traceback to match the sibling LocationParseError handler below
933 # — a malformed URL is benign user input, not a server fault, so an
934 # ERROR-level stack trace would only add noise.
935 logger.warning("Failed to parse service URL")
936 return False, "Invalid URL format"
938 # Check for scheme
939 if not parsed.scheme:
940 return False, "Service URL must have a protocol (e.g., https://)"
942 scheme = parsed.scheme.lower()
944 # Check for blocked schemes
945 if scheme in NotificationURLValidator.BLOCKED_SCHEMES:
946 logger.warning(
947 "Blocked unsafe notification protocol: {} in URL: {}",
948 redact_and_bound_for_log(scheme, _LOG_HOST_MAX_CHARS),
949 redact_url_for_log(url),
950 )
951 return (
952 False,
953 "Blocked unsafe protocol: "
954 f"{redact_and_bound_for_log(scheme, _LOG_HOST_MAX_CHARS)}",
955 )
957 # Check for allowed schemes
958 if scheme not in NotificationURLValidator.ALLOWED_SCHEMES:
959 if scheme == "telegram":
960 # Dedicated migration error: 'telegram://' was an Apprise
961 # scheme removed upstream; point users at the canonical
962 # 'tgram://' form instead of the generic allowed-list.
963 return (
964 False,
965 "The 'telegram://' scheme is no longer supported by "
966 "Apprise (removed upstream); replace it with Apprise's "
967 "canonical 'tgram://<bot_token>/<chat_id>' form "
968 "(e.g. tgram://123456789:AAexample_token/123456789)",
969 )
970 logger.warning(
971 "Unknown notification protocol: {} in URL: {}",
972 redact_and_bound_for_log(scheme, _LOG_HOST_MAX_CHARS),
973 redact_url_for_log(url),
974 )
975 return (
976 False,
977 "Unsupported protocol: "
978 f"{redact_and_bound_for_log(scheme, _LOG_HOST_MAX_CHARS)}. "
979 f"Allowed: {', '.join(NotificationURLValidator.ALLOWED_SCHEMES[:5])}...",
980 )
982 # Apprise supports query parameters that can select resources or
983 # outbound destinations independently of the authority we validate
984 # below. Enforce this deterministic policy before parsing the host or
985 # performing any attacker-controlled DNS lookup.
986 query_error = NotificationURLValidator._validate_apprise_query_policy(
987 url, scheme
988 )
989 if query_error:
990 return False, query_error
992 # Reject authorities with more than one "@" (fail closed). urllib3's
993 # parse_url (which the SSRF host-check + the DNS pin validate against)
994 # and Apprise's own parse_url disagree on which host a multi-"@"
995 # authority denotes: for
996 # ``json://token@169.254.169.254@decoy-public.example.com/path``
997 # urllib3 sees ``decoy-public.example.com`` (validated/pinned) while
998 # Apprise connects to ``169.254.169.254``. This is the same
999 # parser-differential class as GHSA-g23j-2vwm-5c25; there is no
1000 # legitimate reason for a second "@" in a notification authority, so
1001 # reject it before the host is ever extracted. ``netloc`` is the
1002 # authority only (path/query/fragment excluded), so an "@" in a query
1003 # string is not counted. A single "@" (RFC 3986 userinfo, e.g.
1004 # ``mailto://user:pass@host``) is still allowed.
1005 if parsed.netloc.count("@") > 1:
1006 logger.warning(
1007 "Blocked notification URL: authority contains multiple '@' "
1008 "(parser-differential SSRF risk)"
1009 )
1010 return (
1011 False,
1012 "URL authority contains multiple '@' characters, "
1013 "which is not allowed",
1014 )
1016 # Reject a scheme://-form URL whose authority is EMPTY while content
1017 # (a host smuggled into the path) follows — another parser-differential
1018 # SSRF (defense-in-depth; the send-time block window backstops it).
1019 # urllib3/requests parse an empty ``//`` authority as "no host", so the
1020 # per-scheme IP checks below are skipped and the URL is accepted, but
1021 # Apprise falls back to treating the first path segment as the host and
1022 # dials it: ``json:///169.254.169.254/path`` reaches
1023 # ``169.254.169.254``. No legitimate notification URL has an empty
1024 # ``//`` authority, so reject it before host extraction. This fires
1025 # ONLY when the ``//`` authority marker is present — a scheme that
1026 # legitimately has no ``//`` authority (an RFC ``mailto:user@host``
1027 # form) never reaches here, so it is not broken.
1028 after_scheme = url[len(parsed.scheme) + 1 :]
1029 if after_scheme.startswith("//") and not parsed.netloc:
1030 remainder = after_scheme[2:] # everything past the '//'
1031 if remainder:
1032 logger.warning(
1033 "Blocked notification URL with empty authority but "
1034 "in-path host (parser-differential SSRF risk)"
1035 )
1036 return (
1037 False,
1038 "URL host (authority) is empty; a host in the path after "
1039 "'scheme:///' is not allowed",
1040 )
1042 # ``tgram://`` is checked here, BEFORE host extraction, because its
1043 # authority is a CREDENTIAL (``<bot_id>:<token>``), not a network
1044 # destination: Apprise's Telegram plugin always dials the hardcoded
1045 # api.telegram.org endpoint. urllib3 cannot parse that authority at
1046 # all (it reads ``:<token>`` as a port and raises LocationParseError),
1047 # so the host-based checks below can never run for a valid tgram URL.
1048 #
1049 # ORDERING NOTE for future merges: this early return deliberately
1050 # precedes the numeric-IPv4 screening below, and that is safe ONLY
1051 # because ``tgram`` is not in ADDRESS_BEARING_SCHEMES. The regex below
1052 # is a strict allowlist that already rejects every legacy IPv4 host
1053 # form with a non-decimal component (``0x7f000001``, ``0177.0.0.1``,
1054 # ``127.1`` all fail ``[0-9]+``). The one remaining overlap — a bare
1055 # decimal integer such as ``2130706433`` — is exactly the shape of a
1056 # legitimate Telegram bot id, so it must NOT be rejected as an
1057 # ambiguous numeric IPv4 host. Any check that IS address-bearing must
1058 # be placed above this block, not below it.
1059 if scheme == "tgram":
1060 if not _APPRISE_TELEGRAM_URL_RE.match(url):
1061 return (
1062 False,
1063 "Invalid Telegram service URL; expected "
1064 "tgram://<bot_id>:<token>/<chat_id>",
1065 )
1066 return True, None
1068 # Extract the host for address-bearing schemes. We use urllib3 (the
1069 # parser ``requests`` uses internally) instead of urlparse —
1070 # urlparse is vulnerable to parser-differential bypasses like
1071 # ``http://127.0.0.1\@1.1.1.1`` (GHSA-g23j-2vwm-5c25).
1072 #
1073 # Effective destinations are classified below. A token/topic in a
1074 # fixed vendor URL is not a host; an authority-bearing plugin must
1075 # pass the same private-IP opt-in as an HTTP URL. The urllib3 host and
1076 # classified endpoint must agree before a URL can be dispatched.
1077 #
1078 # urllib3 2.8 decodes unreserved escapes in HTTP(S) hosts (for
1079 # example ``127%2e0%2e0%2e1`` becomes ``127.0.0.1``). Check their
1080 # original spelling before normalization, even when private
1081 # addresses are explicitly allowed. ``raw_hostname`` is reused by
1082 # the plugin-host escape check below.
1083 raw_hostname = None
1084 if scheme in NotificationURLValidator.ADDRESS_BEARING_SCHEMES:
1085 try:
1086 raw_hostname = parsed.hostname
1087 except ValueError:
1088 return False, "Invalid URL format (parser rejected)"
1089 if raw_hostname and is_percent_encoded_numeric_ipv4_host(
1090 raw_hostname
1091 ):
1092 logger.warning(
1093 "Blocked notification URL with encoded numeric IPv4 host"
1094 )
1095 return False, ENCODED_NUMERIC_IPV4_HOST_ERROR
1097 try:
1098 u3 = parse_url(url)
1099 except LocationParseError:
1100 logger.warning(
1101 "Blocked notification URL: urllib3 parser rejected it"
1102 )
1103 return False, "Invalid URL format (parser rejected)"
1104 hostname = u3.host
1105 # Authority must be ASCII printable (forward-defence vs urllib3
1106 # ever loosening its IDN handling).
1107 if hostname and any(ord(c) < 0x20 or ord(c) > 0x7E for c in hostname): 1107 ↛ 1108line 1107 didn't jump to line 1108 because the condition on line 1107 was never true
1108 logger.warning(
1109 "Blocked notification URL with non-ASCII / control bytes in host"
1110 )
1111 return False, "URL host contains disallowed characters"
1112 hostname = NotificationURLValidator._normalize_host(hostname)
1113 hostname = normalize_host_for_transport(hostname)
1115 # A populated ``//`` authority that urllib3 parses to an EMPTY host
1116 # (e.g. ``json://169.254.169.254:80@/`` — an IP smuggled into the
1117 # userinfo with nothing after the ``@``) is the sibling of the
1118 # multi-``@`` / empty-``//``-authority cases above: urllib3 returns no
1119 # host, so the per-scheme ``_is_private_ip`` checks below (guarded on
1120 # ``if hostname``) are skipped and the URL is accepted, yet the
1121 # authority still carries a target that another parser could dial. No
1122 # legitimate notification URL has a non-empty ``//`` authority with an
1123 # empty host, so reject it fail-closed before the scheme checks.
1124 if after_scheme.startswith("//") and parsed.netloc and not hostname:
1125 logger.warning(
1126 "Blocked notification URL: non-empty authority with empty host "
1127 "(parser-differential SSRF risk)"
1128 )
1129 return (
1130 False,
1131 "URL authority has no host; a host smuggled into the userinfo "
1132 "is not allowed",
1133 )
1135 if (
1136 hostname
1137 and scheme in NotificationURLValidator.ADDRESS_BEARING_SCHEMES
1138 ):
1139 # The encoded-numeric spelling was already refused above, before
1140 # urllib3 normalized it.
1141 # Apprise plugins do not share Requests' URL preparation path,
1142 # and several decode every escape in the host (``%2F`` turns
1143 # ``evil.com%2F.local`` into ``evil.com``). Any escape in a
1144 # plugin authority host is ambiguous; fail before DNS.
1145 if (
1146 scheme not in ("http", "https")
1147 and raw_hostname
1148 and "%" in raw_hostname
1149 ):
1150 return False, "Notification destination host is ambiguous"
1151 if is_ambiguous_numeric_ipv4_host(hostname):
1152 logger.warning(
1153 "Blocked notification URL with ambiguous numeric IPv4 host"
1154 )
1155 return False, AMBIGUOUS_NUMERIC_IPV4_HOST_ERROR
1157 try:
1158 destination = parse_notification_destination(url)
1159 except NotificationDestinationError:
1160 logger.warning(
1161 "Blocked notification URL with ambiguous destination"
1162 )
1163 return False, "Notification destination is unsupported or ambiguous"
1165 effective_host = NotificationURLValidator._normalize_host(
1166 destination.effective_host
1167 )
1168 if destination.kind is NotificationDestinationKind.VENDOR_PLUGIN:
1169 return True, None
1170 if not effective_host or effective_host.lower() != hostname.lower(): 1170 ↛ 1175line 1170 didn't jump to line 1175 because the condition on line 1170 was never true
1171 # The effective endpoint and urllib3's connection host must
1172 # agree. A parser disagreement is unsafe to dispatch. Host names
1173 # are case-insensitive, and urllib3 lowercases only http(s)
1174 # hosts, so a case-only difference is not a disagreement.
1175 return False, "Notification destination host is ambiguous"
1176 # Every host-bearing destination blocks the whole link-local range,
1177 # even with the private-IP opt-in. Apprise turns some http(s) URLs
1178 # into plugins that post to the URL host (Apprise API, Workflows),
1179 # and cloud metadata lives across link-local beyond the
1180 # always-blocked literals (Scaleway serves it at 169.254.42.42).
1181 block_link_local = True
1182 if resolved_ips is None:
1183 try:
1184 ipaddress.ip_address(effective_host)
1185 except ValueError:
1186 # Direct validation (without the admin hint) must reuse one
1187 # DNS answer for both the block and recoverability decision.
1188 # An empty list keeps a failed lookup from being retried.
1189 resolved_ips = (
1190 NotificationURLValidator._resolve_hostname_ips(
1191 effective_host
1192 )
1193 or []
1194 )
1195 if NotificationURLValidator._is_private_ip(
1196 effective_host,
1197 allow_private_ips=allow_private_ips,
1198 _resolved_ips=resolved_ips,
1199 allow_nat64=allow_nat64,
1200 block_link_local=block_link_local,
1201 ):
1202 if NotificationURLValidator._is_private_ip(
1203 effective_host,
1204 allow_private_ips=True,
1205 _resolved_ips=resolved_ips,
1206 allow_nat64=True,
1207 block_link_local=block_link_local,
1208 ):
1209 logger.warning(
1210 "Blocked metadata or link-local notification host"
1211 )
1212 return (
1213 False,
1214 "Blocked cloud-metadata / link-local IP address: "
1215 f"{redact_and_bound_for_log(effective_host, _LOG_HOST_MAX_CHARS)}",
1216 )
1217 logger.warning("Blocked private/internal notification host")
1218 return (
1219 False,
1220 f"{NotificationURLValidator.PRIVATE_IP_REJECTION_PREFIX} "
1221 f"{redact_and_bound_for_log(effective_host, _LOG_HOST_MAX_CHARS)}",
1222 )
1224 # Passed all security checks
1225 return True, None
1227 @staticmethod
1228 def _compute_hint(
1229 hostname: str,
1230 resolved_ips: Optional[List[ResolvedIP]],
1231 *,
1232 block_link_local: bool = False,
1233 ) -> bool:
1234 """Return True iff the combined elevated policy
1235 (LDR_NOTIFICATIONS_ALLOW_PRIVATE_IPS=true AND
1236 LDR_SECURITY_ALLOW_NAT64=true) would unblock every resolved
1237 IP. Probing both flags jointly — rather than each in
1238 isolation — is required for mixed address lists where
1239 neither flag alone unblocks every IP (e.g. 10.0.0.1 needs
1240 the private-IPs flag, 64:ff9b::5db8:d822 needs the NAT64
1241 flag).
1242 """
1243 hint = not NotificationURLValidator._host_blocks_at_level(
1244 hostname,
1245 allow_private_ips=True,
1246 allow_nat64=True,
1247 resolved_ips=resolved_ips,
1248 block_link_local=block_link_local,
1249 )
1250 if not hint: 1250 ↛ 1251line 1250 didn't jump to line 1251 because the condition on line 1250 was never true
1251 logger.opt(lazy=True).debug(
1252 "hint suppressed: {} targets an always-blocked "
1253 "category (metadata / 6to4 / Teredo / discard / "
1254 "IPv4-Compatible / IPv4-Translated-SIIT / "
1255 "NAT64-wrapped metadata)",
1256 lambda: redact_and_bound_for_log(hostname, _LOG_HOST_MAX_CHARS),
1257 )
1258 return hint
1260 @staticmethod
1261 def validate_service_url_with_hint(
1262 url: str, allow_private_ips: bool = False
1263 ) -> Tuple[bool, Optional[str], bool]:
1264 """Validate URL and compute the admin-hint decision, using a
1265 two-phase approach to ensure DNS is only queried for URLs that
1266 pass ALL deterministic (non-network) checks.
1268 Phase 1 — structural validation (no DNS):
1269 Calls ``_validate_service_url_impl`` with ``resolved_ips=[]``, which
1270 suppresses DNS for every host-bearing private-IP check. IP literals
1271 are still evaluated directly. This means bad schemes, illegal characters, parse
1272 errors, and malformed URLs are all rejected WITHOUT network
1273 activity.
1275 Phase 2 — DNS resolution + IP policy (host-bearing destinations):
1276 Only reached for HTTP or authority-plugin URLs with non-literal hosts that
1277 passed Phase 1. Resolves DNS ONCE and shares the result across
1278 the default-level rejection and the combined elevated-policy hint check,
1279 closing the DNS-rebinding TOCTOU window. When DNS fails, the
1280 failure is authoritative (``[]`` sentinel prevents retries).
1282 For IP literals there is no DNS to rebind.
1284 Args:
1285 url: Service URL to validate
1286 allow_private_ips: Operator-level flag. When True, the
1287 private address categories (RFC1918 / CGNAT / loopback
1288 / IPv6 ULA; link-local stays blocked) are already permitted at this
1289 level. ``hint_would_help`` may still be True for a
1290 rejected NAT64-wrapped non-metadata destination,
1291 because the NAT64 carve-out is governed by the separate
1292 ``allow_nat64`` path rather than this flag.
1294 Returns:
1295 ``(is_valid, error_msg, hint_would_help)``:
1297 * ``is_valid`` — True iff URL passes at the requested
1298 ``allow_private_ips`` level.
1299 * ``error_msg`` — None on success, the validator's reason
1300 on rejection (identical to ``validate_service_url``).
1301 * ``hint_would_help`` — True iff enabling the documented
1302 escape hatches together
1303 (``LDR_NOTIFICATIONS_ALLOW_PRIVATE_IPS`` for
1304 RFC1918/CGNAT/loopback/IPv6 ULA, never link-local), and
1305 ``LDR_SECURITY_ALLOW_NAT64`` for NAT64-wrapped
1306 non-metadata destinations) would unblock all resolved
1307 addresses — covering cases needing one or both flags.
1308 Always False when ``is_valid`` is True. May still be
1309 True under ``allow_private_ips=True`` for a rejected
1310 NAT64-wrapped non-metadata destination, because the
1311 NAT64 carve-out is governed by the separate
1312 ``allow_nat64`` path. Only meaningful when
1313 ``error_msg`` starts with
1314 ``PRIVATE_IP_REJECTION_PREFIX`` (i.e., the URL was
1315 rejected for an http(s) private-IP target).
1317 Caller contract:
1318 ``notifications.service.NotificationService.test_service``
1319 calls this to surface the validator's reason AND decide
1320 whether to append the admin env-var hint in a single pass.
1321 The previous design called ``validate_service_url`` two to
1322 three times (once at the default level, then one or two
1323 more via the former ``_admin_hint_would_help`` helper for
1324 the private-IPs and NAT64 probes), opening a DNS-rebinding
1325 window between the resolutions. The parametrized test
1326 ``test_test_service_ip_rejection_matrix`` and the direct
1327 contract test
1328 ``test_validate_service_url_with_hint_contract`` in
1329 tests/web/services/test_notification_coverage.py pin this
1330 end-to-end.
1331 """
1332 if not url or not isinstance(url, str):
1333 return False, "Service URL must be a non-empty string", False
1335 url = url.strip()
1337 if allow_private_ips and not url:
1338 return (
1339 False,
1340 "Service URL must have a protocol (e.g., https://)",
1341 False,
1342 )
1344 # ------------------------------------------------------------------
1345 # Phase 1: structural validation (no DNS for hostnames).
1346 #
1347 # resolved_ips=[] suppresses DNS in host-bearing private-IP checks
1348 # (_host_blocks_at_level treats [] as "no blocked IPs"). IP
1349 # literals are still checked directly. This means
1350 # ALL deterministic rejections (bad scheme, illegal chars, parse
1351 # errors, non-ASCII host, invalid structure) complete before any
1352 # attacker-controlled DNS query.
1353 # ------------------------------------------------------------------
1354 is_valid, error_msg = (
1355 NotificationURLValidator._validate_service_url_impl(
1356 url,
1357 allow_private_ips=allow_private_ips,
1358 resolved_ips=[],
1359 )
1360 )
1362 try:
1363 destination = parse_notification_destination(url)
1364 except NotificationDestinationError:
1365 return is_valid, error_msg, False
1366 hostname = NotificationURLValidator._normalize_host(
1367 destination.effective_host
1368 )
1369 # Same link-local policy as ``_validate_service_url_impl``: the
1370 # opt-in never unblocks link-local, so the hint must not offer it.
1371 block_link_local = True
1373 if not is_valid:
1374 if error_msg in (
1375 AMBIGUOUS_NUMERIC_IPV4_HOST_ERROR,
1376 ENCODED_NUMERIC_IPV4_HOST_ERROR,
1377 ):
1378 return False, error_msg, False
1379 if (
1380 not error_msg
1381 or not error_msg.startswith(
1382 NotificationURLValidator.PRIVATE_IP_REJECTION_PREFIX
1383 )
1384 or not hostname
1385 ):
1386 logger.opt(lazy=True).debug(
1387 "hint suppressed: non-private-IP rejection ({})",
1388 # (#6938: redacted before it is cut, and only when a
1389 # DEBUG sink takes the line.)
1390 lambda: (
1391 redact_and_bound_for_log(error_msg, 60)
1392 if error_msg
1393 else "<none>"
1394 ),
1395 )
1396 return False, error_msg, False
1397 return (
1398 False,
1399 error_msg,
1400 NotificationURLValidator._compute_hint(
1401 hostname, None, block_link_local=block_link_local
1402 ),
1403 )
1405 if (
1406 not hostname
1407 or destination.kind is NotificationDestinationKind.VENDOR_PLUGIN
1408 ):
1409 return True, None, False
1410 try:
1411 ipaddress.ip_address(hostname)
1412 except ValueError:
1413 pass
1414 else:
1415 return True, None, False
1417 # Resolve once after structural validation, then reuse that address
1418 # set for the decision and operator hint. The send-time guard covers
1419 # any later DNS change.
1420 dns_result = NotificationURLValidator._resolve_hostname_ips(hostname)
1421 resolved_ips: List[ResolvedIP] = (
1422 dns_result if dns_result is not None else []
1423 )
1424 is_valid, error_msg = (
1425 NotificationURLValidator._validate_service_url_impl(
1426 url,
1427 allow_private_ips=allow_private_ips,
1428 resolved_ips=resolved_ips,
1429 )
1430 )
1431 if is_valid:
1432 return True, None, False
1433 if not error_msg or not error_msg.startswith(
1434 NotificationURLValidator.PRIVATE_IP_REJECTION_PREFIX
1435 ):
1436 return False, error_msg, False
1437 return (
1438 False,
1439 error_msg,
1440 NotificationURLValidator._compute_hint(
1441 hostname, resolved_ips, block_link_local=block_link_local
1442 ),
1443 )
1445 @staticmethod
1446 def validate_service_url_strict(
1447 url: str, allow_private_ips: bool = False
1448 ) -> bool:
1449 """
1450 Strict validation that raises an exception on invalid URLs.
1452 Args:
1453 url: Service URL to validate
1454 allow_private_ips: Whether to allow private IPs (default: False)
1456 Returns:
1457 True if valid
1459 Raises:
1460 NotificationURLValidationError: If URL fails security validation
1461 """
1462 is_valid, error_message = NotificationURLValidator.validate_service_url(
1463 url, allow_private_ips
1464 )
1466 if not is_valid:
1467 raise NotificationURLValidationError(
1468 f"Notification service URL validation failed: {error_message}"
1469 )
1471 return True
1473 @staticmethod
1474 def validate_multiple_urls(
1475 urls: str, allow_private_ips: bool = False, separator: str = ","
1476 ) -> Tuple[bool, Optional[str]]:
1477 """
1478 Validate multiple separator-delimited service URLs.
1480 Args:
1481 urls: Service URLs delimited by ``separator``
1482 allow_private_ips: Whether to allow private IPs (default: False)
1483 separator: URL separator (default: ",")
1485 Returns:
1486 Tuple of (all_valid, error_message)
1487 - all_valid: True if all URLs pass validation
1488 - error_message: None if all valid, first error if any invalid
1489 """
1490 if not urls or not isinstance(urls, str):
1491 return False, "Service URLs must be a non-empty string"
1493 # Match Apprise's scheme-aware partition so commas inside one service
1494 # URL are preserved, while rejecting URL-like fragments it repairs
1495 # away or absorbs into another entry.
1496 url_list, invalid_fragment = parse_notification_url_list(
1497 urls, separator
1498 )
1499 if invalid_fragment is not None:
1500 # The fragment is refused because the INPUT does not partition
1501 # unambiguously, not because the fragment itself is a bad URL.
1502 # A fragment can be a perfectly valid URL on its own — the
1503 # illegal character that carved it out may only trail the
1504 # entry, or the token after it may be a well-formed vendor URL
1505 # smuggled behind a hostile one — in which case
1506 # ``validate_service_url`` returns ``(True, None)`` and the
1507 # unconditional interpolation below produced the nonsense
1508 # "Invalid notification service URL: None". Fall back to the
1509 # parse-level reason, whose static text echoes nothing at all.
1510 # The fragment's raw text is never echoed WHOLE, but the
1511 # ``validate_service_url`` message used by the other branch is
1512 # not credential-free by construction: a few of its returns
1513 # interpolate a piece the caller supplied (the scheme in
1514 # "Unsupported protocol: …", the hostname in the private-IP /
1515 # metadata rejections). That is the same exposure the
1516 # single-URL path already has, and no Apprise credential lives
1517 # in a scheme or a resolvable hostname — but do not read this
1518 # as a guarantee that nothing derived from the fragment can
1519 # reach the user.
1520 _, error_message = NotificationURLValidator.validate_service_url(
1521 invalid_fragment, allow_private_ips
1522 )
1523 if not error_message:
1524 error_message = (
1525 "the URL list could not be parsed unambiguously "
1526 "(unencoded space, backslash or control character, "
1527 "or a fragment with no scheme)"
1528 )
1529 return (
1530 False,
1531 f"Invalid notification service URL: {error_message}",
1532 )
1534 if not url_list:
1535 return False, "No valid URLs found after parsing"
1537 # Validate each URL
1538 for url in url_list:
1539 is_valid, error_message = (
1540 NotificationURLValidator.validate_service_url(
1541 url, allow_private_ips
1542 )
1543 )
1545 if not is_valid:
1546 # Return first error found
1547 return (
1548 False,
1549 f"Invalid notification service URL: {error_message}",
1550 )
1552 # All URLs passed validation
1553 return True, None