Skip to content

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 invoice

SEC-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éessayer

SEC-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_sites en 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:* +@all

SEC-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;  // Safe

SEC-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

PROJET MOSAÏQUE — 81 outils, 22 conteneurs, 500+ revendeurs WiFi Zone