Security Standards
Core Principles
- Fail securely — errors don't leak internal details or grant access
- Least privilege — users can only access their own data; admin endpoints require admin role
- Validate at boundaries — sanitize all user input, all external content
- 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:
.envin.gitignore, never committed.env.exampledocuments all required variables with placeholder values- Backend validates required env vars on startup via pydantic
Settingsclass - 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_KEYis long, random, and not the default value -
.envis 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)