Aller au contenu

Bonnes pratiques de sécurité

Secure by design, validation des entrees, encodage des sorties et headers HTTP — les fondations d'un code résistant par construction.


Secure by design

Sécuriser par conception signifie intégrer la sécurité dans l'architecture avant d'écrire le code, pas après.

graph TD
    A[Conception] -->|"threat model"| B[Architecture securisee]
    B -->|"validation, encodage"| C[Developpement]
    C -->|"SAST, revue"| D[Tests]
    D -->|"DAST, pentest"| E[Production]
    E -->|"monitoring, alertes"| A

Principes fondateurs :

  • Minimalisme : n'implémenter que ce qui est nécessaire (pas de fonctionnalités "au cas où")
  • Separations des responsabilités : chaque composant a un périmètre clair et limite
  • Defaults sécurisés : la configuration par défaut doit être la plus sure
  • Échec sécurisé : en cas d'erreur, refuser l'accès plutôt que l'autoriser

Validation des entrees

Whitelist vs blacklist

La validation par whitelist (ce qui est autorise) est toujours supérieure à la blacklist (ce qui est interdit).

import re
from typing import Optional

# VULNERABLE — blacklist incomplete
def validate_username_bad(username: str) -> bool:
    forbidden = ["<", ">", "'", '"', ";", "--"]
    return not any(c in username for c in forbidden)
    # Oubli de `\n`, `\r`, null bytes, caracteres unicode confusables...

# SECURISE — whitelist stricte
def validate_username(username: str) -> bool:
    # Seulement alphanumerique, tiret et underscore, 3-32 caracteres
    return bool(re.match(r'^[a-zA-Z0-9_\-]{3,32}$', username))

# SECURISE — validation avec Pydantic (recommande)
from pydantic import BaseModel, constr, validator

class UserCreate(BaseModel):
    username: constr(pattern=r'^[a-zA-Z0-9_\-]{3,32}$')
    email: str  # Pydantic valide le format email automatiquement
    age: int

    @validator('age')
    def age_must_be_positive(cls, v):
        if v < 0 or v > 150:
            raise ValueError("Age invalide")
        return v

Validation côté serveur obligatoire

// VULNERABLE — validation cote client uniquement
// HTML: <input type="email" required maxlength="100">
// La validation HTML est triviale a contourner avec curl ou devtools

// SECURISE — toujours valider cote serveur
const { body, validationResult } = require('express-validator');

app.post('/register', [
  body('email').isEmail().normalizeEmail(),
  body('username').isAlphanumeric().isLength({ min: 3, max: 32 }),
  body('age').isInt({ min: 0, max: 150 })
], (req, res) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    return res.status(400).json({ errors: errors.array() });
  }
  // Traitement...
});

Encodage des sorties

L'encodage contextuel empêche l'interpretation des données comme du code.

from markupsafe import escape
import json

user_input = "<script>alert('xss')</script>"

# Contexte HTML — echapper les caracteres speciaux HTML
html_safe = escape(user_input)
# &lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;

# Contexte JavaScript — serialiser en JSON
js_safe = json.dumps(user_input)
# "\"<script>alert('xss')</script>\""

# Contexte SQL — toujours des parametres lies, jamais de concatenation
cursor.execute("SELECT * FROM users WHERE name = %s", (user_input,))

# Contexte URL — encoder les parametres
from urllib.parse import quote
url_safe = quote(user_input, safe='')
# %3Cscript%3Ealert%28%27xss%27%29%3C%2Fscript%3E

Requêtes parametrees

Jamais de concatenation de chaînes pour construire des requêtes. Toujours des parametres.

# VULNERABLE — tous types de DB
query = f"SELECT * FROM products WHERE category = '{category}' AND price < {max_price}"

# SECURISE — SQLite / SQLAlchemy raw
cursor.execute(
    "SELECT * FROM products WHERE category = ? AND price < ?",
    (category, max_price)
)

