Skip to main content

Security Standards

Core Principles

  1. Fail securely — errors don't leak internal details or grant access
  2. Least privilege — users can only access their own data; admin endpoints require admin role
  3. Validate at boundaries — sanitize all user input, all external content
  4. Never log secrets — no passwords, tokens, SSH keys in logs or responses

Secrets Management

# .env (never committed, listed in .gitignore)
DATABASE_URL=postgresql+asyncpg://user:password@localhost/platform
SECRET_KEY=change-me-in-production
LXD_CERT_PATH=/app/.lxd/client.crt
LXD_KEY_PATH=/app/.lxd/client.key

# backend/.env.example (committed, placeholder values)
DATABASE_URL=postgresql+asyncpg://platform:password@localhost:5432/platform
SECRET_KEY=change-me
LXD_ENDPOINT=https://10.0.0.3:8443

Rules:

  • .env in .gitignore, never committed
  • .env.example documents all required variables with placeholder values
  • Backend validates required env vars on startup via pydantic Settings class
  • Never log secret values (JWT tokens, passwords, SSH keys)
  • Never expose secrets in HTTP responses

Authentication — JWT

JWT tokens signed with HS256, 8-hour expiry, stored in localStorage.

# Token creation (auth.py)
def create_access_token(data: dict) -> str:
payload = data.copy()
payload["exp"] = datetime.utcnow() + timedelta(hours=8)
return jwt.encode(payload, SECRET_KEY, algorithm="HS256")

# Token validation dependency
async def get_current_user(token: str = Depends(oauth2_scheme), db = Depends(get_db)):
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
user_id = payload.get("sub")
except JWTError:
raise HTTPException(status_code=401, detail="Invalid token")
...

Rules:

  • Missing or invalid token → 401 Unauthorized
  • Valid token, insufficient role → 403 Forbidden
  • Public endpoints only: POST /api/auth/login, POST /api/auth/register

Password Security — bcrypt

from passlib.context import CryptContext

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

def hash_password(password: str) -> str:
return pwd_context.hash(password)

def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)

Rules:

  • Always bcrypt — never MD5, SHA1, or plain text
  • Never store plain text passwords
  • Never return password hashes in API responses

Role-Based Access Control

Three roles: student, instructor, admin.

# FastAPI dependency for admin endpoints
async def require_admin(current_user = Depends(get_current_user)):
if current_user.role != "admin":
raise HTTPException(status_code=403, detail="Admin access required")
return current_user

# Usage
@router.delete("/admin/users/{user_id}")
async def delete_user(user_id: int, admin = Depends(require_admin), db = Depends(get_db)):
...

Rules:

  • Never trust client-side role checks — always enforce server-side via dependencies
  • Students can only access their own data (filter by current_user.id)
  • Admin UI is hidden from non-admins in frontend but server always enforces access

SQL Injection Prevention

SQLAlchemy ORM with parameterized queries is used throughout. Never use raw string formatting.

# GOOD — ORM/parameterized
result = await db.execute(select(Exam).where(Exam.id == exam_id))

# GOOD — raw query with bound params
await db.execute(text("SELECT * FROM users WHERE id = :id"), {"id": user_id})

# BAD — string formatting (SQL injection)
await db.execute(f"SELECT * FROM users WHERE id = {user_id}")

XSS Prevention — Markdown Rendering

All markdown content rendered in the frontend must be sanitized before using v-html.

import { marked } from 'marked'
import DOMPurify from 'dompurify'

const renderedMarkdown = computed(() => {
const html = marked.parse(markdownContent.value || '')
return DOMPurify.sanitize(html)
})

Never use v-html with unsanitized content.


CORS Configuration

# main.py
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins, # Explicit list, never "*"
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# .env — comma-separated list
CORS_ORIGINS=https://mvp.zafarsaidov.uz,http://localhost:5173

Never use wildcard * for allow_origins in production.


LXD / SSH Security

  • LXD API access uses client certificates (stored at LXD_CERT_PATH, LXD_KEY_PATH)
  • Terminal WebSocket uses JWT token in query param: /ws/terminal/{container_id}?token={jwt}
  • Containers are namespaced per user: user-{id}-sandbox, user-{id}-exam-{attempt_id}
  • Verification scripts run inside isolated containers via SSH — never on the app server

Verification Script Safety

Exam verification scripts execute user-submitted bash inside LXD containers. The container provides isolation.

# Always execute in the container via SSH, never on the host
output = await execute_script_via_ssh(container_name, script_content)

Script contract: scripts must print PASS or FAIL and complete within 30 seconds.


Production Security Checklist

Before deploying:

  • SECRET_KEY is long, random, and not the default value
  • .env is not committed to git
  • LXD client certificates are in place (/app/.lxd/)
  • SSH private key for lessons repo is in place (/app/private_key)
  • CORS origins list only includes the actual frontend URL
  • Database uses a dedicated user with limited permissions (not superuser)
  • All endpoints return generic error messages (no stack traces in 500 responses)
  • Nginx terminates TLS (HTTPS enforced in production)
  • Containers expire and are cleaned up (background cleanup task running)