Coverage for src/local_deep_research/notifications/service.py: 91%

191 statements  

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

1""" 

2Core notification service using Apprise. 

3""" 

4 

5import contextlib 

6import functools 

7import re 

8from typing import Dict, List, Optional, Any, Tuple 

9from urllib.parse import urlparse 

10 

11import apprise 

12from loguru import logger 

13from tenacity import ( 

14 retry, 

15 stop_after_attempt, 

16 wait_exponential, 

17 retry_if_not_exception_type, 

18) 

19 

20from .exceptions import ServiceError, SendError, SecurityBlockError 

21from .apprise_log_utils import install_apprise_log_record_factory 

22from .templates import EventType, NotificationTemplate 

23from ..security import dns_pinning 

24from ..security.notification_validator import ( 

25 NotificationURLValidator, 

26 parse_notification_url_list, 

27) 

28 

29PRIVATE_IP_REJECTION_PREFIX = ( 

30 NotificationURLValidator.PRIVATE_IP_REJECTION_PREFIX 

31) 

32 

33 

34# Backward compatibility constants - now handled by Tenacity internally 

35MAX_RETRY_ATTEMPTS = 3 

36INITIAL_RETRY_DELAY = 0.5 

37RETRY_BACKOFF_MULTIPLIER = 2 

38 

39# Maximum number of notification targets Apprise may parse out of one 

40# test/configured URL string. Prevents an authenticated caller from 

41# triggering unbounded DNS resolution work (and an unbounded fan-out of 

42# outbound requests) via a comma/space-separated hostname list. 

43MAX_NOTIFICATION_TARGETS = 20 

44 

45 

46class NotificationService: 

47 """ 

48 Low-level notification service that wraps Apprise. 

49 """ 

50 

51 # Regex patterns for common service types (for validation) 

52 SERVICE_PATTERNS = { 

53 "email": r"^mailto://", 

54 "discord": r"^discord://", 

55 "slack": r"^slack://", 

56 "telegram": r"^tgram://", 

57 "smtp": r"^(smtp|smtps)://", 

58 } 

59 

60 def __init__( 

61 self, 

62 allow_private_ips: bool = False, 

63 outbound_allowed: bool = False, 

64 ): 

65 """ 

66 Initialize the notification service. 

67 

68 Args: 

69 allow_private_ips: Whether to allow notifications to private/local IPs 

70 (default: False for security). Set to True for 

71 development/testing environments only. 

72 outbound_allowed: Server-level master switch 

73 (env-only via LDR_NOTIFICATIONS_ALLOW_OUTBOUND). 

74 Default False — outbound notifications are off until 

75 the operator opts in. See SECURITY.md "Notification 

76 Webhook SSRF" for the rationale. 

77 """ 

78 install_apprise_log_record_factory() 

79 self.apprise = self._new_apprise() 

80 self.allow_private_ips = allow_private_ips 

81 self.outbound_allowed = outbound_allowed 

82 

83 @staticmethod 

84 def _new_apprise() -> apprise.Apprise: 

85 """Create an Apprise instance that delivers synchronously and does 

86 not follow HTTP redirects. 

87 

88 ``async_mode=False`` forces Apprise to run every plugin's 

89 ``notify()`` in the calling thread instead of fanning out to a 

90 ``ThreadPoolExecutor``. This is required for the SSRF hardening in 

91 :meth:`_send_with_retry`: the DNS pin and the block-private window 

92 (``security.dns_pinning``) are thread-local, so they only apply to 

93 the resolution the sending thread performs. With the default async 

94 fan-out the plugins would resolve in worker threads that carry no 

95 pin/block and the guard would silently not apply (verified: the 

96 default asset resolves target hosts on worker threads). 

97 

98 ``http_redirects=False`` disables 30x redirect-following for the 

99 raw-webhook plugins (``json``/``xml``/``form``, which pass 

100 ``allow_redirects=self.redirects`` to ``requests``). This closes the 

101 redirect attack class outright: a user-configured webhook can no 

102 longer 30x-redirect the send to a private/loopback host or to an 

103 arbitrary public host for data exfiltration. The pin + 

104 block-private window is then only responsible for the original 

105 validated host's own rebind, not for chasing redirect hops. It is a 

106 DEFAULT: a per-URL ``?redirect=yes`` can still override it, so 

107 :meth:`_disable_redirects` re-forces it off per-plugin after 

108 ``add()`` (see there). 

109 """ 

110 return apprise.Apprise( 

111 asset=apprise.AppriseAsset(async_mode=False, http_redirects=False) 

112 ) 

113 

114 @staticmethod 

115 def _disable_redirects(apprise_instance: apprise.Apprise) -> None: 

116 """Force redirect-following OFF on every added plugin. 

117 

118 ``_new_apprise`` sets the asset-level ``http_redirects=False`` 

119 default, but Apprise lets a per-URL ``?redirect=yes`` query 

120 parameter override that default (``self.redirects = 

121 parse_bool(kwargs.get("redirect", asset.http_redirects))``). A 

122 low-privilege user could therefore re-enable redirect-following on 

123 their own webhook and reopen redirect-to-private / redirect-to-exfil. 

124 Setting ``redirects = False`` directly on each plugin AFTER it is 

125 parsed makes the closure airtight: the value is read at send time 

126 (``allow_redirects=self.redirects``), so this wins over any URL 

127 parameter. Redirect-following is never needed for legitimate 

128 webhook/plugin delivery (real endpoints answer 2xx directly), so 

129 this cannot break a genuine send. 

130 

131 Not every plugin threads ``self.redirects`` into its HTTP call: the 

132 raw-webhook ``json``/``xml``/``form`` schemes and e.g. ``ntfy`` / 

133 ``gotify`` honour it, but a few (e.g. ``matrix://``) issue requests 

134 without it, so re-forcing the flag has no effect there. Those are 

135 backstopped by the block-private send window 

136 (``dns_pinning.pinned_notification_send``), which refuses a 

137 redirect/rebind to a blocked address at the ``getaddrinfo`` layer 

138 regardless of whether the plugin followed the redirect. 

139 """ 

140 try: 

141 servers = list(apprise_instance) 

142 except TypeError as exc: 

143 # A mocked Apprise in unit tests is not iterable, so there is 

144 # nothing to harden (those tests never exercise a real redirect). 

145 # But if a REAL Apprise ever stops being iterable (upstream API 

146 # drift), silently returning here would leave the per-plugin 

147 # redirect-disable un-applied — fail-open. The asset-level 

148 # ``http_redirects=False`` default and the block-private window 

149 # still apply, but a per-URL ``?redirect=yes`` could re-enable 

150 # redirect-following. Log a warning (narrowed to TypeError) so 

151 # that drift is visible instead of silent. 

152 logger.warning( 

153 "Could not enumerate Apprise servers to force " 

154 "redirect-following off per plugin ({}); relying on the " 

155 "asset-level default and the block-private window", 

156 type(exc).__name__, 

157 ) 

158 return 

159 for server in servers: 

160 server.redirects = False 

161 

162 def _enforce_guarded_send_invariants( 

163 self, apprise_instance: apprise.Apprise, tag: Optional[str] 

164 ) -> None: 

165 """Enforce the fail-closed invariants for a DNS-guarded send. 

166 

167 The pin + block-private window (``security.dns_pinning``) are 

168 thread-local and only cover IN-THREAD delivery. Refuse the send 

169 (fail closed) rather than silently sending unguarded unless: 

170 

171 * ``async_mode`` is off — otherwise Apprise fans the plugins out to 

172 worker threads that carry neither the pin nor the block; and 

173 * ``tag`` is None — a multi-token tag likewise makes Apprise 

174 dispatch matching plugins across worker threads. No production 

175 caller passes a tag; this neutralizes a future one. 

176 

177 Then force redirect-following off per plugin so a per-URL 

178 ``?redirect=yes`` cannot override the asset-level default. 

179 

180 Raises: 

181 SecurityBlockError: if either invariant is violated — a 

182 deliberate fail-closed security refusal, not an incidental 

183 runtime error, so it is raised directly as this type 

184 (rather than a generic ``RuntimeError`` a caller would have 

185 to re-classify) and is excluded from Tenacity retries so it 

186 fails fast. See ``send()``'s docstring for why this must 

187 not be mistaken for a transient delivery failure. 

188 """ 

189 if getattr(apprise_instance.asset, "async_mode", True) is True: 

190 raise SecurityBlockError( 

191 "Guarded notification send requires async_mode=False; " 

192 "refusing to send (the thread-local DNS pin/block would not " 

193 "apply to Apprise's worker-thread fan-out)." 

194 ) 

195 if tag is not None: 

196 raise SecurityBlockError( 

197 "Guarded notification send does not support tag targeting; " 

198 "refusing to send (a tag can fan delivery out to worker " 

199 "threads that bypass the thread-local DNS pin/block)." 

200 ) 

201 self._disable_redirects(apprise_instance) 

202 

203 @staticmethod 

204 def _partition_urls(service_urls: str) -> Tuple[List[str], List[str]]: 

205 """Split an Apprise URL string into (http(s), everything-else). 

206 

207 Partitioning uses the notification validator's scheme-boundary 

208 split (``parse_notification_url_list``): a comma/whitespace 

209 sequence only separates entries when a URL scheme follows it, so 

210 commas INSIDE one Apprise URL (e.g. a multi-target Telegram 

211 ``?to=id1,id2``) are preserved and dispatched as a single entry — 

212 the same partition the validator applies before the send. The two 

213 groups get different send-time SSRF policies (see 

214 :meth:`_dispatch`), mirroring the notification validator's 

215 per-scheme rules: ``http``/``https`` block private IPs 

216 unless the operator opted in, while plugin / raw-webhook schemes 

217 allow private (self-hosted LAN) but always block cloud-metadata. 

218 

219 NOTE: the ``strict`` (``http``/``https``) partition is UNREACHABLE 

220 for delivery today — Apprise rejects bare ``http(s)://`` notification 

221 URLs at ``add()`` (verified: ``apprise.Apprise().add("http://x/")`` 

222 is False), so a strict-partition ``add()`` in :meth:`_dispatch` 

223 returns False and nothing is sent. The stricter policy is retained 

224 as defense-in-depth for a hypothetical future where a raw ``http(s)`` 

225 scheme becomes deliverable; deliverable webhooks use the raw-webhook 

226 schemes ``json``/``xml``/``form`` (in the ``lenient`` partition). 

227 """ 

228 strict: List[str] = [] 

229 lenient: List[str] = [] 

230 entries, invalid_fragment = parse_notification_url_list( 

231 service_urls, "," 

232 ) 

233 if invalid_fragment is not None: 233 ↛ 240line 233 didn't jump to line 240 because the condition on line 233 was never true

234 # Never log the fragment itself — it may contain credentials. 

