Coverage for src/local_deep_research/utilities/url_utils.py: 92%

246 statements  

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

1"""URL utility functions for the local deep research application.""" 

2 

3from functools import lru_cache 

4from typing import Optional 

5import re 

6from urllib.parse import SplitResult, unquote, urlsplit, urlunsplit 

7 

8from loguru import logger 

9 

10from .chunk_anchor import ( 

11 LIBRARY_ALIAS_HOST, 

12 LIBRARY_ROUTE_PREFIXES, 

13 LIBRARY_ROUTE_SUFFIX_ALTERNATION, 

14 MAX_CHUNK_INDEX, 

15 is_safe_document_id, 

16) 

17from ..security import redact_url_for_log, validate_url 

18from ..security.network_utils import is_private_ip 

19 

20# Re-export for backwards compatibility 

21__all__ = [ 

22 "normalize_url", 

23 "is_private_ip", 

24 "canonical_url_key", 

25 "content_web_cache_key", 

26 "library_display_url", 

27 "is_safe_custom_llm_endpoint", 

28] 

29 

30# Tracking query parameter keys (matched lowercased). 

31_TRACKING_PARAMS = frozenset( 

32 { 

33 "fbclid", 

34 "gclid", 

35 "msclkid", 

36 "yclid", 

37 "dclid", 

38 "gad_source", 

39 "mc_eid", 

40 "mc_cid", 

41 "ref_src", 

42 "igshid", 

43 "_ga", 

44 "_gl", 

45 } 

46) 

47# Tracking param name prefixes (matched lowercased). 

48_TRACKING_PREFIXES = ("utm_",) 

49 

50 

51def normalize_url(raw_url: str) -> str: 

52 """ 

53 Normalize a URL to ensure it has a proper scheme and format. 

54 

55 Args: 

56 raw_url: The raw URL string to normalize 

57 

58 Returns: 

59 A properly formatted URL string 

60 

61 Examples: 

62 >>> normalize_url("localhost:11434") 

63 'http://localhost:11434' 

64 >>> normalize_url("https://example.com:11434") 

65 'https://example.com:11434' 

66 >>> normalize_url("http:example.com") 

67 'http://example.com' 

68 """ 

69 if not raw_url: 

70 raise ValueError("URL cannot be empty") 

71 

72 # Clean up the URL 

73 raw_url = raw_url.strip() 

74 

75 # First check if the URL already has a proper scheme 

76 if raw_url.startswith(("http://", "https://")): 

77 return raw_url 

78 

79 # Handle case where URL is malformed like "http:hostname" (missing //) 

80 if raw_url.startswith(("http:", "https:")) and not raw_url.startswith( 

81 ("http://", "https://") 

82 ): 

83 scheme = raw_url.split(":", 1)[0] 

84 rest = raw_url.split(":", 1)[1] 

85 return f"{scheme}://{rest}" 

86 

87 # Handle URLs that start with // 

88 if raw_url.startswith("//"): 

89 # Remove the // and process 

90 raw_url = raw_url[2:] 

91 

92 # At this point, we should have hostname:port or just hostname 

93 # Determine if this is localhost or an external host 

94 hostname = raw_url.split(":")[0].split("/")[0] 

95 

96 # Handle IPv6 addresses in brackets 

97 if hostname.startswith("[") and "]" in raw_url: 

98 # Extract the IPv6 address including brackets 

99 hostname = raw_url.split("]")[0] + "]" 

100 

101 # Use http for local/private addresses, https for external hosts 

102 scheme = "http" if is_private_ip(hostname) else "https" 

103 

104 return f"{scheme}://{raw_url}" 

105 

106 

107# Internal library-document routes emitted as citation URLs by the RAG 

108# search engines (see ``LibraryRAGSearchEngine._get_document_url``). These 

109# are the only scheme-less URLs whose shape ``canonical_url_key`` knows 

110# well enough to canonicalize; everything else falls back to the raw 

111# string. Imported from ``chunk_anchor`` so the producer side (which 

112# decides what counts as a library hit) and this consumer side cannot 

113# disagree about the prefix set. 

114_LIBRARY_ROUTE_PREFIXES = LIBRARY_ROUTE_PREFIXES 

115 

116 

117# The absolute alias the agent sometimes emits for a library document; the 

118# fetch resolver accepts it (see ``library_resolver._parse_library_reference``) 

119# and hands the ORIGINAL string back, which is then registered as a citation 

120# link. It must key and display as the relative route it denotes, or one 

121# document occupies two bibliography entries. 

122_LIBRARY_ALIAS_SCHEME = "https" 

