Close medium exposure on reports, registration, uploads, and exercise HTML.
Some checks failed
Deploy Development / deploy (push) Successful in 59s
Test Suite / pytest-backend (push) Failing after 47s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 17s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Failing after 7m48s

A superadmin can also turn off another account''s two-factor when the authenticator is lost.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-10-13 16:47:57 +02:00
parent 5ea225ec6b
commit f10c28e5cd
20 changed files with 480 additions and 247 deletions

View File

@ -7,6 +7,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
| profiles | `GET /api/profiles/me` | ja | `resolve_tenant_context` inline (`invalid_header_policy=ignore`) | teils | + `effective_club_id`; veralteter Header bricht Refresh nicht |
| profiles | `GET /api/profiles`, `GET /profiles/{pid}`, `POST /profiles`, `DELETE /profiles/{pid}` | ja/teils | `require_auth` | ja | Liste nur Plattform-Admin; GET nach ID eigenes Profil oder Admin; POST/DELETE nur Admin |
| profiles | `PUT /api/profiles/{id}`, `PUT /api/profile` | ja | `get_tenant_context` | `active_club_id` Mitgliedschaft | Validiert `X-Active-Club-Id` konsistent zu Mitgliedschaft |
| profiles | `POST /api/profiles/{id}/totp-reset` | Plattform | `get_tenant_context` | nur `superadmin`, nicht das eigene Konto | Schaltet TOTP ab, löscht Wiederherstellungscodes, beendet Sitzungen |
| clubs | geschützte `/api/clubs*`, `/divisions*`, `/groups*` | ja | `get_tenant_context` | Mitgliedschaft / `can_manage_*` | Öffentlich: `/clubs/public-directory` ohne Auth |
| club_memberships | `/clubs/{id}/members*` | ja | `get_tenant_context` | ja | |
| club_join_requests | `/me/club-join-requests`, `/clubs/{id}/join-requests*` | ja | `get_tenant_context` | ja | |

View File

@ -8,7 +8,9 @@
from __future__ import annotations
import re
from html.parser import HTMLParser
from typing import FrozenSet, Optional, Set
from html import escape as html_escape
from fastapi import HTTPException
@ -40,7 +42,126 @@ def normalize_inline_exercise_media_markup(html: Optional[str]) -> Optional[str]
f'class="shinkan-inline-media"></span>'
)
return _BRACE_PATTERN.sub(_repl, html)
return sanitize_exercise_rich_html(_BRACE_PATTERN.sub(_repl, html))
_ALLOWED_RICH_TAGS = frozenset(
{"p", "div", "br", "ul", "ol", "li", "h3", "b", "strong", "i", "em", "u", "span", "a"}
)
_DROP_WITH_CHILDREN = frozenset(
{"script", "iframe", "object", "embed", "style", "link", "meta", "svg", "math"}
)
_VOID_TAGS = frozenset({"br"})
_MEDIA_SIZES = frozenset({"small", "medium", "full"})
_CAPTION_MAX = 120
def _clean_caption(raw: str) -> str:
text = re.sub(r"[\u0000-\u001f\u007f]", " ", raw or "")
text = re.sub(r"[\"'`<>]", " ", text)
text = re.sub(r"\s+", " ", text).strip()
return text[:_CAPTION_MAX]
def _safe_rich_attrs(tag: str, attrs: list[tuple[str, Optional[str]]]) -> list[tuple[str, str]]:
values = {}
for name, value in attrs:
if not name:
continue
values[name.lower()] = "" if value is None else str(value)
if tag == "a":
href = values.get("href", "").strip()
if href.lower().startswith(("http://", "https://")) and not re.search(r"[\u0000-\u001f\u007f]", href):
return [("href", href)]
return []
if tag != "span":
return []
media_id = values.get("data-shinkan-exercise-media", "").strip()
if not re.fullmatch(r"\d+", media_id):
return []
out = [("data-shinkan-exercise-media", media_id)]
size = values.get("data-shinkan-exercise-media-size", "").strip().lower()
if size in _MEDIA_SIZES:
out.append(("data-shinkan-exercise-media-size", size))
caption = _clean_caption(values.get("data-shinkan-exercise-media-caption", ""))
if caption:
out.append(("data-shinkan-exercise-media-caption", caption))
classes = [c for c in values.get("class", "").split() if c == "shinkan-inline-media"]
if classes:
out.append(("class", "shinkan-inline-media"))
return out
class _RichHtmlSanitizer(HTMLParser):
"""Baut erlaubtes Übungs-HTML neu auf. Unbekannte Tags fallen weg, ihr Text bleibt."""
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.parts: list[str] = []
self._drop_depth = 0
self._stack: list[str] = []
def handle_starttag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None:
self._start(tag.lower(), attrs, void=False)
def handle_startendtag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None:
self._start(tag.lower(), attrs, void=True)
def _start(self, tag: str, attrs: list[tuple[str, Optional[str]]], *, void: bool) -> None:
if self._drop_depth:
if not void and tag not in _VOID_TAGS:
self._drop_depth += 1
return
if tag in _DROP_WITH_CHILDREN:
if not void:
self._drop_depth = 1
return
if tag not in _ALLOWED_RICH_TAGS:
return
safe = _safe_rich_attrs(tag, attrs)
rendered = tag
if safe:
rendered += "".join(f' {name}="{html_escape(value, quote=True)}"' for name, value in safe)
if void or tag in _VOID_TAGS:
self.parts.append(f"<{rendered}>")
return
self._stack.append(tag)
self.parts.append(f"<{rendered}>")
def handle_endtag(self, tag: str) -> None:
tag = tag.lower()
if self._drop_depth:
if tag in _DROP_WITH_CHILDREN or self._drop_depth > 1:
self._drop_depth = max(0, self._drop_depth - 1)
return
if self._stack and self._stack[-1] == tag:
self._stack.pop()
self.parts.append(f"</{tag}>")
def handle_data(self, data: str) -> None:
if self._drop_depth or not data:
return
self.parts.append(html_escape(data))
def handle_entityref(self, name: str) -> None:
self.handle_data(f"&{name};")
def handle_charref(self, name: str) -> None:
self.handle_data(f"&#{name};")
def sanitize_exercise_rich_html(html: Optional[str]) -> Optional[str]:
"""Allowlist für gespeichertes Übungs-HTML. Skripte, Handler und fremde Attribute fallen weg."""
if html is None:
return None
if not isinstance(html, str):
html = str(html)
if not html.strip():
return html
parser = _RichHtmlSanitizer()
parser.feed(html)
parser.close()
return "".join(parser.parts)
def collect_inline_exercise_media_ids(html: Optional[str]) -> Set[int]:

View File

@ -41,7 +41,10 @@ class RegisterRequest(BaseModel):
requested_club_id: Optional[int] = Field(default=None, ge=1)
class PasswordResetRequest(BaseModel):
email: str
email: EmailStr
class ResendVerificationRequest(BaseModel):
email: EmailStr
class PasswordResetConfirm(BaseModel):
token: str

View File

@ -53,6 +53,7 @@ Neues Passwort setzen:
Der Link ist 1 Stunde gültig. Erst wenn du ihn nutzt und ein neues Passwort wählst, wird dein bestehendes
Passwort ersetzt — bis dahin kannst du dich wie gewohnt anmelden.
Der Link meldet dich nicht an und schaltet die Zwei-Faktor-Anmeldung nicht ab.
Falls du diese Anfrage nicht erwartest, ignoriere diese E-Mail; dein Zugang bleibt unverändert.

View File

@ -13,7 +13,7 @@ router = APIRouter(prefix="/api/admin", tags=["admin_users"])
_SAFE_PROFILE_COLS = """
id, name, email, role, tier, email_verified, active_club_id,
created_at, updated_at, auth_type
created_at, updated_at, auth_type, totp_enabled
"""

View File

@ -38,6 +38,7 @@ from models import (
PasswordResetRequest,
PasswordResetConfirm,
RegisterRequest,
ResendVerificationRequest,
TotpCodeRequest,
TotpDisableRequest,
)
@ -643,55 +644,76 @@ async def register(req: RegisterRequest, request: Request):
with get_db() as conn:
cur = get_cursor(conn)
# Check if email already exists
cur.execute("SELECT id FROM profiles WHERE email=%s", (email,))
if cur.fetchone():
raise HTTPException(400, "E-Mail-Adresse bereits registriert")
cur.execute("SELECT id, name FROM profiles WHERE email=%s", (email,))
existing = cur.fetchone()
if existing:
existing_name = existing.get("name")
else:
existing_name = None
# Generate verification token
verification_token = secrets.token_urlsafe(32)
verification_expires = datetime.now(timezone.utc) + timedelta(hours=24)
if existing is None:
# Generate verification token
verification_token = secrets.token_urlsafe(32)
verification_expires = datetime.now(timezone.utc) + timedelta(hours=24)
# Rolle: erster Nutzer oder ADMIN_BOOTSTRAP_EMAILS → superadmin
role = registration_role(cur, email)
# Rolle: erster Nutzer oder ADMIN_BOOTSTRAP_EMAILS → superadmin
role = registration_role(cur, email)
# Create profile (inactive until verified) — profiles.id ist SERIAL (INT), keine String-IDs einfügen.
pin_hash = hash_pin(password)
trial_ends = datetime.now(timezone.utc) + timedelta(days=14) # 14-day trial
# Create profile (inactive until verified) — profiles.id ist SERIAL (INT), keine String-IDs einfügen.
pin_hash = hash_pin(password)
trial_ends = datetime.now(timezone.utc) + timedelta(days=14) # 14-day trial
cur.execute("""
INSERT INTO profiles (
name, email, pin_hash, auth_type, role, tier,
email_verified, verification_token, verification_expires,
trial_ends_at, created_at
) VALUES (%s, %s, %s, 'email', %s, 'free', FALSE, %s, %s, %s, CURRENT_TIMESTAMP)
RETURNING id
""", (name, email, pin_hash, role, verification_token, verification_expires, trial_ends))
new_profile_id = cur.fetchone()["id"]
cur.execute("""
INSERT INTO profiles (
name, email, pin_hash, auth_type, role, tier,
email_verified, verification_token, verification_expires,
trial_ends_at, created_at
) VALUES (%s, %s, %s, 'email', %s, 'free', FALSE, %s, %s, %s, CURRENT_TIMESTAMP)
RETURNING id
""", (name, email, pin_hash, role, verification_token, verification_expires, trial_ends))
new_profile_id = cur.fetchone()["id"]
req_club = req.requested_club_id
if req_club is not None:
cur.execute(
"SELECT id FROM clubs WHERE id = %s AND status = 'active'",
(int(req_club),),
)
if cur.fetchone():
req_club = req.requested_club_id
if req_club is not None:
cur.execute(
"""
SELECT id FROM club_membership_requests
WHERE profile_id = %s AND club_id = %s AND status = 'pending'
LIMIT 1
""",
(new_profile_id, int(req_club)),
"SELECT id FROM clubs WHERE id = %s AND status = 'active'",
(int(req_club),),
)
if not cur.fetchone():
if cur.fetchone():
cur.execute(
"""
INSERT INTO club_membership_requests (profile_id, club_id, status, message)
VALUES (%s, %s, 'pending', NULL)
SELECT id FROM club_membership_requests
WHERE profile_id = %s AND club_id = %s AND status = 'pending'
LIMIT 1
""",
(new_profile_id, int(req_club)),
)
if not cur.fetchone():
cur.execute(
"""
INSERT INTO club_membership_requests (profile_id, club_id, status, message)
VALUES (%s, %s, 'pending', NULL)
""",
(new_profile_id, int(req_club)),
)
generic = {
"ok": True,
"message": "Registrierung erfolgreich! Bitte prüfe dein E-Mail-Postfach und bestätige deine E-Mail-Adresse.",
}
if existing is not None:
send_email(
email,
"Shinkan Jinkendo – Registrierung",
f"""Hallo {existing_name or name},
für diese E-Mail-Adresse gibt es bereits ein Konto. Es wurde kein neues Passwort gesetzt.
Wenn du den Zugang brauchst, melde dich an oder fordere ein neues Passwort an.
Dein Shinkan Jinkendo Team
""",
)
return generic
verify_url = verification_link(verification_token)
@ -711,10 +733,7 @@ Dein Shinkan Jinkendo Team
if not send_email(email, "Shinkan Jinkendo – E-Mail bestätigen", email_body):
print("[SMTP] Verifizierungs-Mail konnte nicht gesendet werden — Logs prüfen, SMTP_* in der Laufzeitumgebung.")
return {
"ok": True,
"message": "Registrierung erfolgreich! Bitte prüfe dein E-Mail-Postfach und bestätige deine E-Mail-Adresse."
}
return generic
@router.get("/verify/{token}")
@ -773,44 +792,34 @@ async def verify_email(token: str):
@router.post("/resend-verification")
@limiter.limit("3/hour")
async def resend_verification(req: dict, request: Request):
"""Resend verification email for unverified account."""
email = req.get('email', '').strip().lower()
if not email:
raise HTTPException(400, "E-Mail-Adresse erforderlich")
async def resend_verification(req: ResendVerificationRequest, request: Request):
"""Bestätigung erneut senden. Bekannte und unbekannte Adressen antworten gleich."""
email = str(req.email).lower().strip()
generic = {
"ok": True,
"message": "Falls ein Account mit dieser E-Mail existiert, wurde eine Bestätigungs-E-Mail versendet.",
}
with get_db() as conn:
cur = get_cursor(conn)
# Find profile by email
cur.execute("""
SELECT id, name, email, email_verified, verification_token, verification_expires
FROM profiles
WHERE email=%s
""", (email,))
prof = cur.fetchone()
if not prof or prof["email_verified"]:
return generic
if not prof:
# Don't leak info about existing emails
return {"ok": True, "message": "Falls ein Account mit dieser E-Mail existiert, wurde eine Bestätigungs-E-Mail versendet."}
if prof['email_verified']:
raise HTTPException(400, "E-Mail-Adresse bereits bestätigt")
# Generate new verification token
verification_token = secrets.token_urlsafe(32)
verification_expires = datetime.now(timezone.utc) + timedelta(hours=24)
cur.execute("""
UPDATE profiles
SET verification_token=%s, verification_expires=%s
WHERE id=%s
""", (verification_token, verification_expires, prof['id']))
""", (verification_token, verification_expires, prof["id"]))
verify_url = verification_link(verification_token)
email_body = f"""Hallo {prof['name']},
du hast eine neue Bestätigungs-E-Mail angefordert.
@ -823,8 +832,5 @@ Dieser Link ist 24 Stunden gültig.
Dein Shinkan Jinkendo Team
"""
if not send_email(email, "Shinkan Jinkendo – E-Mail bestätigen", email_body):
raise HTTPException(502, "E-Mail konnte nicht versendet werden. SMTP-Einstellungen und Container-Logs prüfen.")
return {"ok": True, "message": "Bestätigungs-E-Mail wurde erneut versendet."}
send_email(email, "Shinkan Jinkendo – E-Mail bestätigen", email_body)
return generic

View File

@ -28,11 +28,12 @@ import os
import re
from typing import Optional
from fastapi import APIRouter, Depends, Header, HTTPException
from fastapi import APIRouter, Depends, Header, HTTPException, Request
from pydantic import BaseModel, Field, field_validator
from club_tenancy import is_platform_admin, is_superadmin
from db import get_db, get_cursor, r2d
from routers.auth import limiter
from media_legal_hold import (
LEGAL_HOLD_REASON_CODES,
assert_superadmin_for_legal_hold,
@ -432,8 +433,17 @@ def _report_row_to_dict(row) -> dict:
# ─── Endpoints ──────────────────────────────────────────────────────────────
def _content_report_rate_marker(request: Request) -> None:
"""SlowAPI-Ziel für 10 Meldungen pro Stunde. Nicht als Route: der Decorator bricht die Annotationen."""
return None
limiter.limit("10/hour")(_content_report_rate_marker)
@router.post("/content-reports", status_code=201)
def submit_content_report(
request: Request,
body: ContentReportCreate,
x_auth_token: Optional[str] = Header(default=None),
):
@ -446,6 +456,7 @@ def submit_content_report(
- Journaleintrag im Medien-Audit-Log (wenn target_type=media_asset)
- E-Mail-Bestaetigung an Melder + Benachrichtigung an Plattform-Admins (best-effort)
"""
limiter._check_request_limit(request, _content_report_rate_marker, False)
if not body.good_faith_confirmed:
raise HTTPException(
status_code=400,

View File

@ -163,24 +163,9 @@ ALLOWED_UPLOAD_MIMES = frozenset(
}
)
# Dateiendung → MIME wenn der Client keinen sinnvollen Content-Type sendet (häufig mobil / iOS).
_UPLOAD_FILENAME_MIME_FALLBACK = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".heic": "image/heic",
".heif": "image/heif",
".mp4": "video/mp4",
".mov": "video/quicktime",
".pdf": "application/pdf",
}
def _sniff_allowed_upload_mime(raw: bytes) -> Optional[str]:
"""Erkennt erlaubte MIME-Typen anhand weniger Magic Bytes (ohne Pillow/python-magic)."""
if len(raw) < 12:
return None
if raw[:3] == b"\xff\xd8\xff":
return "image/jpeg"
if raw[:8] == b"\x89PNG\r\n\x1a\n":
@ -189,7 +174,7 @@ def _sniff_allowed_upload_mime(raw: bytes) -> Optional[str]:
return "image/gif"
if raw[:4] == b"%PDF":
return "application/pdf"
if raw[4:8] != b"ftyp":
if len(raw) < 12 or raw[4:8] != b"ftyp":
return None
brand = raw[8:12]
# HEIC / HEIF (u. a. iPhone „高效率“)
@ -208,17 +193,11 @@ def resolve_upload_mime_type(
content_type: Optional[str],
filename: Optional[str],
) -> str:
"""Ermittelt ein erlaubtes MIME (Client-Header, Magic Bytes oder Dateiendung)."""
ct = (content_type or "").split(";")[0].strip().lower()
if ct in ALLOWED_UPLOAD_MIMES:
return ct
"""MIME nur aus dem Dateiinhalt. Client-Header und Dateiendung entscheiden nicht."""
guessed = _sniff_allowed_upload_mime(raw)
if guessed in ALLOWED_UPLOAD_MIMES:
return guessed
ext = Path(filename or "").suffix.lower()
fb = _UPLOAD_FILENAME_MIME_FALLBACK.get(ext)
if fb in ALLOWED_UPLOAD_MIMES:
return fb
ct = (content_type or "").split(";")[0].strip().lower()
raise ValueError(f"Dateityp nicht erlaubt: {ct or 'unbekannt'}")
@ -3083,16 +3062,28 @@ def delete_exercise_variant(
return {"ok": True}
def _content_disposition_inline(filename: Optional[str]) -> str:
"""Inline-Darstellung im Browser (<video>/<img>), keine attachment-Download-Leiste."""
def _safe_download_name(filename: Optional[str]) -> str:
"""Dateiname ohne Steuerzeichen und ohne Anführungszeichen für Content-Disposition."""
if not filename:
return "inline"
fn = str(filename).strip()
return ""
cleaned = []
for ch in str(filename):
if ch < " " or ch == "\x7f" or ch in {'"', "\\", ";"}:
continue
cleaned.append(ch)
return "".join(cleaned).strip()[:180]
def _content_disposition(filename: Optional[str], *, attachment: bool) -> str:
"""Bilder und Videos inline. PDF als Anhang. Dateiname ohne Steuerzeichen."""
disposition = "attachment" if attachment else "inline"
fn = _safe_download_name(filename)
if not fn:
return disposition
quoted = quote(fn, safe="")
escaped = fn.replace('"', "\\")
if quoted != fn:
return f"inline; filename*=utf-8''{quoted}"
return f'inline; filename="{escaped}"'
return f"{disposition}; filename*=utf-8''{quoted}"
return f'{disposition}; filename="{fn}"'
def _parse_bytes_range_single(range_header: str, file_size: int) -> Optional[Tuple[int, int]]:
@ -3152,7 +3143,10 @@ def _binary_media_response(
path_str = str(path_arg.resolve())
stat_r = os.stat(path_str)
file_size = int(stat_r.st_size)
cd_val = _content_disposition_inline(download_name)
cd_val = _content_disposition(
download_name,
attachment=(mime_type or "").split(";")[0].strip().lower() == "application/pdf",
)
base_h = {"accept-ranges": "bytes", "content-disposition": cd_val}
if request.method.upper() == "HEAD":

View File

@ -283,6 +283,39 @@ def management_password_reset(
return {"ok": True, "mode": "email", "email_sent": bool(sent)}
@router.post("/profiles/{pid}/totp-reset")
def management_totp_reset(pid: str, tenant: TenantContext = Depends(get_tenant_context)):
"""Superadmin schaltet Zwei-Faktor ab, wenn der Nutzer den Authenticator nicht mehr hat."""
if not is_superadmin(tenant.global_role or ""):
raise HTTPException(status_code=403, detail="Nur Super-Admins")
try:
target = int(pid)
except ValueError:
raise HTTPException(status_code=400, detail="Ungültige Profil-ID")
if target == int(tenant.profile_id):
raise HTTPException(status_code=400, detail="Eigenen Zwei-Faktor unter Einstellungen ändern")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT id, totp_enabled FROM profiles WHERE id=%s", (target,))
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Profil nicht gefunden")
if not row.get("totp_enabled"):
return {"ok": True, "totp_enabled": False, "changed": False}
cur.execute(
"""
UPDATE profiles
SET totp_enabled=FALSE, totp_secret=NULL, totp_pending_secret=NULL, updated_at=NOW()
WHERE id=%s
""",
(target,),
)
cur.execute("DELETE FROM profile_recovery_codes WHERE profile_id=%s", (target,))
revoke_profile_sessions(cur, target)
return {"ok": True, "totp_enabled": False, "changed": True}
@router.get("/profiles/{pid}")
def get_profile(pid: str, session=Depends(require_auth)):
"""Profil nach ID — nur eigenes Profil oder Plattform-Admin (wie PUT)."""

View File

@ -43,6 +43,41 @@ def test_change_password_requires_current_password() -> None:
ChangePasswordRequest.model_validate({"pin": "neuespasswort"})
def test_password_reset_rejects_header_injection() -> None:
from pydantic import ValidationError
from models import PasswordResetRequest, ResendVerificationRequest
with pytest.raises(ValidationError):
PasswordResetRequest.model_validate({"email": "trainer@example.com\nBcc: x@y.z"})
with pytest.raises(ValidationError):
ResendVerificationRequest.model_validate({"email": "nicht-eine-email"})
def test_upload_mime_comes_from_bytes_not_client_header() -> None:
from routers.exercises import _content_disposition, resolve_upload_mime_type
jpeg = b"\xff\xd8\xff" + b"\x00" * 16
assert resolve_upload_mime_type(jpeg, "text/html", "evil.html") == "image/jpeg"
pdf = b"%PDF-1.4\n" + b"0" * 8
assert resolve_upload_mime_type(pdf, "image/png", "bild.png") == "application/pdf"
try:
resolve_upload_mime_type(b"<html><script>alert(1)</script></html>", "image/jpeg", "x.jpg")
raised = False
except ValueError:
raised = True
assert raised
disposition = _content_disposition('bericht.pdf\r\nX-Evil: 1', attachment=True)
assert disposition.startswith("attachment")
assert "\r" not in disposition
assert "\n" not in disposition
def test_totp_reset_requires_auth(client: TestClient) -> None:
response = client.post("/api/profiles/1/totp-reset")
assert response.status_code == 401
def test_login_rejects_oversized_password(client: TestClient) -> None:
response = client.post(
"/api/auth/login",

View File

@ -10,11 +10,31 @@ from exercise_rich_text import (
assert_no_inline_media_references_on_create,
collect_inline_exercise_media_ids,
normalize_inline_exercise_media_markup,
sanitize_exercise_rich_html,
validate_inline_exercise_media_ids_for_exercise,
)
def test_normalize_curly_to_span() -> None:
def test_normalize_strips_script_and_handlers() -> None:
raw = '<p onclick="alert(1)">Hallo <script>alert(1)</script></p><a href="javascript:alert(1)">Link</a>'
out = normalize_inline_exercise_media_markup(raw)
assert out is not None
lowered = out.lower()
assert "script" not in lowered
assert "onclick" not in lowered
assert "javascript:" not in lowered
assert "Hallo" in out
assert "Link" in out
def test_normalize_keeps_inline_media_span() -> None:
raw = '<span class="shinkan-inline-media evil" data-shinkan-exercise-media="7" data-shinkan-exercise-media-size="full">x</span>'
out = sanitize_exercise_rich_html(raw)
assert out is not None
assert 'data-shinkan-exercise-media="7"' in out
assert 'data-shinkan-exercise-media-size="full"' in out
assert "shinkan-inline-media" in out
assert "evil" not in out
s = '<p>Vor {{exerciseMedia: 42 }} nach</p>'
out = normalize_inline_exercise_media_markup(s)
assert 'data-shinkan-exercise-media="42"' in out

View File

@ -1,13 +1,13 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.241"
APP_VERSION = "0.8.243"
BUILD_DATE = "2026-10-13"
DB_SCHEMA_VERSION = "20261012096"
MODULE_VERSIONS = {
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
"auth": "1.3.2", # TOTP-Einrichtung zeigt einen QR-Code zum Scannen
"profiles": "1.8.1", # GET /profiles/me: account_state + club_roles
"auth": "1.3.3", # Registrierung und Bestätigung antworten gleich; Reset-Mail nur mit gültiger E-Mail
"profiles": "1.8.2", # Superadmin: POST /profiles/{id}/totp-reset
"tenant_context": "1.1.0", # M3: account_state + email_verified im TenantContext
"capabilities": "1.2.0", # Registry-first: module-Spalte; Admin nur registrierte Rechte
"rights_registry": "1.0.0", # register_capability/feature + startup sync
@ -16,7 +16,7 @@ MODULE_VERSIONS = {
"club_memberships": "1.0.1", # Depends(get_tenant_context)
"club_join_requests": "1.0.1", # Depends(get_tenant_context)
"club_creation_requests": "1.0.1", # superseded wenn freigegebener Verein gelöscht
"admin_users": "1.0.0", # GET /api/admin/users
"admin_users": "1.0.1", # GET /api/admin/users inkl. totp_enabled
"club_features": "1.6.0",
"admin_rights": "1.1.0", # Matrix UX, Enforcement-Audit, clear club grants by capability
"capability_enforcement_audit": "1.0.0",
@ -37,7 +37,7 @@ MODULE_VERSIONS = {
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
"methods": "0.1.0",
"exercises": "2.37.1", # KI-Endpoints: feature_usage nach ai_calls consume
"exercises": "2.37.2", # Upload-MIME nur aus Magic Bytes; PDF als Anhang; Rich-Text-Allowlist beim Speichern
"planning_exercise_suggest": "0.23.8", # Progressionsgraph: Katalog-Kontext (Fokus/Stil/TT/ZG) im Match-Profil
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
"training_programs": "0.1.0",
@ -49,10 +49,28 @@ MODULE_VERSIONS = {
"membership": "1.0.0",
"catalogs": "1.5.0", # Updated: Trainer Contexts API (Migration 012)
"maturity_models": "1.4.0", # matrix_stack_bundle: vollständiger Katalog+Modelle+Bindings Export/Import
"content_reports": "1.5.1", # P-13: Fruehzeitige 403 fuer plain Admin in set_legal_hold_from_report (CI-Fix)
"content_reports": "1.5.2", # POST /content-reports: 10 Meldungen pro Stunde und IP
}
CHANGELOG = [
{
"version": "0.8.243",
"date": "2026-10-13",
"changes": [
"Übungstexte werden beim Speichern und in der Anzeige auf eine feste HTML-Allowlist gekürzt.",
"Uploads: der Dateityp kommt aus dem Inhalt. PDF wird als Anhang ausgeliefert.",
"Registrierung und erneute Bestätigung verraten nicht, ob die E-Mail existiert. Passwort-Reset verlangt eine gültige Adresse.",
"Anonyme und angemeldete Inhaltsmeldungen sind auf 10 pro Stunde und IP begrenzt.",
],
},
{
"version": "0.8.242",
"date": "2026-10-13",
"changes": [
"Superadmin kann die Zwei-Faktor-Anmeldung eines anderen Kontos aufheben, wenn der Authenticator verloren ist.",
"Passwort-Reset ändert nur das Passwort. Zwei-Faktor bleibt aktiv, der Link meldet nicht an.",
],
},
{
"version": "0.8.241",
"date": "2026-10-13",

View File

@ -11,6 +11,7 @@
},
"dependencies": {
"@tanstack/react-virtual": "^3.13.24",
"dompurify": "^3.4.17",
"jspdf": "^4.2.1",
"lucide-react": "^0.344.0",
"marked": "^18.0.3",
@ -23,6 +24,7 @@
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.1",
"jsdom": "^29.1.1",
"vite": "^5.1.4",
"vitest": "^3.0.5"
}

View File

@ -156,6 +156,24 @@ export default function AdminUsersPage() {
}
}
const clearUserTotp = async (row) => {
const label = row.name || row.email || `#${row.id}`
if (
!confirm(
`Zwei-Faktor für ${label} aufheben? Danach genügt das Passwort. Authenticator und Wiederherstellungscodes gelten nicht mehr. Offene Sitzungen werden beendet.`
)
) {
return
}
try {
await api.resetUserTotp(row.id)
await loadPlatform()
alert('Zwei-Faktor ist aus. Die Person meldet sich mit dem Passwort an und kann ihn neu einrichten.')
} catch (e) {
alert(e.message || String(e))
}
}
const submitPasswordDirect = async () => {
if (!pwdModal) return
if (pwdNew.length < 8) {
@ -240,6 +258,8 @@ export default function AdminUsersPage() {
<div style={{ fontSize: '0.875rem', color: 'var(--text2)' }}>{row.email || '—'}</div>
<div style={{ fontSize: '0.78rem', color: 'var(--text3)', marginTop: '0.35rem' }}>
Verifiziert: {row.email_verified ? 'ja' : 'nein'}
{' · '}
Zwei-Faktor: {row.totp_enabled ? 'aktiv' : 'aus'}
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'flex-end' }}>
@ -279,6 +299,17 @@ export default function AdminUsersPage() {
>
Passwort / Link
</button>
{row.totp_enabled ? (
<button
type="button"
className="btn btn-secondary"
disabled={row.id === user?.id}
title={row.id === user?.id ? 'Eigenen Zwei-Faktor unter Einstellungen ändern' : undefined}
onClick={() => clearUserTotp(row)}
>
Zwei-Faktor aufheben
</button>
) : null}
<button type="button" className="btn btn-secondary" onClick={() => savePortal(row.id)}>
Portal speichern
</button>
@ -573,7 +604,7 @@ export default function AdminUsersPage() {
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{pwdModal.label}</p>
<p className="muted" style={{ fontSize: '0.82rem', marginBottom: '0.75rem' }}>
Standard: Es wird ein sicherer Link per E-Mail verschickt (wie „Passwort vergessen“). Das bisherige Passwort
bleibt gültig, bis die Person den Link nutzt und ein neues Passwort wählt.
bleibt gültig, bis die Person den Link nutzt und ein neues Passwort wählt. Zwei-Faktor bleibt dabei aktiv.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<button type="button" className="btn btn-primary" onClick={submitPasswordEmail}>

View File

@ -1571,7 +1571,7 @@ function ClubsPage() {
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{pwdModal.label}</p>
<p className="muted" style={{ fontSize: '0.82rem', marginBottom: '0.75rem' }}>
Standard: Es wird ein sicherer Link per E-Mail verschickt (wie „Passwort vergessen“). Das bisherige Passwort
bleibt gültig, bis die Person den Link nutzt und ein neues Passwort wählt.
bleibt gültig, bis die Person den Link nutzt und ein neues Passwort wählt. Zwei-Faktor bleibt dabei aktiv.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<button type="button" className="btn btn-primary" onClick={submitMembersPasswordEmail}>

View File

@ -151,6 +151,11 @@ export async function updateProfile(profileId, data) {
})
}
/** Superadmin: Zwei-Faktor eines anderen Kontos abschalten. */
export async function resetUserTotp(profileId) {
return request(`/api/profiles/${profileId}/totp-reset`, { method: 'POST', body: '{}' })
}
/**
* Passwort anderer Konten: Standard leerer Body → E-Mail mit Reset-Link (wie Passwort vergessen).
* Nur Super-Admins dürfen `newPassword` setzen (direktes Überschreiben des Passwort-Hashes).
@ -1055,6 +1060,7 @@ export const api = {
deleteAdminUserContentItem,
updateProfile,
managementPasswordReset,
resetUserTotp,
changePassword,
verifyEmail,
resendVerification,

View File

@ -1,111 +1,79 @@
/**
* Sanitizer für Übungs-Rich-HTML inkl. §11 Platzhalter (span data-shinkan-exercise-media).
* Restriktiver als sanitizeTrainerHtml: Allowlist für XSS-Minimierung.
* DOMPurify mit fester Allowlist.
*/
import DOMPurify from 'dompurify'
import { sanitizeInlineMediaCaption } from './inlineMediaCaption'
const ALLOWED_BLOCK = new Set(['p', 'div', 'br', 'ul', 'ol', 'li', 'h3'])
const ALLOWED_INLINE = new Set(['b', 'strong', 'i', 'em', 'u', 'span', 'a'])
const ALLOWED_TAGS = ['p', 'div', 'br', 'ul', 'ol', 'li', 'h3', 'b', 'strong', 'i', 'em', 'u', 'span', 'a']
const ALLOWED_ATTR = [
'href',
'class',
'data-shinkan-exercise-media',
'data-shinkan-exercise-media-size',
'data-shinkan-exercise-media-caption',
]
const SIZE_OK = new Set(['small', 'medium', 'full'])
function isHttpsUrl(val) {
if (!val || typeof val !== 'string') return false
const s = val.trim()
return s.startsWith('http://') || s.startsWith('https://')
function hookAttribute(_node, data) {
const name = data.attrName
if (name === 'href') {
const href = String(data.attrValue || '').trim()
if (!/^https?:\/\//i.test(href)) data.keepAttr = false
else data.attrValue = href
return
}
if (name === 'class') {
const keep = String(data.attrValue || '')
.split(/\s+/)
.filter((c) => c === 'shinkan-inline-media')
if (!keep.length) data.keepAttr = false
else data.attrValue = keep.join(' ')
return
}
if (name === 'data-shinkan-exercise-media') {
if (!/^\d+$/.test(String(data.attrValue || '').trim())) data.keepAttr = false
return
}
if (name === 'data-shinkan-exercise-media-size') {
const size = String(data.attrValue || '').trim().toLowerCase()
if (!SIZE_OK.has(size)) data.keepAttr = false
else data.attrValue = size
return
}
if (name === 'data-shinkan-exercise-media-caption') {
const caption = sanitizeInlineMediaCaption(String(data.attrValue || ''))
if (!caption) data.keepAttr = false
else data.attrValue = caption
}
}
/** Nur für unsere Embed-Markierung: erlaubt data-attribut und optionale Marker-Klasse + Größe. */
const _SIZE_OK = new Set(['small', 'medium', 'full'])
function isInlineExerciseMediaPlaceholderSpan(el) {
if (!el?.getAttribute || el.tagName.toLowerCase() !== 'span') return false
const raw = el.getAttribute('data-shinkan-exercise-media')
if (!raw || !String(raw).trim().match(/^\d+$/)) return false
return true
}
const instances = new WeakMap()
function sanitizeAttributes(el, tagLower) {
if (tagLower === 'a') {
const href = el.getAttribute('href')
if (href && isHttpsUrl(href)) {
const out = document.createElement('a')
out.setAttribute('href', href.trim())
return out.attributes
}
return []
}
if (tagLower === 'span' && isInlineExerciseMediaPlaceholderSpan(el)) {
const out = document.createElement('span')
out.setAttribute('data-shinkan-exercise-media', el.getAttribute('data-shinkan-exercise-media').trim())
const sz = (el.getAttribute('data-shinkan-exercise-media-size') || '').trim().toLowerCase()
if (sz && _SIZE_OK.has(sz)) {
out.setAttribute('data-shinkan-exercise-media-size', sz)
}
const capRaw = el.getAttribute('data-shinkan-exercise-media-caption')
if (capRaw != null && String(capRaw).trim()) {
const cap = sanitizeInlineMediaCaption(String(capRaw))
if (cap) out.setAttribute('data-shinkan-exercise-media-caption', cap)
}
const cls = (el.getAttribute('class') || '').trim().split(/\s+/).filter(Boolean)
const keep = cls.filter((c) => c === 'shinkan-inline-media')
if (keep.length) out.setAttribute('class', keep.join(' '))
return out.attributes
}
return []
}
function cloneAsAllowed(tagLower, el) {
const fresh = document.createElement(tagLower)
const attrs = sanitizeAttributes(el, tagLower)
for (const a of attrs) {
fresh.setAttribute(a.name, a.value)
}
return fresh
}
function cleanTree(parent) {
const nodes = Array.from(parent.childNodes)
for (const node of nodes) {
if (node.nodeType === Node.TEXT_NODE) continue
if (node.nodeType === Node.COMMENT_NODE) {
parent.removeChild(node)
continue
}
if (node.nodeType !== Node.ELEMENT_NODE) {
parent.removeChild(node)
continue
}
const tag = node.tagName.toLowerCase()
if (tag === 'script' || tag === 'iframe' || tag === 'object' || tag === 'embed') {
parent.removeChild(node)
continue
}
if (ALLOWED_BLOCK.has(tag) || ALLOWED_INLINE.has(tag)) {
const repl = cloneAsAllowed(tag, node)
while (node.firstChild) {
repl.appendChild(node.firstChild)
}
cleanTree(repl)
parent.replaceChild(repl, node)
continue
}
while (node.firstChild) {
parent.insertBefore(node.firstChild, node)
}
parent.removeChild(node)
}
function purifyFor(win) {
const existing = instances.get(win)
if (existing) return existing
const purify = DOMPurify(win)
purify.addHook('uponSanitizeAttribute', hookAttribute)
instances.set(win, purify)
return purify
}
/**
* @param {string|null|undefined} html
* @param {Window=} win
* @returns {string}
*/
export function sanitizeExerciseRichDisplayHtml(html) {
export function sanitizeExerciseRichDisplayHtml(html, win) {
if (html == null || typeof html !== 'string') return ''
const trimmed = html.trim()
if (!trimmed) return ''
const tpl = document.createElement('template')
tpl.innerHTML = trimmed
cleanTree(tpl.content)
return tpl.innerHTML
const target = win || (typeof window !== 'undefined' ? window : null)
if (!target) return ''
return purifyFor(target).sanitize(trimmed, {
ALLOWED_TAGS,
ALLOWED_ATTR,
ALLOW_DATA_ATTR: false,
})
}

View File

@ -0,0 +1,21 @@
import { JSDOM } from 'jsdom'
import { describe, expect, it } from 'vitest'
import { sanitizeExerciseRichDisplayHtml } from './exerciseRichTextSanitize'
const { window } = new JSDOM('<!doctype html><html><body></body></html>')
describe('sanitizeExerciseRichDisplayHtml', () => {
it('entfernt Skripte und Handler und behält den Medien-Platzhalter', () => {
const dirty =
'<p onclick="alert(1)">Hallo <script>alert(1)</script></p>' +
'<a href="javascript:alert(1)">Link</a>' +
'<span class="shinkan-inline-media evil" data-shinkan-exercise-media="7" data-shinkan-exercise-media-size="full"></span>'
const clean = sanitizeExerciseRichDisplayHtml(dirty, window)
expect(clean.toLowerCase()).not.toContain('script')
expect(clean.toLowerCase()).not.toContain('onclick')
expect(clean.toLowerCase()).not.toContain('javascript:')
expect(clean).toContain('Hallo')
expect(clean).toContain('data-shinkan-exercise-media="7"')
expect(clean).not.toContain('evil')
})
})

View File

@ -1,5 +1,7 @@
/** Einfache HTML-Hilfen für Rich-Text (Trainer-Content, kein öffentliches CMS). */
import { sanitizeExerciseRichDisplayHtml } from './exerciseRichTextSanitize'
export function stripHtmlToText(html) {
if (!html || typeof html !== 'string') return ''
const d = document.createElement('div')
@ -7,17 +9,7 @@ export function stripHtmlToText(html) {
return (d.textContent || '').replace(/\s+/g, ' ').trim()
}
/** Entfernt script/iframes und Event-Handler-Attribute grob. */
/** Entfernt alles außerhalb der Übungs-Allowlist. */
export function sanitizeTrainerHtml(html) {
if (!html || typeof html !== 'string') return ''
const d = document.createElement('div')
d.innerHTML = html
d.querySelectorAll('script, iframe, object, embed').forEach((n) => n.remove())
d.querySelectorAll('*').forEach((el) => {
for (const attr of [...el.attributes]) {
const n = attr.name.toLowerCase()
if (n.startsWith('on') || n === 'srcdoc') el.removeAttribute(attr.name)
}
})
return d.innerHTML
return sanitizeExerciseRichDisplayHtml(html)
}

View File

@ -1,41 +1,11 @@
/**
* Reduziert HTML aus Übungs-Kurztexten auf eine kleine erlaubte Menge von Tags (ohne Attribute).
* Für Anzeige mit dangerouslySetInnerHTML.
* Reduziert HTML aus Übungs-Kurztexten auf die Allowlist von sanitizeExerciseRichDisplayHtml.
*/
const ALLOWED_TAGS = new Set(['b', 'strong', 'i', 'em', 'br', 'p', 'span', 'ul', 'ol', 'li'])
function cleanTree(parent) {
const nodes = Array.from(parent.childNodes)
for (const node of nodes) {
if (node.nodeType === Node.TEXT_NODE) continue
if (node.nodeType !== Node.ELEMENT_NODE) {
parent.removeChild(node)
continue
}
const tag = node.tagName.toLowerCase()
if (!ALLOWED_TAGS.has(tag)) {
while (node.firstChild) {
parent.insertBefore(node.firstChild, node)
}
parent.removeChild(node)
continue
}
while (node.attributes.length > 0) {
node.removeAttribute(node.attributes[0].name)
}
cleanTree(node)
}
}
import { sanitizeExerciseRichDisplayHtml } from './exerciseRichTextSanitize'
export function sanitizeExerciseRichText(html) {
if (html == null || typeof html !== 'string') return ''
const trimmed = html.trim()
if (!trimmed) return ''
const tpl = document.createElement('template')
tpl.innerHTML = trimmed
cleanTree(tpl.content)
return tpl.innerHTML
return sanitizeExerciseRichDisplayHtml(html)
}
export function coerceApiNameList(value) {