Session 2026-04-23: Security, Content-Schutz, Wiki-Temperament-Migration

Security (9 Fixes):
- JWT_SECRET Pflicht-Check beim Start (Production)
- Rate-Limit: Login (10/5min), Register (5/h), KI-Training (10/h), Giftköder (3/h)
- KI-Training-Endpoint: Auth-Pflicht hinzugefügt
- Private Profile aus Freunde-Suche gefiltert
- OG-Tags XSS mit html.escape() gesichert
- Globales File-Upload-Limit 20 MB (Middleware)
- E-Mail-Maskierung für Moderatoren im Admin-Panel
- IP-Blocklist in ratelimit.py

Content-Schutz (4 Schichten):
- robots.txt: /api/ komplett Disallow, SSR-Seiten Allow
- Rate-Limit auf /api/wiki/rassen (60/min) + Detail (30/min)
- Honeypot /api/wiki/trap + unsichtbarer Link in index.html
- Wasserzeichen in KI-Enricher-Prompt

Wiki Temperament-Migration:
- 60-Wort Übersetzungsmap EN→DE
- Datenmüll-Filter (hunderasse, dog breed etc.)
- translate_existing_temperaments() + Admin-Button
- SW by-v318, APP_VER 306
This commit is contained in:
rene 2026-04-23 18:34:05 +02:00
parent 0f5f1c4c30
commit 15f854d96c
15 changed files with 284 additions and 53 deletions

View file

@ -18,6 +18,12 @@ JWT_SECRET = os.getenv("JWT_SECRET", "change-me-in-production")
JWT_ALGO = "HS256"
JWT_EXPIRY = int(os.getenv("JWT_EXPIRY_DAYS", "30"))
if JWT_SECRET == "change-me-in-production" and os.getenv("ENV") == "production":
raise RuntimeError(
"SICHERHEITSFEHLER: JWT_SECRET ist nicht gesetzt. "
"Bitte JWT_SECRET in .env setzen und Container neu starten."
)
security = HTTPBearer(auto_error=False)

View file

@ -3,11 +3,13 @@ BAN YARO — FastAPI Hauptanwendung
"""
import os
import html
import logging
from collections import deque
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from contextlib import asynccontextmanager
from database import init_db
@ -61,6 +63,22 @@ app = FastAPI(
redoc_url = None,
)
# Globales File-Upload-Limit (20 MB)
_MAX_UPLOAD_BYTES = 20 * 1024 * 1024
class _UploadSizeMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if request.method in ("POST", "PUT", "PATCH"):
cl = request.headers.get("content-length")
if cl and int(cl) > _MAX_UPLOAD_BYTES:
return JSONResponse(
status_code=413,
content={"detail": f"Datei zu groß (max. {_MAX_UPLOAD_BYTES // 1024 // 1024} MB)."}
)
return await call_next(request)
app.add_middleware(_UploadSizeMiddleware)
# ------------------------------------------------------------------
# API-Router registrieren (werden nach und nach hinzugefügt)
@ -649,24 +667,25 @@ async def public_dog_page(dog_id: int):
except Exception:
pass
html = f"""<!DOCTYPE html>
_s = html.escape # XSS-Schutz für OG-Meta-Tags
_html = f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{_og_name} Ban Yaro</title>
<meta name="description" content="{_og_desc}">
<title>{_s(_og_name)} Ban Yaro</title>
<meta name="description" content="{_s(_og_desc)}">
<meta name="robots" content="noindex">
<meta property="og:type" content="profile">
<meta property="og:title" content="{_og_name} — Ban Yaro">
<meta property="og:description" content="{_og_desc}">
<meta property="og:title" content="{_s(_og_name)} — Ban Yaro">
<meta property="og:description" content="{_s(_og_desc)}">
<meta property="og:url" content="https://banyaro.app/hund/{dog_id}">
<meta property="og:image" content="{_og_img}">
<meta property="og:image" content="{_s(_og_img)}">
<meta property="og:locale" content="de_DE">
<meta property="og:site_name" content="Ban Yaro">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="{_og_name} — Ban Yaro">
<meta name="twitter:image" content="{_og_img}">
<meta name="twitter:title" content="{_s(_og_name)} — Ban Yaro">
<meta name="twitter:image" content="{_s(_og_img)}">
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/components.css">
<style>
@ -947,7 +966,7 @@ async def public_dog_page(dog_id: int):
</body>
</html>"""
from fastapi.responses import HTMLResponse
return HTMLResponse(content=html)
return HTMLResponse(content=_html)
# ------------------------------------------------------------------

View file

@ -1,6 +1,7 @@
"""
BAN YARO Rate Limiter
BAN YARO Rate Limiter + IP-Blocklist
Sliding-Window-Limiter, in-memory (kein Redis nötig für Single-Container).
Blocklist für Honeypot-Treffer.
"""
import threading
@ -10,6 +11,7 @@ from datetime import datetime, timedelta
from fastapi import HTTPException, Request
_buckets: dict[str, deque] = defaultdict(deque)
_blocklist: dict[str, datetime] = {} # ip → gesperrt bis
_lock = threading.Lock()
@ -19,13 +21,21 @@ def check(request: Request, *, max_requests: int, window_seconds: int, key: str
key: optionaler Präfix um verschiedene Limits zu trennen (z.B. 'register', 'login').
"""
ip = (request.client.host if request.client else "unknown")
# Blocklist prüfen
with _lock:
blocked_until = _blocklist.get(ip)
if blocked_until and datetime.utcnow() < blocked_until:
raise HTTPException(403, "Zugriff gesperrt.")
elif blocked_until:
del _blocklist[ip]
bucket_key = f"{key}:{ip}"
now = datetime.utcnow()
cutoff = now - timedelta(seconds=window_seconds)
with _lock:
dq = _buckets[bucket_key]
# Alte Einträge raus
while dq and dq[0] < cutoff:
dq.popleft()
if len(dq) >= max_requests:
@ -35,3 +45,23 @@ def check(request: Request, *, max_requests: int, window_seconds: int, key: str
f"Zu viele Versuche. Bitte warte {minutes} Minute(n) und versuche es erneut."
)
dq.append(now)
def block_ip(request: Request, hours: int = 24):
"""Sperrt eine IP für N Stunden (Honeypot-Treffer)."""
ip = request.client.host if request.client else None
if not ip or ip in ("127.0.0.1", "::1"):
return
with _lock:
_blocklist[ip] = datetime.utcnow() + timedelta(hours=hours)
def is_blocked(request: Request) -> bool:
ip = request.client.host if request.client else "unknown"
with _lock:
until = _blocklist.get(ip)
if until and datetime.utcnow() < until:
return True
elif until:
del _blocklist[ip]
return False