123_LIBRARY_ALIAS_HOST = LIBRARY_ALIAS_HOST 

124# Mirrors ``library_resolver._LIBRARY_PATH_RE``: a document id plus at most 

125# one known view. Anything deeper is not a library route and must not be 

126# rewritten into one. 

127_LIBRARY_ALIAS_PATH_RE = re.compile( 

128 rf"^/(?P<doc_id>[^/?#]+)(?:/(?P<suffix>{LIBRARY_ROUTE_SUFFIX_ALTERNATION}))?/?$" 

129) 

130# The same shape for the RELATIVE routes, including the ``/lib/`` spelling. 

131# Both spellings denote one document (``library_resolver._LIBRARY_PATH_RE`` 

132# accepts either), so both normalize to the ``/library/`` form: keying them 

133# separately fans a document across two bibliography entries, and only 

134# ``/library/document/<id>`` is a registered Flask route — a ``/lib/...`` 

135# link renders dead. 

136_LIBRARY_ROUTE_PATH_RE = re.compile( 

137 r"^/(?:library|lib)/document/(?P<doc_id>[^/?#]+)" 

138 rf"(?:/(?P<suffix>{LIBRARY_ROUTE_SUFFIX_ALTERNATION}))?/?$" 

139) 

140 

141 

142def _is_library_route(path: str) -> bool: 

143 return path.startswith(_LIBRARY_ROUTE_PREFIXES) 

144 

145 

146def _has_control_chars(value: str) -> bool: 

147 """True if *value* carries a C0/DEL character. 

148 

149 These are rendered verbatim into the ``## Sources`` block, where an 

150 embedded newline forges extra lines. The library-route branches build 

151 their result by string-splitting rather than via ``urlunsplit``, so — 

152 unlike the absolute-URL branch — they cannot drop such a character on 

153 their own and must reject it explicitly. 

154 """ 

155 return any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in value) 

156 

157 

158def _normalize_library_alias(url: str) -> str | None: 

159 """Map ``https://library.document/<id>[/pdf|/chunks]`` to its relative route. 

160 

161 Returns ``None`` for anything else. The accepted shape is deliberately 

162 narrow — a document id followed by at most the ``pdf`` or ``chunks`` 

163 view, mirroring ``library_resolver._LIBRARY_PATH_RE`` — because the 

164 result is keyed AND displayed as an internal route. A permissive 

165 rewrite lets a crafted alias merge into a real document's bibliography 

166 entry, inherit its title, and replace its link: pasting an unvalidated 

167 path after ``/library/document`` turns 

168 ``https://library.document/<id>/../../../admin/x#chunk-1`` into a 

169 same-origin link to ``/admin/x`` rendered under the real document's 

170 name. 

171 

172 Two deliberate differences from the resolver, both narrowing or 

173 identity-preserving rather than widening: 

174 

175 * A query disqualifies (as in the resolver) — it can carry 

176 server-meaningful state, unlike a client-side anchor. 

177 * Host comparison uses ``hostname``/``port``, so ``LIBRARY.DOCUMENT``, 

178 ``:443`` and userinfo forms name the same document rather than 

179 fanning out into separate bibliography entries. The resolver rejects 

180 those for fetching; they are still the same source for citation 

181 purposes. 

182 

183 The fragment is carried over — it is the chunk anchor. 

184 """ 

185 # Cheap reject first: this runs once per citation inside 

186 # ``format_links_to_markdown``, and almost every real citation is a 

187 # plain external URL that can never match. Skipping ``urlsplit`` for 

188 # those keeps the bibliography pass off a per-link parse. 

189 if "://" not in url or _LIBRARY_ALIAS_HOST not in url.lower(): 

190 return None 

191 # Reject tab/newline/CR before ``urlsplit`` sees them: it deletes them 

192 # silently, which would collide ``/a<TAB>b`` with a real ``/ab`` and 

193 # rewrite the citation to a DIFFERENT document. The neighbouring 

194 # manual splits avoid ``urlsplit`` for exactly this reason. 

195 if any(ch in url for ch in "\t\n\r"): 195 ↛ 196line 195 didn't jump to line 196 because the condition on line 195 was never true

196 return None 

197 try: 

198 parsed = urlsplit(url) 

199 except ValueError: 

200 return None 

201 if ( 

202 parsed.scheme != _LIBRARY_ALIAS_SCHEME 

203 or parsed.query 

204 or (parsed.hostname or "") != _LIBRARY_ALIAS_HOST 

205 ): 

206 return None 

207 try: 

208 if parsed.port not in (None, 443): 

209 return None 

210 except ValueError: # malformed port 

211 return None 

