Coverage for src/local_deep_research/security/notification_destination.py: 87%

222 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1"""Classify the network destination represented by an Apprise URL.""" 

2 

3from dataclasses import dataclass 

4from enum import Enum 

5import ipaddress 

6import re 

7from string import ascii_letters, digits, hexdigits 

8from urllib.parse import SplitResult, unquote, urlsplit 

9 

10from urllib3.exceptions import LocationParseError 

11from urllib3.util import parse_url 

12 

13 

14class NotificationDestinationKind(str, Enum): 

15 HTTP = "http" 

16 AUTHORITY_PLUGIN = "authority_plugin" 

17 VENDOR_PLUGIN = "vendor_plugin" 

18 

19 

20@dataclass(frozen=True, slots=True) 

21class NotificationDestination: 

22 kind: NotificationDestinationKind 

23 effective_host: str | None 

24 

25 

26class NotificationDestinationError(ValueError): 

27 """A notification URL whose destination cannot be classified safely.""" 

28 

29 def __init__(self, reason: str) -> None: 

30 super().__init__(reason) 

31 self.reason = reason 

32 

33 def __str__(self) -> str: 

34 return self.reason 

35 

36 

37_UNRESERVED_HOST_ESCAPES = re.compile(r"%([0-9A-Fa-f]{2})") 

38_UNRESERVED_HOST_CHARACTERS = frozenset(ascii_letters + digits + "-._~") 

39 

40 

41def normalize_host_for_transport(host: str) -> str: 

42 """Decode one pass of unreserved host escapes as Requests prepares URLs.""" 

43 

44 def unescape(match: re.Match[str]) -> str: 

45 decoded = chr(int(match.group(1), 16)) 

46 return ( 

47 decoded 

48 if decoded in _UNRESERVED_HOST_CHARACTERS 

49 else match.group(0) 

50 ) 

51 

52 return _UNRESERVED_HOST_ESCAPES.sub(unescape, host) 

53 

54 

55def _required_host(parsed: SplitResult) -> str: 

56 try: 

57 host = parsed.hostname 

58 except ValueError as exc: 

59 raise NotificationDestinationError("invalid authority") from exc 

60 if not host: 

61 raise NotificationDestinationError("destination host is missing") 

62 if ":" in host and "%" in host: 

63 # An IPv6 zone identifier (``[fd00::1%25eth0]``) scopes a literal to 

64 # an interface; no notification endpoint needs one, and a scoped 

65 # address no longer equals the unscoped metadata literals. 

66 raise NotificationDestinationError("IPv6 zone identifier in host") 

67 # urlsplit leaves percent escapes in the hostname. Requests decodes 

68 # unreserved escapes while preparing the outbound URL, but urllib3's 

69 # parse_url changed its own decoding behavior between 2.7 and 2.8. 

70 # Normalize once so policy resolves the same host under either version. 

71 authority = f"[{host}]" if ":" in host else host 

72 try: 

73 network_host = parse_url(f"http://{authority}/").host 

74 except LocationParseError as exc: 

75 raise NotificationDestinationError("invalid authority") from exc 

76 if not network_host: 76 ↛ 77line 76 didn't jump to line 77 because the condition on line 76 was never true

77 raise NotificationDestinationError("destination host is missing") 

78 if network_host.startswith("[") and network_host.endswith("]"): 

79 network_host = network_host[1:-1] 

80 return normalize_host_for_transport(network_host).rstrip(".") 

81 

82 

83def _plugin_host(parsed: SplitResult) -> str: 

84 """Return the authority host of an Apprise plugin URL. 

85 

86 Several plugins (Signal, ntfy, Matrix, email) parse with 

87 ``verify_host=False`` and fully percent-decode the host, so an escape 

88 such as ``%2F`` or ``%40`` moves the real destination 

89 (``evil.com%2F.local`` is sent to ``evil.com``). No legitimate plugin 

90 host needs an escape, so any ``%`` is refused instead of guessing each 

91 plugin's decoding. 

92 """ 

93 try: 

94 raw_host = parsed.hostname 

95 except ValueError as exc: 

96 raise NotificationDestinationError("invalid authority") from exc 

97 if raw_host and "%" in raw_host: 

98 raise NotificationDestinationError( 

99 "percent-encoded plugin host is ambiguous" 

100 ) 

101 return _required_host(parsed) 

102 

103 

104def parse_apprise_query(url: str) -> dict[str, list[str]]: 

105 """Parse query keys with the same normalization Apprise applies.""" 

106 _, separator, query = url.partition("?") 

107 if not separator or not query: 

108 return {} 

109 

110 parsed_query: dict[str, list[str]] = {} 

111 pairs = [ 

112 part for section in query.split("&") for part in section.split(";") 

113 ] 

114 for pair in pairs: 

115 raw_key, _, raw_value = pair.partition("=") 

116 index = 0 

117 while index < len(raw_key): 

118 if raw_key[index] != "%": 

119 index += 1 

120 continue 

121 if ( 121 ↛ 126line 121 didn't jump to line 126 because the condition on line 121 was never true

122 index + 2 >= len(raw_key) 

123 or raw_key[index + 1] not in hexdigits 

124 or raw_key[index + 2] not in hexdigits 

125 ): 

126 raise NotificationDestinationError( 

127 "Malformed percent-encoding in notification parameter name" 

128 ) 

129 index += 3 

130 

131 apprise_key = ( 

132 raw_key[:1] + raw_key[1:].replace("+", " ") if raw_key else "" 

133 ) 

134 canonical_key = unquote(apprise_key).lower().strip() 

135 parsed_query.setdefault(canonical_key, []).append( 

136 unquote(raw_value).strip() 

137 ) 

138 return parsed_query 

139 

140 

141def _query_value(url: str, key: str) -> str | None: 

142 values = parse_apprise_query(url).get(key) 

143 if values is None: 

144 return None 

145 if len(values) != 1 or not values[0].strip(): 145 ↛ 146line 145 didn't jump to line 146 because the condition on line 145 was never true

146 raise NotificationDestinationError(f"ambiguous {key} parameter") 

147 return values[0].strip().lower() 

148 

149 

150def _apprise_host(host: object) -> str: 

151 """Normalize a host reported by Apprise for comparison with ours.""" 

152 if not isinstance(host, str) or not host: 152 ↛ 153line 152 didn't jump to line 153 because the condition on line 152 was never true

153 raise NotificationDestinationError("destination host is missing") 

154 if host.startswith("[") and host.endswith("]"): 

155 host = host[1:-1] 

156 return host.rstrip(".").lower() 

157 

158 

159def _confirm_apprise_ipv6_host(url: str, host: str) -> str: 

160 """Refuse an IPv6 literal that Apprise would send somewhere else. 

161 

162 Apprise's generic URL parser rewrites compressed IPv6 literals in the 

163 authority: on Apprise 1.13 ``json://[fd00::1]/x`` builds a plugin whose 

164 host is ``[fd00::]`` and ``[::ffff:10.0.0.5]`` becomes ``[::ffff:10]``. 

165 Policy would then vet one address while Apprise connects to another. 

166 For an IPv6 literal the plugin is therefore built the way 

167 ``Apprise.add`` builds it (``Apprise.instantiate``, no network I/O) and 

168 its host must be the same address. A URL Apprise cannot build is 

169 returned unchanged: ``Apprise.add`` rejects it and nothing is sent. 

170 Hostnames and IPv4 literals are not rewritten by that parser and are 

171 returned as they are. 

172 

173 The port is compared too: Apprise's port regex for a bracketed literal 

174 is case-sensitive, so ``json://[FD00:0:0:0:0:0:0:1]:8080/x`` keeps the 

175 host but silently drops ``:8080`` and would post to the default port. 

176 """ 

177 try: 

178 expected = ipaddress.ip_address(host) 

179 except ValueError: 

180 return host 

181 if expected.version != 6: 

182 return host 

183 

184 from apprise import Apprise, AppriseAsset 

185 

186 plugin = Apprise.instantiate( 

187 url, asset=AppriseAsset(async_mode=False, http_redirects=False) 

188 ) 

189 if plugin is None: 

190 return host 

191 try: 

