From 71e588a2405732271edc14e593cfc152629f2125 Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 23 Apr 2026 18:42:05 +0200 Subject: [PATCH] Security Nice-to-Have: Dockerfile, Magic-Bytes, Path-Traversal, TABLE_MAP, Deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dockerfile: non-root user appuser, chown /data + /app - media_utils: validate_upload() Magic-Byte-Check (JPEG/PNG/GIF/WebP/MP4/WebM) - media_utils: safe_media_path() Path-Traversal-Schutz beim Löschen - diary/health/dogs: safe_media_path() statt os.path.join + lstrip - diary: validate_upload() vor jedem Medien-Upload - forum: _LIKE_TABLE dict statt dynamischer String-Interpolation - requirements: uvicorn 0.34, PyJWT 2.10.1, pydantic 2.10.6, bcrypt 4.3, httpx 0.28.1, anthropic 0.49 - SW by-v319, APP_VER 307 --- Dockerfile | 10 +++++-- backend/media_utils.py | 56 ++++++++++++++++++++++++++++++++++++++++ backend/requirements.txt | 14 +++++----- backend/routes/diary.py | 20 +++++++++----- backend/routes/dogs.py | 5 ++-- backend/routes/forum.py | 6 +++-- backend/routes/health.py | 14 +++++----- backend/static/js/app.js | 2 +- backend/static/sw.js | 2 +- 9 files changed, 100 insertions(+), 29 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5707b44..8d22fa4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ffmpeg \ && rm -rf /var/lib/apt/lists/* +# Non-root user für sichereren Betrieb +RUN adduser --disabled-password --gecos "" appuser + # Python-Dependencies zuerst (Docker Layer Cache) COPY backend/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt @@ -15,8 +18,11 @@ RUN pip install --no-cache-dir -r requirements.txt # App-Code COPY backend/ . -# Media-Verzeichnis -RUN mkdir -p /data/media/dogs /data/media/diary /data/media/poison +# Media-Verzeichnis mit korrekten Rechten für appuser +RUN mkdir -p /data/media/dogs /data/media/diary /data/media/poison \ + && chown -R appuser:appuser /data /app + +USER appuser EXPOSE 8000 diff --git a/backend/media_utils.py b/backend/media_utils.py index cae419b..4b70300 100644 --- a/backend/media_utils.py +++ b/backend/media_utils.py @@ -7,6 +7,62 @@ from typing import Tuple _HEIC_EXTS = {".heic", ".heif"} _VIDEO_EXTS = {".mov", ".avi", ".m4v"} +# Magic-Byte-Signaturen erlaubter Medientypen +_IMAGE_MAGIC = [ + b'\xff\xd8\xff', # JPEG + b'\x89PNG\r\n', # PNG + b'GIF87a', # GIF87 + b'GIF89a', # GIF89 +] +_VIDEO_MAGIC = [ + b'\x1a\x45\xdf\xa3', # WebM / MKV +] + +def validate_upload(data: bytes, filename: str) -> None: + """ + Prüft Magic Bytes des Upload-Inhalts gegen die erwartete Dateitype. + Wirft ValueError bei Mismatch. + Bilder (JPEG/PNG/GIF) und Videos (MP4/WebM/MOV) werden geprüft. + HEIC/HEIF und reine Text-/JSON-Dateien werden übersprungen (Pillow/FFmpeg prüfen selbst). + """ + ext = os.path.splitext(filename or "")[1].lower() + if not data: + raise ValueError("Leere Datei.") + + if ext in (".jpg", ".jpeg"): + if not data[:3] == b'\xff\xd8\xff': + raise ValueError("Datei ist kein gültiges JPEG.") + elif ext == ".png": + if not data[:6] == b'\x89PNG\r\n': + raise ValueError("Datei ist kein gültiges PNG.") + elif ext in (".gif",): + if not (data[:6] in (b'GIF87a', b'GIF89a')): + raise ValueError("Datei ist kein gültiges GIF.") + elif ext in (".webp",): + if not (data[:4] == b'RIFF' and data[8:12] == b'WEBP'): + raise ValueError("Datei ist kein gültiges WebP.") + elif ext in (".mp4",): + # MP4: 4-Byte-Größe gefolgt von 'ftyp' oder 'mdat' + if not (len(data) >= 8 and data[4:8] in (b'ftyp', b'mdat', b'moov', b'free')): + raise ValueError("Datei ist kein gültiges MP4.") + elif ext in (".webm",): + if not data[:4] == b'\x1a\x45\xdf\xa3': + raise ValueError("Datei ist kein gültiges WebM.") + # HEIC, MOV, AVI, M4V: Pillow/FFmpeg prüfen beim Konvertieren + + +def safe_media_path(media_dir: str, url: str) -> str | None: + """ + Konstruiert einen sicheren Dateipfad aus einer gespeicherten URL. + Gibt None zurück wenn der Pfad außerhalb von media_dir liegt (Path-Traversal-Schutz). + """ + relative = url.lstrip("/media/").lstrip("/") + candidate = os.path.realpath(os.path.join(media_dir, relative)) + real_base = os.path.realpath(media_dir) + if not candidate.startswith(real_base + os.sep) and candidate != real_base: + return None + return candidate + def to_jpeg_if_heic(data: bytes, filename: str) -> Tuple[bytes, str]: """Convert HEIC/HEIF to JPEG; return (data, ext) unchanged for all other types.""" diff --git a/backend/requirements.txt b/backend/requirements.txt index fbcd007..27190fc 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,13 +1,13 @@ fastapi==0.115.0 Pillow==11.2.1 pillow-heif==0.22.0 -uvicorn[standard]==0.30.6 -python-multipart==0.0.9 -pydantic[email]==2.8.2 -bcrypt==4.2.0 -PyJWT==2.9.0 -httpx==0.27.2 +uvicorn[standard]==0.34.0 +python-multipart==0.0.20 +pydantic[email]==2.10.6 +bcrypt==4.3.0 +PyJWT==2.10.1 +httpx==0.28.1 openai==1.50.0 -anthropic==0.34.0 +anthropic==0.49.0 pywebpush==2.0.0 apscheduler==3.10.4 diff --git a/backend/routes/diary.py b/backend/routes/diary.py index c0afd0b..b469bbe 100644 --- a/backend/routes/diary.py +++ b/backend/routes/diary.py @@ -8,7 +8,7 @@ from database import db from auth import get_current_user import ki as KI import httpx -from media_utils import convert_media, extract_video_thumb +from media_utils import convert_media, extract_video_thumb, safe_media_path, validate_upload router = APIRouter() MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") @@ -488,6 +488,10 @@ async def upload_media(dog_id: int, entry_id: int, raise HTTPException(415, "Nur Bilder, Videos und PDFs erlaubt.") raw_data = await file.read() + try: + validate_upload(raw_data, file.filename or "") + except ValueError as e: + raise HTTPException(415, str(e)) raw_data, ext = convert_media(raw_data, file.filename or "") if not ext: ext = ".jpg" @@ -539,9 +543,10 @@ async def delete_media_item(dog_id: int, entry_id: int, media_id: int, ).fetchone() if not row: raise HTTPException(404, "Medium nicht gefunden.") - file_path = os.path.join(MEDIA_DIR, row["url"].lstrip("/media/")) - try: os.remove(file_path) - except OSError: pass + file_path = safe_media_path(MEDIA_DIR, row["url"]) + if file_path: + try: os.remove(file_path) + except OSError: pass conn.execute("DELETE FROM diary_media WHERE id=?", (media_id,)) @@ -556,9 +561,10 @@ async def delete_media_legacy(dog_id: int, entry_id: int, user=Depends(get_curre if not row: raise HTTPException(404, "Eintrag nicht gefunden.") if row["media_url"]: - path = os.path.join(MEDIA_DIR, row["media_url"].lstrip("/media/")) - try: os.remove(path) - except OSError: pass + path = safe_media_path(MEDIA_DIR, row["media_url"]) + if path: + try: os.remove(path) + except OSError: pass conn.execute("UPDATE diary SET media_url=NULL WHERE id=?", (entry_id,)) diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py index 26f07a8..c7cfd65 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -8,6 +8,7 @@ from typing import Optional from database import db from auth import get_current_user from routes.push import send_push_to_user +from media_utils import safe_media_path router = APIRouter() MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") @@ -208,8 +209,8 @@ async def delete_photo(dog_id: int, user=Depends(get_current_user)): if not row: raise HTTPException(404, "Hund nicht gefunden.") if row["foto_url"]: - path = os.path.join(MEDIA_DIR, row["foto_url"].lstrip("/media/")) - if os.path.exists(path): + path = safe_media_path(MEDIA_DIR, row["foto_url"]) + if path and os.path.exists(path): os.remove(path) with db() as conn: conn.execute( diff --git a/backend/routes/forum.py b/backend/routes/forum.py index 755f287..a7ce455 100644 --- a/backend/routes/forum.py +++ b/backend/routes/forum.py @@ -476,12 +476,14 @@ async def upload_post_foto( # ------------------------------------------------------------------ # POST /api/forum/like — Toggle # ------------------------------------------------------------------ +_LIKE_TABLE = {'thread': 'forum_threads', 'post': 'forum_posts'} + @router.post("/like") async def toggle_like(data: LikeBody, user=Depends(get_current_user)): - if data.target_type not in ('thread', 'post'): + if data.target_type not in _LIKE_TABLE: raise HTTPException(400, "Ungültiger Typ.") - table = f"forum_{data.target_type}s" + table = _LIKE_TABLE[data.target_type] with db() as conn: existing = conn.execute( "SELECT 1 FROM forum_likes WHERE user_id=? AND target_type=? AND target_id=?", diff --git a/backend/routes/health.py b/backend/routes/health.py index 185c363..2ae86ea 100644 --- a/backend/routes/health.py +++ b/backend/routes/health.py @@ -7,6 +7,7 @@ from pydantic import BaseModel from typing import Optional from database import db from auth import get_current_user +from media_utils import safe_media_path router = APIRouter() MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") @@ -219,10 +220,8 @@ async def delete_dokument(dog_id: int, entry_id: int, user=Depends(get_current_u datei_url = entry["datei_url"] if datei_url: - # datei_url z.B. "/media/health/health_42_abc12345.pdf" - filename = datei_url.lstrip("/media/") - path = os.path.join(MEDIA_DIR, filename) - if os.path.isfile(path): + path = safe_media_path(MEDIA_DIR, datei_url) + if path and os.path.isfile(path): os.remove(path) conn.execute( @@ -338,9 +337,10 @@ async def delete_media_item(dog_id: int, entry_id: int, media_id: int, ).fetchone() if not row: raise HTTPException(404, "Medium nicht gefunden.") - file_path = os.path.join(MEDIA_DIR, row["url"].lstrip("/media/")) - try: os.remove(file_path) - except OSError: pass + file_path = safe_media_path(MEDIA_DIR, row["url"]) + if file_path: + try: os.remove(file_path) + except OSError: pass conn.execute("DELETE FROM health_media WHERE id=?", (media_id,)) diff --git a/backend/static/js/app.js b/backend/static/js/app.js index a36b71e..da4c2ba 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '306'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '307'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const App = (() => { diff --git a/backend/static/sw.js b/backend/static/sw.js index 19c14f5..e3b1022 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v318'; +const CACHE_VERSION = 'by-v319'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten