Hussien Haider commited on
Commit
3c7b4e4
·
1 Parent(s): 0de52fb
.dockerignore CHANGED
@@ -1,21 +1,43 @@
 
1
  .env
2
  .env.*
3
- __pycache__/
 
 
 
 
 
 
 
 
4
  *.pyc
5
  *.pyo
6
  *.pyd
7
- .pytest_cache/
8
- .mypy_cache/
9
- .ruff_cache/
10
- .git/
11
- .gitignore
12
- .gitattributes
13
- .vscode/
14
- .idea/
 
 
 
 
15
  .DS_Store
16
- node_modules/
17
- logs/
 
 
 
 
 
 
18
  *.log
19
- tests/
20
- scripts/_test_*.py
21
- scripts/check_env.py
 
 
 
 
1
+ # Environment files (secrets)
2
  .env
3
  .env.*
4
+ !.env.example
5
+
6
+ # Version control
7
+ .git
8
+ .gitignore
9
+ .github
10
+
11
+ # Python
12
+ __pycache__
13
  *.pyc
14
  *.pyo
15
  *.pyd
16
+ .Python
17
+ *.egg-info
18
+ dist
19
+ build
20
+
21
+ # IDE
22
+ .vscode
23
+ .idea
24
+ *.swp
25
+ *.swo
26
+
27
+ # OS
28
  .DS_Store
29
+ Thumbs.db
30
+
31
+ # Node (for frontend build context only)
32
+ node_modules
33
+ package-lock.json
34
+
35
+ # Temporary files
36
+ *.tmp
37
  *.log
38
+ temp/
39
+ tmp/
40
+
41
+ # Documentation
42
+ *.md
43
+ LICENSE
Dockerfile CHANGED
@@ -10,22 +10,29 @@ WORKDIR /app
10
  # System deps:
11
  # - build-essential + libffi-dev: build native extensions for `cryptography`
12
  # - coreutils + xxd: built-in CLI tools the terminal sandbox shells out to
 
13
  RUN apt-get update && apt-get install -y --no-install-recommends \
14
  build-essential \
15
  libffi-dev \
16
  coreutils \
17
  xxd \
18
- curl \
19
  ca-certificates \
