Spaces:
Sleeping
Sleeping
Hussien Haider commited on
Commit ·
3c7b4e4
1
Parent(s): 0de52fb
- .dockerignore +36 -14
- Dockerfile +10 -3
- app/api/auth.py +10 -3
- app/api/certificates.py +10 -3
- app/api/onevone.py +54 -31
- app/api/terminal.py +38 -13
- app/api/training.py +14 -5
- app/api/xp.py +13 -6
- app/core/auth.py +98 -0
- app/generators/vulnerability_hunter.py +7 -7
- app/main.py +34 -4
- app/sandbox/terminal.py +113 -4
- app/sandbox/tools.py +35 -3
- app/services/evaluator.py +5 -9
- app/services/supabase_service.py +35 -0
- requirements.txt +10 -8
.dockerignore
CHANGED
|
@@ -1,21 +1,43 @@
|
|
|
|
|
| 1 |
.env
|
| 2 |
.env.*
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
*.pyc
|
| 5 |
*.pyo
|
| 6 |
*.pyd
|
| 7 |
-
.
|
| 8 |
-
.
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
.vscode
|
| 14 |
-
.idea
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
.DS_Store
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
*.log
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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.
|
| 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":
|
| 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":
|
| 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 |
-
|
| 377 |
-
|
|
|
|
|
|
|
| 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(
|
| 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(
|
| 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":
|
| 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":
|
| 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(
|
| 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
|
| 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.{
|
| 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(
|
| 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(
|
| 551 |
raise HTTPException(status_code=403, detail="لست لاعباً في هذه المباراة")
|
| 552 |
-
await _mark_player_ready(match_id, str(
|
| 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(
|
| 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"]
|
| 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":
|
| 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":
|
| 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":
|
| 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":
|
| 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,
|
| 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(
|
| 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(
|
| 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 |
-
|
|
|
|
| 21 |
"""Execute a terminal command in the challenge's sandbox for real."""
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
|
| 26 |
@router.post("/api/training/terminal/write")
|
| 27 |
-
|
|
|
|
| 28 |
"""Write/edit a file inside the sandbox workdir (path-traversal safe)."""
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
| 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,
|
| 41 |
except ValueError as e:
|
| 42 |
-
return {"ok": False, "error":
|
| 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":
|
| 51 |
|
| 52 |
|
| 53 |
@router.post("/api/training/terminal/list")
|
| 54 |
-
|
|
|
|
| 55 |
"""List files currently in the sandbox workdir (for the file explorer)."""
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
| 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":
|
| 22 |
)
|
| 23 |
data = resp.json()
|
| 24 |
if resp.status_code != 200:
|
| 25 |
-
raise HTTPException(status_code=resp.status_code, detail=
|
| 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 =
|
| 34 |
-
- POOL_THRESHOLD =
|
| 35 |
-
- POOL_BATCH =
|
| 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
|
| 266 |
-
POOL_TARGET =
|
| 267 |
-
POOL_THRESHOLD =
|
| 268 |
-
POOL_BATCH =
|
| 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.
|
| 6 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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":
|
|
|
|
| 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 =
|
|
|
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
|
|
|
| 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":
|
| 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 |
-
|
|
|
|
|
|
|
| 89 |
try:
|
| 90 |
-
|
| 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=
|
| 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
|