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

@ -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,)