192 actual = ipaddress.ip_address( 

193 _apprise_host(getattr(plugin, "host", None)) 

194 ) 

195 except ValueError as exc: 

196 raise NotificationDestinationError( 

197 "IPv6 host is rewritten by Apprise" 

198 ) from exc 

199 if actual != expected: 

200 raise NotificationDestinationError("IPv6 host is rewritten by Apprise") 

201 try: 

202 expected_port = urlsplit(url).port 

203 except ValueError as exc: 

204 raise NotificationDestinationError("IPv6 port is malformed") from exc 

205 actual_port = getattr(plugin, "port", None) 

206 if isinstance(actual_port, str) and actual_port.isdigit(): 206 ↛ 207line 206 didn't jump to line 207 because the condition on line 206 was never true

207 actual_port = int(actual_port) 

208 if actual_port != expected_port: 

209 raise NotificationDestinationError("IPv6 port is rewritten by Apprise") 

210 return host 

211 

212 

213def _apprise_ntfy_destination(url: str, host: str) -> NotificationDestination: 

214 """Classify an ntfy URL from the plugin object Apprise itself builds. 

215 

216 ntfy picks cloud mode (a fixed POST to ntfy.sh, with the authority used 

217 as a topic) or private mode (a POST to the authority host) from the 

218 host's validity, the path, ``to=`` and ``mode=``. Re-implementing those 

219 rules drifts in both directions, so the decision is read from the plugin 

220 built the way ``Apprise.instantiate`` (called by ``Apprise.add``) builds 

221 it: ``url_to_dict`` followed by the plugin constructor. Construction 

222 performs no network I/O. 

223 """ 

224 from apprise import AppriseAsset 

225 from apprise.plugins import url_to_dict 

226 from apprise.plugins.ntfy import NotifyNtfy 

227 

228 results = url_to_dict(url) 

229 if not results or str(results.get("schema", "")).lower() not in ( 

230 "ntfy", 

231 "ntfys", 

232 ): 

233 raise NotificationDestinationError( 

234 "ntfy URL is not accepted by Apprise" 

235 ) 

236 try: 

237 plugin = NotifyNtfy( 

238 **{ 

239 **results, 

240 "asset": AppriseAsset(async_mode=False, http_redirects=False), 

241 "tag": set(), 

242 } 

243 ) 

244 except Exception as exc: # Apprise.instantiate treats any error as reject 

245 raise NotificationDestinationError( 

246 "ntfy URL is not accepted by Apprise" 

247 ) from exc 

248 mode = str(getattr(plugin, "mode", "") or "").strip().lower() 

249 if mode == "cloud": 

250 return NotificationDestination( 

251 NotificationDestinationKind.VENDOR_PLUGIN, 

252 None, 

253 ) 

254 if mode != "private": 254 ↛ 255line 254 didn't jump to line 255 because the condition on line 254 was never true

255 raise NotificationDestinationError("unsupported ntfy mode") 

256 if not getattr(plugin, "topics", None): 

257 # Apprise would send nothing; refuse rather than guess. 

258 raise NotificationDestinationError("private ntfy target is missing") 

259 apprise_host = _apprise_host(getattr(plugin, "host", None)) 

260 if apprise_host != host: 260 ↛ 261line 260 didn't jump to line 261 because the condition on line 260 was never true

261 raise NotificationDestinationError("ntfy host is ambiguous") 

262 if apprise_host == "ntfy.sh": 

263 return NotificationDestination( 

264 NotificationDestinationKind.VENDOR_PLUGIN, 

265 None, 

266 ) 

267 return NotificationDestination( 

268 NotificationDestinationKind.AUTHORITY_PLUGIN, 

269 apprise_host, 

270 ) 

271 

272 

273def _apprise_matrix_details(url: str) -> tuple[bool, str]: 

274 """Return Apprise's own (is_t2bot, host) for a Matrix URL. 

275 

276 Uses the parser ``Apprise.add`` calls (``url_to_dict``) without building 

277 the plugin, whose constructor reads Apprise's persistent store. 

278 """ 

279 from apprise.plugins import url_to_dict 

280 

281 results = url_to_dict(url) 