235 # ``send()`` runs ``validate_multiple_urls`` (same 

236 # scheme-boundary parse) before reaching here, so a malformed 

237 # fragment is already rejected there; this branch only fires 

238 # for direct callers of this helper, which must validate the 

239 # list themselves before dispatching it. 

240 logger.warning( 

241 "Notification service_url list contains a malformed " 

242 "fragment; callers must validate the list before dispatch" 

243 ) 

244 for entry in entries: 

245 entry = entry.strip() 

246 if not entry: 246 ↛ 247line 246 didn't jump to line 247 because the condition on line 246 was never true

247 continue 

248 scheme = entry.split(":", 1)[0].lower() if ":" in entry else "" 

249 if scheme in ("http", "https"): 

250 strict.append(entry) 

251 else: 

252 lenient.append(entry) 

253 return strict, lenient 

254 

255 def _guarded_notify( 

256 self, 

257 title: str, 

258 body: str, 

259 apprise_instance: apprise.Apprise, 

260 guard_factory=None, 

261 tag: Optional[str] = None, 

262 attach: Optional[List[str]] = None, 

263 ) -> bool: 

264 """Run a single Apprise ``notify()`` under the DNS-guard and the 

265 fail-closed invariants. Returns Apprise's raw boolean result. 

266 

267 Single source of truth for the guarded send: both the retrying 

268 delivery path (:meth:`_send_with_retry`) and the admin 

269 "Send Test Notification" path (:meth:`test_service`) call this, so 

270 neither re-implements the invariant-enforcement / redirect-disable / 

271 guarded-``notify`` sequence that the SSRF hardening depends on. 

272 

273 When ``guard_factory`` is provided, the fail-closed invariants are 

274 enforced (``async_mode`` off, no ``tag``, redirects forced off) and 

275 the send runs inside the pin + block-private window it returns. The 

276 per-thread SSRF-block marker is reset first so a block during THIS 

277 ``notify`` is attributable to it (see 

278 ``dns_pinning.consume_ssrf_block``). ``guard_factory=None`` sends 

279 WITHOUT a guard and is for tests ONLY — it exercises the unguarded 

280 baseline the SSRF teeth-tests assert against; every production caller 

281 MUST pass a ``guard_factory``. 

282 

283 Args: 

284 title: Notification title 

285 body: Notification body text 

286 apprise_instance: Apprise instance to use for sending 

287 guard_factory: Optional zero-arg callable returning a context 

288 manager wrapped around the ``notify()`` call (the DNS pin + 

289 block-private window; see ``security.dns_pinning``). 

290 tag: Optional tag to target specific services 

291 attach: Optional list of file paths to attach 

292 

293 Returns: 

294 True iff Apprise reported the notification delivered. 

295 """ 

296 if guard_factory is not None: 

297 # Guarded (user-supplied URL) send: enforce the fail-closed 

298 # invariants the thread-local pin/block depend on and disable 

299 # redirects before dispatching, then clear any stale SSRF-block 

300 # marker so consume_ssrf_block reflects only this attempt. 

301 self._enforce_guarded_send_invariants(apprise_instance, tag) 

302 dns_pinning.reset_ssrf_block() 

303 

304 guard = ( 

305 guard_factory() 

306 if guard_factory is not None 

307 else contextlib.nullcontext() 

308 ) 

309 

310 # Send notification inside the guard so the pin + block-private 

311 # window (if any) governs Apprise's send-time DNS resolution. 

312 # ``notify`` is typed ``bool | None``; coerce to a definite bool 

313 # (None == nothing delivered == False). 

314 with guard: 

315 return bool( 

316 apprise_instance.notify( 

317 title=title, 

318 body=body, 

319 tag=tag, 

320 attach=attach, 

321 ) 

322 ) 

323 

324 @retry( 

325 stop=stop_after_attempt(3), 

326 wait=wait_exponential(multiplier=1, min=0.5, max=10), 

327 # Retry transient delivery failures (SendError, connection errors, 

328 # …) but NOT deliberate security refusals: a ValueError is a 

329 # connect-time SSRF block (the pin's rebind-to-private/metadata catch, 

330 # or the block-private window's send-time refusal surfaced below); a 

331 # RuntimeError (specifically 

332 # ``dns_pinning.NotificationGuardUnavailableError``) is the DNS-pin 

333 # shim not being installed; and a SecurityBlockError is the 

334 # async_mode/tag fail-closed invariant guard 

335 # (``_enforce_guarded_send_invariants``), raised directly as that 

336 # type rather than a generic RuntimeError. A confirmed-malicious or 

337 # misconfigured destination must fail fast, not be retried 3x. 

338 retry=retry_if_not_exception_type( 

339 (ValueError, RuntimeError, SecurityBlockError) 

340 ), 

341 reraise=True, 

342 ) 

343 def _send_with_retry( 

344 self, 

345 title: str, 

346 body: str, 

347 apprise_instance: apprise.Apprise, 

348 guard_factory=None, 

349 tag: Optional[str] = None, 

350 attach: Optional[List[str]] = None, 

351 ) -> bool: 