# SECURISE — SQLAlchemy ORM (prefere)
from sqlalchemy import select, and_
stmt = select(Product).where(
    and_(Product.category == category, Product.price < max_price)
)
results = session.execute(stmt).scalars().all()

# SECURISE — PostgreSQL avec psycopg2
cursor.execute(
    "SELECT * FROM products WHERE category = %s AND price < %s",
    (category, max_price)
)

Headers de sécurité HTTP

Les headers HTTP sont la première ligne de defense côté navigateur.

# Flask — middleware de headers de securite
from flask import Flask
from flask_talisman import Talisman

app = Flask(__name__)
Talisman(app,
    content_security_policy={
        'default-src': "'self'",
        'script-src': ["'self'", "'nonce-{nonce}'"],
        'style-src': ["'self'", "'unsafe-inline'"],
        'img-src': ["'self'", "data:", "https:"],
        'connect-src': "'self'",
        'frame-ancestors': "'none'"
    },
    force_https=True,
    strict_transport_security=True,
    strict_transport_security_max_age=31536000,
    referrer_policy='strict-origin-when-cross-origin'
)
// Express — helmet.js (recommande)
const helmet = require('helmet');

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", "data:", "https:"],
      frameAncestors: ["'none'"]
    }
  },
  hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
  referrerPolicy: { policy: "strict-origin-when-cross-origin" }
}));

Headers essentiels

Header Valeur recommandee Ce qu'il previent
Strict-Transport-Security max-age=31536000; includeSubDomains Downgrade HTTP, interception TLS
Content-Security-Policy Politique stricte par domaine XSS, injection de scripts tiers
X-Frame-Options DENY ou SAMEORIGIN Clickjacking
X-Content-Type-Options nosniff MIME sniffing, XSS via upload
Referrer-Policy strict-origin-when-cross-origin Fuite d'URL interne via Référer
Permissions-Policy Désactiver les APIs non utilisées Accès camera/micro/geoloc non voulu

Authentification et sessions

import secrets
import hashlib
from datetime import datetime, timedelta

# SECURISE — generation de token de session
def create_session_token() -> str:
    return secrets.token_urlsafe(32)  # 256 bits d'entropie

# SECURISE — cookie de session
@app.after_request
def set_secure_cookie(response):
    # HttpOnly : inaccessible depuis JavaScript
    # Secure : HTTPS uniquement
    # SameSite=Strict : protection CSRF
    response.set_cookie(
        'session_id',
        value=session_token,
        httponly=True,
        secure=True,
        samesite='Strict',
        max_age=3600  # 1h
    )
    return response

# SECURISE — invalidation de session a la deconnexion
def logout(session_id: str):
    db.delete_session(session_id)
    # Regenerer le cookie avec une valeur invalide et expiration passee

Erreurs d'authentification courantes

  • Stocker le mot de passe en clair ou avec MD5/SHA1
  • Ne pas invalider la session côté serveur à la déconnexion
  • Permettre des sessions sans expiration
  • Utiliser un identifiant predictable (incremental) comme token de session
  • Ne pas régénérer le token de session après une elevation de privilege

Synthèse : pratique et prevention

Pratique Ce qu'elle previent
Validation whitelist Injection, XSS, overflow, données incoherentes
Validation côté serveur Bypass des contrôles client-side
Encodage HTML contextuel XSS reflechi, stocke et DOM
Requêtes parametrees SQL injection, NoSQL injection
CSP stricte XSS, chargement de ressources malveillantes
HSTS Downgrade HTTPS, attaques MITM
X-Frame-Options: DENY Clickjacking
HttpOnly + Secure + SameSite Vol de cookie, CSRF
Fail secure Escalade de privileges sur erreur
Moindre privilege Mouvement lateral, impact des compromissions

Chapitre suivant : Cas avances — audit de code, pentest, conformité et bug bounty.