Infrastructure

Sécurité et traçabilité de ce portfolio

Comment ce site est durci et instrumenté — TLS, CSP stricte, journalisation JSON corrélée par request-id, limitation de débit, fail2ban et vérification automatisée.

16 septembre 20268 min de lecture1694 mots
NginxTLSCSPfail2banLogs JSONPython

Ce portfolio n'est pas seulement une vitrine : c'est aussi une démonstration. Quand on travaille sur les systèmes, les réseaux et la sécurité, on se doit d'appliquer à son propre site ce que l'on défend au quotidien. Cette fiche documente la chaîne complète.

Principes retenus#

  1. Défense en profondeur — plusieurs couches indépendantes, aucune n'étant un point

    de défaillance unique.

  2. Traçabilité de bout en bout — chaque requête reçoit un identifiant unique que l'on

    retrouve du serveur web jusqu'à la couche applicative.

  3. Zéro dépendance externe côté visiteur — aucun CDN, aucune police tierce, aucun

    traqueur. La surface d'attaque et l'exposition de données sont réduites au minimum.

  4. Moindre privilège — le service applicatif tourne sans droits root, en systemd

    durci, et n'est jamais exposé directement à Internet.

  5. Vérifier plutôt que supposer — ce que décrit cette page est contrôlé par un script

    rejoué contre la production après chaque déploiement.

Vue d'ensemble#

text
                      Internet
                         │  HTTPS (TLS 1.2 / 1.3)
                         ▼
             ┌───────────────────────┐
             │        Nginx          │  ← durcissement, en-têtes, rate-limit
             │  request-id attribué  │
             └───────────┬───────────┘
              statique   │   /api, /healthz
          ┌──────────────┴───────────────┐
          ▼                              ▼
   fichiers statiques           ┌─────────────────┐
   (HTML/CSS/JS)                │  app Python      │  ← 127.0.0.1 uniquement
                                │  systemd durci   │
                                └────────┬─────────┘
                                         ▼
                            audit.jsonl  ·  messages.jsonl
                                         │
                    access.json.log ◀── même request-id ──▶ corrélation
                                         │
                              logrotate  ·  fail2ban

Couche transport — TLS#

  • TLS 1.2 et 1.3 uniquement, protocoles antérieurs refusés.
  • Suites de chiffrement AEAD à confidentialité persistante (forward secrecy) uniquement.
  • Échange de clés hybride post-quantique X25519MLKEM768 — X25519 combiné à

    ML-KEM-768, standardisé par le NIST (FIPS 203) — proposé en priorité aux navigateurs qui le prennent en charge, courbes classiques sinon.

  • HSTS : le navigateur refuse toute connexion non chiffrée pendant deux ans.
  • Hôtes inconnus rejetés : un accès direct par l'IP, ou par un nom de domaine tiers

    pointant vers le serveur, voit sa poignée de main TLS refusée sans qu'aucun certificat ne soit présenté ; en HTTP, la connexion est fermée sans réponse.

  • Certificat Let's Encrypt renouvelé automatiquement. Pas d'agrafage OCSP : Let's Encrypt

    a arrêté ce service en 2025.

  • Pare-feu (firewalld) : seuls SSH, HTTP et HTTPS sont joignables depuis Internet ;

    le backend n'écoute que sur la boucle locale.

Couche applicative — en-têtes de sécurité#

En-têteValeurRôle
Strict-Transport-Securitymax-age=63072000; includeSubDomainsHTTPS imposé par le navigateur
Content-Security-Policydefault-src 'self' (stricte)Bloque tout script ou style non local
X-Content-Type-OptionsnosniffEmpêche la ré-interprétation des types MIME
X-Frame-OptionsDENYAnti-clickjacking
Referrer-Policystrict-origin-when-cross-originLimite la fuite d'URL vers l'extérieur
Permissions-Policycaméra, micro, géoloc, paiement, USBRéduit la surface d'API navigateur
Cross-Origin-*-Policysame-originIsole la page et ses ressources
X-Request-IDidentifiant de la requêteTraçabilité (voir plus bas)