352 """ 

353 Send a notification using the provided Apprise instance with retry logic. 

354 

355 This method is decorated with Tenacity to handle retries automatically. 

356 A fresh guard context is created per attempt (via ``guard_factory``) 

357 so retries re-resolve and re-pin. 

358 

359 Args: 

360 title: Notification title 

361 body: Notification body text 

362 apprise_instance: Apprise instance to use for sending 

363 guard_factory: Optional zero-arg callable returning the per-send 

364 DNS guard context (see :meth:`_guarded_notify`). 

365 tag: Optional tag to target specific services 

366 attach: Optional list of file paths to attach 

367 

368 Returns: 

369 True if notification was sent successfully 

370 

371 Raises: 

372 SendError: If sending fails after all retry attempts (transient). 

373 ValueError: If a guarded send failed because the block-private 

374 window refused a send-time resolution as an SSRF target — 

375 a confirmed security block, raised non-retryably so it fails 

376 fast instead of being retried. 

377 SecurityBlockError: Propagated non-retryably from 

378 ``_guarded_notify`` -> ``_enforce_guarded_send_invariants`` 

379 if the async_mode/tag fail-closed invariant is violated. 

380 dns_pinning.NotificationGuardUnavailableError: Propagated 

381 non-retryably (a ``RuntimeError`` subclass) from 

382 ``_guarded_notify`` -> ``guard_factory`` if the DNS-pin 

383 shim is not installed. 

384 """ 

385 logger.debug( 

386 f"Sending notification: title='{title[:50]}...', tag={tag}" 

387 ) 

388 logger.debug(f"Body preview: {body[:200]}...") 

389 

390 notify_result = self._guarded_notify( 

391 title, body, apprise_instance, guard_factory, tag, attach 

392 ) 

393 

394 if notify_result: 

395 logger.debug(f"Notification sent successfully: '{title[:50]}...'") 

396 return True 

397 

398 # A guarded send that failed AND tripped the block-private window is a 

399 # confirmed SSRF block: the window raised socket.gaierror on a 

400 # send-time resolution to an internal/private/metadata IP, which 

401 # Apprise swallowed into a generic delivery failure. Fail fast — raise 

402 # a non-retryable ValueError (excluded from the retry predicate above) 

403 # instead of a retryable SendError, so a confirmed-malicious / 

404 # misconfigured destination is not retried 3x (~10s and 3x DNS to a 

405 # hostile authority). The pin path's own rebind catch already raises 

406 # ValueError before this point; this covers the UNPINNED block-window 

407 # path (plugin-scheme endpoints, unresolvable-at-pin-time hosts). 

408 if guard_factory is not None and dns_pinning.consume_ssrf_block(): 

409 logger.warning( 

410 "Notification send blocked by the SSRF block-private window; " 

411 "failing fast (not retrying)" 

412 ) 

413 raise ValueError( 

414 "Notification send refused: a send-time DNS resolution was " 

415 "blocked as internal/private/metadata (possible SSRF)" 

416 ) 

417 

418 error_msg = "Failed to send notification to any service" 

419 logger.warning(error_msg) 

420 raise SendError(error_msg) 

421 

422 def send( 

423 self, 

424 title: str, 

425 body: str, 

426 service_urls: Optional[str] = None, 

427 tag: Optional[str] = None, 

428 attach: Optional[List[str]] = None, 

429 ) -> bool: 

430 """ 

431 Send a notification to service URLs with automatic retry. 

432 

433 Args: 

434 title: Notification title 

435 body: Notification body text 

436 service_urls: Comma-separated list of service URLs to override configured ones 

437 tag: Optional tag to target specific services 

438 attach: Optional list of file paths to attach 

439 

440 Returns: 

441 True if notification was sent successfully to at least one service 

442 

443 Raises: 

444 SendError: If sending fails after all retry attempts (transient 

445 — the endpoint refused/failed the dispatch). 

446 SecurityBlockError: If a send-time SSRF/DNS-rebind block 

447 confirmed the destination is internal/private/metadata 

448 (permanent — not worth retrying). A ``ServiceError`` 

449 subclass — it still satisfies ``except ServiceError`` / 

450 ``pytest.raises(ServiceError)`` and shares the same 

451 non-retryable signal as pre-dispatch validation rejects, 

452 but is tagged distinctly so callers can give it a more 

453 accurate message than "invalid URL". 

454 

455 Note: 

456 Temporary Apprise instances are created for each send operation 

457 and are automatically garbage collected by Python when they go 

458 out of scope. This simple approach is ideal for small deployments 

459 (~5 users) and avoids memory management complexity. 

460 """ 

461 # Defense-in-depth: enforce the operator-level master switch at 

462 # the service layer too, not just at NotificationManager. 

463 # Today the manager always wraps this method, but keeping the 

464 # gate here means a future direct caller cannot accidentally 

465 # bypass it. See SECURITY.md "Notification Webhook SSRF". 

466 if not self.outbound_allowed: 

467 logger.warning( 

468 "Notification not sent: outbound notifications are disabled " 

469 "at the server level. Set " 

470 "LDR_NOTIFICATIONS_ALLOW_OUTBOUND=true to enable. See " 

471 "SECURITY.md 'Notification Webhook SSRF'." 

472 ) 

473 return False 

474 

475 # If service_urls are provided, validate before trying to send 

476 if service_urls: 

477 logger.debug("Creating Apprise instance for provided service URLs") 

478 

479 # Validate service URLs for security (SSRF prevention) 

480 is_valid, error_msg = ( 

481 NotificationURLValidator.validate_multiple_urls( 

482 service_urls, allow_private_ips=self.allow_private_ips 

483 ) 

484 ) 

485 

486 if not is_valid: 

487 logger.error("Service URL validation failed") 

488 raise ServiceError(f"Invalid service URL: {error_msg}") 

489 

490 try: 

