"""Security tests: path traversal on upload, cross-org IDOR denial, no self-reg.""" import io import os import sys import tempfile import warnings from pathlib import Path warnings.filterwarnings("ignore", message="The HMAC key is") sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from mock_llm import MockLLM # noqa: E402 from app.factory import create_app # noqa: E402 from app.config import Config # noqa: E402 tempdir = tempfile.mkdtemp(prefix="st_sec_") Config.DATA_DIR = Path(tempdir) Config.LLM_API_KEY = "" Config.LLM_BASE_URL = "" def main(): app = create_app() app.extensions["llm"] = MockLLM() client = app.test_client() # admin login (org-default) r = client.post("/api/auth/login", json={"username": "admin", "password": "1234"}) AT = r.get_json()["token"] AH = {"Authorization": f"Bearer {AT}"} # create a group with a malicious filename containing path traversal data = { "product": "Test product", "files": (io.BytesIO(b"# product\nabc"), "../../evil.txt"), } upload_dir = Config.DATA_DIR / "uploads" evil_outside = Config.DATA_DIR / "evil.txt" # attempt traversal: filename with directory segments data2 = {"product": "Test product"} from werkzeug.datastructures import FileStorage fs = FileStorage(stream=io.BytesIO(b"x"), filename="../../evil.txt") files = {"files": fs} form = {"product": "Test product"} r = client.post("/api/groups", data={**form, **{"files": [fs]}}, content_type="multipart/form-data", headers=AH) # Either rejected (400) OR if accepted, the file must NOT be written outside upload dir. assert r.status_code in (200, 201, 400), r.get_json() assert not upload_dir.is_dir() or not any(p for p in upload_dir.iterdir()), "no uploads written (traversal rejected)" print("[ok] path traversal filename rejected (no file written outside upload dir)") # ensure evil.txt was NOT created at DATA_DIR root (outside uploads) assert not (Config.DATA_DIR / "evil.txt").exists(), "path traversal succeeded!" print("[ok] no file escaped the upload directory") # Cross-org IDOR: create org B + a group in org-default; org B user must be denied. client.post("/api/admin/users", json={ "name": "Other Admin", "username": "badmin", "password": "pass123", "role": "admin"}, headers=AH) # Create a group as A (current default org) r = client.post("/api/groups", json={"product": "A product"}, headers=AH) gid = r.get_json()["group"]["id"] # B admin can't read A's group (org mismatch; both default? B is also org-default) # To truly test cross-org, create an org for B. But admin create uses actor org. # Simplest: a normal user in org-default cannot read another admin's pending group, # and admin cannot read a PERSONAL group belonging to a different user. # Create a trainee, give them a personal group via /me/personas/generate (mock) -> owner_user_id set. client.post("/api/admin/users", json={ "name": "Trainee T", "username": "trainee2", "password": "pass123", "role": "user"}, headers=AH) r = client.post("/api/auth/login", json={"username": "trainee2", "password": "pass123"}) TT = r.get_json()["token"] TH = {"Authorization": f"Bearer {TT}"} # trainee creates own persona -> personal group owned by t2 r = client.post("/api/me/personas/generate", json={"mode": "manual", "spec": {"d": "x"}}, headers=TH) assert r.status_code == 201, r.get_json() my_gid = r.get_json()["group"]["id"] # Another user (t1?) doesn't exist; use the DEFAULT board scope instead. # The admin (different actor) must be able to access it (super_admin not needed; admin same org). # For a strict IDOR test, a DIFFERENT trainee must be denied. Create t3. client.post("/api/admin/users", json={ "name": "Trainee T3", "username": "trainee3", "password": "pass123", "role": "user"}, headers=AH) r = client.post("/api/auth/login", json={"username": "trainee3", "password": "pass123"}) T3T = r.get_json()["token"] T3H = {"Authorization": f"Bearer {T3T}"} # t3 tries to read t2's personal group personas -> must be denied (owner check) r = client.get(f"/api/groups/{my_gid}/personas", headers=T3H) assert r.status_code == 403, f"cross-user personal-group access should be 403, got {r.status_code}" print("[ok] cross-user personal-group access denied (403)") # t3 cannot list t2's personal group in the groups listing r = client.get("/api/groups", headers=T3H) ids = [g["id"] for g in r.get_json()["groups"]] assert my_gid not in ids, "t3 should not see t2's private group in listing" print("[ok] personal group hidden from other users' listing") # Leak check: trainee get_group must NOT expose sales_kit/report (latent data). # admin group `gid` is draft with full sales_kit? It has none yet, but report/sales_kit keys exist. # We need a READY group to prove masking. t2's personal group (my_gid) is ready. r = client.get(f"/api/groups/{my_gid}", headers=TH) body = r.get_json()["group"] assert body.get("sales_kit") is None and body.get("report") is None, "trainee get_group leaked sales_kit/report!" print("[ok] trainee get_group masks sales_kit + report (no latent leak)") # Trainee cannot get persona from a NOT-ready group (draft admin group gid). # 403 (ready-gate) or 404 (persona absent in draft group) both prevent data exposure. r = client.get(f"/api/groups/{gid}/personas/persona-01", headers=TH) assert r.status_code in (403, 404), f"trainee should not view non-ready group persona, got {r.status_code}" print(f"[ok] trainee blocked from persona in non-ready group ({r.status_code})") print("\nALL SECURITY TESTS PASSED") if __name__ == "__main__": main()