La CSP est stricte : ni unsafe-inline, ni unsafe-eval. C'est possible parce que tout le JavaScript et le CSS sont servis en fichiers depuis l'origine, et que le rendu Markdown de cette documentation est fait à la génération, jamais dans le navigateur.

Le piège de l'héritage add_header#

Dans Nginx, un bloc location qui déclare un seul add_header n'hérite plus d'aucun en-tête du niveau server. Une ligne ajoutée pour régler le cache d'un fichier suffit à faire disparaître, sans erreur ni avertissement, HSTS et la CSP des pages concernées. Ici, tous les en-têtes sont posés au niveau server, aucune location n'utilise add_header, et ce qui varie selon l'URL — la politique de cache — est calculé par une map :

nginx
map $request_uri $portfolio_cache_control {
    default                              "no-cache";
    "~^/assets/[^?]+\?v=[0-9a-f]{8,}$"   "public, max-age=31536000, immutable";
    "~^/(?:api/|healthz)"                "";
}

# … puis, au niveau server uniquement (une valeur vide n'est pas envoyée) :
add_header Cache-Control $portfolio_cache_control;

Le générateur ajoute à chaque ressource une empreinte de son contenu (style.css?v=f2847d55e4a3) : une URL versionnée ne change jamais de contenu et peut être gardée en cache un an, tandis que les pages HTML sont revalidées à chaque visite.

Traçabilité — le request-id partagé#

Le cœur du dispositif. Nginx attribue un identifiant unique à chaque requête via sa variable $request_id, puis :

  • l'écrit dans son journal d'accès JSON (access.json.log) ;
  • le transmet au backend dans l'en-tête X-Request-ID ;
  • le backend l'inscrit dans son journal applicatif (audit.jsonl) ;
  • il est renvoyé au visiteur dans l'en-tête X-Request-ID de chaque réponse.

Une seule valeur relie donc les trois niveaux. Face à un incident, on part de l'identifiant et on reconstitue toute la trajectoire de la requête.

json
// access.json.log — vu par Nginx
{"time":"2026-09-16T10:22:04+00:00","request_id":"a7f3c9d2e1b8…","remote_addr":"203.0.113.7",
 "method":"POST","uri":"/api/contact","status":200,"protocol":"HTTP/2.0","tls":"TLSv1.3"}

// audit.jsonl — vu par l'application
{"event":"contact_received","ip_hash":"9f2a…","request_id":"a7f3c9d2e1b8…",
 "status":200,"ts":"2026-09-16T10:22:04.118Z"}

Protection de la vie privée#

L'IP en clair ne figure que dans les journaux Nginx, nécessaires à la détection (fail2ban) et purgés au bout de 90 jours. Dans le journal applicatif, elle est pseudonymisée (HMAC-SHA-256 avec un sel aléatoire régénéré chaque jour et jamais écrit sur disque) : on peut regrouper l'activité d'un visiteur sur une journée sans stocker de donnée directement identifiante.

Limitation de débit et anti-abus#

  • Nginx : limit_req sur l'API et les pages, plafond de connexions par IP. Un

    dépassement renvoie 429 — et non le 503 par défaut — que fail2ban comptabilise.

  • Application : fenêtre glissante par IP — 3 messages/heure sur le contact,

    60 événements/heure sur la télémétrie.

  • Formulaire : champ pot de miel (honeypot) invisible ; s'il est rempli, la

    soumission est silencieusement ignorée mais tracée.

fail2ban#

Une prison lit access.json.log et bannit temporairement, via nftables, les IP qui accumulent des codes 4xx/429 : signature typique d'une recherche de fichiers sensibles (/.env, /.git/config…) ou d'un brute-force. La durée du bannissement double à chaque récidive, jusqu'à une semaine. Une seconde prison applique le même principe aux tentatives de connexion SSH, lues dans le journal systemd.