212 path_match = _LIBRARY_ALIAS_PATH_RE.match(parsed.path) 

213 if not path_match: 

214 return None 

215 doc_id = unquote(path_match.group("doc_id")) 

216 # Same predicate ``extract_document_id`` applies, imported rather than 

217 # restated: the doc id is decoded before BOTH validating and 

218 # re-emitting, so ``/%2D`` and ``/-`` forms of one id give one key. 

219 if not is_safe_document_id(doc_id): 219 ↛ 220line 219 didn't jump to line 220 because the condition on line 219 was never true

220 return None 

221 suffix = path_match.group("suffix") 

222 rel = f"/library/document/{doc_id}" 

223 if suffix: 

224 rel = f"{rel}/{suffix}" 

225 return f"{rel}#{parsed.fragment}" if parsed.fragment else rel 

226 

227 

228def _parse_library_citation(raw: str) -> tuple[str, str] | None: 

229 """Return ``(dedup_key, display_url)`` for a library citation, else ``None``. 

230 

231 One parser for every accepted spelling — the relative 

232 ``/library/document/<id>`` route, its ``/lib/`` abbreviation, and the 

233 absolute ``https://library.document/<id>`` alias — so the two paths 

234 cannot enforce different rules. They used to: only the alias validated 

235 its document id, which meant the exact traversal this function's 

236 sibling docstring promises to block sailed straight through when the 

237 same URL arrived relative. ``/library/document/7/../../../admin/wipe`` 

238 keyed as document ``7`` and *displayed* as ``/admin/wipe``, so a 

239 crafted citation merged into a real document's entry, inherited its 

240 title and citation numbers, and replaced its link. 

241 

242 * ``dedup_key`` is ``/library/document/<id>`` — the ``/pdf``, 

243 ``/chunks`` and ``#chunk-<n>`` views of one document are one source 

244 and belong on one bibliography line. 

245 * ``display_url`` keeps the view suffix and the ``#chunk-<n>`` anchor, 

246 which is what makes a citation scroll to the cited text. 

247 

248 Normalisations, all identity-preserving: ``/lib/`` becomes 

249 ``/library/``, the document id is percent-decoded (so ``a%2Db`` and 

250 ``a-b`` are one key, matching what the resolver looks up), and a query 

251 string is dropped (the route ignores it). 

252 """ 

253 if not raw: 

254 return None 

255 stripped = raw.strip() 

256 # Cheap reject before any parsing: the overwhelming majority of 

257 # citations are ordinary external URLs. 

258 if not ( 

259 stripped.startswith(_LIBRARY_ROUTE_PREFIXES) 

260 or ("://" in stripped and _LIBRARY_ALIAS_HOST in stripped.lower()) 

261 ): 

262 return None 

263 # Refuse anything carrying a control character rather than truncating 

264 # the doc-id segment at it. Truncating keys the crafted URL as the 

265 # real ``/library/document/<id>`` it was prefixed with, merging it 

266 # into that document's bibliography entry and lending it the 

267 # document's title and citation number. Returning ``None`` keeps the 

268 # two sources apart; the payload still cannot forge a Sources line, 

269 # because every field rendered into that block goes through 

270 # ``search_utilities._sanitize_sources_field``. 

271 if _has_control_chars(stripped): 

272 return None 

273 alias = _normalize_library_alias(stripped) 

274 if alias is not None: 

275 stripped = alias 

276 if not _is_library_route(stripped): 

277 return None 

278 # Split fragment and query manually rather than via ``urlsplit``, 

279 # which silently deletes embedded tab/newline and would collide 

280 # ``/a<TAB>b`` with a real ``/ab``. 

281 head, sep, fragment = stripped.partition("#") 

282 path = head.split("?", 1)[0] 

283 path_match = _LIBRARY_ROUTE_PATH_RE.match(path) 

284 if not path_match: 

285 return None 

286 doc_id = unquote(path_match.group("doc_id")) 

287 # Same predicate ``extract_document_id`` applies, imported rather than 

288 # restated: the id is decoded before BOTH validating and re-emitting, 

289 # so ``/%2D`` and ``/-`` forms of one id give one key. 

290 if not is_safe_document_id(doc_id): 

291 return None 

292 key = f"/library/document/{doc_id}" 

293 suffix = path_match.group("suffix") 

294 display = f"{key}/{suffix}" if suffix else key 

295 # Re-attach the fragment ONLY when it is a real chunk anchor. A 

296 # library route carries no other meaningful fragment, so anything else 

297 # is either a producer bug or an unvalidated string that reached us 

298 # from the agent (``fetch_content`` passes its argument through 