491 # If service_urls are provided, partition them by scheme and 

492 # dispatch each group with its own SSRF send-time guard. 

493 if service_urls: 

494 strict_urls, lenient_urls = self._partition_urls(service_urls) 

495 if not strict_urls and not lenient_urls: 495 ↛ 496line 495 didn't jump to line 496 because the condition on line 495 was never true

496 logger.error("No service URLs after parsing") 

497 return False 

498 return self._dispatch( 

499 title, body, strict_urls, lenient_urls, tag, attach 

500 ) 

501 # The pre-configured instance (self.apprise) is created empty in 

502 # __init__ and is never populated by this class — all delivery 

503 # goes through the guarded per-call service_urls path above. 

504 if len(self.apprise) == 0: 504 ↛ 513line 504 didn't jump to line 513 because the condition on line 504 was always true

505 logger.debug("No notification services configured in Apprise") 

506 return False 

507 

508 # Defense-in-depth: if a future caller ever adds URLs directly to 

509 # self.apprise, refuse rather than deliver them here — this branch 

510 # has no DNS pin / block-private guard, so sending would silently 

511 # bypass the SSRF hardening the service_urls path enforces. Fail 

512 # closed. See SECURITY.md "Notification Webhook SSRF". 

513 logger.error( 

514 "Refusing to send via the pre-configured Apprise instance: " 

515 "it is populated but this path is unguarded. Pass " 

516 "service_urls to send() so the DNS pin / block-private guard " 

517 "applies." 

518 ) 

519 return False 

520 

521 except SecurityBlockError: 

522 # _enforce_guarded_send_invariants (called from _guarded_notify, 

523 # which _send_with_retry / _dispatch call) raises this DIRECTLY 

524 # — not a generic RuntimeError — for the async_mode/tag 

525 # fail-closed invariant guard: a deliberate security refusal, 

526 # not an incidental runtime error. It is already the precise, 

527 # final exception type (see its docstring), so propagate it 

528 # unchanged rather than re-wrapping: NotificationManager's 

529 # ``except SecurityBlockError`` maps it to the same 

530 # non-transient INVALID_URL reason as the confirmed-block paths 

531 # below. 

532 raise 

533 

534 except ValueError as e: 

535 # _send_with_retry raises ValueError (excluded from Tenacity's 

536 # retry predicate) ONLY for a confirmed, non-retryable send-time 

537 # SSRF/DNS-rebind block — see its docstring and 

538 # dns_pinning.pinned_notification_send's own rebind catch. That 

539 # is a permanent, invalid-destination outcome, not a transient 

540 # delivery failure, so it must NOT be re-wrapped as SendError: 

541 # NotificationManager's ``except SendError`` maps to the 

542 # retryable WEBHOOK_FAILED reason, which would mislabel a 

543 # confirmed security block as "webhook delivery failed after 

544 # retries" and could invite a caller/monitor to retry a 

545 # malicious/rebinding destination. Route it through 

546 # SecurityBlockError — a ServiceError subclass, so it still 

547 # maps to INVALID_URL via NotificationManager's existing 

548 # ``except ServiceError`` handling — but tagged distinctly so 

549 # the manager can give this confirmed security block a more 

550 # accurate user-facing detail than the pre-dispatch 

551 # "check your URL/settings" text (see manager.py). 

552 logger.exception( 

553 f"Notification send blocked (SSRF/DNS-rebind): " 

554 f"'{title[:50]}...'" 

555 ) 

556 raise SecurityBlockError(str(e)) from e 

557 

558 except dns_pinning.NotificationGuardUnavailableError as e: 

559 # pinned_notification_send (via the guard_factory _dispatch 

560 # builds) raises this — a RuntimeError subclass defined in 

561 # security.dns_pinning, deliberately NOT imported/raised as a 

562 # notifications exception there so that general-purpose 

563 # security module stays free of a dependency on this 

564 # higher-level package — when the getaddrinfo shim it depends 

565 # on is not the active resolver. Also a deliberate fail-closed 

566 # security refusal, not a transient send failure (excluded from 

567 # Tenacity's retry predicate as a RuntimeError — see 

568 # _send_with_retry's decorator), so route it through 

569 # SecurityBlockError for the same reason as the ValueError 

570 # branch above: it must not be mislabeled as a retryable 

571 # WEBHOOK_FAILED. 

572 logger.exception( 

573 f"Notification send blocked (DNS-pin guard unavailable): " 

574 f"'{title[:50]}...'" 

575 ) 

576 raise SecurityBlockError(str(e)) from e 

577 

578 except SendError: 

579 # _send_with_retry's own terminal raise (line ~420, once all 

580 # retries are exhausted) is already the precise, final 

581 # exception for a confirmed webhook delivery failure. 

582 # Propagate unchanged — re-wrapping it here would just nest a 

583 # second SendError around the first for no benefit, and 

584 # routing it through the catch-all below would make it 

585 # indistinguishable from an incidental dispatch-time bug. 

586 raise 

587 

588 except Exception: 

589 # Anything else reaching here is an incidental/unexpected 

590 # error from the dispatch machinery itself (e.g. a bug in 

591 # this method's call wiring, an Apprise internals exception 

592 # that isn't a genuine delivery failure) — NOT a confirmed 

593 # webhook delivery failure. Do NOT launder it into SendError: 

594 # NotificationManager maps SendError to the retryable 

595 # WEBHOOK_FAILED reason, which would mislabel an incidental 

596 # bug as "webhook delivery failed after retries" and would 