ini
[portfolio]
enabled   = true
filter    = portfolio
logpath   = /var/log/nginx/portfolio/access.json.log
maxretry  = 20
findtime  = 120
bantime   = 3600
bantime.increment = true
banaction = nftables-multiport

Durcissement systemd#

Le service applicatif s'exécute avec un compte éphémère sans shell et un bac à sable systemd :

ini
DynamicUser=yes
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
RestrictAddressFamilies=AF_INET AF_INET6
IPAddressAllow=localhost
IPAddressDeny=any
SystemCallFilter=@system-service
MemoryDenyWriteExecute=yes

Concrètement : pas d'élévation de privilèges possible, système de fichiers en lecture seule sauf le répertoire de logs, réseau limité à la boucle locale, appels système réduits au strict nécessaire.

Conservation des journaux#

logrotate fait tourner chaque jour les journaux Nginx et applicatifs, les comprime et les conserve 90 jours avant purge. À la rotation, Nginx rouvre ses fichiers (USR1) et le service Python reçoit un SIGHUP (systemctl reload) : aucune ligne n'est perdue. Les journaux Nginx du site ont leur propre dossier, /var/log/nginx/portfolio/ : le motif /var/log/nginx/*.log du paquet Nginx les prendrait sinon en charge une seconde fois, ce que logrotate refuse.

Vérification automatisée#

Deux scripts, sans dépendance, contrôlent que ce qui est écrit ici est vrai :

  • tests/test_backend.py — tests de non-régression du backend sur une instance jetable :

    connexions keep-alive, corps invalides, unicité du request-id, formulaire et honeypot.

  • deploy/verify.sh — contrôle de la production de bout en bout : présence et unicité

    des en-têtes de sécurité sur chaque type de réponse, politique de cache, rejet des hôtes inconnus, versions TLS et échange post-quantique, présence d'un même request-id dans les deux journaux, état de fail2ban, de logrotate et du pare-feu.

Retour d'expérience#

La première mise en production semblait fonctionner : le site s'affichait, en HTTPS. Le script de vérification a pourtant révélé plusieurs défauts silencieux, tous corrigés :

ConstatCauseCorrection
HSTS et CSP absents des pages principaleshéritage add_header rompu par des locationen-têtes au seul niveau server, cache via une map
fail2ban arrêté depuis l'installationjournal surveillé inexistant au démarrage du servicejournal créé avant, ordre d'installation revu
logrotate en erreur chaque nuitjournaux déclarés deux fois (configuration du paquet Nginx)dossier de journaux dédié
Requête en erreur (400/501) juste après un 429corps non lu laissé sur une connexion keep-alive mutualiséecorps consommé ou connexion fermée, test ajouté
Site servi sous n'importe quel nom d'hôtele premier serveur déclaré devient le serveur par défautserveurs par défaut qui rejettent les hôtes inconnus
HTTPS bloqué au prochain redémarragefirewalld activé mais arrêté, ports jamais ouvertsconfiguration permanente écrite même démon arrêté
Clients limités à la courbe P-256 refusésliste de groupes d'échange de clés trop restrictiveX25519MLKEM768:X25519:prime256v1:secp384r1

Un site qui s'affiche n'est pas un site conforme : seul un contrôle automatisé, rejoué après chaque déploiement, permet de l'affirmer.

Récapitulatif des couches#

CoucheContrôle principal
Réseaupare-feu (SSH, HTTP, HTTPS), TLS 1.2/1.3 post-quantique, HSTS, hôtes inconnus rejetés
ApplicationCSP stricte, en-têtes de sécurité sur toutes les réponses
Traçabilitérequest-id corrélé client ↔ Nginx ↔ backend, journaux JSON
Anti-abusrate-limit Nginx (429) + applicatif, honeypot
Détectionfail2ban (site et SSH) via nftables, bannissement progressif
Exécutionsystemd durci, moindre privilège, 127.0.0.1
Vie privéeIP pseudonymisée côté application, rétention 90 j, zéro tiers
Contrôletests de non-régression, vérification de la production automatisée