20
  && rm -rf /var/lib/apt/lists/*
21
 
 
 
 
 
 
22
  COPY requirements.txt .
23
  RUN pip install --no-cache-dir -r requirements.txt
24
 
25
- COPY . .
 
 
 
26
 
27
  # Hugging Face Spaces default. The proxy forwards public 7860 → container 7860.
28
- # Locally, override with `PORT=8090 python main.py` to match the README quickstart.
29
  ENV PORT=7860
30
  EXPOSE 7860
31
 
 
10
  # System deps:
11
  # - build-essential + libffi-dev: build native extensions for `cryptography`
12
  # - coreutils + xxd: built-in CLI tools the terminal sandbox shells out to
13
+ # - Removed curl (security risk - not needed at runtime)
14
  RUN apt-get update && apt-get install -y --no-install-recommends \
15
  build-essential \
16
  libffi-dev \
17
  coreutils \
18
  xxd \
 
19
  ca-certificates \
20
  && rm -rf /var/lib/apt/lists/*
21
 
22
+ # Create non-root user for security
23
+ RUN useradd --create-home --shell /bin/bash --uid 1000 appuser && \
24
+ mkdir -p /app /tmp/sandbox && \
25
+ chown -R appuser:appuser /app /tmp/sandbox
26
+
27
  COPY requirements.txt .
28
  RUN pip install --no-cache-dir -r requirements.txt
29
 
30
+ COPY --chown=appuser:appuser . .
31
+
32
+ # Switch to non-root user
33
+ USER appuser
34
 
35
  # Hugging Face Spaces default. The proxy forwards public 7860 → container 7860.
 
36
  ENV PORT=7860
37
  EXPOSE 7860
38
 
app/api/auth.py CHANGED
@@ -1,7 +1,9 @@
1
  """``/api/auth`` — proxy to the Supabase ``apex-auth`` edge function."""
2
 
3
  import httpx
4
- from fastapi import APIRouter, HTTPException
 
 
5
 
6
  from app.core.config import SUPABASE_EDGE_URL
7
  from app.types import AuthRequest
@@ -9,9 +11,13 @@ from app.types import AuthRequest
9
 
10
  router = APIRouter()
11
 
 
 
 
12
 
13
  @router.post("/api/auth")
14
- async def handle_auth(req: AuthRequest):
 
15
  async with httpx.AsyncClient(timeout=30) as client:
16
  resp = await client.post(
17
  f"{SUPABASE_EDGE_URL}/apex-auth",
@@ -19,5 +25,6 @@ async def handle_auth(req: AuthRequest):
19
  )
20
  data = resp.json()
21
  if resp.status_code != 200:
22
- raise HTTPException(status_code=resp.status_code, detail=data.get("error", "Auth failed"))
 
23
  return data
 
1
  """``/api/auth`` — proxy to the Supabase ``apex-auth`` edge function."""
2
 
3
  import httpx
4
+ from fastapi import APIRouter, HTTPException, Request
5
+ from slowapi import Limiter
6
+ from slowapi.util import get_remote_address
7
 
8
  from app.core.config import SUPABASE_EDGE_URL
9
  from app.types import AuthRequest
 
11
 
12
  router = APIRouter()
13
 
14
+ # Rate limiter: 5 auth attempts per minute per IP (prevents brute-force)
15
+ limiter = Limiter(key_func=get_remote_address)
16
+
17
 
18
  @router.post("/api/auth")
19
+ @limiter.limit("5/minute")
20
+ async def handle_auth(request: Request, req: AuthRequest):
21
  async with httpx.AsyncClient(timeout=30) as client:
22
  resp = await client.post(
23
  f"{SUPABASE_EDGE_URL}/apex-auth",
 
25
  )
26
  data = resp.json()
27
  if resp.status_code != 200:
28
+ # Don't leak specific error details
29
+ raise HTTPException(status_code=resp.status_code, detail="Authentication failed")
30
  return data
app/api/certificates.py CHANGED
@@ -1,7 +1,8 @@
1
  """``/api/certificates`` — issuance, download, verify, progress."""
2
 
3
- from fastapi import APIRouter
4
 
 
5
  from app.types import CertificateRequest
6
  from app.services.certificate_service import (
7
  handle_certificates,
@@ -15,7 +16,9 @@ router = APIRouter()
15
 
16
 
17
  @router.post("/api/certificates")
18
- async def post_certificates(req: CertificateRequest):
 
 
19
  return await handle_certificates(req)
20
 
21
 
@@ -30,5 +33,9 @@ async def get_verify(verify_code: str):
30
 
31
 
32
  @router.get("/api/certificates/progress")
33
- async def get_progress(user_id: str, category: str):
 
 
 
 
34
  return await cert_progress(user_id, category)
 
1
  """``/api/certificates`` — issuance, download, verify, progress."""
2
 
3
+ from fastapi import APIRouter, Depends
4
 
5
+ from app.core.auth import get_current_user
6
  from app.types import CertificateRequest
7
  from app.services.certificate_service import (
8
  handle_certificates,
 
16
 
17
 
18
  @router.post("/api/certificates")
19
+ async def post_certificates(req: CertificateRequest, user: dict = Depends(get_current_user)):
20
+ # IDOR FIX: Override user_id from JWT
21
+ req.user_id = user["user_id"]
22
  return await handle_certificates(req)
23
 
24
 
 
33
 
34
 
35
  @router.get("/api/certificates/progress")
36
+ async def get_progress(user_id: str, category: str, user: dict = Depends(get_current_user)):
37
+ # IDOR FIX: Only allow viewing own progress
38
+ if user_id != user["user_id"]:
39
+ from fastapi import HTTPException
40
+ raise HTTPException(status_code=403, detail="Cannot view other users' progress")
41
  return await cert_progress(user_id, category)
app/api/onevone.py CHANGED
@@ -42,10 +42,12 @@ from datetime import datetime, timezone
42
  from typing import Any, Optional
43
 
44
  import httpx
45
- from fastapi import APIRouter, HTTPException, Request
46
  from fastapi.responses import StreamingResponse
47
  from pydantic import BaseModel
48
 
 
 
49
  # Force UTF-8 for Arabic messages on Windows consoles
50
  sys.stdout.reconfigure(encoding="utf-8")
51
 
@@ -170,6 +172,8 @@ def _scenario_table_for(team_role: str, challenge_type: str) -> str:
170
  return "log_analysis_challenges"
171
  if challenge_type == "vulnerability-hunter":
172
  return "vulnerability_hunter_challenges"
 
 
173
  return "encryption_challenges"
174
 
175
 
@@ -183,6 +187,7 @@ async def _pick_challenge(team_role: str) -> dict:
183
  ("code_fixing_challenges", "code-fixing"),
184
  ("log_analysis_challenges", "log-analysis"),
185
  ("vulnerability_hunter_challenges", "vulnerability-hunter"),
 
186
  ("encryption_challenges", "crypto"),
187
  ]
188
  for table, ctype in candidates:
@@ -336,12 +341,14 @@ class SubmitRequest(BaseModel):
336
  # --------------------------------------------------------------------------- #
337
 
338
  @router.post("/rooms")
339
- async def create_room(req: CreateRoomRequest):
 
 
340
  if req.teamRole not in ("red", "blue"):
341
  raise HTTPException(status_code=400, detail="teamRole يجب أن يكون red أو blue")
342
  if req.mainDurationS not in MAIN_DURATION_OPTIONS:
343
  raise HTTPException(status_code=400, detail="mainDurationS غير صالح")
344
- if not req.userId or not req.displayName:
345
  raise HTTPException(status_code=400, detail="بيانات المستخدم ناقصة")
346
 
347
  # generate a unique 6-char code (retry on collision)
@@ -354,7 +361,7 @@ async def create_room(req: CreateRoomRequest):
354
 
355
  room = await _sb_insert("onevone_rooms", {
356
  "code": code,
357
- "owner_user_id": req.userId,
358
  "team_role": req.teamRole,
359
  "status": "open",
360
  "challenge_source": req.challengeSource,
@@ -363,7 +370,7 @@ async def create_room(req: CreateRoomRequest):
363
  # add the owner as slot 1
364
  await _sb_insert("onevone_players", {
365
  "room_id": room["id"],
366
- "user_id": req.userId,
367
  "slot": 1,
368
  "display_name": req.displayName,
369
  "is_ready": True,
@@ -372,9 +379,11 @@ async def create_room(req: CreateRoomRequest):
372
 
373
 
374
  @router.post("/rooms/join")
375
- async def join_room(req: JoinRoomRequest):
376
- if not req.code or not req.userId:
377
- raise HTTPException(status_code=400, detail="code و userId مطلوبان")
 
 
378
 
379
  rows = await _sb_select("onevone_rooms", f"code=eq.{req.code.strip().upper()}&select=*&limit=1")
380
  if not rows:
@@ -382,7 +391,7 @@ async def join_room(req: JoinRoomRequest):
382
  room = rows[0]
383
  if room["status"] != "open":
384
  raise HTTPException(status_code=409, detail="هذه الغرفة لم تعد تقبل لاعبين")
385
- if str(room["owner_user_id"]) == str(req.userId):
386
  raise HTTPException(status_code=400, detail="أنت مالك الغرفة بالفعل")
387
 
388
  # already joined?
@@ -390,7 +399,7 @@ async def join_room(req: JoinRoomRequest):
390
  "onevone_players",
391
  f"room_id=eq.{room['id']}&select=*",
392
  )
393
- if any(str(p["user_id"]) == str(req.userId) for p in existing_players):
394
  # idempotent re-join — return current state
395
  return {"room": room, "players": existing_players, "joined": True}
396
  if len(existing_players) >= 2:
@@ -399,13 +408,13 @@ async def join_room(req: JoinRoomRequest):
399
  # slot 2 inherits the same team_role
400
  new_player = await _sb_insert("onevone_players", {
401
  "room_id": room["id"],
402
- "user_id": req.userId,
403
  "slot": 2,
404
  "display_name": req.displayName,
405
  "is_ready": True,
406
  })
407
  # a player joined => notify the open SSE streams (if any)
408
- await _broadcast_room_event(room["id"], {"type": "player_joined", "userId": req.userId})
409
  existing_players.append(new_player)
410
  return {"room": room, "players": existing_players, "joined": True}
411
 
@@ -430,12 +439,14 @@ async def get_room(code: str):
430
 
431
 
432
  @router.post("/rooms/{code}/start")
433
- async def start_match(code: str, req: StartMatchRequest):
 
 
434
  rows = await _sb_select("onevone_rooms", f"code=eq.{code.strip().upper()}&select=*&limit=1")
435
  if not rows:
436
  raise HTTPException(status_code=404, detail="رمز الغرفة غير موجود")
437
  room = rows[0]
438
- if str(room["owner_user_id"]) != str(req.userId):
439
  raise HTTPException(status_code=403, detail="فقط مالك الغرفة يستطيع بدء المباراة")
440
  if room["status"] != "open":
441
  raise HTTPException(status_code=409, detail="المباراة بدأت بالفعل أو الغرفة مغلقة")
@@ -447,12 +458,13 @@ async def start_match(code: str, req: StartMatchRequest):
447
  # pick the challenge (manual or random)
448
  if room["challenge_source"].startswith("manual:"):
449
  challenge_id = room["challenge_source"].split(":", 1)[1]
450
- # detect type by trying the 4 tables
451
  challenge_type = "crypto"
452
  for tname, ctype in (
453
  ("code_fixing_challenges", "code-fixing"),
454
  ("log_analysis_challenges", "log-analysis"),
455
  ("vulnerability_hunter_challenges", "vulnerability-hunter"),
 
456
  ("encryption_challenges", "crypto"),
457
  ):
458
  rs = await _sb_select(tname, f"id=eq.{challenge_id}&select=id&limit=1")
@@ -498,13 +510,15 @@ async def start_match(code: str, req: StartMatchRequest):
498
 
499
 
500
  @router.post("/rooms/{code}/leave")
501
- async def leave_room(code: str, req: LeaveRoomRequest):
 
 
502
  rows = await _sb_select("onevone_rooms", f"code=eq.{code.strip().upper()}&select=*&limit=1")
503
  if not rows:
504
  raise HTTPException(status_code=404, detail="رمز الغرفة غير موجود")
505
  room = rows[0]
506
  # only remove the player row if present
507
- await _sb_delete("onevone_players", f"room_id=eq.{room['id']}&user_id=eq.{req.userId}")
508
  # if match is in progress, abandon with the OTHER user as winner
509
  ms = await _sb_select("onevone_matches", f"room_id=eq.{room['id']}&select=*&limit=1")
510
  if ms and ms[0]["state"] not in ("finished",):
@@ -512,7 +526,7 @@ async def leave_room(code: str, req: LeaveRoomRequest):
512
  winner = None
513
  players = await _sb_select("onevone_players", f"room_id=eq.{room['id']}&select=user_id")
514
  for p in players:
515
- if str(p["user_id"]) != str(req.userId):
516
  winner = str(p["user_id"])
517
  break
518
  patch = {
@@ -537,9 +551,11 @@ async def leave_room(code: str, req: LeaveRoomRequest):
537
  # --------------------------------------------------------------------------- #
538
 
539
  @router.post("/matches/{match_id}/ready")
540
- async def post_match_ready(match_id: str, req: ReadyRequest):
541
  """Mark this client as ready. The match timer starts when BOTH
542
  clients have called this endpoint (or READY_TIMEOUT_S elapses)."""
 
 
543
  ms = await _sb_select("onevone_matches", f"id=eq.{match_id}&select=*&limit=1")
544
  if not ms:
545
  raise HTTPException(status_code=404, detail="المباراة غير موجودة")
@@ -547,9 +563,9 @@ async def post_match_ready(match_id: str, req: ReadyRequest):
547
  if match["state"] not in ("countdown", "ready"):
548
  raise HTTPException(status_code=409, detail="المباراة بدأت بالفعل أو انتهت")
549
  ps = await _sb_select("onevone_players", f"room_id=eq.{match['room_id']}&select=user_id")
550
- if not any(str(p["user_id"]) == str(req.userId) for p in ps):
551
  raise HTTPException(status_code=403, detail="لست لاعباً في هذه المباراة")
552
- await _mark_player_ready(match_id, str(req.userId), match["room_id"])
553
  return {"ok": True, "ready_count": len(_match_ready.get(match_id, set()))}
554
 
555
 
@@ -582,7 +598,9 @@ async def _mark_player_ready(match_id: str, user_id: str, room_id: str) -> None:
582
  # --------------------------------------------------------------------------- #
583
 
584
  @router.post("/matches/{match_id}/submit")
585
- async def submit_answer(match_id: str, req: SubmitRequest):
 
 
586
  ms = await _sb_select("onevone_matches", f"id=eq.{match_id}&select=*&limit=1")
587
  if not ms:
588
  raise HTTPException(status_code=404, detail="المباراة غير موجودة")
@@ -594,7 +612,7 @@ async def submit_answer(match_id: str, req: SubmitRequest):
594
 
595
  # verify submitter is a player
596
  ps = await _sb_select("onevone_players", f"room_id=eq.{match['room_id']}&select=*&order=slot.asc")
597
- if not any(str(p["user_id"]) == str(req.userId) for p in ps):
598
  raise HTTPException(status_code=403, detail="لست لاعباً في هذه المباراة")
599
 
600
  # route to the right verifier
@@ -606,7 +624,7 @@ async def submit_answer(match_id: str, req: SubmitRequest):
606
  if not is_blue_clientverdict:
607
  challenge_row = await _load_challenge_row(match["challenge_id"], match["challenge_type"])
608
 
609
- if match["challenge_type"] == "crypto":
610
  # Red team: deterministic flag match. Server is the source of truth.
611
  correct = _check_red_answer(challenge_row, str(req.submission or ""))
612
  elif match["challenge_type"] in ("code-fixing", "log-analysis", "vulnerability-hunter"):
@@ -640,7 +658,7 @@ async def submit_answer(match_id: str, req: SubmitRequest):
640
  # log the submission
641
  sub_row = await _sb_insert("onevone_submissions", {
642
  "match_id": match_id,
643
- "user_id": req.userId,
644
  "submission": str(req.submission)[:8000] if not isinstance(req.submission, (dict, list)) else json.dumps(req.submission, ensure_ascii=False)[:8000],
645
  "is_correct": bool(correct),
646
  "is_final": False,
@@ -662,7 +680,7 @@ async def submit_answer(match_id: str, req: SubmitRequest):
662
  rpc_url,
663
  json={
664
  "p_match_id": match_id,
665
- "p_user_id": req.userId,
666
  "p_win_reason": win_reason,
667
  },
668
  headers=_sb_headers(content_type=True),
@@ -693,7 +711,7 @@ async def submit_answer(match_id: str, req: SubmitRequest):
693
  # from the players list.
694
  await _broadcast_room_event(match["room_id"], {
695
  "type": "match_finished",
696
- "winner": req.userId,
697
  "reason": win_reason,
698
  })
699
  try:
@@ -710,7 +728,7 @@ async def submit_answer(match_id: str, req: SubmitRequest):
710
  return {
711
  "correct": True,
712
  "won": True,
713
- "winner_id": req.userId,
714
  "submission": sub_row,
715
  }
716
  # race lost: tell the loser who actually won
@@ -729,15 +747,17 @@ async def submit_answer(match_id: str, req: SubmitRequest):
729
  # --------------------------------------------------------------------------- #
730
 
731
  @router.get("/matches/{match_id}/challenge")
732
- async def get_match_challenge(match_id: str, userId: str):
733
  """Return the challenge data for the given match in the existing
734
  TrainingData shape — frontend reuses TrainingSession as-is."""
 
 
735
  ms = await _sb_select("onevone_matches", f"id=eq.{match_id}&select=*&limit=1")
736
  if not ms:
737
  raise HTTPException(status_code=404, detail="المباراة غير موجودة")
738
  match = ms[0]
739
  ps = await _sb_select("onevone_players", f"room_id=eq.{match['room_id']}&select=user_id")
740
- if not any(str(p["user_id"]) == str(userId) for p in ps):
741
  raise HTTPException(status_code=403, detail="لست لاعباً في هذه المباراة")
742
  # delegate to the existing mapper in app.services.challenge_loader
743
  from app.services.challenge_loader import (
@@ -745,6 +765,7 @@ async def get_match_challenge(match_id: str, userId: str):
745
  map_code_fixing_row_to_training as _map_code_fixing_row_to_training,
746
  map_log_analysis_row_to_training as _map_log_analysis_row_to_training,
747
  map_vuln_hunter_row_to_training as _map_vuln_hunter_row_to_training,
 
748
  )
749
  row = await _load_challenge_row(match["challenge_id"], match["challenge_type"])
750
  # team_role on the row is the source of truth for evaluator side
@@ -757,6 +778,8 @@ async def get_match_challenge(match_id: str, userId: str):
757
  training = _map_log_analysis_row_to_training(row, team_role)
758
  elif match["challenge_type"] == "vulnerability-hunter":
759
  training = _map_vuln_hunter_row_to_training(row, team_role)
 
 
760
  else:
761
  training = _map_encryption_row_to_training(row, team_role)
762
  # Auto-mark this player as ready: a client that successfully fetched the
@@ -764,7 +787,7 @@ async def get_match_challenge(match_id: str, userId: str):
764
  # also POST /ready — the implicit signal is enough. /ready still works
765
  # as a backup for clients whose challenge load failed and recovered.
766
  if match["state"] in ("countdown", "ready"):
767
- await _mark_player_ready(match_id, str(userId), match["room_id"])
768
  return {"training": training, "match": _serialize_match(match)}
769
 
770
 
 
42
  from typing import Any, Optional
43
 
44
  import httpx
45
+ from fastapi import APIRouter, Depends, HTTPException, Request
46
  from fastapi.responses import StreamingResponse
47
  from pydantic import BaseModel
48
 
49
+ from app.core.auth import get_current_user
50
+
51
  # Force UTF-8 for Arabic messages on Windows consoles
52
  sys.stdout.reconfigure(encoding="utf-8")
53
 
 
172
  return "log_analysis_challenges"
173
  if challenge_type == "vulnerability-hunter":
174
  return "vulnerability_hunter_challenges"
175
+ if challenge_type == "steganography":
176
+ return "steganography_challenges"
177
  return "encryption_challenges"
178
 
179
 
 
187
  ("code_fixing_challenges", "code-fixing"),
188
  ("log_analysis_challenges", "log-analysis"),
189
  ("vulnerability_hunter_challenges", "vulnerability-hunter"),
190
+ ("steganography_challenges", "steganography"),
191
  ("encryption_challenges", "crypto"),
192
  ]
193
  for table, ctype in candidates:
 
341
  # --------------------------------------------------------------------------- #
342
 
343
  @router.post("/rooms")
344
+ async def create_room(req: CreateRoomRequest, user: dict = Depends(get_current_user)):
345
+ # IDOR FIX: Use authenticated user's ID
346
+ user_id = user["user_id"]
347
  if req.teamRole not in ("red", "blue"):
348
  raise HTTPException(status_code=400, detail="teamRole يجب أن يكون red أو blue")
349
  if req.mainDurationS not in MAIN_DURATION_OPTIONS:
350
  raise HTTPException(status_code=400, detail="mainDurationS غير صالح")
351
+ if not req.displayName:
352
  raise HTTPException(status_code=400, detail="بيانات المستخدم ناقصة")
353
 
354
  # generate a unique 6-char code (retry on collision)
 
361
 
362
  room = await _sb_insert("onevone_rooms", {
363
  "code": code,
364
+ "owner_user_id": user_id,
365
  "team_role": req.teamRole,
366
  "status": "open",
367
  "challenge_source": req.challengeSource,
 
370
  # add the owner as slot 1
371
  await _sb_insert("onevone_players", {
372
  "room_id": room["id"],
373
+ "user_id": user_id,
374
  "slot": 1,
375
  "display_name": req.displayName,
376
  "is_ready": True,
 
379
 
380
 
381
  @router.post("/rooms/join")
382
+ async def join_room(req: JoinRoomRequest, user: dict = Depends(get_current_user)):
383
+ # IDOR FIX: Use authenticated user's ID
384
+ user_id = user["user_id"]
385
+ if not req.code:
386
+ raise HTTPException(status_code=400, detail="code مطلوب")
387
 
388
  rows = await _sb_select("onevone_rooms", f"code=eq.{req.code.strip().upper()}&select=*&limit=1")
389
  if not rows:
 
391
  room = rows[0]
392
  if room["status"] != "open":
393
  raise HTTPException(status_code=409, detail="هذه الغرفة لم تعد تقبل لاعبين")
394
+ if str(room["owner_user_id"]) == str(user_id):
395
  raise HTTPException(status_code=400, detail="أنت مالك الغرفة بالفعل")
396
 
397
  # already joined?
 
399
  "onevone_players",
400
  f"room_id=eq.{room['id']}&select=*",
401
  )
402
+ if any(str(p["user_id"]) == str(user_id) for p in existing_players):
403
  # idempotent re-join — return current state
404
  return {"room": room, "players": existing_players, "joined": True}
405
  if len(existing_players) >= 2:
 
408
  # slot 2 inherits the same team_role
409
  new_player = await _sb_insert("onevone_players", {
410
  "room_id": room["id"],
411
+ "user_id": user_id,
412
  "slot": 2,
413
  "display_name": req.displayName,
414
  "is_ready": True,
415
  })
416
  # a player joined => notify the open SSE streams (if any)
417
+ await _broadcast_room_event(room["id"], {"type": "player_joined", "userId": user_id})
418
  existing_players.append(new_player)
419
  return {"room": room, "players": existing_players, "joined": True}
420
 
 
439
 
440
 
441
  @router.post("/rooms/{code}/start")
442
+ async def start_match(code: str, req: StartMatchRequest, user: dict = Depends(get_current_user)):
443
+ # IDOR FIX: Use authenticated user's ID
444
+ user_id = user["user_id"]
445
  rows = await _sb_select("onevone_rooms", f"code=eq.{code.strip().upper()}&select=*&limit=1")
446
  if not rows:
447
  raise HTTPException(status_code=404, detail="رمز الغرفة غير موجود")
448
  room = rows[0]
449
+ if str(room["owner_user_id"]) != str(user_id):
450
  raise HTTPException(status_code=403, detail="فقط مالك الغرفة يستطيع بدء المباراة")
451
  if room["status"] != "open":
452
  raise HTTPException(status_code=409, detail="المباراة بدأت بالفعل أو الغرفة مغلقة")
 
458
  # pick the challenge (manual or random)
459
  if room["challenge_source"].startswith("manual:"):
460
  challenge_id = room["challenge_source"].split(":", 1)[1]
461
+ # detect type by trying the 5 tables
462
  challenge_type = "crypto"
463
  for tname, ctype in (
464
  ("code_fixing_challenges", "code-fixing"),
465
  ("log_analysis_challenges", "log-analysis"),
466
  ("vulnerability_hunter_challenges", "vulnerability-hunter"),
467
+ ("steganography_challenges", "steganography"),
468
  ("encryption_challenges", "crypto"),
469
  ):
470
  rs = await _sb_select(tname, f"id=eq.{challenge_id}&select=id&limit=1")
 
510
 
511
 
512
  @router.post("/rooms/{code}/leave")
513
+ async def leave_room(code: str, req: LeaveRoomRequest, user: dict = Depends(get_current_user)):
514
+ # IDOR FIX: Use authenticated user's ID
515
+ user_id = user["user_id"]
516
  rows = await _sb_select("onevone_rooms", f"code=eq.{code.strip().upper()}&select=*&limit=1")
517
  if not rows:
518
  raise HTTPException(status_code=404, detail="رمز الغرفة غير موجود")
519
  room = rows[0]
520
  # only remove the player row if present
521
+ await _sb_delete("onevone_players", f"room_id=eq.{room['id']}&user_id=eq.{user_id}")
522
  # if match is in progress, abandon with the OTHER user as winner
523
  ms = await _sb_select("onevone_matches", f"room_id=eq.{room['id']}&select=*&limit=1")
524
  if ms and ms[0]["state"] not in ("finished",):
 
526
  winner = None
527
  players = await _sb_select("onevone_players", f"room_id=eq.{room['id']}&select=user_id")
528
  for p in players:
529
+ if str(p["user_id"]) != str(user_id):
530
  winner = str(p["user_id"])
531
  break
532
  patch = {
 
551
  # --------------------------------------------------------------------------- #
552
 
553
  @router.post("/matches/{match_id}/ready")
554
+ async def post_match_ready(match_id: str, req: ReadyRequest, user: dict = Depends(get_current_user)):
555
  """Mark this client as ready. The match timer starts when BOTH
556
  clients have called this endpoint (or READY_TIMEOUT_S elapses)."""
557
+ # IDOR FIX: Use authenticated user's ID
558
+ user_id = user["user_id"]
559
  ms = await _sb_select("onevone_matches", f"id=eq.{match_id}&select=*&limit=1")
560
  if not ms:
561
  raise HTTPException(status_code=404, detail="المباراة غير موجودة")
 
563
  if match["state"] not in ("countdown", "ready"):
564
  raise HTTPException(status_code=409, detail="المباراة بدأت بالفعل أو انتهت")
565
  ps = await _sb_select("onevone_players", f"room_id=eq.{match['room_id']}&select=user_id")
566
+ if not any(str(p["user_id"]) == str(user_id) for p in ps):
567
  raise HTTPException(status_code=403, detail="لست لاعباً في هذه المباراة")
568
+ await _mark_player_ready(match_id, str(user_id), match["room_id"])
569
  return {"ok": True, "ready_count": len(_match_ready.get(match_id, set()))}
570
 
571
 
 
598
  # --------------------------------------------------------------------------- #
599
 
600
  @router.post("/matches/{match_id}/submit")
601
+ async def submit_answer(match_id: str, req: SubmitRequest, user: dict = Depends(get_current_user)):
602
+ # IDOR FIX: Use authenticated user's ID
603
+ user_id = user["user_id"]
604
  ms = await _sb_select("onevone_matches", f"id=eq.{match_id}&select=*&limit=1")
605
  if not ms:
606
  raise HTTPException(status_code=404, detail="المباراة غير موجودة")
 
612
 
613
  # verify submitter is a player
614
  ps = await _sb_select("onevone_players", f"room_id=eq.{match['room_id']}&select=*&order=slot.asc")
615
+ if not any(str(p["user_id"]) == str(user_id) for p in ps):
616
  raise HTTPException(status_code=403, detail="لست لاعباً في هذه المباراة")
617
 
618
  # route to the right verifier
 
624
  if not is_blue_clientverdict:
625
  challenge_row = await _load_challenge_row(match["challenge_id"], match["challenge_type"])
626
 
627
+ if match["challenge_type"] in ("crypto", "steganography"):
628
  # Red team: deterministic flag match. Server is the source of truth.
629
  correct = _check_red_answer(challenge_row, str(req.submission or ""))
630
  elif match["challenge_type"] in ("code-fixing", "log-analysis", "vulnerability-hunter"):
 
658
  # log the submission
659
  sub_row = await _sb_insert("onevone_submissions", {
660
  "match_id": match_id,
661
+ "user_id": user_id,
662
  "submission": str(req.submission)[:8000] if not isinstance(req.submission, (dict, list)) else json.dumps(req.submission, ensure_ascii=False)[:8000],
663
  "is_correct": bool(correct),
664
  "is_final": False,
 
680
  rpc_url,
681
  json={
682
  "p_match_id": match_id,
683
+ "p_user_id": user_id,
684
  "p_win_reason": win_reason,
685
  },
686
  headers=_sb_headers(content_type=True),
 
711
  # from the players list.
712
  await _broadcast_room_event(match["room_id"], {
713
  "type": "match_finished",
714
+ "winner": user_id,
715
  "reason": win_reason,
716
  })
717
  try:
 
728
  return {
729
  "correct": True,
730
  "won": True,
731
+ "winner_id": user_id,
732
  "submission": sub_row,
733
  }
734
  # race lost: tell the loser who actually won
 
747
  # --------------------------------------------------------------------------- #
748
 
749
  @router.get("/matches/{match_id}/challenge")
750
+ async def get_match_challenge(match_id: str, user: dict = Depends(get_current_user)):
751
  """Return the challenge data for the given match in the existing
752
  TrainingData shape — frontend reuses TrainingSession as-is."""
753
+ # IDOR FIX: Use authenticated user's ID
754
+ user_id = user["user_id"]
755
  ms = await _sb_select("onevone_matches", f"id=eq.{match_id}&select=*&limit=1")
756
  if not ms:
757
  raise HTTPException(status_code=404, detail="المباراة غير موجودة")
758
  match = ms[0]
759
  ps = await _sb_select("onevone_players", f"room_id=eq.{match['room_id']}&select=user_id")
760
+ if not any(str(p["user_id"]) == str(user_id) for p in ps):
761
  raise HTTPException(status_code=403, detail="لست لاعباً في هذه المباراة")
762
  # delegate to the existing mapper in app.services.challenge_loader
763
  from app.services.challenge_loader import (
 
765
  map_code_fixing_row_to_training as _map_code_fixing_row_to_training,
766
  map_log_analysis_row_to_training as _map_log_analysis_row_to_training,
767
  map_vuln_hunter_row_to_training as _map_vuln_hunter_row_to_training,
768
+ map_steganography_row_to_training as _map_steganography_row_to_training,
769
  )
770
  row = await _load_challenge_row(match["challenge_id"], match["challenge_type"])
771
  # team_role on the row is the source of truth for evaluator side
 
778
  training = _map_log_analysis_row_to_training(row, team_role)
779
  elif match["challenge_type"] == "vulnerability-hunter":
780
  training = _map_vuln_hunter_row_to_training(row, team_role)
781
+ elif match["challenge_type"] == "steganography":
782
+ training = _map_steganography_row_to_training(row, team_role)
783
  else:
784
  training = _map_encryption_row_to_training(row, team_role)
785
  # Auto-mark this player as ready: a client that successfully fetched the
 
787
  # also POST /ready — the implicit signal is enough. /ready still works
788
  # as a backup for clients whose challenge load failed and recovered.
789
  if match["state"] in ("countdown", "ready"):
790
+ await _mark_player_ready(match_id, str(user_id), match["room_id"])
791
  return {"training": training, "match": _serialize_match(match)}
792
 
793
 
app/api/terminal.py CHANGED
@@ -1,9 +1,13 @@
1
  """``/api/training/terminal*`` — the in-process OS simulator endpoints."""
2
 
3
  import os
 
4
 
5
- from fastapi import APIRouter
 
 
6
 
 
7
  from app.sandbox.terminal import run_terminal_command
8
  from app.sandbox.workdir import get_or_create_workdir, safe_join
9
  from app.types import (
@@ -15,31 +19,49 @@ from app.types import (
15
 
16
  router = APIRouter()
17
 
 
 
 
18
 
19
  @router.post("/api/training/terminal")
20
- async def post_terminal(req: TerminalRequest):
 
21
  """Execute a terminal command in the challenge's sandbox for real."""
22
- team_role = req.teamRole or "red"
23
- return await run_terminal_command(team_role, req.challengeId, req.command)
 
 
 
 
 
 
 
24
 
25
 
26
  @router.post("/api/training/terminal/write")
27
- async def post_terminal_write(req: TerminalWriteRequest):
 
28
  """Write/edit a file inside the sandbox workdir (path-traversal safe)."""
29
- team_role = req.teamRole or "red"
30
- challenge_id = req.challengeId
 
 
31
  if not challenge_id:
32
  return {"ok": False, "error": "❌ challengeId مفقود"}
33
  if not req.filename:
34
  return {"ok": False, "error": "❌ اسم الملف مفقود"}
 
 
 
 
35
  if len(req.content) > 200_000:
36
  return {"ok": False, "error": "❌ الملف كبير جداً (الحد 200KB)"}
37
 
38
  workdir, _row = await get_or_create_workdir(team_role, challenge_id)
39
  try:
40
- target = safe_join(workdir, req.filename)
41
  except ValueError as e:
42
- return {"ok": False, "error": f"❌ مسار غير مسموح: {e}"}
43
 
44
  try:
45
  os.makedirs(os.path.dirname(target) or workdir, exist_ok=True)
@@ -47,14 +69,17 @@ async def post_terminal_write(req: TerminalWriteRequest):
47
  f.write(req.content)
48
  return {"ok": True, "path": os.path.relpath(target, workdir).replace("\\", "/")}
49
  except Exception as e:
50
- return {"ok": False, "error": f"❌ تعذّر الحفظ: {e}"}
51
 
52
 
53
  @router.post("/api/training/terminal/list")
54
- async def post_terminal_list(req: TerminalListRequest):
 
55
  """List files currently in the sandbox workdir (for the file explorer)."""
56
- team_role = req.teamRole or "red"
57
- challenge_id = req.challengeId
 
 
58
  if not challenge_id:
59
  return {"files": []}
60
 
 
1
  """``/api/training/terminal*`` — the in-process OS simulator endpoints."""
2
 
3
  import os
4
+ import re
5
 
6
+ from fastapi import APIRouter, Depends, Request
7
+ from slowapi import Limiter
8
+ from slowapi.util import get_remote_address
9
 
10
+ from app.core.auth import get_current_user
11
  from app.sandbox.terminal import run_terminal_command
12
  from app.sandbox.workdir import get_or_create_workdir, safe_join
13
  from app.types import (
 
19
 
20
  router = APIRouter()
21
 
22
+ # Rate limiter: 30 terminal commands per minute per IP
23
+ limiter = Limiter(key_func=get_remote_address)
24
+
25
 
26
  @router.post("/api/training/terminal")
27
+ @limiter.limit("30/minute")
28
+ async def post_terminal(request: Request, req: TerminalRequest, user: dict = Depends(get_current_user)):
29
  """Execute a terminal command in the challenge's sandbox for real."""
30
+ # IDOR FIX: Use authenticated user's team role from JWT
31
+ team_role = user.get("team_role", "red")[:10]
32
+ challenge_id = re.sub(r"[^a-f0-9\-]", "", req.challengeId)[:128]
33
+ command = (req.command or "")[:500] # Limit command length
34
+
35
+ if not challenge_id or not command:
36
+ return {"stdout": "", "stderr": "❌ مدخلات غير صالحة", "exitCode": 1}
37
+
38
+ return await run_terminal_command(team_role, challenge_id, command)
39
 
40
 
41
  @router.post("/api/training/terminal/write")
42
+ @limiter.limit("20/minute")
43
+ async def post_terminal_write(request: Request, req: TerminalWriteRequest, user: dict = Depends(get_current_user)):
44
  """Write/edit a file inside the sandbox workdir (path-traversal safe)."""
45
+ # IDOR FIX: Use authenticated user's team role from JWT
46
+ team_role = user.get("team_role", "red")[:10]
47
+ challenge_id = re.sub(r"[^a-f0-9\-]", "", req.challengeId)[:128]
48
+
49
  if not challenge_id:
50
  return {"ok": False, "error": "❌ challengeId مفقود"}
51
  if not req.filename:
52
  return {"ok": False, "error": "❌ اسم الملف مفقود"}
53
+
54
+ # Sanitize filename
55
+ filename = re.sub(r"[^a-zA-Z0-9\.\-_\/]", "", req.filename)[:200]
56
+
57
  if len(req.content) > 200_000:
58
  return {"ok": False, "error": "❌ الملف كبير جداً (الحد 200KB)"}
59
 
60
  workdir, _row = await get_or_create_workdir(team_role, challenge_id)
61
  try:
62
+ target = safe_join(workdir, filename)
63
  except ValueError as e:
64
+ return {"ok": False, "error": "❌ مسار غير مسموح"}
65
 
66
  try:
67
  os.makedirs(os.path.dirname(target) or workdir, exist_ok=True)
 
69
  f.write(req.content)
70
  return {"ok": True, "path": os.path.relpath(target, workdir).replace("\\", "/")}
71
  except Exception as e:
72
+ return {"ok": False, "error": "❌ تعذّر الحفظ"}
73
 
74
 
75
  @router.post("/api/training/terminal/list")
76
+ @limiter.limit("60/minute")
77
+ async def post_terminal_list(request: Request, req: TerminalListRequest, user: dict = Depends(get_current_user)):
78
  """List files currently in the sandbox workdir (for the file explorer)."""
79
+ # IDOR FIX: Use authenticated user's team role from JWT
80
+ team_role = user.get("team_role", "red")[:10]
81
+ challenge_id = re.sub(r"[^a-f0-9\-]", "", req.challengeId)[:128]
82
+
83
  if not challenge_id:
84
  return {"files": []}
85
 
app/api/training.py CHANGED
@@ -9,8 +9,9 @@ Per-type generation / mapping logic lives in
9
  from typing import Optional
10
  import random
11
 
12
- from fastapi import APIRouter, BackgroundTasks, HTTPException
13
 
 
14
  from app.core.constants import CYBER_SECURITY_TOPICS
15
  from app.core.module_router import challenge_type_for_module
16
  from app.services.challenge_loader import (
@@ -334,7 +335,9 @@ async def _fetch_random_scenario_typed(
334
  # --------------------------------------------------------------------------- #
335
 
336
  @router.post("/api/training/evaluate")
337
- async def post_evaluate_training(req: EvaluateRequest, background_tasks: BackgroundTasks):
 
 
338
  return await evaluate_training(req, background_tasks)
339
 
340
 
@@ -343,7 +346,9 @@ async def post_evaluate_training(req: EvaluateRequest, background_tasks: Backgro
343
  # --------------------------------------------------------------------------- #
344
 
345
  @router.post("/api/training/evaluate-code-fix")
346
- async def post_evaluate_code_fix(req: CodeFixEvaluateRequest, background_tasks: BackgroundTasks):
 
 
347
  return await evaluate_code_fix(req, background_tasks)
348
 
349
 
@@ -352,7 +357,9 @@ async def post_evaluate_code_fix(req: CodeFixEvaluateRequest, background_tasks:
352
  # --------------------------------------------------------------------------- #
353
 
354
  @router.post("/api/training/evaluate-log-analysis")
355
- async def post_evaluate_log_analysis(req: LogAnalysisEvaluateRequest, background_tasks: BackgroundTasks):
 
 
356
  return await evaluate_log_analysis(req, background_tasks)
357
 
358
 
@@ -361,7 +368,9 @@ async def post_evaluate_log_analysis(req: LogAnalysisEvaluateRequest, background
361
  # --------------------------------------------------------------------------- #
362
 
363
  @router.post("/api/training/evaluate-vulnerability-hunter")
364
- async def post_evaluate_vuln_hunter(req: VulnHunterEvaluateRequest, background_tasks: BackgroundTasks):
 
 
365
  return await evaluate_vuln_hunter(req, background_tasks)
366
 
367
 
 
9
  from typing import Optional
10
  import random
11
 
12
+ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
13
 
14
+ from app.core.auth import get_current_user
15
  from app.core.constants import CYBER_SECURITY_TOPICS
16
  from app.core.module_router import challenge_type_for_module
17
  from app.services.challenge_loader import (
 
335
  # --------------------------------------------------------------------------- #
336
 
337
  @router.post("/api/training/evaluate")
338
+ async def post_evaluate_training(req: EvaluateRequest, background_tasks: BackgroundTasks, user: dict = Depends(get_current_user)):
339
+ # IDOR FIX: Override userId from JWT
340
+ req.userId = user["user_id"]
341
  return await evaluate_training(req, background_tasks)
342
 
343
 
 
346
  # --------------------------------------------------------------------------- #
347
 
348
  @router.post("/api/training/evaluate-code-fix")
349
+ async def post_evaluate_code_fix(req: CodeFixEvaluateRequest, background_tasks: BackgroundTasks, user: dict = Depends(get_current_user)):
350
+ # IDOR FIX: Override userId from JWT
351
+ req.userId = user["user_id"]
352
  return await evaluate_code_fix(req, background_tasks)
353
 
354
 
 
357
  # --------------------------------------------------------------------------- #
358
 
359
  @router.post("/api/training/evaluate-log-analysis")
360
+ async def post_evaluate_log_analysis(req: LogAnalysisEvaluateRequest, background_tasks: BackgroundTasks, user: dict = Depends(get_current_user)):
361
+ # IDOR FIX: Override userId from JWT
362
+ req.userId = user["user_id"]
363
  return await evaluate_log_analysis(req, background_tasks)
364
 
365
 
 
368
  # --------------------------------------------------------------------------- #
369
 
370
  @router.post("/api/training/evaluate-vulnerability-hunter")
371
+ async def post_evaluate_vuln_hunter(req: VulnHunterEvaluateRequest, background_tasks: BackgroundTasks, user: dict = Depends(get_current_user)):
372
+ # IDOR FIX: Override userId from JWT
373
+ req.userId = user["user_id"]
374
  return await evaluate_vuln_hunter(req, background_tasks)
375
 
376
 
app/api/xp.py CHANGED
@@ -1,26 +1,33 @@
1
  """``/api/xp`` — proxy to the Supabase ``apex-xp`` edge function."""
2
 
3
  import httpx
4
- from fastapi import APIRouter, HTTPException
 
 
5
 
 
6
  from app.core.config import SUPABASE_EDGE_URL
7
  from app.types import XpRequest
8
 
9
 
10
  router = APIRouter()
11
 
 
 
 
12
 
13
  @router.post("/api/xp")
14
- async def handle_xp(req: XpRequest):
15
- if not req.user_id:
16
- raise HTTPException(status_code=400, detail="user_id is required")
 
17
 
18
  async with httpx.AsyncClient(timeout=30) as client:
19
  resp = await client.post(
20
  f"{SUPABASE_EDGE_URL}/apex-xp",
21
- json={"action": req.action, "user_id": req.user_id, "xp_amount": req.xp_amount},
22
  )
23
  data = resp.json()
24
  if resp.status_code != 200:
25
- raise HTTPException(status_code=resp.status_code, detail=data.get("error", "XP operation failed"))
26
  return data
 
1
  """``/api/xp`` — proxy to the Supabase ``apex-xp`` edge function."""
2
 
3
  import httpx
4
+ from fastapi import APIRouter, Depends, HTTPException, Request
5
+ from slowapi import Limiter
6
+ from slowapi.util import get_remote_address
7
 
8
+ from app.core.auth import get_current_user
9
  from app.core.config import SUPABASE_EDGE_URL
10
  from app.types import XpRequest
11
 
12
 
13
  router = APIRouter()
14
 
15
+ # Rate limiter: 10 XP requests per minute per IP
16
+ limiter = Limiter(key_func=get_remote_address)
17
+
18
 
19
  @router.post("/api/xp")
20
+ @limiter.limit("10/minute")
21
+ async def handle_xp(request: Request, req: XpRequest, user: dict = Depends(get_current_user)):
22
+ # IDOR FIX: Use authenticated user's ID from JWT, ignore client-provided user_id
23
+ user_id = user["user_id"]
24
 
25
  async with httpx.AsyncClient(timeout=30) as client:
26
  resp = await client.post(
27
  f"{SUPABASE_EDGE_URL}/apex-xp",
28
+ json={"action": req.action, "user_id": user_id, "xp_amount": req.xp_amount},
29
  )
30
  data = resp.json()
31
  if resp.status_code != 200:
32
+ raise HTTPException(status_code=resp.status_code, detail="XP operation failed")
33
  return data
app/core/auth.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """JWT Authentication middleware for CyberArena.
2
+
3
+ Validates Supabase JWT tokens from the Authorization header and
4
+ extracts the authenticated user_id. This prevents user ID spoofing.
5
+ """
6
+
7
+ import jwt
8
+ from typing import Optional
9
+
10
+ from fastapi import Request, HTTPException, status
11
+
12
+ from app.core.config import SUPABASE_ANON_KEY
13
+
14
+
15
+ def _validate_jwt(token: str) -> Optional[dict]:
16
+ """Validate JWT and return payload if valid.
17
+
18
+ Uses PyJWT to verify:
19
+ 1. Token format is valid JWT
20
+ 2. Token signature is valid (using Supabase JWT secret)
21
+ 3. Token is not expired (exp claim)
22
+ 4. Token has required claims (sub for user_id)
23
+
24
+ The JWT secret is derived from the anon key's JWT payload.
25
+ In production, set SUPABASE_JWT_SECRET env var with the actual secret.
26
+ """
27
+ import os
28
+
29
+ # Get JWT secret from environment (must be set in production)
30
+ jwt_secret = os.environ.get("SUPABASE_JWT_SECRET", "")
31
+
32
+ if not jwt_secret:
33
+ # Fallback: decode anon key to get the secret for development
34
+ # In production, ALWAYS set SUPABASE_JWT_SECRET
35
+ try:
36
+ # Extract secret from anon key (development only)
37
+ payload = jwt.decode(SUPABASE_ANON_KEY, options={"verify_signature": False})
38
+ # For dev, use a derived secret - in production this MUST be the real JWT secret
39
+ jwt_secret = SUPABASE_ANON_KEY[:32] # Not secure, dev only
40
+ except Exception:
41
+ return None
42
+
43
+ try:
44
+ # Verify signature and decode
45
+ payload = jwt.decode(
46
+ token,
47
+ jwt_secret,
48
+ algorithms=["HS256"],
49
+ options={"require": ["exp", "sub"]}
50
+ )
51
+ return payload
52
+ except jwt.ExpiredSignatureError:
53
+ return None
54
+ except jwt.InvalidTokenError:
55
+ return None
56
+
57
+
58
+ async def get_current_user(request: Request) -> dict:
59
+ """FastAPI dependency that extracts authenticated user from JWT.
60
+
61
+ Returns:
62
+ dict with keys: user_id, email, role
63
+
64
+ Raises:
65
+ HTTPException 401 if no valid token
66
+ """
67
+ auth_header = request.headers.get("Authorization", "")
68
+
69
+ if not auth_header.startswith("Bearer "):
70
+ raise HTTPException(
71
+ status_code=status.HTTP_401_UNAUTHORIZED,
72
+ detail="Missing or invalid Authorization header",
73
+ headers={"WWW-Authenticate": "Bearer"},
74
+ )
75
+
76
+ token = auth_header[7:] # Remove "Bearer " prefix
77
+
78
+ payload = _validate_jwt(token)
79
+ if not payload:
80
+ raise HTTPException(
81
+ status_code=status.HTTP_401_UNAUTHORIZED,
82
+ detail="Invalid or expired token",
83
+ headers={"WWW-Authenticate": "Bearer"},
84
+ )
85
+
86
+ return {
87
+ "user_id": payload["sub"],
88
+ "email": payload.get("email", ""),
89
+ "role": payload.get("role", "authenticated"),
90
+ }
91
+
92
+
93
+ async def get_optional_user(request: Request) -> Optional[dict]:
94
+ """Like get_current_user but returns None instead of raising 401."""
95
+ try:
96
+ return await get_current_user(request)
97
+ except HTTPException:
98
+ return None
app/generators/vulnerability_hunter.py CHANGED
@@ -30,9 +30,9 @@ Difficulty uses 5 levels (Beginner / Easy / Medium / Hard / Expert), mapped
30
  to Arabic values already in the platform: مبتدئ / سهل / متوسط / صعب / خبير.
31
 
32
  Public API (used by main.py):
33
- - POOL_TARGET = 15
34
- - POOL_THRESHOLD = 5
35
- - POOL_BATCH = 5
36
  - get_pool_count(team_role) -> int
37
  - async refill_pool(team_role, count) -> int
38
  - async start_pool_watcher(team_role)
@@ -262,10 +262,10 @@ DIFFICULTY_PROFILE = {
262
  }
263
 
264
  # Pool constants
265
- # 5 difficulty levels × 3 unique challenges per level = 15 unique challenges
266
- POOL_TARGET = 15
267
- POOL_THRESHOLD = 5
268
- POOL_BATCH = 5
269
 
270
  SUPABASE_URL = os.environ.get("SUPABASE_URL", "")
271
  SUPABASE_ANON_KEY = os.environ.get("SUPABASE_ANON_KEY", "")
 
30
  to Arabic values already in the platform: مبتدئ / سهل / متوسط / صعب / خبير.
31
 
32
  Public API (used by main.py):
33
+ - POOL_TARGET = 5
34
+ - POOL_THRESHOLD = 2
35
+ - POOL_BATCH = 3
36
  - get_pool_count(team_role) -> int
37
  - async refill_pool(team_role, count) -> int
38
  - async start_pool_watcher(team_role)
 
262
  }
263
 
264
  # Pool constants
265
+ # Target: 5 challenges always available, refill when drops below 3
266
+ POOL_TARGET = 5
267
+ POOL_THRESHOLD = 2
268
+ POOL_BATCH = 3
269
 
270
  SUPABASE_URL = os.environ.get("SUPABASE_URL", "")
271
  SUPABASE_ANON_KEY = os.environ.get("SUPABASE_ANON_KEY", "")
app/main.py CHANGED
@@ -16,8 +16,12 @@ import asyncio
16
  import os
17
  import sys
18
 
19
- from fastapi import FastAPI
20
  from fastapi.middleware.cors import CORSMiddleware
 
 
 
 
21
 
22
  from app.generators import REGISTRY as GENERATOR_REGISTRY
23
 
@@ -33,17 +37,43 @@ except Exception:
33
  # App + middleware #
34
  # --------------------------------------------------------------------------- #
35
 
 
 
 
 
36
  app = FastAPI(title="CyberArena Backend")
37
 
 
 
 
 
38
  app.add_middleware(
39
  CORSMiddleware,
40
- allow_origins=["*"],
 
 
 
 
 
41
  allow_credentials=True,
42
- allow_methods=["*"],
43
  allow_headers=["*"],
44
  )
45
 
46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  # --------------------------------------------------------------------------- #
48
  # Routers #
49
  # --------------------------------------------------------------------------- #
@@ -111,7 +141,7 @@ async def startup_event():
111
  # Run this as soon as a worker imports the module. It only fires once
112
  # (see app/_env.py::_is_loaded_marker_set) and gives a clear, single-line
113
  # error instead of a 401 from Supabase ten seconds later.
114
- assert_critical_env("SUPABASE_URL", "SUPABASE_ANON_KEY")
115
 
116
 
117
  __all__ = ["app", "populate_pool_background"]
 
16
  import os
17
  import sys
18
 
19
+ from fastapi import FastAPI, Request
20
  from fastapi.middleware.cors import CORSMiddleware
21
+ from fastapi.responses import JSONResponse
22
+ from slowapi import Limiter, _rate_limit_exceeded_handler
23
+ from slowapi.util import get_remote_address
24
+ from slowapi.errors import RateLimitExceeded
25
 
26
  from app.generators import REGISTRY as GENERATOR_REGISTRY
27
 
 
37
  # App + middleware #
38
  # --------------------------------------------------------------------------- #
39
 
40
+ # Rate limiter - 100 requests per minute per IP for general endpoints
41
+ # Stricter limits for sensitive endpoints
42
+ limiter = Limiter(key_func=get_remote_address)
43
+
44
  app = FastAPI(title="CyberArena Backend")
45
 
46
+ # Add rate limiter to app
47
+ app.state.limiter = limiter
48
+ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
49
+
50
  app.add_middleware(
51
  CORSMiddleware,
52
+ allow_origins=[
53
+ "http://localhost:5173",
54
+ "http://localhost:3000",
55
+ "http://127.0.0.1:5173",
56
+ "https://alpha-team-cyberarena.hf.space",
57
+ ],
58
  allow_credentials=True,
59
+ allow_methods=["GET", "POST"],
60
  allow_headers=["*"],
61
  )
62
 
63
 
64
+ # Security headers middleware
65
+ @app.middleware("http")
66
+ async def add_security_headers(request: Request, call_next):
67
+ response = await call_next(request)
68
+ response.headers["X-Content-Type-Options"] = "nosniff"
69
+ response.headers["X-Frame-Options"] = "DENY"
70
+ response.headers["X-XSS-Protection"] = "1; mode=block"
71
+ response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
72
+ response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
73
+ response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
74
+ return response
75
+
76
+
77
  # --------------------------------------------------------------------------- #
78
  # Routers #
79
  # --------------------------------------------------------------------------- #
 
141
  # Run this as soon as a worker imports the module. It only fires once
142
  # (see app/_env.py::_is_loaded_marker_set) and gives a clear, single-line
143
  # error instead of a 401 from Supabase ten seconds later.
144
+ assert_critical_env("SUPABASE_URL", "SUPABASE_ANON_KEY", "SUPABASE_JWT_SECRET")
145
 
146
 
147
  __all__ = ["app", "populate_pool_background"]
app/sandbox/terminal.py CHANGED
@@ -2,8 +2,8 @@
2
 
3
  Parses a single command with ``shlex``, runs built-ins via
4
  :mod:`app.sandbox.tools`, and runs external tools via subprocess — but
5
- only if the challenge row whitelists them. ``python`` / ``python3`` are
6
- always allowed so the student can run their own scripts.
7
 
8
  Returns the standard ``{stdout, stderr, exitCode}`` dict (plus ``clear``
9
  for the ``clear`` built-in). Used by :mod:`app.api.terminal`.
@@ -12,6 +12,7 @@ for the ``clear`` built-in). Used by :mod:`app.api.terminal`.
12
  import os
13
  import shlex
14
  import subprocess
 
15
  from typing import Optional
16
 
17
  from .workdir import get_or_create_workdir
@@ -19,7 +20,99 @@ from .tools import shell_builtins
19
 
20
 
21
  # External tools that are always allowed regardless of row whitelist.
22
- ALWAYS_ALLOWED = {"python", "python3", "py"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
 
25
  def parse_command(command: str) -> Optional[list[str]]:
@@ -82,6 +175,22 @@ async def run_terminal_command(team_role: str, challenge_id: str, command: str)
82
  env = os.environ.copy()
83
  env["PYTHONIOENCODING"] = "utf-8"
84
  env["PYTHONUTF8"] = "1"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  proc = subprocess.run(
86
  parts,
87
  cwd=workdir,
@@ -101,4 +210,4 @@ async def run_terminal_command(team_role: str, challenge_id: str, command: str)
101
  except subprocess.TimeoutExpired:
102
  return {"stdout": "", "stderr": "⏱️ انتهت المهلة (8 ثوانٍ). قد يكون الأمر يدور في حلقة لا نهائية.", "exitCode": 124}
103
  except Exception as e:
104
- return {"stdout": "", "stderr": f"❌ خطأ: {e}", "exitCode": 1}
 
2
 
3
  Parses a single command with ``shlex``, runs built-ins via
4
  :mod:`app.sandbox.tools`, and runs external tools via subprocess — but
5
+ only if the challenge row whitelists them. Python execution is sandboxed
6
+ with restricted imports.
7
 
8
  Returns the standard ``{stdout, stderr, exitCode}`` dict (plus ``clear``
9
  for the ``clear`` built-in). Used by :mod:`app.api.terminal`.
 
12
  import os
13
  import shlex
14
  import subprocess
15
+ import sys
16
  from typing import Optional
17
 
18
  from .workdir import get_or_create_workdir
 
20
 
21
 
22
  # External tools that are always allowed regardless of row whitelist.
23
+ # NOTE: Python is NOT in this list anymore — it must be explicitly whitelisted.
24
+ ALWAYS_ALLOWED: set[str] = set()
25
+
26
+ # Dangerous Python modules that must be blocked
27
+ BLOCKED_PYTHON_MODULES = {
28
+ "os", "subprocess", "shutil", "sys", "importlib", "ctypes",
29
+ "socket", "http", "urllib", "requests", "eval", "exec",
30
+ "pickle", "marshal", "shelve", "signal", "multiprocessing",
31
+ "threading", "asyncio", "pathlib",
32
+ }
33
+
34
+
35
+ def _run_restricted_python(code: str, workdir: str, env: dict) -> dict:
36
+ """Execute Python code in a restricted environment."""
37
+ import tempfile
38
+
39
+ # Create a wrapper script that blocks dangerous modules
40
+ wrapper_code = f'''
41
+ import sys
42
+ import os
43
+
44
+ # Change to workdir
45
+ os.chdir(r"{workdir}")
46
+
47
+ # Block dangerous modules
48
+ _blocked = {repr(BLOCKED_PYTHON_MODULES)}
49
+
50
+ class _BlockImporter:
51
+ def find_module(self, name, path=None):
52
+ top = name.split('.')[0]
53
+ if top in _blocked or name in _blocked:
54
+ return self
55
+ return None
56
+ def load_module(self, name):
57
+ raise ImportError(f"Module '{{name}}' is blocked in the sandbox")
58
+
59
+ sys.meta_path.insert(0, _BlockImporter())
60
+
61
+ # Block dangerous builtins
62
+ import builtins
63
+ _original_eval = builtins.eval
64
+ _original_exec = builtins.exec
65
+ def _safe_eval(*a, **kw):
66
+ raise RuntimeError("eval() is disabled in the sandbox")
67
+ def _safe_exec(*a, **kw):
68
+ raise RuntimeError("exec() is disabled in the sandbox")
69
+ builtins.eval = _safe_eval
70
+ builtins.exec = _safe_exec
71
+
72
+ # Execute user code
73
+ try:
74
+ {chr(10).join(" " + line for line in code.split(chr(10)))}
75
+ except Exception as e:
76
+ print(f"Error: {{e}}", file=sys.stderr)
77
+ sys.exit(1)
78
+ '''
79
+
80
+ try:
81
+ # Write wrapper to temp file
82
+ with tempfile.NamedTemporaryFile(
83
+ mode='w', suffix='.py', delete=False,
84
+ encoding='utf-8', dir=workdir
85
+ ) as f:
86
+ f.write(wrapper_code)
87
+ script_path = f.name
88
+
89
+ # Execute the restricted script
90
+ proc = subprocess.run(
91
+ [sys.executable, script_path],
92
+ cwd=workdir,
93
+ capture_output=True,
94
+ timeout=8,
95
+ env=env,
96
+ )
97
+
98
+ stdout = (proc.stdout or b"").decode("utf-8", errors="replace")
99
+ stderr = (proc.stderr or b"").decode("utf-8", errors="replace")
100
+
101
+ # Clean up
102
+ try:
103
+ os.unlink(script_path)
104
+ except:
105
+ pass
106
+
107
+ return {
108
+ "stdout": stdout[:8000],
109
+ "stderr": stderr[:3000],
110
+ "exitCode": proc.returncode,
111
+ }
112
+ except subprocess.TimeoutExpired:
113
+ return {"stdout": "", "stderr": "⏱️ انتهت المهلة (8 ثوانٍ).", "exitCode": 124}
114
+ except Exception as e:
115
+ return {"stdout": "", "stderr": "❌ خطأ غير متوقع في تنفيذ Python", "exitCode": 1}
116
 
117
 
118
  def parse_command(command: str) -> Optional[list[str]]:
 
175
  env = os.environ.copy()
176
  env["PYTHONIOENCODING"] = "utf-8"
177
  env["PYTHONUTF8"] = "1"
178
+
179
+ # For Python execution, use restricted mode
180
+ if tool in ("python", "python3", "py") and len(parts) > 1:
181
+ if parts[1] == "-c":
182
+ # Execute Python code with restrictions
183
+ user_code = " ".join(parts[2:])
184
+ return _run_restricted_python(user_code, workdir, env)
185
+ elif parts[1].endswith(".py"):
186
+ # Execute Python file with restrictions
187
+ script_path = os.path.join(workdir, parts[1])
188
+ if not os.path.exists(script_path):
189
+ return {"stdout": "", "stderr": f"❌ الملف '{parts[1]}' غير موجود", "exitCode": 1}
190
+ with open(script_path, "r", encoding="utf-8", errors="replace") as f:
191
+ user_code = f.read()
192
+ return _run_restricted_python(user_code, workdir, env)
193
+
194
  proc = subprocess.run(
195
  parts,
196
  cwd=workdir,
 
210
  except subprocess.TimeoutExpired:
211
  return {"stdout": "", "stderr": "⏱️ انتهت المهلة (8 ثوانٍ). قد يكون الأمر يدور في حلقة لا نهائية.", "exitCode": 124}
212
  except Exception as e:
213
+ return {"stdout": "", "stderr": "❌ خطأ غير متوقع", "exitCode": 1}
app/sandbox/tools.py CHANGED
@@ -14,6 +14,22 @@ from typing import Optional
14
  from app.core.constants import HELP_TEXT
15
 
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  def shell_builtins(args: list[str], workdir: str) -> Optional[dict]:
18
  """Run a built-in command. Returns a ``{stdout, stderr, exitCode}``
19
  result dict, or ``None`` if the tool is not a built-in (caller should
@@ -26,7 +42,10 @@ def shell_builtins(args: list[str], workdir: str) -> Optional[dict]:
26
  return {"stdout": "", "stderr": "cat: missing filename", "exitCode": 1}
27
  out, err = [], ""
28
  for fname in rest:
29
- p = os.path.join(workdir, fname.lstrip("/\\").replace("..", "_"))
 
 
 
30
  if not os.path.isfile(p):
31
  err += f"cat: {fname}: No such file\n"
32
  continue
@@ -61,7 +80,10 @@ def shell_builtins(args: list[str], workdir: str) -> Optional[dict]:
61
  return {"stdout": "", "stderr": f"{tool}: missing filename", "exitCode": 1}
62
  out, err = [], ""
63
  for fname in rest:
64
- p = os.path.join(workdir, fname.lstrip("/\\").replace("..", "_"))
 
 
 
65
  if not os.path.isfile(p):
66
  err += f"{tool}: {fname}: No such file\n"
67
  continue
@@ -178,12 +200,22 @@ def shell_builtins(args: list[str], workdir: str) -> Optional[dict]:
178
  try:
179
  out_lines = [f"Archive: {fname}"]
180
  with zipfile.ZipFile(p, 'r') as zip_ref:
 
 
 
 
 
 
 
 
 
 
181
  zip_ref.extractall(workdir)
182
  for info in zip_ref.infolist():
183
  out_lines.append(f" extracting: {info.filename}")
184
  return {"stdout": "\n".join(out_lines), "stderr": "", "exitCode": 0}
185
  except Exception as e:
186
- return {"stdout": "", "stderr": f"unzip: error: {e}", "exitCode": 1}
187
 
188
  except Exception as e:
189
  return {"stdout": "", "stderr": f"{tool}: {e}", "exitCode": 1}
 
14
  from app.core.constants import HELP_TEXT
15
 
16
 
17
+ def _safe_path(workdir: str, filename: str) -> Optional[str]:
18
+ """Resolve filename safely within workdir, preventing path traversal.
19
+
20
+ Returns the resolved path if safe, None if traversal detected.
21
+ """
22
+ # Strip leading slashes and replace ..
23
+ cleaned = filename.lstrip("/\\").replace("..", "_")
24
+ target = os.path.normpath(os.path.join(workdir, cleaned))
25
+ # Verify the resolved path is within workdir
26
+ real_workdir = os.path.realpath(workdir)
27
+ real_target = os.path.realpath(target)
28
+ if not real_target.startswith(real_workdir + os.sep) and real_target != real_workdir:
29
+ return None
30
+ return target
31
+
32
+
33
  def shell_builtins(args: list[str], workdir: str) -> Optional[dict]:
34
  """Run a built-in command. Returns a ``{stdout, stderr, exitCode}``
35
  result dict, or ``None`` if the tool is not a built-in (caller should
 
42
  return {"stdout": "", "stderr": "cat: missing filename", "exitCode": 1}
43
  out, err = [], ""
44
  for fname in rest:
45
+ p = _safe_path(workdir, fname)
46
+ if p is None:
47
+ err += f"cat: {fname}: Path traversal blocked\n"
48
+ continue
49
  if not os.path.isfile(p):
50
  err += f"cat: {fname}: No such file\n"
51
  continue
 
80
  return {"stdout": "", "stderr": f"{tool}: missing filename", "exitCode": 1}
81
  out, err = [], ""
82
  for fname in rest:
83
+ p = _safe_path(workdir, fname)
84
+ if p is None:
85
+ err += f"{tool}: {fname}: Path traversal blocked\n"
86
+ continue
87
  if not os.path.isfile(p):
88
  err += f"{tool}: {fname}: No such file\n"
89
  continue
 
200
  try:
201
  out_lines = [f"Archive: {fname}"]
202
  with zipfile.ZipFile(p, 'r') as zip_ref:
203
+ # Validate all paths before extraction (Zip Slip prevention)
204
+ for info in zip_ref.infolist():
205
+ target = os.path.realpath(os.path.join(workdir, info.filename))
206
+ if not target.startswith(os.path.realpath(workdir) + os.sep) and target != os.path.realpath(workdir):
207
+ return {
208
+ "stdout": "",
209
+ "stderr": f"unzip: blocked path traversal: {info.filename}",
210
+ "exitCode": 1,
211
+ }
212
+ # Safe to extract
213
  zip_ref.extractall(workdir)
214
  for info in zip_ref.infolist():
215
  out_lines.append(f" extracting: {info.filename}")
216
  return {"stdout": "\n".join(out_lines), "stderr": "", "exitCode": 0}
217
  except Exception as e:
218
+ return {"stdout": "", "stderr": "unzip: error occurred", "exitCode": 1}
219
 
220
  except Exception as e:
221
  return {"stdout": "", "stderr": f"{tool}: {e}", "exitCode": 1}
app/services/evaluator.py CHANGED
@@ -62,9 +62,6 @@ async def evaluate_training(req, background_tasks: BackgroundTasks) -> dict:
62
  كود/استغلال المستخدم:
63
  {user_code}"""
64
 
65
- print(f"[DEBUG] GROQ_API_KEY exists: {bool(GROQ_API_KEY)}")
66
- print(f"[DEBUG] GROQ_API_KEY length: {len(GROQ_API_KEY) if GROQ_API_KEY else 0}")
67
-
68
  async with httpx.AsyncClient(timeout=60) as client:
69
  resp = await client.post(
70
  GROQ_API_URL,
@@ -83,16 +80,15 @@ async def evaluate_training(req, background_tasks: BackgroundTasks) -> dict:
83
  },
84
  )
85
 
86
- print(f"[DEBUG] Groq API response status: {resp.status_code}")
87
  if resp.status_code != 200:
88
- error_detail = f"Groq API error: {resp.status_code}"
 
 
89
  try:
90
- error_body = resp.text
91
- print(f"[DEBUG] Groq API error body: {error_body}")
92
- error_detail += f" - {error_body}"
93
  except Exception:
94
  pass
95
- raise HTTPException(status_code=500, detail=error_detail)
96
 
97
  content = resp.json()["choices"][0]["message"]["content"]
98
  evaluation = parse_json_safe(content)
 
62
  كود/استغلال المستخدم:
63
  {user_code}"""
64
 
 
 
 
65
  async with httpx.AsyncClient(timeout=60) as client:
66
  resp = await client.post(
67
  GROQ_API_URL,
 
80
  },
81
  )
82
 
 
83
  if resp.status_code != 200:
84
+ # Log error server-side only, don't leak to client
85
+ import logging
86
+ logging.error(f"Groq API error: {resp.status_code}")
87
  try:
88
+ logging.error(f"Groq API error body: {resp.text[:200]}")
 
 
89
  except Exception:
90
  pass
91
+ raise HTTPException(status_code=500, detail="AI evaluation service temporarily unavailable")
92
 
93
  content = resp.json()["choices"][0]["message"]["content"]
94
  evaluation = parse_json_safe(content)
app/services/supabase_service.py CHANGED
@@ -5,6 +5,7 @@ challenge-loader / scenario-service modules build on top of these.
5
  """
6
 
7
  import random
 
8
  from typing import Optional
9
 
10
  import httpx
@@ -12,6 +13,26 @@ import httpx
12
  from app.core.config import SUPABASE_URL, SUPABASE_ANON_KEY
13
 
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  # --------------------------------------------------------------------------- #
16
  # Table routing #
17
  # --------------------------------------------------------------------------- #
@@ -77,6 +98,13 @@ async def fetch_scenario_by_id(
77
  if not SUPABASE_ANON_KEY or not SUPABASE_URL:
78
  return None
79
 
 
 
 
 
 
 
 
80
  async def _fetch(tbl: str) -> Optional[dict]:
81
  url = f"{SUPABASE_URL}/rest/v1/{tbl}?id=eq.{scenario_id}&team_role=eq.{team_role}"
82
  try:
@@ -126,6 +154,13 @@ async def fetch_random_scenario_from_supabase(
126
  if not SUPABASE_ANON_KEY or not SUPABASE_URL:
127
  return None
128
  table = scenario_table(team_role, challenge_type)
 
 
 
 
 
 
 
129
  # When ``module`` is empty we deliberately skip the module filter
130
  # — this is the "give me any row of this type" code path used by
131
  # the dashboard list. The previous behaviour built a
 
5
  """
6
 
7
  import random
8
+ import re
9
  from typing import Optional
10
 
11
  import httpx
 
13
  from app.core.config import SUPABASE_URL, SUPABASE_ANON_KEY
14
 
15
 
16
+ # --------------------------------------------------------------------------- #
17
+ # Input sanitization #
18
+ # --------------------------------------------------------------------------- #
19
+
20
+ def _sanitize_id(value: str) -> str:
21
+ """Strip anything that isn't a UUID or alphanumeric — prevents PostgREST injection."""
22
+ if not value:
23
+ return ""
24
+ return re.sub(r"[^a-zA-Z0-9\-]", "", value)[:128]
25
+
26
+
27
+ def _sanitize_string(value: str, max_len: int = 200) -> str:
28
+ """Strip PostgREST operators and limit length."""
29
+ if not value:
30
+ return ""
31
+ # Remove PostgREST filter operators
32
+ cleaned = re.sub(r"[=&<>!|;()\[\]{}]", "", value)
33
+ return cleaned[:max_len]
34
+
35
+
36
  # --------------------------------------------------------------------------- #
37
  # Table routing #
38
  # --------------------------------------------------------------------------- #
 
98
  if not SUPABASE_ANON_KEY or not SUPABASE_URL:
99
  return None
100
 
101
+ # Sanitize inputs to prevent PostgREST injection
102
+ scenario_id = _sanitize_id(scenario_id)
103
+ team_role = _sanitize_string(team_role, 10)
104
+
105
+ if not scenario_id or not team_role:
106
+ return None
107
+
108
  async def _fetch(tbl: str) -> Optional[dict]:
109
  url = f"{SUPABASE_URL}/rest/v1/{tbl}?id=eq.{scenario_id}&team_role=eq.{team_role}"
110
  try:
 
154
  if not SUPABASE_ANON_KEY or not SUPABASE_URL:
155
  return None
156
  table = scenario_table(team_role, challenge_type)
157
+
158
+ # Sanitize inputs
159
+ team_role = _sanitize_string(team_role, 10)
160
+ module = _sanitize_string(module, 50)
161
+
162
+ if not team_role:
163
+ return None
164
  # When ``module`` is empty we deliberately skip the module filter
165
  # — this is the "give me any row of this type" code path used by
166
  # the dashboard list. The previous behaviour built a
requirements.txt CHANGED
@@ -1,8 +1,10 @@
1
- fastapi
2
- uvicorn
3
- httpx
4
- groq
5
- python-dotenv
6
- reportlab
7
- qrcode[pil]
8
- cryptography
 
 
 
1
+ fastapi==0.115.0
2
+ uvicorn==0.34.0
3
+ httpx==0.28.0
4
+ groq==0.13.0
5
+ python-dotenv==1.0.1
6
+ reportlab==4.2.5
7
+ qrcode[pil]==8.0
8
+ cryptography==44.0.0
9
+ slowapi==0.1.9
10
+ PyJWT==2.9.0