381 lines
14 KiB
Python
381 lines
14 KiB
Python
|
|
"""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 `<!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).'),
|
||
|
|
)
|