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.
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#
- Défense en profondeur — plusieurs couches indépendantes, aucune n'étant un point
de défaillance unique.
- 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.
- 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.
- Moindre privilège — le service applicatif tourne sans droits root, en systemd
durci, et n'est jamais exposé directement à Internet.
- 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#
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 · fail2banCouche 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ête | Valeur | Rôle |
|---|---|---|
Strict-Transport-Security | max-age=63072000; includeSubDomains | HTTPS imposé par le navigateur |
Content-Security-Policy | default-src 'self' (stricte) | Bloque tout script ou style non local |
X-Content-Type-Options | nosniff | Empêche la ré-interprétation des types MIME |
X-Frame-Options | DENY | Anti-clickjacking |
Referrer-Policy | strict-origin-when-cross-origin | Limite la fuite d'URL vers l'extérieur |
Permissions-Policy | caméra, micro, géoloc, paiement, USB | Réduit la surface d'API navigateur |
Cross-Origin-*-Policy | same-origin | Isole la page et ses ressources |
X-Request-ID | identifiant de la requête | Traçabilité (voir plus bas) |
La CSP est stricte : ni
unsafe-inline, niunsafe-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 :
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-IDde 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.
// 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_reqsur l'API et les pages, plafond de connexions par IP. Undépassement renvoie
429— et non le503par 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.
[portfolio]
enabled = true
filter = portfolio
logpath = /var/log/nginx/portfolio/access.json.log
maxretry = 20
findtime = 120
bantime = 3600
bantime.increment = true
banaction = nftables-multiportDurcissement systemd#
Le service applicatif s'exécute avec un compte éphémère sans shell et un bac à sable systemd :
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=yesConcrè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 :
| Constat | Cause | Correction |
|---|---|---|
| HSTS et CSP absents des pages principales | héritage add_header rompu par des location | en-têtes au seul niveau server, cache via une map |
| fail2ban arrêté depuis l'installation | journal surveillé inexistant au démarrage du service | journal créé avant, ordre d'installation revu |
| logrotate en erreur chaque nuit | journaux déclarés deux fois (configuration du paquet Nginx) | dossier de journaux dédié |
Requête en erreur (400/501) juste après un 429 | corps non lu laissé sur une connexion keep-alive mutualisée | corps consommé ou connexion fermée, test ajouté |
| Site servi sous n'importe quel nom d'hôte | le premier serveur déclaré devient le serveur par défaut | serveurs par défaut qui rejettent les hôtes inconnus |
| HTTPS bloqué au prochain redémarrage | firewalld activé mais arrêté, ports jamais ouverts | configuration permanente écrite même démon arrêté |
| Clients limités à la courbe P-256 refusés | liste de groupes d'échange de clés trop restrictive | X25519MLKEM768: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#
| Couche | Contrôle principal |
|---|---|
| Réseau | pare-feu (SSH, HTTP, HTTPS), TLS 1.2/1.3 post-quantique, HSTS, hôtes inconnus rejetés |
| Application | CSP stricte, en-têtes de sécurité sur toutes les réponses |
| Traçabilité | request-id corrélé client ↔ Nginx ↔ backend, journaux JSON |
| Anti-abus | rate-limit Nginx (429) + applicatif, honeypot |
| Détection | fail2ban (site et SSH) via nftables, bannissement progressif |
| Exécution | systemd durci, moindre privilège, 127.0.0.1 |
| Vie privée | IP pseudonymisée côté application, rétention 90 j, zéro tiers |
| Contrôle | tests de non-régression, vérification de la production automatisée |