"""Minimal, hardened WebDAV client behind the ebook cloud import. Only PROPFIND (Depth 1) and GET are implemented — enough to browse a remote folder and pull a single file out of it. Every request here is made by the server to a URL the *user* supplied, which makes this an SSRF surface: registration is open (accounts/views.py:23) and diora normally runs in a Docker network next to other services. Three things guard it, and all three are load-bearing: * `assert_safe_url` refuses hosts that resolve to non-public addresses, * `_assert_peer_is_safe` re-checks the address we *actually* connected to, because requests resolves the hostname a second time and a hostile resolver can answer differently across the two lookups (DNS rebinding), * redirects are refused rather than followed, so a public host cannot bounce us onto a private one. Residual risk worth knowing about: a rebinding attacker can still cause a single request to be *sent* to an internal address; the peer check runs before the body is read, so nothing comes back to them. Fully closing that needs connect-time DNS pinning, which requests does not expose without reaching into urllib3. Response bodies are read through `_read_capped` and parsed only after any DTD is refused — the remote server is attacker-chosen, so an unbounded read or a billion-laughs document would otherwise be a cheap way to OOM the container. """ import ipaddress import socket import xml.etree.ElementTree as ET from urllib.parse import quote, unquote, urljoin, urlsplit import requests from django.conf import settings DAV_NS = '{DAV:}' BOOK_EXTENSIONS = ('.epub', '.pdf') # A directory listing is XML; anything this large is not a real one. MAX_LISTING_BYTES = 8 * 1024 * 1024 class WebDAVError(Exception): """Failure with a message that is safe to show the user directly.""" class WebDAVInputError(WebDAVError): """The user's own URL/path/credentials are at fault, not the remote server. Separated so views can answer 400 instead of 502 — a rejected traversal attempt is not an upstream outage. """ def _timeout(): return getattr(settings, 'WEBDAV_TIMEOUT', 15) def _allow_private(): return getattr(settings, 'WEBDAV_ALLOW_PRIVATE_HOSTS', False) # --------------------------------------------------------------------------- # SSRF guard # --------------------------------------------------------------------------- def _unreachable(host): """One message for every "we won't talk to this" case. Deliberately does not say whether the name failed to resolve or resolved to a private address, and never echoes the resolved IP: telling those apart would turn this endpoint into a scanner for the Docker network's service names and addresses. """ return WebDAVInputError( f'„{host}“ ist nicht öffentlich erreichbar. Aus Sicherheitsgründen sind nur ' 'öffentlich auflösbare Server erlaubt.' ) def _is_public(raw_address): """True only for addresses we are willing to open a connection to.""" try: # Scope IDs ('fe80::1%eth0') are not part of the address itself. ip = ipaddress.ip_address(raw_address.split('%')[0]) except ValueError: return False if not ip.is_global: return False # is_global misses two IPv6 transition ranges that can carry an embedded # private IPv4 address: the well-known NAT64 prefix (RFC 6052) and the # deprecated IPv4-compatible range. if ip.version == 6: for unsafe in ('64:ff9b::/96', '::/96'): if ip in ipaddress.ip_network(unsafe): return False return True def assert_safe_url(url): """Reject URLs that don't resolve to a public address. Self-hosted setups that legitimately want a LAN target can opt out with WEBDAV_ALLOW_PRIVATE_HOSTS=True, which is why this is a setting and not a hard rule. """ parts = urlsplit(url) if parts.scheme not in ('http', 'https'): raise WebDAVInputError('Nur http:// und https:// werden unterstützt.') host = parts.hostname if not host: raise WebDAVInputError('Die URL enthält keinen Hostnamen.') try: port = parts.port or (443 if parts.scheme == 'https' else 80) except ValueError: # urlsplit validates the port lazily, on attribute access. raise WebDAVInputError('Die URL enthält einen ungültigen Port.') if _allow_private(): return try: infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP) except socket.gaierror: raise _unreachable(host) for info in infos: if not _is_public(info[4][0]): raise _unreachable(host) def _assert_peer_is_safe(response): """Re-check the address the socket actually connected to. `assert_safe_url` validates DNS before the request, but requests resolves the name again when it opens the connection. A resolver answering public-then-private across those two lookups would otherwise hand us an internal service's response body. """ if _allow_private(): return sock = getattr(getattr(response.raw, '_connection', None), 'sock', None) if sock is None: return # nothing to introspect (mocked, or already released) try: peer = sock.getpeername()[0] except OSError: return if not _is_public(peer): raise WebDAVInputError('Der Server ist nicht öffentlich erreichbar.') # --------------------------------------------------------------------------- # Path helpers # --------------------------------------------------------------------------- def safe_rel_path(raw): """Normalise a client-supplied path and keep it inside the source root. Whatever survives here is percent-encoded by `_build_url` with `quote()`'s default `safe='/'`, and that is what stops double-encoded traversal ('%252e%252e%252f') and smuggled absolute URLs: '%' and ':' both get escaped, so they land as literal filename characters. Widening that safe set would reopen those paths. """ raw = (raw or '').strip().strip('/') if not raw: return '' segments = [] for segment in raw.split('/'): if not segment or segment == '.': continue if segment == '..': raise WebDAVInputError('Ungültiger Pfad.') segments.append(segment) return '/'.join(segments) def _build_url(source, rel_path): base = source.normalized_base_url() if not rel_path: return base return base + quote(rel_path) def _request(method, url, source, **kwargs): assert_safe_url(url) auth = (source.username, source.password) if source.username else None try: # Always streamed: it keeps the connection open long enough to inspect # the peer, and stops requests from buffering an unbounded body before # we get the chance to cap it. response = requests.request( method, url, auth=auth, timeout=_timeout(), allow_redirects=False, stream=True, **kwargs ) except requests.Timeout: raise WebDAVError('Zeitüberschreitung beim Server.') except requests.RequestException as exc: raise WebDAVError(f'Verbindung fehlgeschlagen: {exc.__class__.__name__}') try: _assert_peer_is_safe(response) if response.status_code in (301, 302, 303, 307, 308): raise WebDAVError( 'Der Server hat eine Weiterleitung geschickt. ' 'Bitte trage die Ziel-URL direkt ein.' ) if response.status_code in (401, 403): raise WebDAVInputError('Anmeldung abgelehnt — Benutzername oder App-Passwort prüfen.') if response.status_code == 404: raise WebDAVError('Pfad auf dem Server nicht gefunden.') if response.status_code >= 400: raise WebDAVError(f'Server antwortete mit HTTP {response.status_code}.') except Exception: response.close() raise return response def _read_capped(response, max_bytes, oversize_error): """Read a streamed body, aborting as soon as it exceeds `max_bytes`.""" declared = response.headers.get('Content-Length') if declared and declared.isdigit() and int(declared) > max_bytes: response.close() raise oversize_error() chunks = [] total = 0 try: for chunk in response.iter_content(chunk_size=64 * 1024): total += len(chunk) if total > max_bytes: raise oversize_error() chunks.append(chunk) except requests.RequestException: raise WebDAVError('Übertragung abgebrochen.') finally: response.close() return b''.join(chunks) # --------------------------------------------------------------------------- # PROPFIND / GET # --------------------------------------------------------------------------- _PROPFIND_BODY = ( '' '' '' '' ) def _parse_listing_xml(payload): """Parse a multistatus body, refusing any document that carries a DTD. The remote server is chosen by the user, so a billion-laughs document is within an attacker's reach, and ElementTree *does* expand internal entities (verified on 3.12: a 3-level document expands). Installing an expat EntityDeclHandler is not an option either — `XMLParser.parser` no longer exists in 3.12, so setting it silently does nothing. That leaves rejecting the DTD before parsing. It costs nothing: a real multistatus never has one, and a filename containing this text would arrive escaped as `<!DOCTYPE`, so these bytes can only ever be markup. """ lowered = payload.lower() if b' block that actually applies. RFC 4918 lets a server split its answer across several blocks — typically a 200 for the properties it found and a 404 for the ones it did not, in no guaranteed order. Taking the first one blindly mislabels directories (whose getcontentlength is the missing property) as files, and the client then filters them out of the listing entirely. """ fallback = None for propstat in node.findall(f'{DAV_NS}propstat'): prop = propstat.find(f'{DAV_NS}prop') if prop is None: continue if ' 200 ' in (propstat.findtext(f'{DAV_NS}status') or ''): return prop if fallback is None: fallback = prop return fallback def list_directory(source, rel_path=''): """PROPFIND Depth 1, returned as entries relative to the source root.""" rel_path = safe_rel_path(rel_path) url = _build_url(source, rel_path) if not url.endswith('/'): url += '/' response = _request( 'PROPFIND', url, source, headers={'Depth': '1', 'Content-Type': 'application/xml; charset=utf-8'}, data=_PROPFIND_BODY.encode('utf-8'), ) payload = _read_capped( response, MAX_LISTING_BYTES, lambda: WebDAVError('Verzeichnisliste ist unerwartet groß.'), ) root = _parse_listing_xml(payload) base_path = unquote(urlsplit(url).path) entries = [] for node in root.findall(f'{DAV_NS}response'): href = node.findtext(f'{DAV_NS}href') or '' # hrefs come as absolute URLs, absolute paths, or relative references. href_path = unquote(urlsplit(urljoin(url, href)).path) if not href_path.startswith(base_path): continue remainder = href_path[len(base_path):].strip('/') if not remainder: continue # the collection we asked about if '/' in remainder: continue # Depth 1 should not return these, but be strict anyway prop = _select_prop(node) is_dir = ( prop is not None and prop.find(f'{DAV_NS}resourcetype/{DAV_NS}collection') is not None ) size_text = prop.findtext(f'{DAV_NS}getcontentlength') if prop is not None else None try: size = int(size_text) if size_text else 0 except ValueError: size = 0 entries.append({ 'name': remainder, 'path': f'{rel_path}/{remainder}' if rel_path else remainder, 'is_dir': is_dir, 'size': size, 'modified': (prop.findtext(f'{DAV_NS}getlastmodified') or '') if prop is not None else '', 'is_book': (not is_dir) and remainder.lower().endswith(BOOK_EXTENSIONS), }) entries.sort(key=lambda e: (not e['is_dir'], e['name'].lower())) return entries def fetch_file(source, rel_path, max_bytes): """Download one file, refusing anything larger than `max_bytes`. Reads into memory rather than to disk: the caller hands the bytes straight back to the browser, which encrypts them. Nothing readable is persisted server-side. """ rel_path = safe_rel_path(rel_path) if not rel_path: raise WebDAVInputError('Kein Pfad angegeben.') if not rel_path.lower().endswith(BOOK_EXTENSIONS): raise WebDAVInputError('Nur .epub- und .pdf-Dateien können importiert werden.') response = _request('GET', _build_url(source, rel_path), source) return _read_capped( response, max_bytes, lambda: WebDAVInputError(f'Datei ist zu groß (max. {max_bytes // 1024 // 1024} MB).'), )