# Trouve-KA — fetcher HTTP sécurisé # Author: Simon-Pierre Boucher # Contact: contact@spboucher.ai """Fetch HTTP avec garde SSRF revalidée à chaque redirection, limites de taille, gestion des content-types et cache HTTP conditionnel (ETag / Last-Modified). Jamais de Chromium ici (CLAUDE.md §5.10) : fetch HTTP pur. """ import time from urllib.parse import urlsplit import httpx from trouveka.config import Settings from trouveka.shared import canonicalize_url, is_safe_url from trouveka.types import ErrorCode, FetchResult ACCEPTED_CONTENT_TYPES = ("text/html", "application/xhtml+xml", "text/plain") class Fetcher: def __init__(self, client: httpx.AsyncClient, settings: Settings): self._client = client self._s = settings async def fetch( self, url: str, *, etag: str | None = None, last_modified: str | None = None ) -> FetchResult: """Fetch une URL en suivant manuellement les redirections (revalidation SSRF à chaque saut).""" start = time.monotonic() chain: list[str] = [] current = url for _hop in range(self._s.max_redirects + 1): if not is_safe_url(current): return self._fail(url, current, ErrorCode.SSRF_BLOCKED, start, chain) # Accept-Encoding volontairement absent : httpx annonce lui-même les # encodages qu'il sait décoder (br seulement si brotli est installé). # L'annoncer à la main fait indexer du binaire compressé (bug itum.qc.ca). headers = { "User-Agent": self._s.crawler_user_agent, "Accept": "text/html,application/xhtml+xml;q=0.9,text/plain;q=0.5,*/*;q=0.1", "Accept-Language": "fr-CA,fr;q=0.9,en-CA;q=0.8,en;q=0.7", } if etag and current == url: headers["If-None-Match"] = etag if last_modified and current == url: headers["If-Modified-Since"] = last_modified try: async with self._client.stream( "GET", current, headers=headers, timeout=self._s.fetch_timeout, follow_redirects=False ) as resp: if resp.status_code in (301, 302, 303, 307, 308): location = resp.headers.get("location") if not location: return self._fail(url, current, ErrorCode.HTTP_4XX, start, chain, resp.status_code) next_url = canonicalize_url(location, base=current) if not next_url: return self._fail(url, current, ErrorCode.SSRF_BLOCKED, start, chain, resp.status_code) chain.append(next_url) current = next_url continue if resp.status_code == 304: return FetchResult( url=url, final_url=current, status_code=304, duration_ms=self._ms(start), redirect_chain=chain, ) if 400 <= resp.status_code < 500: return self._fail(url, current, ErrorCode.HTTP_4XX, start, chain, resp.status_code) if resp.status_code >= 500: return self._fail(url, current, ErrorCode.HTTP_5XX, start, chain, resp.status_code) # X-Robots-Tag : noindex au niveau HTTP x_robots = (resp.headers.get("x-robots-tag") or "").lower() raw_ct = resp.headers.get("content-type") or "" content_type = raw_ct.split(";")[0].strip().lower() charset = None if "charset=" in raw_ct.lower(): charset = raw_ct.lower().split("charset=")[-1].split(";")[0].strip(' "\'') or None if content_type and not any(content_type.startswith(t) for t in ACCEPTED_CONTENT_TYPES): return self._fail( url, current, ErrorCode.UNSUPPORTED_CONTENT, start, chain, resp.status_code ) declared = resp.headers.get("content-length") if declared and int(declared) > self._s.max_response_bytes: return self._fail(url, current, ErrorCode.TOO_LARGE, start, chain, resp.status_code) body = b"" async for part in resp.aiter_bytes(): body += part if len(body) > self._s.max_response_bytes: return self._fail(url, current, ErrorCode.TOO_LARGE, start, chain, resp.status_code) result = FetchResult( url=url, final_url=current, status_code=resp.status_code, content_type=content_type or None, charset=charset, body=body, etag=resp.headers.get("etag"), last_modified=resp.headers.get("last-modified"), duration_ms=self._ms(start), redirect_chain=chain, ) if "noindex" in x_robots: result.error_code = ErrorCode.ROBOTS_DENIED return result except httpx.TimeoutException: return self._fail(url, current, ErrorCode.TIMEOUT, start, chain) except httpx.ConnectError as exc: code = ErrorCode.TLS if "SSL" in str(exc) or "certificate" in str(exc).lower() else ErrorCode.DNS return self._fail(url, current, code, start, chain) except httpx.HTTPError: return self._fail(url, current, ErrorCode.CONNECTION, start, chain) return self._fail(url, current, ErrorCode.TOO_MANY_REDIRECTS, start, chain) @staticmethod def _ms(start: float) -> int: return int((time.monotonic() - start) * 1000) def _fail( self, url: str, final_url: str, code: ErrorCode, start: float, chain: list[str], status: int | None = None, ) -> FetchResult: return FetchResult( url=url, final_url=final_url, status_code=status, error_code=code, duration_ms=self._ms(start), redirect_chain=chain, ) def scheme_host(url: str) -> str: parts = urlsplit(url) return f"{parts.scheme}://{parts.netloc}"