299 # ``library_resolver`` verbatim). Re-emitting it built a citation URL 

300 # whose anchor cannot name a chunk — a dead link that still claims to 

301 # cite one — and smuggled arbitrary text into the rendered Sources 

302 # block, bypassing the ``MAX_CHUNK_INDEX``/format contract that 

303 # ``chunk_anchor`` calls itself the single source of truth for. 

304 # Dropping it keeps the citation (the route still resolves to the 

305 # document) and loses only an anchor that never pointed anywhere. 

306 if sep and fragment and is_valid_chunk_fragment(fragment): 

307 display = f"{display}#{fragment}" 

308 return key, display 

309 

310 

311def library_display_url(raw: str) -> str | None: 

312 """Return the display URL when *raw* is a library route, else ``None``. 

313 

314 ``canonical_url_key`` collapses library routes to a per-document key, 

315 which is right for grouping and wrong for display: the ``#chunk-<n>`` 

316 anchor is the entire point of a chunk-targeted citation. Renderers 

317 group on the key and display this. 

318 

319 The returned URL is the *normalized* route (``/library/`` spelling, 

320 percent-decoded id, no query), never the caller's raw string — a 

321 string that does not parse as a library route returns ``None`` rather 

322 than being echoed back into the report. 

323 """ 

324 parsed = _parse_library_citation(raw) 

325 return parsed[1] if parsed is not None else None 

326 

327 

328# A chunk anchor is ``#chunk-<n>`` with the exact index shape 

329# ``build_chunk_anchor_url`` emits. ``\d`` is Unicode-wide, so it is spelled 

330# out here: ``chunk-\u0667`` and ``chunk-12`` would otherwise be accepted 

331# while ``chunk_anchor.extract_chunk_index`` — which calls itself the 

332# single source of truth for this — rejects them. 

333# ``\Z``, not ``$``: Python's ``$`` also matches just before a single 

334# trailing newline even without ``re.MULTILINE``, so ``chunk-1\n`` 

335# passed. Both current callers strip and reject control characters 

336# upstream, so nothing reaches here with one today — but this is the 

337# shared predicate, and a future caller that skips those gates would 

338# inherit a newline-smuggling hole in the one rule both paths trust. 

339_CHUNK_FRAGMENT_RE = re.compile(r"^chunk-(0|[1-9][0-9]*)\Z", re.ASCII) 

340 

341 

342def is_valid_chunk_fragment(fragment: str) -> bool: 

343 """Return ``True`` if *fragment* (the part after ``#``) is a chunk 

344 anchor that can actually name a chunk. 

345 

346 Shared by :func:`library_display_url` (which drops a fragment failing 

347 this test) and :func:`preferred_chunk_display` (which rejects the whole 

348 URL), so the render path and the store path cannot disagree about what 

349 a valid anchor is. 

350 """ 

351 if not isinstance(fragment, str): 351 ↛ 352line 351 didn't jump to line 352 because the condition on line 351 was never true

352 return False 

353 match = _CHUNK_FRAGMENT_RE.match(fragment) 

354 if match is None: 

355 return False 

356 # Bound the digit run BEFORE converting: the pattern admits an 

357 # unbounded one, and int() raises above CPython's 4300-digit limit — 

358 # out of a pure formatter with no caller catching it, which would take 

359 # down the whole Sources block. 

360 if len(match.group(1)) > len(str(MAX_CHUNK_INDEX)): 

361 return False 

362 # Bound it the way the producer does, so a fragment that cannot name a 

363 # real chunk is not treated as though it could. 

364 return int(match.group(1)) <= MAX_CHUNK_INDEX 

365 

366 

367# Result-dict key holding a chunk-anchored spelling recorded alongside a 

368# citation whose stored link has no anchor. A separate key, never an 

369# overwrite of ``link``/``url``: consumers opt in, and a stray write can 

370# only add an ignored hint rather than corrupt a citation URL. 

371CHUNK_DISPLAY_KEY = "chunk_display_url" 

372 

373# Result-dict key holding the page's own ``<title>``, recorded alongside a 

374# citation whose stored title came from a search engine's result listing. A 

375# separate key for the same reason as ``CHUNK_DISPLAY_KEY``: never an overwrite. 

376FETCHED_TITLE_KEY = "fetched_title" 

377 

378 

379def preferred_chunk_display(raw: str) -> str | None: 