597 # make the manager's own ``except Exception`` -> EXCEPTION 

598 # branch unreachable for dispatch-time errors. Let it 

599 # propagate as-is so the manager's catch-all handles it. 

600 logger.exception( 

601 f"Unexpected error sending notification: '{title[:50]}...'" 

602 ) 

603 raise 

604 

605 def _dispatch( 

606 self, 

607 title: str, 

608 body: str, 

609 strict_urls: List[str], 

610 lenient_urls: List[str], 

611 tag: Optional[str], 

612 attach: Optional[List[str]], 

613 ) -> bool: 

614 """Send each scheme-partition under its SSRF send-time guard. 

615 

616 ``strict_urls`` (http/https): pin each host and block private + 

617 metadata resolution for the send (honoring the operator 

618 ``allow_private_ips`` opt-in) — closes rebinding and 

619 redirect-to-internal. 

620 

621 ``lenient_urls`` (plugin / raw-webhook schemes): pin the raw-webhook 

622 hosts (json/xml/form) and block cloud-metadata AND the whole 

623 link-local range (``allow_private_ips=True`` + ``block_link_local=True``) 

624 — self-hosted LAN targets (RFC1918 / loopback / non-link-local ULA) 

625 keep working while a send-time rebind/redirect to a metadata or 

626 link-local IP is refused. This mirrors the notification validator's 

627 per-scheme policy exactly. 

628 

629 Each non-empty partition is dispatched (and retried by Tenacity) 

630 under its own guard. A partition that adds no URLs returns False 

631 (matching the historical add-failure behavior); a partition that 

632 fails to deliver after all retries raises ``SendError`` (which the 

633 caller re-wraps), so overall success requires every partition to 

634 deliver. 

635 """ 

636 partitions = [] 

637 if strict_urls: 637 ↛ 638line 637 didn't jump to line 638 because the condition on line 637 was never true

638 partitions.append( 

639 ( 

640 strict_urls, 

641 lambda: dns_pinning.pinned_notification_send( 

642 strict_urls, 

643 allow_localhost=False, 

644 allow_private_ips=self.allow_private_ips, 

645 ), 

646 ) 

647 ) 

648 if lenient_urls: 648 ↛ 667line 648 didn't jump to line 667 because the condition on line 648 was always true

649 partitions.append( 

650 ( 

651 lenient_urls, 

652 lambda: dns_pinning.pinned_notification_send( 

653 lenient_urls, 

654 allow_localhost=False, 

655 allow_private_ips=True, 

656 # Plugin/raw-webhook schemes allow private LAN targets 

657 # but NOT link-local: cloud-provider metadata lives 

658 # there beyond the always-blocked literals (e.g. 

659 # some providers' IMDS) and no legitimate self-hosted 

660 # notifier does. Mirrors the validator's plugin-scheme 

661 # IMDS guard. 

662 block_link_local=True, 

663 ), 

664 ) 

665 ) 

666 

667 any_ok = False 

668 for urls, guard_factory in partitions: 

669 temp_apprise = self._new_apprise() 

670 # apprise types ``add(servers=...)`` as an invariant ``list`` whose 

671 # element union includes ``str``; a ``list[str]`` is valid at 

672 # runtime but mypy rejects it on list invariance (upstream should 

673 # use ``Sequence``). 

674 if not temp_apprise.add(urls, tag=tag): # type: ignore[arg-type] 

675 logger.error("Failed to add service URLs to Apprise") 

676 return False 

677 # _send_with_retry returns True or raises SendError after 

678 # Tenacity exhausts its retries; a raise propagates to send(). 

679 self._send_with_retry( 

680 title, body, temp_apprise, guard_factory, tag, attach 

681 ) 

682 any_ok = True 

683 return any_ok 

684 

685 def send_event( 

686 self, 

687 event_type: EventType, 

688 context: Dict[str, Any], 

689 service_urls: Optional[str] = None, 

690 tag: Optional[str] = None, 

691 custom_template: Optional[Dict[str, str]] = None, 

692 ) -> bool: 

693 """ 

694 Send a notification for a specific event type. 

695 

696 Args: 

697 event_type: Type of event 

698 context: Context data for template formatting 

699 service_urls: Comma-separated list of service URLs 

700 tag: Optional tag to target specific services 

701 custom_template: Optional custom template override 

702 

703 Returns: 

704 True if notification was sent successfully 

705 """ 

706 logger.debug(f"send_event: event_type={event_type.value}, tag={tag}") 

707 logger.debug(f"Context: {context}") 

708 

709 # Format notification using template 

710 message = NotificationTemplate.format( 

711 event_type, context, custom_template 

712 ) 

713 logger.debug( 

714 f"Template formatted - title: '{message['title'][:50]}...'" 

715 ) 

716 

717 # Send notification 

718 return self.send( 

719 title=message["title"], 

720 body=message["body"], 

721 service_urls=service_urls, 

722 tag=tag, 

723 ) 

724 

725 def test_service(self, url: str) -> Dict[str, Any]: 

726 """ 

727 Test a notification service. 

728 

729 Args: 

730 url: Apprise-compatible service URL 

731 

732 Returns: 

733 Dict with 'success' boolean and optional 'error' message 

734 """ 

735 # Server-level master switch (env-only). Mirrors 

736 # NotificationManager's gate so the "Send Test Notification" 

737 # button cannot bypass it. WARNING level so the operator sees 

738 # the actionable signal naming the env var. 