282 if not results or str(results.get("schema", "")).lower() != "matrix": 282 ↛ 283line 282 didn't jump to line 283 because the condition on line 282 was never true

283 raise NotificationDestinationError( 

284 "matrix URL is not accepted by Apprise" 

285 ) 

286 mode = str(results.get("mode") or "").strip().lower() 

287 return mode == "t2bot", str(results.get("host") or "") 

288 

289 

290def _email_template_smtp_hosts() -> frozenset[str]: 

291 """Return the fixed SMTP hosts of Apprise's built-in mail providers.""" 

292 from apprise.plugins.email.templates import EMAIL_TEMPLATES 

293 

294 return frozenset( 

295 str(template[2]["smtp_host"]).rstrip(".").lower() 

296 for template in EMAIL_TEMPLATES 

297 if template[2].get("smtp_host") 

298 ) 

299 

300 

301def _apprise_mail_host(url: str, host: str) -> str: 

302 """Confirm that Apprise's mail plugin connects to ``host`` or a provider. 

303 

304 Apprise's email parser discards an authority it does not accept as a 

305 hostname (an empty port, an underscore) and then takes the SMTP host from 

306 the domain of an e-mail address in the userinfo or ``?user=``. The URL 

307 authority would then no longer be the host Apprise connects to. The 

308 plugin is therefore built the way ``Apprise.instantiate`` builds it 

309 (``url_to_dict`` and the constructor, no network I/O), and only two 

310 outcomes are accepted: Apprise keeps ``host`` and connects to it, or it 

311 connects to the fixed SMTP host of one of its built-in providers selected 

312 by ``host``. A URL whose plugin cannot be built is returned unchanged: 

313 ``Apprise.add`` builds the same plugin, rejects the URL and sends nothing. 

314 """ 

315 from apprise import AppriseAsset 

316 from apprise.plugins import url_to_dict 

317 from apprise.plugins.email import NotifyEmail 

318 

319 results = url_to_dict(url) 

320 if not results or str(results.get("schema", "")).lower() != "mailto": 320 ↛ 321line 320 didn't jump to line 321 because the condition on line 320 was never true

321 raise NotificationDestinationError( 

322 "mailto URL is not accepted by Apprise" 

323 ) 

324 parsed_host = results.get("host") 

325 if not parsed_host or _apprise_host(parsed_host) != host: 

326 raise NotificationDestinationError("mail host is ambiguous") 

327 try: 

328 plugin = NotifyEmail( 

329 **{ 

330 **results, 

331 "asset": AppriseAsset(async_mode=False, http_redirects=False), 

332 "tag": set(), 

333 } 

334 ) 

335 except Exception: # Apprise.instantiate treats any error as reject 

336 return host 

337 smtp_host = _apprise_host(getattr(plugin, "smtp_host", None)) 

338 if smtp_host != host and smtp_host not in _email_template_smtp_hosts(): 338 ↛ 339line 338 didn't jump to line 339 because the condition on line 338 was never true

339 raise NotificationDestinationError("mail host is ambiguous") 

340 return host 

341 

342 

343def _smtp_override_host(value: str) -> str: 

344 if any(character.isspace() for character in value): 344 ↛ 345line 344 didn't jump to line 345 because the condition on line 344 was never true

345 raise NotificationDestinationError("invalid smtp override") 

346 try: 

347 parsed = urlsplit(f"//{value}") 

348 port = parsed.port 

349 except ValueError as exc: 

350 raise NotificationDestinationError("invalid smtp override") from exc 

351 if ( 351 ↛ 359line 351 didn't jump to line 359 because the condition on line 351 was never true

352 parsed.username is not None 

353 or parsed.password is not None 

354 or port is not None 

355 or parsed.path 

356 or parsed.query 

357 or parsed.fragment 

358 ): 

359 raise NotificationDestinationError("invalid smtp override") 

360 if "%" in value: 360 ↛ 361line 360 didn't jump to line 361 because the condition on line 360 was never true

361 raise NotificationDestinationError("invalid smtp override") 

362 return _required_host(parsed) 

363 

364 

365def parse_notification_destination(url: str) -> NotificationDestination: 