380 """Return the display URL when *raw* is a library route WITH a valid 

381 chunk anchor, else ``None``. 

382 

383 ``library_display_url`` validates the route and keeps a VALID chunk 

384 anchor, dropping any other fragment — a citation without an anchor is 

385 still a citation. This is the stricter question a caller asks when it 

386 wants to *store* an anchored spelling in preference to one it already 

387 has: the anchor has to be present, not merely permitted. 

388 

389 (This paragraph said "re-emits the fragment verbatim" until that 

390 behaviour was removed — the wording was corrected in the twin function 

391 and left stale here, which is the same one-instance-at-a-time slip the 

392 functions themselves keep exhibiting.) 

393 

394 One helper for both callers on purpose. The collector previously 

395 re-derived this rule from a comment saying it matched the renderer's, 

396 and the copy drifted twice — once accepting any ``#chunk-`` substring, 

397 once validating the route while ignoring the fragment entirely. 

398 """ 

399 display = library_display_url(raw) 

400 if display is None: 

401 return None 

402 _, sep, fragment = display.partition("#") 

403 # ``library_display_url`` already drops a fragment that fails this 

404 # test, so the ``is_valid_chunk_fragment`` call here is INERT today: 

405 # mutating it away kills no test, because nothing can reach it with a 

406 # fragment that would fail. It is defence in depth, not a live check — 

407 # said plainly so nobody reads a passing test as evidence it fires. 

408 # 

409 # Kept anyway, deliberately: it makes this function correct 

410 # independently of what ``library_display_url`` does, and the two 

411 # answer different questions ("what do I render" vs "is this worth 

412 # storing"), so a future change to either must not silently make this 

413 # one the looser of the pair — which is exactly how the collector's 

414 # hand-rolled copy of this rule drifted twice before it was 

415 # centralised here. The ``not sep`` half IS load-bearing: it is what 

416 # distinguishes "has an anchor" from "has none". 

417 if not sep or not is_valid_chunk_fragment(fragment): 

418 return None 

419 return display 

420 

421 

422def _is_wayback_replay_url(url: str, parsed: SplitResult) -> bool: 

423 """Recognize a capture URL whose embedded target must stay verbatim. 

424 

425 Capture availability and redirect safety are checked by the archive 

426 client. This only keeps citation normalization from changing its target. 

427 """ 

428 if any(character.isspace() or ord(character) < 32 for character in url): 

429 return False 

430 try: 

431 if ( 

432 parsed.scheme not in {"http", "https"} 

433 or parsed.hostname != "web.archive.org" 

434 or parsed.port not in {None, 80, 443} 

435 or parsed.username is not None 

436 or parsed.password is not None 

437 ): 

438 return False 

439 replay = re.fullmatch( 

440 r"/web/[0-9]{14}(?:id_|if_)?/(https?://.+)", parsed.path 

441 ) 

442 if replay is None: 

443 return False 

444 target = urlsplit(replay.group(1)) 

445 # Match the archive client's absolute, credential-free target 

446 # boundary without importing transport code into this utility. 

447 if ( 

448 not target.hostname 

449 or target.username is not None 

450 or target.password is not None 

451 ): 

452 return False 

453 target.port 

454 except ValueError: 

455 return False 

456 return True 

457 

458 

459@lru_cache(maxsize=1024) 

460def canonical_url_key(url: str) -> str: 

461 """Return a canonical form of ``url`` suitable for deduplication and 

462 display in a Sources / citations listing. 

463 

464 The canonical form: 

465 - lowercases scheme and host (paths stay case-sensitive), 

466 - strips userinfo (``user:pass@`` — never leak creds), 

467 - strips default ports (80/http, 443/https), 

468 - strips fragments, 

469 - drops tracking query params (``utm_*``, ``fbclid``, ``gclid``, 

470 ``msclkid``, ``yclid``, ``dclid``, ``gad_source``, ``mc_eid``, 

471 ``mc_cid``, ``ref_src``, ``igshid``, ``_ga``, ``_gl``), 

472 - trims a trailing ``/`` from non-root paths. 

473 

474 Click-through behavior is preserved — tracking params carry no 

475 content, and mainstream browsers already strip them automatically. 

476 Percent-encoding is not normalized: the raw query spellings 

477 ``?q=a+b`` vs ``?q=a%20b`` and ``?a=1;b=2`` vs ``?a=1%3Bb=2`` hash 

478 differently (a server may treat ``+``/``;`` literally, so merging 

479 them would risk linking a resource to the wrong document). Query 

480 param order is preserved as-is here; callers that need 

481 order-insensitivity (e.g. download-tracker hashing) sort afterwards. 

482 

483 Timestamped Wayback replay URLs retain their complete spelling, 

484 including the capture modifier and embedded target's path, query and 

485 fragment. Removing a trailing slash or a tracking parameter there 

486 changes the captured target rather than merely cleaning a live link. 

487 

488 Internal library-document routes (``/library/document/<id>``, plus its 

489 ``/pdf`` and ``/chunks#chunk-<n>`` views) collapse to a per-document 

490 key, because they are views of one source and belong on one 

491 bibliography line. That key drops the ``#chunk-<n>`` anchor, so 

492 anything that RENDERS a link must display the original URL instead — 

493 see :func:`library_display_url`. 

494 

495 Falls back to ``url.strip()`` for everything else that is not a 

496 recognizable absolute URL (``mailto:``, ``data:``, protocol-relative 

497 ``//host/p``, bare filesystem paths, SPA ``/app#/route`` links), since 

498 canonicalization would be ambiguous — and merging distinct sources is 

499 worse than leaving them separate. 

500 """ 