739 if not self.outbound_allowed: 

740 logger.warning( 

741 "Notification test refused: outbound notifications are " 

742 "disabled at the server level. Set " 

743 "LDR_NOTIFICATIONS_ALLOW_OUTBOUND=true to enable. See " 

744 "SECURITY.md 'Notification Webhook SSRF'." 

745 ) 

746 return { 

747 "success": False, 

748 "error": ( 

749 "Outbound notifications are disabled. The server " 

750 "administrator must set " 

751 "LDR_NOTIFICATIONS_ALLOW_OUTBOUND=true to enable " 

752 "notification webhooks. See SECURITY.md " 

753 "'Notification Webhook SSRF' for details." 

754 ), 

755 } 

756 

757 try: 

758 # ``url`` is a single form field, but Apprise's own 

759 # scheme-aware splitter treats it as a LIST — and commas are 

760 # legal URL characters. Validating ``url`` as ONE url 

761 # therefore checked only the leading entry while Apprise 

762 # still registered, and notified, every other entry: 

763 # ``discord://valid/tok,json://<cloud-metadata-host>/x`` 

764 # passed validation on the discord entry alone and the 

765 # cloud-metadata target received the test notification 

766 # unvetted. Partition the input with the shared scheme-aware 

767 # parser — the same one the configured-send path uses via 

768 # ``validate_multiple_urls`` — and vet EVERY entry. 

769 url_entries, invalid_fragment = parse_notification_url_list(url) 

770 if invalid_fragment is not None: 

771 # A URL-like fragment with no scheme: Apprise may repair 

772 # it or absorb it into a neighbouring entry, so refuse the 

773 # whole input rather than dispatch the parseable subset. 

774 # Mirrors validate_multiple_urls. The fragment fails the 

775 # validation loop below, which sources the user message. 

776 url_entries = [invalid_fragment] 

777 if not url_entries: 777 ↛ 778line 777 didn't jump to line 778 because the condition on line 777 was never true

778 return { 

779 "success": False, 

780 "error": "Invalid notification service URL.", 

781 } 

782 if len(url_entries) > MAX_NOTIFICATION_TARGETS: 

783 logger.warning( 

784 f"Test notification refused: {len(url_entries)} targets " 

785 f"exceeds the cap of {MAX_NOTIFICATION_TARGETS}." 

786 ) 

787 return { 

788 "success": False, 

789 "error": ( 

790 f"Too many notification targets " 

791 f"({len(url_entries)}). Maximum is " 

792 f"{MAX_NOTIFICATION_TARGETS}." 

793 ), 

794 } 

795 

796 for entry in url_entries: 

797 # Validate each entry for security (SSRF prevention) and, 

798 # in the same pass, compute whether the admin env-var hint 

799 # would actually unblock a recoverable private-IP 

800 # rejection. Single-pass avoids a DNS-rebinding TOCTOU 

801 # window between the default-level validation and the 

802 # elevated-level hint decision — see 

803 # NotificationURLValidator.validate_service_url_with_hint. 

804 is_valid, error_msg, hint_would_help = ( 

805 NotificationURLValidator.validate_service_url_with_hint( 

806 entry, allow_private_ips=self.allow_private_ips 

807 ) 

808 ) 

809 

810 if not is_valid: 

811 # Never log the entry itself — an Apprise service URL 

812 # carries credentials (bot tokens, webhook secrets). 

813 # #5576 removed the masked-URL echo from this line for 

814 # exactly that reason; keep it out of the per-entry 

815 # loop too. 

816 logger.warning( 

817 f"Test service URL validation failed: {error_msg}" 

818 ) 

819 # Surface the validator's reason so users know what to 

820 # fix. The hostname/scheme echoed here was supplied by 

821 # the user in the same request, so this is not a 

822 # server-side leak. When the rejection is a recoverable 

823 # private/internal IP — one that 

824 # LDR_NOTIFICATIONS_ALLOW_PRIVATE_IPS=true OR 

825 # LDR_SECURITY_ALLOW_NAT64=true would actually unblock — 

826 # append the admin escape hatches; the user cannot fix 

827 # this themselves. Suppress the hint for always-blocked 

828 # categories (metadata, 6to4, Teredo, discard, 

829 # IPv4-mapped IPv6 of metadata, NAT64-wrapped metadata): 

830 # no env var can help, so naming one would mislead. 

831 user_error = ( 

832 error_msg or "Invalid notification service URL." 

833 ) 

834 if hint_would_help: 

835 user_error += ( 

836 ". To unblock this destination, ask the server " 

837 "administrator to set " 

838 "LDR_NOTIFICATIONS_ALLOW_PRIVATE_IPS=true " 

839 "(IPv6-only NAT64 deployments also need " 

840 "LDR_SECURITY_ALLOW_NAT64=true). " 

841 "See SECURITY.md 'Notification Webhook SSRF'." 

842 ) 

843 return { 

844 "success": False, 

845 "error": user_error, 

846 } 

847 

848 # Create temporary Apprise instance (synchronous so the pin + 

849 # block-private window below applies in this thread). 

850 temp_apprise = self._new_apprise() 

851 add_result = temp_apprise.add(url) 

852 

853 if not add_result: 

854 return { 

855 "success": False, 

856 "error": "Failed to add service URL", 

857 } 

858 

859 # Fail closed on a parser differential: if Apprise registered 

860 # MORE targets than we validated, at least one destination 

861 # would be notified without ever passing the SSRF validator. 

