diora-web/books/webdav.py
marwin b205625e21
All checks were successful
Build and push Docker image / build (push) Successful in 13s
Test / test (push) Successful in 52s
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

89 lines
3.1 KiB
Python

"""Cloud import endpoints for the ebook library.
These let the browser browse a user-configured WebDAV server and pull one book
out of it. The fetch is proxied through the server rather than done in the
browser because the WebDAV host is a different origin and will not send CORS
headers for it.
That proxy is a deliberate, narrow relaxation of the same rule the ISBN lookup
in books/views.py bends: the server sees the file's bytes in memory for the
duration of one request, but never persists them. The book is encrypted in the
browser and only then POSTed to /books/upload/ as ciphertext, exactly like a
local upload — nothing readable is ever stored server-side.
"""
from django.conf import settings
from django.http import HttpResponse, JsonResponse
from django.utils import timezone
from django.views.decorators.http import require_http_methods
from accounts.models import WebDAVSource
from accounts.webdav import WebDAVError, WebDAVInputError, list_directory, fetch_file
from .views import _require_auth
def _get_source(request, pk):
return WebDAVSource.objects.filter(pk=pk, user=request.user).first()
@require_http_methods(['GET'])
def cloud_sources(request):
unauthorized = _require_auth(request)
if unauthorized:
return unauthorized
sources = request.user.webdav_sources.all()
return JsonResponse({'sources': [
{'id': s.id, 'label': s.label, 'host': s.base_url}
for s in sources
]})
@require_http_methods(['GET'])
def cloud_browse(request, pk):
unauthorized = _require_auth(request)
if unauthorized:
return unauthorized
source = _get_source(request, pk)
if not source:
return JsonResponse({'error': 'unknown source'}, status=404)
path = request.GET.get('path', '')
try:
entries = list_directory(source, path)
except WebDAVInputError as exc:
return JsonResponse({'error': str(exc)}, status=400)
except WebDAVError as exc:
return JsonResponse({'error': str(exc)}, status=502)
source.last_used_at = timezone.now()
source.save(update_fields=['last_used_at'])
return JsonResponse({'ok': True, 'path': path, 'entries': entries})
@require_http_methods(['GET'])
def cloud_fetch(request, pk):
unauthorized = _require_auth(request)
if unauthorized:
return unauthorized
source = _get_source(request, pk)
if not source:
return JsonResponse({'error': 'unknown source'}, status=404)
path = request.GET.get('path', '')
try:
payload = fetch_file(source, path, settings.EBOOK_MAX_BYTES)
except WebDAVInputError as exc:
return JsonResponse({'error': str(exc)}, status=400)
except WebDAVError as exc:
return JsonResponse({'error': str(exc)}, status=502)
response = HttpResponse(payload, content_type='application/octet-stream')
# These are the book's plaintext bytes, on their way to be encrypted in the
# browser. Keeping them out of the HTTP disk cache (and any reverse proxy
# in front) is what makes "nothing readable is stored" true end to end.
response['Cache-Control'] = 'no-store'
return response