[verified] Security hardening + UX/UI polish

Security (requesting-code-review pipeline + independent reviewer):
- Fix path traversal on file upload (basename sanitize + resolve-containment)
- Fix IDOR: org + owner scoping on all group/chat routes (_authorize_group/_get_owned_group),
  hide other users' personal groups in listings
- Remove XSS via v-html in Chat task (text interpolation)
- Add test_security.py (traversal + cross-user denial) — all pass

UX/UI (ui-ux-pro-max + frontend-dev-verification):
- Global: focus rings, 44px touch targets, hover/press transitions, input focus glow,
  prefers-reduced-motion, skeleton loaders, empty states, back links, spinner
- Login: password toggle, autocomplete, spinner, disabled-when-empty
- Cards lift on hover; dashboard skeleton + empty state; analyze button spinner

All backend tests pass (m0/m1/routes/security/e2e); frontend builds; served SPA verified via curl.
This commit is contained in:
Macky
2026-08-07 16:00:43 +07:00
parent c3d31c06e2
commit ff0f680090
13 changed files with 350 additions and 41 deletions

View File

@@ -27,14 +27,28 @@ def _sim(group, persona):
return Simulator(llm)
def _get_ready_group(s, gid: str) -> dict:
"""Org-scoped group access for trainees + require ready status (IDOR defense)."""
group = s["groups"].get_or_none(gid)
if not group or group.get("status") != "ready":
raise ApiError("group not ready", 404)
actor = current_user()
# super_admin can access any; otherwise owner (for personal groups) + same org.
owner = group.get("owner_user_id")
if actor.get("role") != "super_admin":
if owner and owner != actor["id"]:
raise ApiError("permission denied", 403)
if group.get("org_id") != actor.get("org_id"):
raise ApiError("permission denied", 403)
return group
@chat_bp.post("/<gid>/personas/<pid>/chat/start")
@require_auth
@require_roles("user")
def start_session(gid: str, pid: str):
s = _stores()
group = s["groups"].get_or_none(gid)
if not group or group.get("status") != "ready":
raise ApiError("group not ready", 404)
group = _get_ready_group(s, gid)
persona = s["groups"].get_persona(gid, pid)
if not persona:
raise ApiError("persona not found", 404)
@@ -87,7 +101,9 @@ def send_message(gid: str, pid: str):
raise ApiError("message too long")
group = s["groups"].get_or_none(gid)
persona = s["groups"].get_persona(gid, pid)
persona = s["groups"].get_persona(gid, pid) if group else None
if not group or not persona:
raise ApiError("session context missing", 404)
messages = list(session.get("messages", []))
messages.append({"role": "seller", "text": text})

View File

