Règles de Sécurité
🔴 Règles Critiques (SEC-01 à SEC-09)
SEC-01 — Protection IDOR
Toujours vérifier l'ownership avant d'accéder à une ressource.
python
# MAUVAIS — IDOR possible
@router.get("/invoices/{invoice_id}")
async def get_invoice(invoice_id: UUID, db: Session = Depends(get_db)):
return db.query(Invoice).filter(Invoice.id == invoice_id).first()
# BON — vérification ownership
@router.get("/invoices/{invoice_id}")
async def get_invoice(
invoice_id: UUID,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first()
if invoice.reseller_id != current_user.reseller_id:
raise HTTPException(status_code=403, detail="Forbidden")
return invoiceSEC-02 — Race Conditions Vouchers
Utiliser Redis WATCH/MULTI/EXEC pour éviter les race conditions lors de l'usage d'un voucher.
python
# BON — atomicité Redis
def use_voucher(redis_client, voucher_code: str, subscriber_id: str) -> bool:
key = f"rgz:voucher:used:{voucher_code}"
with redis_client.pipeline() as pipe:
while True:
try:
pipe.watch(key)
if pipe.exists(key):
return False # Déjà utilisé
pipe.multi()
pipe.set(key, subscriber_id, ex=86400)
pipe.execute()
return True
except redis.WatchError:
continue # RéessayerSEC-03 — Webhook KKiaPay
python
@router.post("/payments/webhook")
async def payment_webhook(request: Request, db: Session = Depends(get_db)):
# 1. Vérifier le secret
secret = request.headers.get("x-kkiapay-secret")
if secret != settings.KKIAPAY_SECRET:
raise HTTPException(status_code=401)
payload = await request.json()
# 2. Idempotency — rejeter si déjà traité
existing = db.query(Payment).filter(
Payment.kkiapay_transaction_id == payload["transactionId"]
).first()
if existing:
return {"status": "already_processed"}
# 3. Traiter le paiement
# ...SEC-04 — Montants en FCFA (Entiers)
python
# MAUVAIS — float pour argent
def calculate_split(amount: float) -> dict:
return {"reseller": amount * 0.5} # Erreurs d'arrondi!
# BON — entiers FCFA uniquement
def calculate_split(amount_fcfa: int) -> dict:
fee_kkiapay = round(amount_fcfa * 0.015) # round() → int
net = amount_fcfa - fee_kkiapay
reseller_share = net // 2 # Division entière
rgz_share = net - reseller_share
return {"reseller": reseller_share, "rgz": rgz_share, "fee": fee_kkiapay}SEC-05 — RADIUS_SECRET
- JAMAIS hardcoder le secret RADIUS dans le code
- Toujours lire depuis
settings.RADIUS_SECRET(env var) - NAS-Identifier validé contre la table
reseller_sitesen DB
SEC-06 — OTP Lié à subscriber_id
python
# OTP stocké avec subscriber_id UUID (pas le numéro de phone)
redis.set(f"rgz:otp:{subscriber_id}", otp_code, ex=300)
# Vérification avec protection timing attack
import hmac
if not hmac.compare_digest(stored_otp, provided_otp):
raise HTTPException(status_code=400, detail="ERR_OTP_INVALID")SEC-07 — Redis Sécurisé
yaml
# config/redis/redis.conf
requirepass ${REDIS_PASSWORD}
# ACL par service (lecture seule pour certains)
acl setuser rgz_api on >${REDIS_API_PASSWORD} ~rgz:* +@all
acl setuser rgz_beat on >${REDIS_BEAT_PASSWORD} ~rgz:* +@allSEC-08 — PostgreSQL Sécurisé
python
# DATABASE_URL avec sslmode
DATABASE_URL = "postgresql://rgz_rw:pass@rgz-db:5432/rgz?sslmode=require"
# Rôles séparés par usage
# rgz_ro → SELECT uniquement (monitoring, reports)
# rgz_rw → SELECT + INSERT + UPDATE (API)
# rgz_log → INSERT ONLY (audit trail, immutable logs)SEC-09 — Docker Sécurisé
yaml
# docker-compose.core.yml
services:
rgz-api:
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:8000/health"]
deploy:
resources:
limits:
memory: 512M🟠 Règles Hautes (SEC-10 à SEC-14)
SEC-10 — JWT RS256
python
# Paramètres JWT
JWT_ALGORITHM = "RS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 15
REFRESH_TOKEN_EXPIRE_DAYS = 7
# Révocation via blacklist Redis
redis.set(f"rgz:jwt:revoked:{jti}", "1", ex=token_expiry)SEC-11 — CORS Whitelist
python
# app/main.py — JAMAIS allow_origins=["*"] en production
app.add_middleware(CORSMiddleware,
allow_origins=settings.ALLOWED_ORIGINS, # Liste explicite
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
allow_credentials=True
)
# settings.ALLOWED_ORIGINS = [
# "https://admin-rgz.duckdns.org",
# "https://access-rgz.duckdns.org"
# ]SEC-12 — Upload Fichiers
python
ALLOWED_MAGIC_BYTES = {
"image/png": b"\x89PNG",
"image/jpeg": b"\xff\xd8\xff",
"image/svg+xml": b"<svg",
}
def validate_upload(file_content: bytes, content_type: str):
if len(file_content) > 5 * 1024 * 1024: # 5MB max
raise ValueError("ERR_FILE_TOO_LARGE")
magic = ALLOWED_MAGIC_BYTES.get(content_type)
if magic and not file_content.startswith(magic):
raise ValueError("ERR_FILE_TYPE_MISMATCH")SEC-13 — XSS Protection
javascript
// MAUVAIS
element.innerHTML = userData; // XSS!
// BON
element.textContent = userData; // SafeSEC-14 — Rate Limiting
nginx
# nginx.conf
limit_req_zone $binary_remote_addr zone=auth_zone:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=otp_zone:10m rate=3r/m;
location /api/v1/auth/ {
limit_req zone=auth_zone burst=3 nodelay;
proxy_pass $api_upstream;
}Dernière mise à jour: 2026-02-21