366 """Return the effective outbound destination for one Apprise URL.""" 

367 if not isinstance(url, str) or not url.strip(): 

368 raise NotificationDestinationError("notification URL is empty") 

369 try: 

370 normalized_url = url.strip() 

371 parsed = urlsplit(normalized_url) 

372 except ValueError as exc: 

373 raise NotificationDestinationError( 

374 "notification URL is malformed" 

375 ) from exc 

376 

377 scheme = parsed.scheme.lower() 

378 match scheme: 

379 case "http" | "https": 

380 return NotificationDestination( 

381 NotificationDestinationKind.HTTP, 

382 _confirm_apprise_ipv6_host( 

383 normalized_url, _required_host(parsed) 

384 ), 

385 ) 

386 case ( 

387 "json" 

388 | "xml" 

389 | "form" 

390 | "gotify" 

391 | "signal" 

392 | "mattermost" 

393 | "rocketchat" 

394 ): 

395 return NotificationDestination( 

396 NotificationDestinationKind.AUTHORITY_PLUGIN, 

397 _confirm_apprise_ipv6_host( 

398 normalized_url, _plugin_host(parsed) 

399 ), 

400 ) 

401 case "discord" | "slack" | "tgram" | "pushover" | "teams": 

402 if not parsed.netloc: 402 ↛ 403line 402 didn't jump to line 403 because the condition on line 402 was never true

403 raise NotificationDestinationError("vendor token is missing") 

404 return NotificationDestination( 

405 NotificationDestinationKind.VENDOR_PLUGIN, 

406 None, 

407 ) 

408 case "ntfy" | "ntfys": 

409 # A duplicate or empty mode is ambiguous; fail before Apprise 

410 # silently picks one spelling. 

411 mode = _query_value(normalized_url, "mode") 

412 if mode not in (None, "cloud", "private"): 

413 raise NotificationDestinationError("unsupported ntfy mode") 

414 return _apprise_ntfy_destination( 

415 normalized_url, _plugin_host(parsed) 

416 ) 

417 case "mailto": 

418 smtp_override = _query_value(normalized_url, "smtp") 

419 effective_host = ( 

420 _smtp_override_host(smtp_override) 

421 if smtp_override is not None 

422 else _apprise_mail_host(normalized_url, _plugin_host(parsed)) 

423 ) 

424 return NotificationDestination( 

425 NotificationDestinationKind.AUTHORITY_PLUGIN, 

426 effective_host, 

427 ) 

428 case "matrix": 

429 mode = _query_value(normalized_url, "mode") 

430 if mode not in ( 

431 None, 

432 "off", 

433 "matrix", 

434 "slack", 

435 "hookshot", 

436 "t2bot", 

437 ): 

438 raise NotificationDestinationError("unsupported matrix mode") 

439 # Apprise alone decides between t2bot (a fixed POST to 

440 # webhooks.t2bot.io, with the authority used as the token) and a 

441 # homeserver/webhook mode that sends to the authority host. 

442 # Mirroring that rule here drifted from Apprise and refused 

443 # documented forms such as ``user@<token>`` and ``/#room``. 

444 apprise_t2bot, apprise_host = _apprise_matrix_details( 

445 normalized_url 

446 ) 

447 if apprise_t2bot: 

448 token = parsed.netloc.rpartition("@")[2] 

449 if not re.fullmatch(r"[A-Za-z0-9]{64}", token): 

450 raise NotificationDestinationError( 

451 "vendor token is invalid" 

452 ) 

453 return NotificationDestination( 

454 NotificationDestinationKind.VENDOR_PLUGIN, 

455 None, 

456 ) 

457 host = _plugin_host(parsed) 

458 if _apprise_host(apprise_host) != host: 458 ↛ 459line 458 didn't jump to line 459 because the condition on line 458 was never true

459 raise NotificationDestinationError("matrix host is ambiguous") 

460 return NotificationDestination( 

461 NotificationDestinationKind.AUTHORITY_PLUGIN, 

462 host, 

463 ) 

464 case _: 

465 raise NotificationDestinationError( 

466 "unsupported notification scheme" 

467 )