501 if not url: 

502 return "" 

503 # Library-document routes — relative, ``/lib/``-abbreviated, or the 

504 # absolute ``https://library.document/<id>`` alias the agent emits — 

505 # all key to one per-document route, so every view of one document 

506 # shares a single bibliography entry. 

507 library = _parse_library_citation(url) 

508 if library is not None: 

509 return library[0] 

510 try: 

511 parsed = urlsplit(url) 

512 except Exception: 

513 return url.strip() 

514 # Require both a scheme and a netloc; otherwise canonicalization is 

515 # ambiguous (mailto:, data:, protocol-relative, etc.). 

516 if not parsed.scheme or not parsed.netloc: 

517 # Library routes were already handled above. Everything else that 

518 # is not a recognizable absolute URL falls back to the raw string: 

519 # canonicalization is deliberately NOT generalized to every 

520 # root-relative path, because a LangChain retriever sets a 

521 # result's url from its ``source`` metadata, which is commonly an 

522 # absolute filesystem path. Treating those as routes merges 

523 # genuinely distinct sources — ``/docs/C#/a.md`` and 

524 # ``/docs/C#/b.md`` would both key to ``/docs/C``, a path that 

525 # does not exist — which is worse than the fan-out being fixed. 

526 # The same applies to SPA-style ``/app#/route`` links. 

527 # 

528 # A library route with a control character does NOT reach here as 

529 # a library key: ``_parse_library_citation`` refuses it, so it 

530 # falls through to this verbatim fallback. That is deliberate. 

531 # Truncating the doc-id segment instead would key the crafted URL 

532 # as the real ``/library/document/<id>`` it was prefixed with, 

533 # merging it into that document's bibliography entry and handing 

534 # it the document's title and citation number. The payload cannot 

535 # forge a line from here either: every field rendered into the 

536 # ``## Sources`` block — this URL included — goes through 

537 # ``search_utilities._sanitize_sources_field``, which flattens 

538 # control characters to spaces. 

539 return url.strip() 

540 

541 if _is_wayback_replay_url(url, parsed): 

542 return url 

543 

544 scheme = parsed.scheme.lower() 

545 

546 # Strip userinfo (user:pass@host) from netloc. 

547 netloc = parsed.netloc.rsplit("@", 1)[-1] 

548 

549 # Split host/port carefully so IPv6 literals survive. 

550 if netloc.startswith("["): 

551 end = netloc.find("]") 

552 raw_host = netloc[: end + 1] 

553 # Lowercase IPv6 address portion while preserving case-sensitive zone ID (RFC 6874) 

554 if "%" in raw_host: 

555 addr, zone = raw_host.split("%", 1) 

556 host = f"{addr.lower()}%{zone}" 

557 else: 

558 host = raw_host.lower() 

559 rest = netloc[end + 1 :] 

560 port = rest[1:] if rest.startswith(":") else "" 

561 elif ":" in netloc: 

562 host, _, port = netloc.rpartition(":") 

563 host = host.lower() 

564 else: 

565 host, port = netloc.lower(), "" 

566 

567 if (scheme == "https" and port == "443") or ( 

568 scheme == "http" and port == "80" 

569 ): 

570 port = "" 

571 netloc = f"{host}:{port}" if port else host 

572 

573 # Filter query params case-insensitively on key; preserve raw 

574 # spellings and order. Operates on the raw ``&``-split pairs (no 

575 # parse_qsl/urlencode round-trip): decoding would merge spellings the 

576 # tracker must keep distinct (``+`` vs ``%20``, ``;`` vs ``%3B`` — 

577 # issue #6758 B2 review). Tracking detection decodes the key with 

578 # ``unquote`` (``%`` only, ``+`` left intact) so an encoded tracking 

579 # key is still stripped, but the kept pairs are re-emitted verbatim. 

580 if parsed.query: 