862 # Fewer is harmless (Apprise dropped an entry), so only the 

863 # smuggling direction is refused. 

864 if len(temp_apprise) > len(url_entries): 

865 logger.warning( 

866 f"Test notification refused: Apprise registered " 

867 f"{len(temp_apprise)} targets but only " 

868 f"{len(url_entries)} were validated." 

869 ) 

870 return { 

871 "success": False, 

872 "error": ( 

873 "Notification service URL could not be parsed " 

874 "unambiguously; refusing to send. Configure one " 

875 "service URL per test." 

876 ), 

877 } 

878 

879 # Send the test through the SAME guarded dispatch core as the real 

880 # send path (:meth:`_guarded_notify`) — fail-closed invariants, 

881 # per-plugin redirect-disable, and the pin + block-private window 

882 # — instead of re-implementing it here. Single-shot (no Tenacity 

883 # retry) for fast test feedback. allow_private_ips mirrors the 

884 # validator's per-scheme policy: the operator flag for http/https, 

885 # True (metadata-only) for plugin / raw-webhook schemes. tag is 

886 # always None here. 

887 # 

888 # With multi-entry input the batch takes the STRICTEST policy of 

889 # the schemes present: deriving it from the leading entry alone 

890 # would let ``json://…,http://…`` run the http target under the 

891 # plugin batch's allow_private_ips=True. 

892 schemes = { 

893 entry.split(":", 1)[0].lower() 

894 for entry in url_entries 

895 if ":" in entry 

896 } 

897 has_http = bool(schemes & {"http", "https"}) 

898 is_plugin_scheme = not schemes or bool(schemes - {"http", "https"}) 

899 allow_private = self.allow_private_ips if has_http else True 

900 # Plugin/raw-webhook schemes run with allow_private_ips=True but 

901 # must still block the whole link-local range (metadata territory 

902 # beyond the always-blocked literals). Mirrors _dispatch's lenient 

903 # partition and the validator's plugin-scheme IMDS guard. 

904 guard_factory = functools.partial( 

905 dns_pinning.pinned_notification_send, 

906 list(url_entries), 

907 allow_localhost=False, 

908 allow_private_ips=allow_private, 

909 block_link_local=is_plugin_scheme, 

910 ) 

911 

912 result = self._guarded_notify( 

913 "Test Notification", 

914 ( 

915 "This is a test notification from Local Deep Research. " 

916 "If you see this, your service is configured correctly!" 

917 ), 

918 temp_apprise, 

919 guard_factory, 

920 tag=None, 

921 ) 

922 

923 if result: 

924 return { 

925 "success": True, 

926 "message": "Test notification sent successfully", 

927 } 

928 

929 # A guarded test send that failed AND tripped the block-private 

930 # window is a confirmed SSRF block: the window raised 

931 # socket.gaierror on a send-time resolution to an 

932 # internal/private/metadata IP, which Apprise swallowed into a 

933 # generic delivery failure. Surface the specific reason to the 

934 # admin instead of the generic message — mirrors the 

935 # consume_ssrf_block short-circuit in _send_with_retry. (The pin 

936 # path's own connect-time rebind catch raises ValueError before 

937 # this point and is reported via the except below; this covers the 

938 # UNPINNED block-window path — plugin-scheme endpoints and 

939 # unresolvable-at-pin-time hosts.) 

940 if dns_pinning.consume_ssrf_block(): 

941 logger.warning( 

942 "Test notification blocked by the SSRF block-private " 

943 "window (send-time resolution to internal/private/" 

944 "metadata)" 

945 ) 

946 return { 

947 "success": False, 

948 "error": ( 

949 "Test notification refused: the destination resolved " 

950 "to an internal/private/metadata address at send time " 

951 "(possible SSRF). See SECURITY.md 'Notification " 

952 "Webhook SSRF'." 

953 ), 

954 } 

955 return { 

956 "success": False, 

957 "error": "Failed to send test notification", 

958 } 

959 

960 except Exception: 

961 logger.exception("Error testing notification service") 

962 return { 

963 "success": False, 

964 "error": "Failed to test notification service.", 

965 } 

966 

967 @staticmethod 

968 def _validate_url(url: str) -> None: 

969 """ 

970 Validate a notification service URL. 

971 

972 Args: 

973 url: URL to validate 

974 

975 Raises: 

976 ServiceError: If URL is invalid 

977 

978 Note: 

979 URL scheme validation is handled by Apprise itself, which maintains 

980 a comprehensive whitelist of supported notification services. 

981 Apprise will reject unsupported schemes like 'file://' or 'javascript://'. 

982 See: https://github.com/caronc/apprise/wiki 

983 """ 

984 if not url or not isinstance(url, str): 

985 raise ServiceError("URL must be a non-empty string") 

986 

987 # Check if it looks like a URL 

988 parsed = urlparse(url) 

989 if not parsed.scheme: 

990 raise ServiceError( 

991 "Invalid URL format. Must be an Apprise-compatible " 

992 "service URL (e.g., discord://webhook_id/token)" 

993 ) 

994 

995 def get_service_type(self, url: str) -> Optional[str]: 

996 """ 

997 Detect service type from URL. 

998 

999 Args: 

1000 url: Service URL 

1001 

1002 Returns: 

1003 Service type name or None if unknown 

1004 """ 

1005 for service_name, pattern in self.SERVICE_PATTERNS.items(): 

1006 if re.match(pattern, url, re.IGNORECASE): 

1007 return service_name 

1008 return "unknown"