diora-web/accounts/webdav.py

381 lines
14 KiB
Python
Raw Normal View History

Bücher: Cloud-Import aus WebDAV/Nextcloud (SW v41) Verbindungen werden pro Nutzer in den Einstellungen angelegt (Nextcloud, ownCloud, Synology oder generisches WebDAV) — der Server ist bewusst nicht auf eine feste Instanz verdrahtet. Im Bücher-Tab lässt sich der entfernte Ordner durchblättern und eine .epub/.pdf direkt in die Bibliothek ziehen. Der Download läuft über den Server, weil der WebDAV-Host cross-origin ist und keine CORS-Header schickt. Verschlüsselt wird trotzdem erst im Browser: uploadEbook ist in _importEbookBuffer aufgeteilt, das sich lokaler Upload und Cloud-Import teilen. Gespeichert wird wie bisher nur Geheimtext. Weil jeder registrierte Nutzer die Ziel-URL bestimmt und diora im Docker-Netz neben anderen Diensten läuft, ist der Import eine SSRF-Fläche. Dagegen: - assert_safe_url weist Hosts ab, die auf nicht-öffentliche Adressen auflösen (inkl. NAT64 und IPv4-kompatibler v6-Adressen, die is_global durchlässt) - _assert_peer_is_safe prüft die tatsächliche Peer-Adresse nach dem Verbinden — requests löst den Namen ein zweites Mal auf, sonst wäre der Guard per DNS-Rebinding umgehbar - Redirects werden abgelehnt statt verfolgt - identische Fehlermeldung für "nicht auflösbar" und "privat", ohne die IP zu nennen, damit der Endpunkt kein Scanner für interne Dienste wird Antwort-Bodies laufen durch _read_capped, und DTDs werden vor dem Parsen abgewiesen: ElementTree expandiert interne Entities, und seit Python 3.12 gibt es XMLParser.parser nicht mehr, um einen Handler zu setzen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:26:22 +00:00
"""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 = (
'<?xml version="1.0" encoding="utf-8"?>'
'<d:propfind xmlns:d="DAV:"><d:prop>'
'<d:resourcetype/><d:getcontentlength/><d:getlastmodified/>'
'</d:prop></d:propfind>'
)
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 `&lt;!DOCTYPE`, so these bytes can only ever be markup.
"""
lowered = payload.lower()
if b'<!doctype' in lowered or b'<!entity' in lowered:
raise WebDAVError('Die Antwort enthält eine DTD und wurde abgelehnt.')
try:
return ET.fromstring(payload)
except (ET.ParseError, ValueError):
raise WebDAVError('Unerwartete Antwort — ist das wirklich eine WebDAV-URL?')
def _select_prop(node):
"""Return the <prop> block that actually applies.
RFC 4918 lets a server split its answer across several <propstat> 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).'),
)