581 kept_raw: list[str] = [] 

582 for raw_pair in parsed.query.split("&"): 

583 if raw_pair == "": 583 ↛ 584line 583 didn't jump to line 584 because the condition on line 583 was never true

584 continue 

585 raw_key = raw_pair.split("=", 1)[0] 

586 try: 

587 decoded_key = unquote(raw_key).lower() 

588 except Exception: 

589 decoded_key = raw_key.lower() 

590 if decoded_key in _TRACKING_PARAMS or any( 

591 decoded_key.startswith(p) for p in _TRACKING_PREFIXES 

592 ): 

593 continue 

594 kept_raw.append(raw_pair) 

595 query_str = "&".join(kept_raw) 

596 else: 

597 query_str = "" 

598 

599 path = parsed.path 

600 if path and path != "/" and path.endswith("/"): 

601 path = path.rstrip("/") 

602 

603 return urlunsplit((scheme, netloc, path, query_str, "")) 

604 

605 

606@lru_cache(maxsize=1024) 

607def content_web_cache_key(url: str) -> str: 

608 """Return a cache key for *web* content fetches (NOT bibliography dedup). 

609 

610 Same normalisations as :func:`canonical_url_key` — lowercased 

611 scheme/host, stripped default ports, dropped tracking query params — 

612 but with deliberate differences, because bibliographic equivalence 

613 does not establish identical fetched bytes: 

614 

615 * the path is preserved verbatim, including a non-root trailing 

616 ``/``: ``/article`` and ``/article/`` are different HTTP resources 

617 and routinely serve different bodies; 

618 * URL userinfo (``user:pass@``) is preserved verbatim (host still 

619 lowercased): ``https://alice@host/p`` and ``https://bob@host/p`` 

620 authenticate differently and must never share an entry; 

621 * the fragment is preserved verbatim: client-routed SPAs (hash 

622 routing) render different views per fragment under JS rendering 

623 (Playwright navigates with the fragment intact). 

624 

625 Query handling otherwise matches the bibliography key: only tracking 

626 params (``utm_*``, ``fbclid``, ...) collapse; every other param, 

627 its order and its values are preserved, so ``?a=1`` and ``?a=2`` 

628 stay distinct. Dropping tracking parameters accepts the trade-off of 

629 potential over-merge on rare sites that vary content per tracking param 

630 (e.g., campaign- or affiliate-specific landing pages). Raw spellings 

631 (``?q=a+b`` vs ``?q=a%20b``) stay 

632 distinct, matching :func:`canonical_url_key` (#6758 B2). Anything 

633 without a scheme+netloc falls back to the stripped raw string (same as 

634 :func:`canonical_url_key`). 

635 Library routes are NOT handled here — callers resolve those via 

636 ``library_display_url`` first. A relative ``/library/...`` URL 

637 therefore falls through to the raw fallback, never to a web key. 

638 """ 

639 

640 if not url: 640 ↛ 641line 640 didn't jump to line 641 because the condition on line 640 was never true

641 return "" 

642 try: 

643 parsed = urlsplit(url) 

644 except Exception: 

645 return url.strip() 

646 if not parsed.scheme or not parsed.netloc: 646 ↛ 647line 646 didn't jump to line 647 because the condition on line 646 was never true

647 return url.strip() 

648 

649 scheme = parsed.scheme.lower() 

650 

651 # Preserve userinfo verbatim (credentials are case-sensitive and 

652 # select a different origin view); lowercase only the host part. 

653 raw_netloc = parsed.netloc 

654 if "@" in raw_netloc: 

655 userinfo, _, hostport = raw_netloc.rpartition("@") 

656 else: 

657 userinfo, hostport = "", raw_netloc 

658 

659 # Split host/port carefully so IPv6 literals survive. 

660 if hostport.startswith("["): 

661 end = hostport.find("]") 

662 if end == -1: 662 ↛ 663line 662 didn't jump to line 663 because the condition on line 662 was never true

663 return url.strip() 

664 host = hostport[: end + 1].lower() 

665 rest = hostport[end + 1 :] 

666 port = rest[1:] if rest.startswith(":") else "" 

667 if rest and not rest.startswith(":"): 667 ↛ 668line 667 didn't jump to line 668 because the condition on line 667 was never true

668 return url.strip() 

669 elif ":" in hostport: 

670 host, _, port = hostport.rpartition(":") 

671 host = host.lower() 

672 else: 

673 host, port = hostport.lower(), "" 

674 

675 if (scheme == "https" and port == "443") or ( 

676 scheme == "http" and port == "80" 

677 ): 

678 port = "" 