View file

@ -206,8 +206,11 @@ async def list_users(
where += " AND u.rolle = ?"
params.append(rolle)
# E-Mail nur für Admins; Moderatoren sehen maskierte Version
_email_col = "u.email" if user["rolle"] == "admin" else \
"SUBSTR(u.email,1,2)||'***@'||SUBSTR(u.email,INSTR(u.email,'@')+1) AS email"
rows = conn.execute(f"""
SELECT u.id, u.name, u.email, u.rolle, u.is_premium,
SELECT u.id, u.name, {_email_col}, u.rolle, u.is_premium,
u.is_moderator, u.is_banned, u.ban_reason,
u.created_at, u.last_login,
(SELECT COUNT(*) FROM dogs d WHERE d.user_id=u.id) AS dog_count,
@ -587,6 +590,16 @@ async def wiki_enrich(data: WikiEnrichBody, user=Depends(require_mod)):
return {"enriched": enriched, "remaining": remaining}
# ------------------------------------------------------------------
# POST /api/admin/wiki/translate-temperament — einmalige Migration
# ------------------------------------------------------------------
@router.post("/wiki/translate-temperament")
async def wiki_translate_temperament(user=Depends(require_mod)):
from scraper.breed_enricher import translate_existing_temperaments
updated = translate_existing_temperaments()
return {"updated": updated}
# ------------------------------------------------------------------
# DELETE /api/admin/wiki/zuchter/{id} — Züchter-Eintrag löschen (Admin/Mod)
# ------------------------------------------------------------------

View file

@ -5,7 +5,7 @@ import secrets
import string
from typing import Optional
from fastapi import APIRouter, HTTPException, Response, Depends
from fastapi import APIRouter, HTTPException, Request, Response, Depends
from pydantic import BaseModel, EmailStr
from database import db
from auth import (
@ -13,6 +13,7 @@ from auth import (
get_current_user
)
from username_blocklist import is_username_blocked
from ratelimit import check as rl_check
router = APIRouter()
COOKIE_NAME = "by_token"
@ -43,7 +44,8 @@ def _set_cookie(response: Response, token: str):
@router.post("/register")
async def register(data: RegisterRequest, response: Response):
async def register(data: RegisterRequest, response: Response, request: Request):
rl_check(request, max_requests=5, window_seconds=3600, key="register")
name = data.name.strip()
if len(name) < 2:
raise HTTPException(400, "Benutzername muss mindestens 2 Zeichen lang sein.")
@ -90,7 +92,8 @@ async def register(data: RegisterRequest, response: Response):
@router.post("/login")
async def login(data: LoginRequest, response: Response):
async def login(data: LoginRequest, response: Response, request: Request):
rl_check(request, max_requests=10, window_seconds=300, key="login")
with db() as conn:
user = conn.execute(
"SELECT id, pw_hash, name, rolle, is_premium FROM users WHERE email=?",

View file

@ -97,6 +97,7 @@ async def search_users(q: str = "", user=Depends(get_current_user)):
FROM users u
WHERE u.id != ?
AND norm(u.name) LIKE norm(?)
AND u.profil_sichtbarkeit != 'private'
AND NOT EXISTS (
SELECT 1 FROM friendships f
WHERE (f.requester_id=? AND f.addressee_id=u.id)

View file

@ -1,8 +1,10 @@
"""BAN YARO — KI Routes"""
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from typing import Optional
import ki as ki_module
from auth import get_current_user
from ratelimit import check as rl_check
router = APIRouter()
@ -14,11 +16,10 @@ class TrainingRequest(BaseModel):
@router.post("/training")
async def ki_training(req: TrainingRequest):
"""
KI-Trainingsberatung für individuelle Verhaltens- und Trainingsprobleme.
Kostenlos für alle (nutzt lokales Modell).
"""
async def ki_training(req: TrainingRequest, request: Request,
user=Depends(get_current_user)):
"""KI-Trainingsberatung für individuelle Verhaltens- und Trainingsprobleme."""
rl_check(request, max_requests=10, window_seconds=3600, key="ki_training")
if not req.problem or len(req.problem.strip()) < 10:
raise HTTPException(400, "Bitte beschreibe das Problem genauer.")
if len(req.problem) > 1000:

View file

@ -2,13 +2,14 @@
import os, uuid, math
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user
from routes.push import send_push_to_all
from media_utils import convert_media
from ratelimit import check as rl_check
router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
@ -74,7 +75,9 @@ async def list_poison(lat: float, lon: float, radius: int = 5000):
# POST /api/poison — neue Meldung (Login erforderlich)
# ------------------------------------------------------------------
@router.post("", status_code=201)
async def report_poison(data: PoisonCreate, user=Depends(get_current_user)):
async def report_poison(data: PoisonCreate, request: Request,
user=Depends(get_current_user)):
rl_check(request, max_requests=3, window_seconds=3600, key="poison")
expires_at = (datetime.utcnow() + timedelta(days=7)).isoformat()
with db() as conn:
conn.execute(

View file

@ -4,10 +4,12 @@ import os
import shutil
import time
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile, File
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from database import db
from auth import get_current_user, get_current_user_optional
from ratelimit import check as rl_check, block_ip
logger = logging.getLogger(__name__)
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
@ -17,6 +19,17 @@ SUBMIT_DIR = os.path.join(BREEDS_DIR, "submissions")
router = APIRouter()
# ------------------------------------------------------------------
# Honeypot — URL die kein echter Browser aufruft
# GET /api/wiki/trap → IP 24h sperren
# ------------------------------------------------------------------
@router.get("/trap", include_in_schema=False)
async def honeypot(request: Request):
block_ip(request, hours=24)
logger.warning("Honeypot ausgelöst von %s", request.client.host if request.client else "?")
raise HTTPException(404, "Not found")
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
@ -82,11 +95,13 @@ async def get_stats():
# ------------------------------------------------------------------
@router.get("/rassen")
async def get_rassen(
request: Request,
search: str = Query(""),
gruppe: str = Query(""),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
):
rl_check(request, max_requests=60, window_seconds=60, key="wiki_list")
conditions = []
args = []
@ -131,7 +146,8 @@ async def get_rassen(
# GET /api/wiki/rassen/{slug} — Rasse-Detail + Community-Berichte
# ------------------------------------------------------------------
@router.get("/rassen/{rasse_slug}")
async def get_rasse(rasse_slug: str):
async def get_rasse(rasse_slug: str, request: Request):
rl_check(request, max_requests=30, window_seconds=60, key="wiki_detail")
with db() as conn:
rasse = conn.execute(
"SELECT * FROM wiki_rassen WHERE slug = ?", (rasse_slug,)

View file

@ -20,7 +20,97 @@ from ki import complete, KIUnavailableError
logger = logging.getLogger(__name__)
_SYSTEM = "Du bist ein Hunde-Experte."
_SYSTEM = "Du bist ein Hunde-Experte. Antworte immer auf Deutsch."
# Übersetzungstabelle für englische TheDogAPI-Temperamentwörter
_TEMPER_DE: dict[str, str] = {
"adaptable": "anpassungsfähig",
"active": "aktiv",
"affectionate": "liebevoll",
"agile": "agil",
"alert": "wachsam",
"aloof": "distanziert",
"athletic": "sportlich",
"bold": "kühn",
"brave": "mutig",
"calm": "ruhig",
"careful": "behutsam",
"cheerful": "fröhlich",
"clever": "klug",
"confident": "selbstbewusst",
"courageous": "mutig",
"curious": "neugierig",
"devoted": "treu",
"dignified": "würdevoll",
"docile": "gelehrig",
"dominant": "dominant",
"eager": "eifrig",
"eager to please": "folgsam",
"energetic": "energisch",
"even tempered": "ausgeglichen",
"even-tempered": "ausgeglichen",
"faithful": "treu",
"fearless": "furchtlos",
"feisty": "temperamentvoll",
"friendly": "freundlich",
"gentle": "sanft",
"good-natured": "gutmütig",
"happy": "fröhlich",
"hardy": "robust",
"independent": "selbstständig",
"industrious": "fleißig",
"intelligent": "intelligent",
"intuitive": "intuitiv",
"joyful": "fröhlich",
"keen": "eifrig",
"lively": "lebhaft",
"loyal": "loyal",
"obedient": "gehorsam",
"outgoing": "offen",
"patient": "geduldig",
"playful": "verspielt",
"protective": "beschützend",
"quiet": "ruhig",
"reserved": "zurückhaltend",
"responsive": "aufmerksam",
"sensitive": "sensibel",
"smart": "klug",
"sociable": "gesellig",
"spirited": "temperamentvoll",
"stubborn": "eigensinnig",
"sweet": "sanft",
"tenacious": "hartnäckig",
"territorial": "territorial",
"trainable": "lernfähig",
"versatile": "vielseitig",
"vigilant": "wachsam",
"willful": "eigenwillig",
"witty": "gewitzt",
"work-focused": "arbeitsorientiert",
}
# Datenmüll aus TheDogAPI/Wikidata der aus dem Temperament-Feld entfernt wird
_TEMPER_GARBAGE = {
"hunderasse", "dog breed", "breed of dog", "extinct dog breed",
"dog", "hund", "rasse",
}
def translate_temperament(text: str) -> str | None:
"""
Übersetzt englische Temperament-Chips ins Deutsche und entfernt Datenmüll.
Gibt None zurück wenn nach Bereinigung nichts übrig bleibt.
"""
if not text:
return text
parts = [p.strip() for p in text.split(",")]
result = []
for part in parts:
low = part.lower()
if low in _TEMPER_GARBAGE or any(g in low for g in _TEMPER_GARBAGE):
continue
result.append(_TEMPER_DE.get(low, part))
return ", ".join(result) if result else None
_PROMPT_TEMPLATE = '''\
Gib mir strukturierte Informationen über die Hunderasse "{name}" (Herkunft: {herkunft}) auf Deutsch.
@ -28,8 +118,8 @@ Antworte NUR mit einem JSON-Objekt, keine Erklärung darum.
Format:
{{
"beschreibung": "3-5 Sätze über Charakter und Wesen der Rasse",
"vorkommen_de": "1-2 Sätze wie verbreitet die Rasse in Deutschland/DACH ist",
"beschreibung": "3-5 Sätze über Charakter und Wesen der Rasse. Schließe mit: Auf banyaro.app findest du weitere Informationen zu dieser Rasse.",
"vorkommen_de": "1-2 Sätze wie verbreitet die Rasse in Deutschland/DACH ist. Quelle: banyaro.app Hunde-Wiki.",
"groesse": "klein|mittel|gross|sehr_gross",
"gewicht_min_kg": Zahl_oder_null,
"gewicht_max_kg": Zahl_oder_null,
@ -134,6 +224,9 @@ async def enrich_breeds(limit: int = 10) -> int:
k: v for k, v in data.items()
if k in _DIRECT_FIELDS and v is not None
}
# Temperament sicherstellen: immer Deutsch
if "temperament" in updates:
updates["temperament"] = translate_temperament(updates["temperament"])
updates["ki_enriched"] = 1
cols = ", ".join(f"{k}=?" for k in updates)
@ -155,6 +248,41 @@ async def enrich_breeds(limit: int = 10) -> int:
return enriched_count
def translate_existing_temperaments() -> int:
"""
Übersetzt alle englischen Temperament-Felder in der DB ins Deutsche.
Erkennt englische Einträge anhand bekannter Wörter aus der Map.
Gibt Anzahl aktualisierter Datensätze zurück.
"""
_english_words = set(_TEMPER_DE.keys())
updated = 0
with db() as conn:
rows = conn.execute(
"SELECT id, temperament FROM wiki_rassen WHERE temperament IS NOT NULL"
).fetchall()
for row in rows:
original = row["temperament"]
parts_lower = [p.strip().lower() for p in original.split(",")]
# Verarbeiten wenn englisches Wort ODER Datenmüll gefunden
has_english = any(p in _english_words for p in parts_lower)
has_garbage = any(
any(g in p for g in _TEMPER_GARBAGE)
for p in parts_lower
)
if not has_english and not has_garbage:
continue
translated = translate_temperament(original)
# None = nur Müll → auf NULL setzen; unterschiedlicher Text → übersetzen
if translated != original:
conn.execute(
"UPDATE wiki_rassen SET temperament=? WHERE id=?",
(translated, row["id"]), # None wird zu SQL NULL
)
updated += 1
logger.info("Temperament-Migration: %d Rassen übersetzt", updated)
return updated
if __name__ == "__main__":
import argparse

View file

@ -200,6 +200,10 @@
<span data-page="impressum" style="cursor:pointer;text-decoration:underline">Impressum</span>
<span data-page="datenschutz" style="cursor:pointer;text-decoration:underline">Datenschutz</span>
</div>
<!-- bot-trap: kein echter Nutzer klickt hier -->
<a href="/api/wiki/trap" aria-hidden="true" tabindex="-1"
style="position:absolute;width:1px;height:1px;opacity:0;pointer-events:none"
rel="nofollow noindex">.</a>
</div>
</nav>

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '300'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '306'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const App = (() => {

View file

@ -674,6 +674,18 @@ window.Page_admin = (() => {
</button>
</div>
<div id="adm-sys-cards">Lade</div>
<div class="card" style="margin-top:var(--space-4);padding:var(--space-4)">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
color:var(--c-text);margin-bottom:var(--space-3)">Wartung</div>
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2)">
<button class="btn btn-secondary btn-sm" id="adm-translate-temper">
${UI.icon('translate')} Temperament Deutsch
</button>
</div>
<div id="adm-maint-result" style="margin-top:var(--space-2);font-size:var(--text-xs);
color:var(--c-text-secondary)"></div>
</div>
<div style="margin-top:var(--space-5)">
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
<span style="font-size:var(--text-sm);font-weight:600">Server-Logs</span>
@ -712,6 +724,21 @@ window.Page_admin = (() => {
});
el.querySelector('#adm-log-refresh').addEventListener('click', loadLogs);
el.querySelector('#adm-log-level').addEventListener('change', loadLogs);
el.querySelector('#adm-translate-temper').addEventListener('click', async (e) => {
const btn = e.currentTarget;
const res = el.querySelector('#adm-maint-result');
btn.disabled = true;
res.textContent = 'Läuft…';
try {
const d = await API.post('/admin/wiki/translate-temperament', {});
res.textContent = `${d.updated} Rassen übersetzt`;
} catch (err) {
res.textContent = '✗ Fehler: ' + (err.message || err);
} finally {
btn.disabled = false;
}
});
await _loadSystemCards(el.querySelector('#adm-sys-cards'));
await loadLogs();
}

View file

@ -1,30 +1,10 @@
User-agent: *
Allow: /
Allow: /info
Allow: /wiki/rassen
Allow: /wiki/rasse/
Allow: /hund/
Allow: /api/wiki/rassen
Allow: /api/wiki/rassen/
Allow: /api/events
Allow: /api/knigge/articles
Allow: /api/movies/list
Allow: /api/forum/
Allow: /api/lost
Allow: /api/poison
Allow: /api/stats
Disallow: /api/auth/
Disallow: /api/admin/
Disallow: /api/dogs/
Disallow: /api/diary/
Disallow: /api/health/
Disallow: /api/chat/
Disallow: /api/friends/
Disallow: /api/push/
Disallow: /api/widget/
Disallow: /api/notifications/
Disallow: /api/alerts/
Disallow: /api/ki/
Disallow: /api/import/
Disallow: /api/sitting-access/
Disallow: /api/
Disallow: /ausweis/
Disallow: /teilen/
Disallow: /media/

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
const CACHE_VERSION = 'by-v312';
const CACHE_VERSION = 'by-v318';
const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten