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
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:
parent
5ea225ec6b
commit
f10c28e5cd
|
|
@ -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 | |
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
"""
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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)."""
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
21
frontend/src/utils/exerciseRichTextSanitize.test.js
Normal file
21
frontend/src/utils/exerciseRichTextSanitize.test.js
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user