679 hostport_norm = f"{host}:{port}" if port else host 

680 netloc = f"{userinfo}@{hostport_norm}" if userinfo else hostport_norm 

681 

682 # Filter query params case-insensitively on key; preserve raw 

683 # spellings and order (same raw-preserving filter as 

684 # :func:`canonical_url_key` — no parse_qsl/urlencode round-trip). 

685 if parsed.query: 

686 kept_raw: list[str] = [] 

687 for raw_pair in parsed.query.split("&"): 

688 if raw_pair == "": 688 ↛ 689line 688 didn't jump to line 689 because the condition on line 688 was never true

689 continue 

690 raw_key = raw_pair.split("=", 1)[0] 

691 try: 

692 decoded_key = unquote(raw_key).lower() 

693 except Exception: 

694 decoded_key = raw_key.lower() 

695 if decoded_key in _TRACKING_PARAMS or any( 

696 decoded_key.startswith(p) for p in _TRACKING_PREFIXES 

697 ): 

698 continue 

699 kept_raw.append(raw_pair) 

700 query_str = "&".join(kept_raw) 

701 else: 

702 query_str = "" 

703 

704 # Path preserved verbatim: case AND trailing slash both select 

705 # distinct HTTP resources. Fragment preserved: client-routed SPAs 

706 # (hash routing) render different content per fragment under JS 

707 # rendering (playwright_html navigates with fragment intact). 

708 path = parsed.path 

709 fragment = parsed.fragment 

710 

711 return urlunsplit((scheme, netloc, path, query_str, fragment)) 

712 

713 

714def is_safe_custom_llm_endpoint(custom_endpoint: Optional[str]) -> bool: 

715 """SSRF guard for a user-supplied custom LLM endpoint, applied at the 

716 request boundary as fail-fast defense-in-depth. 

717 

718 The endpoint is normalized exactly as the OpenAI-compatible provider 

719 normalizes it (:func:`normalize_url`), so scheme-less local endpoints 

720 such as ``localhost:11434`` or ``192.168.1.10:8000`` are handled the 

721 same way the provider handles them, then validated with 

722 :func:`validate_url` allowing private IPs / localhost. That accepts 

723 local LLM backends (Ollama / LM Studio / vLLM) while still blocking 

724 cloud-metadata and link-local targets. An empty / unset endpoint is 

725 safe (there is nothing to send to). On rejection a redacted warning 

726 is logged (the raw URL may carry credentials). 

727 

728 This is not the sole protection: the OpenAI-compatible provider's 

729 ``assert_base_url_safe`` re-validates the same URL before the 

730 LangChain client is constructed. This guard simply rejects early — 

731 before any DB row is written or research thread is spawned — and 

732 keeps the endpoint out of the logs. 

733 

734 Scope: this validates only the submitted URL. This helper does not validate 

735 redirect targets or pin connection-time name resolution. 

736 

737 Callers hand this whatever a JSON body contained, so a non-string 

738 (other than ``None``) is rejected rather than coerced: it cannot be a 

739 usable endpoint, and coercing it would either raise inside ``strip()`` 

740 or silently treat ``[]`` / ``{}`` as "unset" and persist them. 

741 """ 

742 if custom_endpoint is None: 

743 return True 

744 if not isinstance(custom_endpoint, str): 

745 logger.warning( 

746 "SSRF protection: rejected non-string custom_endpoint of type {}", 

747 type(custom_endpoint).__name__, 

748 ) 

749 return False 

750 endpoint = custom_endpoint.strip() 

751 if not endpoint: 

752 return True 

753 candidate = normalize_url(endpoint) 

754 # allow_private_ips=True is deliberate: a self-hosted LLM backend 

755 # legitimately lives on 127.0.0.1 or an RFC1918 LAN address, so those must 

756 # stay reachable. block_link_local=True is the carve-out inside that -- 

757 # cloud instance metadata uses link-local ranges, while self-hosted model 

758 # servers commonly use localhost or RFC1918 addresses. Blocking the range, 

759 # rather than a short literal list, preserves that distinction for IPv4 

760 # and IPv6. 

761 # 

762 # Regression evidence and self-hosted controls: 

763 # tests/security/test_llm_endpoint_link_local_hardening.py 

764 # - test_link_local_endpoint_is_refused_at_the_http_boundary 

765 # - test_self_hosted_endpoint_still_accepted 

766 if validate_url(candidate, allow_private_ips=True, block_link_local=True): 

767 return True 

768 logger.warning( 

769 "SSRF protection: rejected custom_endpoint URL: {}", 

770 redact_url_for_log(candidate), 

771 ) 

772 return False