@@ -29,6 +29,30 @@ def _stores():
}
def _authorize_group(group: dict) -> None:
"""Enforce org-scoped access (IDOR defense). super_admin may access any org.
Private/personal groups (owner_user_id set) are only accessible by their owner
(or super_admin), even within the same org.
"""
actor = current_user()
if actor.get("role") == "super_admin":
return
owner = group.get("owner_user_id")
if owner and owner != actor["id"]:
raise ApiError("permission denied", 403)
if group.get("org_id") != actor.get("org_id"):
raise ApiError("permission denied", 403)
def _get_owned_group(s, gid: str) -> dict:
group = s["groups"].get_or_none(gid)
if not group:
raise ApiError("group not found", 404)
_authorize_group(group)
return group
def _upload_dir():
d = Config.DATA_DIR / "uploads"
d.mkdir(parents=True, exist_ok=True)
@@ -46,10 +70,21 @@ def create_group():
if request.files:
for file in request.files.getlist("files"):
ext = (file.filename or "").rsplit(".", 1)[-1].lower()
raw_name = file.filename or ""
# Path traversal defense: take only the basename, drop any directory
# segments and reject empty/unsafe names. Never trust client filename as a path.
safe_name = Path(raw_name).name
if not safe_name or safe_name in (".", "..", "/", "\\") or "/" in raw_name or "\\" in raw_name:
raise ApiError("invalid file name")
ext = safe_name.rsplit(".", 1)[-1].lower()
if ext not in Config.ALLOWED_UPLOAD_EXTS:
raise ApiError(f"unsupported file type: {ext}")
dest = _upload_dir() / f"{current_user()['id'].replace('@','_')}__{file.filename}"
dest = _upload_dir() / f"{current_user()['id'].replace('@','_')}__{safe_name}"
# Ensure resolved path stays inside the upload dir (defense in depth).
try:
dest.resolve(strict=False).relative_to(_upload_dir().resolve(strict=True))
except ValueError:
raise ApiError("invalid file path")
file.save(dest)
saved_files.append(dest.name)
@@ -95,6 +130,13 @@ def list_groups():
visible = s["groups"].list_visible_to(
role=actor.get("role"), org_id=actor.get("org_id")
)
# Expose personal/private groups only to their owner (IDOR defense in listing).
if actor.get("role") != "super_admin":
visible = [
g
for g in visible
if not g.get("owner_user_id") or g.get("owner_user_id") == actor["id"]
]
return jsonify({"groups": visible})
@@ -104,11 +146,7 @@ def list_groups():
def analyze_group(gid: str):
"""Run analysis: sales kit + 15 personas. Synchronous for v1 (replaces gen)."""
s = _stores()
group = s["groups"].get_or_none(gid)
if not group:
raise ApiError("group not found", 404)
if group.get("org_id") != (current_user().get("org_id") or "org-default"):
raise ApiError("permission denied", 403)
group = _get_owned_group(s, gid)
inp = group.get("input", {})
if not s["llm"]:
@@ -152,12 +190,8 @@ def analyze_group(gid: str):
@require_auth
def get_group(gid: str):
s = _stores()
group = s["groups"].get_or_none(gid)
if not group:
raise ApiError("group not found", 404)
group = _get_owned_group(s, gid)
actor = current_user()
if actor.get("role") != "super_admin" and group.get("org_id") != actor.get("org_id"):
raise ApiError("permission denied", 403)
view = dict(group)
if actor.get("role") == "user":
@@ -172,9 +206,7 @@ def get_group(gid: str):
@require_auth
def list_personas(gid: str):
s = _stores()
group = s["groups"].get_or_none(gid)
if not group:
raise ApiError("group not found", 404)
group = _get_owned_group(s, gid)
actor = current_user()
if actor.get("role") == "user":
if group.get("status") != "ready":
@@ -197,9 +229,7 @@ def list_personas(gid: str):
@require_auth
def get_persona(gid: str, pid: str):
s = _stores()
group = s["groups"].get_or_none(gid)
if not group:
raise ApiError("group not found", 404)
group = _get_owned_group(s, gid)
p = s["groups"].get_persona(gid, pid)
if not p:
raise ApiError("persona not found", 404)
@@ -215,9 +245,7 @@ def get_persona(gid: str, pid: str):
@require_roles("admin")
def update_persona(gid: str, pid: str):
s = _stores()
group = s["groups"].get_or_none(gid)
if not group:
raise ApiError("group not found", 404)
_get_owned_group(s, gid)
data = request.get_json(silent=True) or {}
try:
updated = s["groups"].update_persona(gid, pid, data)

View File

@@ -32,6 +32,8 @@ def win_lose_board():
outcome_by = {(x.get("group_id"), x.get("persona_id")): x.get("outcome") for x in my_sessions}
groups = s["groups"].list_visible_to(role="user", org_id=current_user().get("org_id"))
# Only the owner sees their personal groups (IDOR defense).
groups = [g for g in groups if not g.get("owner_user_id") or g.get("owner_user_id") == uid]
items = []
for g in groups:
for p in g.get("personas", []):