- SSRF-safe fetcher: public IPs only, connects to the checked address, redirects re-validated, size/time limits, honest user agent - schema.org/Recipe JSON-LD extraction (graphs, HowToSections, ISO durations, yields, keywords, images) without extra dependencies; AI fallback on plain page text when no structured recipe exists - Instagram: one login-free request to the public embed page; when Instagram withholds the caption, the import page explains how to paste the text or import a screenshot instead - Suggested source photo can be downloaded and attached on save - Text import works without AI (text becomes the description) - Installable PWA with manifest, icons and Android share target - Offline tests for SSRF rules, parsers, Instagram and import views Co-Authored-By: Claude Opus 5.5 <[email protected]>
127 lines
5.0 KiB
Python
127 lines
5.0 KiB
Python
"""Outgoing HTTP requests to user-supplied URLs, hardened against SSRF.
|
|
|
|
The host name is resolved once, every resolved address must be a public (global) IP, and
|
|
the connection is made to that checked IP (TLS still verifies the real host name). That
|
|
closes DNS-rebinding tricks. Redirects are followed manually and re-checked on every hop.
|
|
"""
|
|
|
|
import ipaddress
|
|
import socket
|
|
from dataclasses import dataclass
|
|
from urllib.parse import urljoin, urlsplit
|
|
|
|
import urllib3
|
|
|
|
# Honest identification as a bot; sites that refuse it are handled by the paste fallback.
|
|
USER_AGENT = "Mozilla/5.0 (compatible; Kochbuch-Rezeptimport/1.0; self-hosted recipe manager)"
|
|
MAX_REDIRECTS = 5
|
|
DEFAULT_MAX_BYTES = 3 * 1024 * 1024
|
|
|
|
|
|
class FetchError(Exception):
|
|
def __init__(self, user_message):
|
|
super().__init__(user_message)
|
|
self.user_message = user_message
|
|
|
|
|
|
@dataclass
|
|
class FetchResult:
|
|
url: str
|
|
status: int
|
|
content_type: str
|
|
body: bytes
|
|
|
|
@property
|
|
def text(self):
|
|
charset = "utf-8"
|
|
for part in self.content_type.split(";")[1:]:
|
|
key, _, value = part.strip().partition("=")
|
|
if key.lower() == "charset" and value:
|
|
charset = value.strip("\"'")
|
|
try:
|
|
return self.body.decode(charset, errors="replace")
|
|
except LookupError:
|
|
return self.body.decode("utf-8", errors="replace")
|
|
|
|
|
|
def _resolve_public(host, port):
|
|
try:
|
|
infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
|
except (socket.gaierror, UnicodeError):
|
|
raise FetchError("Die Adresse konnte nicht gefunden werden.") from None
|
|
addresses = []
|
|
for info in infos:
|
|
ip = ipaddress.ip_address(info[4][0].split("%")[0])
|
|
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped:
|
|
ip = ip.ipv4_mapped
|
|
if not ip.is_global or ip.is_multicast:
|
|
raise FetchError("Diese Adresse ist nicht erlaubt (internes Netz).")
|
|
addresses.append(str(ip))
|
|
if not addresses:
|
|
raise FetchError("Die Adresse konnte nicht gefunden werden.")
|
|
return addresses[0]
|
|
|
|
|
|
def _validate_url(url):
|
|
parts = urlsplit(url)
|
|
if parts.scheme not in ("http", "https") or not parts.hostname:
|
|
raise FetchError("Bitte gib eine gültige http(s)-Adresse ein.")
|
|
if parts.username or parts.password:
|
|
raise FetchError("Adressen mit Zugangsdaten werden nicht unterstützt.")
|
|
port = parts.port or (443 if parts.scheme == "https" else 80)
|
|
if port not in (80, 443, 8080, 8443):
|
|
raise FetchError("Dieser Port wird nicht unterstützt.")
|
|
return parts, port
|
|
|
|
|
|
def _request_once(url, accept, timeout):
|
|
parts, port = _validate_url(url)
|
|
host = parts.hostname
|
|
ip = _resolve_public(host, port)
|
|
default_port = 443 if parts.scheme == "https" else 80
|
|
headers = {
|
|
"Host": host if port == default_port else f"{host}:{port}",
|
|
"User-Agent": USER_AGENT,
|
|
"Accept": accept,
|
|
"Accept-Language": "de-DE,de;q=0.9,en;q=0.7",
|
|
}
|
|
if parts.scheme == "https":
|
|
pool = urllib3.HTTPSConnectionPool(ip, port, server_hostname=host, assert_hostname=host,
|
|
cert_reqs="CERT_REQUIRED", retries=False, timeout=timeout)
|
|
else:
|
|
pool = urllib3.HTTPConnectionPool(ip, port, retries=False, timeout=timeout)
|
|
path = parts.path or "/"
|
|
if parts.query:
|
|
path += "?" + parts.query
|
|
try:
|
|
return pool.urlopen("GET", path, headers=headers, redirect=False, preload_content=False)
|
|
except urllib3.exceptions.TimeoutError:
|
|
raise FetchError("Die Seite hat nicht rechtzeitig geantwortet.") from None
|
|
except urllib3.exceptions.SSLError:
|
|
raise FetchError("Die sichere Verbindung zur Seite ist fehlgeschlagen.") from None
|
|
except urllib3.exceptions.HTTPError:
|
|
raise FetchError("Die Seite ist nicht erreichbar.") from None
|
|
|
|
|
|
def fetch(url, accept="text/html,application/xhtml+xml;q=0.9,*/*;q=0.8", max_bytes=DEFAULT_MAX_BYTES, timeout=None):
|
|
timeout = timeout or urllib3.Timeout(connect=5, read=15)
|
|
for _ in range(MAX_REDIRECTS + 1):
|
|
response = _request_once(url, accept, timeout)
|
|
try:
|
|
if response.status in (301, 302, 303, 307, 308) and response.headers.get("Location"):
|
|
url = urljoin(url, response.headers["Location"])
|
|
continue
|
|
chunks, size = [], 0
|
|
for chunk in response.stream(64 * 1024, decode_content=True):
|
|
size += len(chunk)
|
|
if size > max_bytes:
|
|
raise FetchError("Die Seite ist zu groß.")
|
|
chunks.append(chunk)
|
|
return FetchResult(url=url, status=response.status,
|
|
content_type=response.headers.get("Content-Type", ""), body=b"".join(chunks))
|
|
except urllib3.exceptions.HTTPError:
|
|
raise FetchError("Die Seite konnte nicht vollständig geladen werden.") from None
|
|
finally:
|
|
response.release_conn()
|
|
raise FetchError("Zu viele Weiterleitungen.")
|