Security Nice-to-Have: Dockerfile, Magic-Bytes, Path-Traversal, TABLE_MAP, Deps
- 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
This commit is contained in:
parent
15f854d96c
commit
71e588a240
9 changed files with 100 additions and 29 deletions
10
Dockerfile
10
Dockerfile
|
|
@ -8,6 +8,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& 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)
|
# Python-Dependencies zuerst (Docker Layer Cache)
|
||||||
COPY backend/requirements.txt .
|
COPY backend/requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r 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
|
# App-Code
|
||||||
COPY backend/ .
|
COPY backend/ .
|
||||||
|
|
||||||
# Media-Verzeichnis
|
# Media-Verzeichnis mit korrekten Rechten für appuser
|
||||||
RUN mkdir -p /data/media/dogs /data/media/diary /data/media/poison
|
RUN mkdir -p /data/media/dogs /data/media/diary /data/media/poison \
|
||||||
|
&& chown -R appuser:appuser /data /app
|
||||||
|
|
||||||
|
USER appuser
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,62 @@ from typing import Tuple
|
||||||
_HEIC_EXTS = {".heic", ".heif"}
|
_HEIC_EXTS = {".heic", ".heif"}
|
||||||
_VIDEO_EXTS = {".mov", ".avi", ".m4v"}
|
_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]:
|
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."""
|
"""Convert HEIC/HEIF to JPEG; return (data, ext) unchanged for all other types."""
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
fastapi==0.115.0
|
fastapi==0.115.0
|
||||||
Pillow==11.2.1
|
Pillow==11.2.1
|
||||||
pillow-heif==0.22.0
|
pillow-heif==0.22.0
|
||||||
uvicorn[standard]==0.30.6
|
uvicorn[standard]==0.34.0
|
||||||
python-multipart==0.0.9
|
python-multipart==0.0.20
|
||||||
pydantic[email]==2.8.2
|
pydantic[email]==2.10.6
|
||||||
bcrypt==4.2.0
|
bcrypt==4.3.0
|
||||||
PyJWT==2.9.0
|
PyJWT==2.10.1
|
||||||
httpx==0.27.2
|
httpx==0.28.1
|
||||||
openai==1.50.0
|
openai==1.50.0
|
||||||
anthropic==0.34.0
|
anthropic==0.49.0
|
||||||
pywebpush==2.0.0
|
pywebpush==2.0.0
|
||||||
apscheduler==3.10.4
|
apscheduler==3.10.4
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from database import db
|
||||||
from auth import get_current_user
|
from auth import get_current_user
|
||||||
import ki as KI
|
import ki as KI
|
||||||
import httpx
|
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()
|
router = APIRouter()
|
||||||
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
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.")
|
raise HTTPException(415, "Nur Bilder, Videos und PDFs erlaubt.")
|
||||||
|
|
||||||
raw_data = await file.read()
|
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 "")
|
raw_data, ext = convert_media(raw_data, file.filename or "")
|
||||||
if not ext:
|
if not ext:
|
||||||
ext = ".jpg"
|
ext = ".jpg"
|
||||||
|
|
@ -539,9 +543,10 @@ async def delete_media_item(dog_id: int, entry_id: int, media_id: int,
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(404, "Medium nicht gefunden.")
|
raise HTTPException(404, "Medium nicht gefunden.")
|
||||||
file_path = os.path.join(MEDIA_DIR, row["url"].lstrip("/media/"))
|
file_path = safe_media_path(MEDIA_DIR, row["url"])
|
||||||
try: os.remove(file_path)
|
if file_path:
|
||||||
except OSError: pass
|
try: os.remove(file_path)
|
||||||
|
except OSError: pass
|
||||||
conn.execute("DELETE FROM diary_media WHERE id=?", (media_id,))
|
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:
|
if not row:
|
||||||
raise HTTPException(404, "Eintrag nicht gefunden.")
|
raise HTTPException(404, "Eintrag nicht gefunden.")
|
||||||
if row["media_url"]:
|
if row["media_url"]:
|
||||||
path = os.path.join(MEDIA_DIR, row["media_url"].lstrip("/media/"))
|
path = safe_media_path(MEDIA_DIR, row["media_url"])
|
||||||
try: os.remove(path)
|
if path:
|
||||||
except OSError: pass
|
try: os.remove(path)
|
||||||
|
except OSError: pass
|
||||||
conn.execute("UPDATE diary SET media_url=NULL WHERE id=?", (entry_id,))
|
conn.execute("UPDATE diary SET media_url=NULL WHERE id=?", (entry_id,))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from typing import Optional
|
||||||
from database import db
|
from database import db
|
||||||
from auth import get_current_user
|
from auth import get_current_user
|
||||||
from routes.push import send_push_to_user
|
from routes.push import send_push_to_user
|
||||||
|
from media_utils import safe_media_path
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
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:
|
if not row:
|
||||||
raise HTTPException(404, "Hund nicht gefunden.")
|
raise HTTPException(404, "Hund nicht gefunden.")
|
||||||
if row["foto_url"]:
|
if row["foto_url"]:
|
||||||
path = os.path.join(MEDIA_DIR, row["foto_url"].lstrip("/media/"))
|
path = safe_media_path(MEDIA_DIR, row["foto_url"])
|
||||||
if os.path.exists(path):
|
if path and os.path.exists(path):
|
||||||
os.remove(path)
|
os.remove(path)
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|
|
||||||
|
|
@ -476,12 +476,14 @@ async def upload_post_foto(
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# POST /api/forum/like — Toggle
|
# POST /api/forum/like — Toggle
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
_LIKE_TABLE = {'thread': 'forum_threads', 'post': 'forum_posts'}
|
||||||
|
|
||||||
@router.post("/like")
|
@router.post("/like")
|
||||||
async def toggle_like(data: LikeBody, user=Depends(get_current_user)):
|
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.")
|
raise HTTPException(400, "Ungültiger Typ.")
|
||||||
|
|
||||||
table = f"forum_{data.target_type}s"
|
table = _LIKE_TABLE[data.target_type]
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
existing = conn.execute(
|
existing = conn.execute(
|
||||||
"SELECT 1 FROM forum_likes WHERE user_id=? AND target_type=? AND target_id=?",
|
"SELECT 1 FROM forum_likes WHERE user_id=? AND target_type=? AND target_id=?",
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from database import db
|
from database import db
|
||||||
from auth import get_current_user
|
from auth import get_current_user
|
||||||
|
from media_utils import safe_media_path
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
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"]
|
datei_url = entry["datei_url"]
|
||||||
if datei_url:
|
if datei_url:
|
||||||
# datei_url z.B. "/media/health/health_42_abc12345.pdf"
|
path = safe_media_path(MEDIA_DIR, datei_url)
|
||||||
filename = datei_url.lstrip("/media/")
|
if path and os.path.isfile(path):
|
||||||
path = os.path.join(MEDIA_DIR, filename)
|
|
||||||
if os.path.isfile(path):
|
|
||||||
os.remove(path)
|
os.remove(path)
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|
@ -338,9 +337,10 @@ async def delete_media_item(dog_id: int, entry_id: int, media_id: int,
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(404, "Medium nicht gefunden.")
|
raise HTTPException(404, "Medium nicht gefunden.")
|
||||||
file_path = os.path.join(MEDIA_DIR, row["url"].lstrip("/media/"))
|
file_path = safe_media_path(MEDIA_DIR, row["url"])
|
||||||
try: os.remove(file_path)
|
if file_path:
|
||||||
except OSError: pass
|
try: os.remove(file_path)
|
||||||
|
except OSError: pass
|
||||||
conn.execute("DELETE FROM health_media WHERE id=?", (media_id,))
|
conn.execute("DELETE FROM health_media WHERE id=?", (media_id,))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
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 = (() => {
|
const App = (() => {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-Cache + Push Notifications + Tile-Cache
|
Offline-Cache + Push Notifications + Tile-Cache
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v318';
|
const CACHE_VERSION = 'by-v319';
|
||||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue