Sécurité serveur — durcissement Debian, nginx, PHP, accès
La priorité #1 du projet Kohinos est l'intégrité financière. Un serveur compromis n'est pas qu'une fuite de données : c'est de la monnaie locale compromise — soldes falsifiables, transactions injectables, mandats SEPA détournés. Cette page liste le hardening minimum non négociable pour exploiter une instance Kohinos en production, que ce soit sur un Raspberry Pi auto-hébergé ou un VPS.
Le durcissement doit être appliqué avant la mise en production. Une migration « on sécurisera plus tard » est une dette qui se paie en incident de sécurité — typiquement au pire moment (clôture mensuelle SSA, prélèvement SEPA). Voir aussi : backup & restauration, monitoring.
Cible & threat model
Kohinos n'est pas une cible d'État-nation. Le modèle de menace pertinent :
- Script kiddies : scans massifs SSH/HTTP, exploits CVE publiques,
bots WordPress qui tentent les URLs
/wp-login.php,/.env,/phpmyadmin. - Ransomware opportuniste : chiffrement des données après accès via SSH password faible ou faille connue non patchée.
- Opportunistes financiers : bots qui détectent un panneau admin Sonata exposé et tentent du credential stuffing.
- Adhérent malveillant : utilisateur authentifié qui tente d'élever
ses droits (escalade vers
ROLE_ADMINouROLE_SUPER_ADMIN) ou d'injecter via un formulaire.
Les mesures ci-dessous sont proportionnées à cette menace : pas de HSM, pas d'auditeurs externes, mais un socle solide qui élimine 99 % des attaques automatisées et complique sérieusement le reste.
Durcissement OS (Debian / Ubuntu LTS)
| Composant | Action | Paquet / fichier |
|---|---|---|
| OS | Debian 12+ ou Ubuntu LTS 24.04+ uniquement (support sécurité long). | — |
| Patchs auto | Patchs security appliqués automatiquement chaque nuit. | unattended-upgrades |
| Firewall | Port 22 fermé sauf via VPN ou IP allow-list ; 80/443 ouverts. | ufw ou nftables |
| Anti-bruteforce | Bannissement IP sur tentatives répétées SSH / nginx. | fail2ban |
| MAC | Mandatory Access Control en mode enforce sur nginx et php-fpm. | apparmor (Debian) ou selinux (RHEL) |
| Antivirus | Scan périodique des uploads public/uploads/. |
clamav + cron |
| Audit | Audit système mensuel et rapport en /var/log/lynis-report.dat. |
lynis |
Configuration unattended-upgrades
sudo apt install -y unattended-upgrades apt-listchanges
sudo dpkg-reconfigure -plow unattended-upgrades
# /etc/apt/apt.conf.d/50unattended-upgrades — n'autoriser QUE security
Unattended-Upgrade::Allowed-Origins {
"${distro_id}:${distro_codename}-security";
"${distro_id}ESMApps:${distro_codename}-apps-security";
};
Unattended-Upgrade::Automatic-Reboot "false"; # contrôle manuel pour PHP-FPM
Unattended-Upgrade::Mail "ops@kohinos.example";
Firewall ufw
sudo ufw default deny incoming
sudo ufw default allow outgoing
# Web public
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
# SSH : restreint à un VPN Wireguard ou à l'IP fixe du trésorier
sudo ufw allow from 10.8.0.0/24 to any port 22 proto tcp # subnet Wireguard
# OU
sudo ufw allow from 203.0.113.42 to any port 22 proto tcp # IP fixe admin
sudo ufw enable
sudo ufw status verbose
fail2ban — jails minimales
# /etc/fail2ban/jail.local
[DEFAULT]
bantime = 24h
findtime = 10m
maxretry = 5
destemail = ops@kohinos.example
action = %(action_mwl)s
[sshd]
enabled = true
port = ssh
[nginx-http-auth]
enabled = true
[nginx-noscript]
enabled = true
filter = nginx-noscript
logpath = /var/log/nginx/access.log
maxretry = 6
[nginx-badbots]
enabled = true
filter = nginx-badbots
logpath = /var/log/nginx/access.log
maxretry = 2
SSH — accès administrateur
Un SSH mal configuré, c'est la promesse d'un incident. Aucune exception : authentification par clé Ed25519, pas de root, pas de password, port 22 fermé au monde. Le trésorier et le mainteneur sont les seuls comptes humains nécessaires.
# /etc/ssh/sshd_config — directives critiques
Port 22
PermitRootLogin no
PasswordAuthentication no
ChallengeResponseAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
PermitEmptyPasswords no
UsePAM yes
X11Forwarding no
AllowAgentForwarding no
AllowTcpForwarding no
MaxAuthTries 3
MaxSessions 3
LoginGraceTime 30
ClientAliveInterval 300
ClientAliveCountMax 2
# Limitation aux comptes humains autorisés
AllowUsers kohinos-admin tresorier
# Algos modernes uniquement
KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org
HostKeyAlgorithms ssh-ed25519,rsa-sha2-512
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com
MACs hmac-sha2-512-etm@openssh.com
# Génération de clé côté admin (poste local)
ssh-keygen -t ed25519 -a 100 -C "tresorier@kohinos $(date +%Y-%m-%d)"
# Validation côté serveur
sudo sshd -t # config valide ?
sudo systemctl reload sshd
sudo ss -tlnp | grep ssh # écoute uniquement où prévu
nginx — hardening HTTP
# /etc/nginx/nginx.conf — bloc http {}
server_tokens off; # masque la version nginx
client_max_body_size 8M; # uploads bornés
client_body_timeout 10s;
client_header_timeout 10s;
send_timeout 10s;
keepalive_timeout 15s;
# Rate limiting partagé entre vhosts
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/s;
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=admin:10m rate=10r/s;
limit_conn_zone $binary_remote_addr zone=perip:10m;
# /etc/nginx/sites-available/kohinos.conf
server {
listen 80;
server_name kohinos.example.org;
return 301 https://$host$request_uri; # HTTPS only
}
server {
listen 443 ssl http2;
server_name kohinos.example.org;
root /var/www/kohinos/public;
index index.php;
# --- TLS Mozilla intermediate ---
ssl_certificate /etc/letsencrypt/live/kohinos.example.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/kohinos.example.org/privkey.pem;
ssl_protocols TLSv1.3 TLSv1.2;
ssl_prefer_server_ciphers off;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
# --- Security headers ---
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
# --- Rate limiting ciblé ---
location = /login {
limit_req zone=login burst=5 nodelay;
try_files $uri /index.php$is_args$args;
}
location /api/ {
limit_req zone=api burst=20 nodelay;
try_files $uri /index.php$is_args$args;
}
location /admin/ {
limit_req zone=admin burst=15 nodelay;
# Optionnel : restriction IP côté nginx en plus de Sonata
# allow 10.8.0.0/24; deny all;
try_files $uri /index.php$is_args$args;
}
limit_conn perip 20;
# --- Bloque accès aux fichiers sensibles ---
location ~ /\.(env|git|ht) { deny all; return 404; }
location ~ /(config|src|var|vendor)/ { deny all; return 404; }
location ~ \.(yml|yaml|sql|sqlite|bak|swp)$ { deny all; return 404; }
# --- Symfony front controller ---
location / { try_files $uri /index.php$is_args$args; }
location ~ ^/index\.php(/|$) {
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
fastcgi_split_path_info ^(.+\.php)(/.*)$;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
internal;
}
location ~ \.php$ { return 404; }
}
PHP-FPM — durcissement runtime
# /etc/php/8.4/fpm/php.ini — directives sécurité
expose_php = Off
display_errors = Off
display_startup_errors = Off
log_errors = On
error_log = /var/log/php/error.log
allow_url_fopen = Off
allow_url_include = Off
max_execution_time = 60
max_input_time = 30
memory_limit = 256M
post_max_size = 10M
upload_max_filesize = 8M
session.cookie_httponly = 1
session.cookie_secure = 1
session.cookie_samesite = "Strict"
session.use_strict_mode = 1
# Fonctions désactivées (sauf si une commande métier en a besoin)
disable_functions = exec,shell_exec,passthru,system,popen,proc_open,pcntl_exec,assert
# /etc/php/8.4/fpm/pool.d/kohinos.conf
[kohinos]
user = kohinos-web ; user dédié, JAMAIS www-data partagé
group = kohinos-web
listen = /run/php/php8.4-fpm-kohinos.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = dynamic
pm.max_children = 12
pm.start_servers = 3
pm.min_spare_servers = 2
pm.max_spare_servers = 5
pm.max_requests = 500 ; recyclage anti-leak
php_admin_value[open_basedir] = /var/www/kohinos:/tmp:/var/log/php
php_admin_value[error_log] = /var/log/php/kohinos-error.log
php_admin_flag[log_errors] = on
php_admin_value[disable_functions] = exec,shell_exec,passthru,system,popen,proc_open
# Permissions filesystem
sudo useradd --system --no-create-home --shell /usr/sbin/nologin kohinos-web
sudo chown -R kohinos-web:www-data /var/www/kohinos
sudo find /var/www/kohinos -type d -exec chmod 750 {} \;
sudo find /var/www/kohinos -type f -exec chmod 640 {} \;
sudo chmod 750 /var/www/kohinos/var
sudo chmod 750 /var/www/kohinos/public
sudo chmod 600 /var/www/kohinos/.env.local
# JAMAIS 777. Si une erreur "permission denied" apparaît, corriger l'owner, pas le mode.
Symfony — configuration prod
| Variable / fichier | Valeur prod | Pourquoi |
|---|---|---|
APP_ENV |
prod |
Désactive WDT, profiler, dump(), cache compilé. |
APP_DEBUG |
0 |
Pas de stack trace exposée au public. |
SECURE_SCHEME |
https |
Référencé par security.yaml (requires_channel). |
TRUSTED_PROXIES |
IPs reverse-proxy uniquement | Évite le spoof de X-Forwarded-For. |
TRUSTED_HOSTS |
Regex restrictive sur le FQDN | Bloque les attaques par Host header injection. |
config/packages/prod/web_profiler.yaml |
Désactivé (ou IP-restricted) | Le profiler en prod = leak total (env, services, DB). |
.env.local |
chmod 600 |
Contient secrets DB / mailer / PayZen / HelloAsso. |
var/log/ |
Hors public/, group-read seulement |
Logs peuvent contenir des données adhérents. |
# Vérification après mise en prod
php bin/console about --env=prod | grep -E "Environment|Debug"
# Environment prod
# Debug false
# S'assurer que le profiler ne répond pas
curl -I https://kohinos.example.org/_profiler/ -k
# Doit retourner 404 (ou 403)
Secrets — vault Symfony et rotation
- Jamais de secret dans Git. Hook pre-commit
git-secretsactif côté trésorier et mainteneur. - Utiliser le vault Symfony chiffré (
secrets:set) plutôt qu'un.env.localnu — la clé de déchiffrement reste hors-repo. - Le backup des secrets ne doit jamais être stocké au même endroit que le backup DB. Sinon, un seul vol = compromission totale.
- Rotation annuelle :
APP_SECRET,KOHINOS_IBAN_SECRET, mot de passe DB, clés HelloAsso/PayZen, clés Ed25519 signatures SEPA.
# Mise en place du vault prod
APP_ENV=prod php bin/console secrets:generate-keys
APP_ENV=prod php bin/console secrets:set DATABASE_URL
APP_ENV=prod php bin/console secrets:set KOHINOS_IBAN_SECRET
APP_ENV=prod php bin/console secrets:set PAYZEN_CERTIFICATE
APP_ENV=prod php bin/console secrets:set MAILER_DSN
# Réimport depuis un .env.local existant (one-shot migration)
APP_ENV=prod php bin/console secrets:encrypt-from-local
# Permissions cle de chiffrement (NE PAS commiter)
sudo chmod 600 config/secrets/prod/prod.decrypt.private.php
sudo chown kohinos-web:kohinos-web config/secrets/prod/prod.decrypt.private.php
TLS — Let's Encrypt et HSTS
# Émission certificat (acme.sh recommandé pour souveraineté ; certbot OK aussi)
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d kohinos.example.org --agree-tos -m ops@kohinos.example --no-eff-email
# Renouvellement automatique (timer systemd installé par le paquet)
sudo systemctl status certbot.timer
sudo certbot renew --dry-run # validation
# Test du score TLS
curl -sI https://kohinos.example.org | grep -i strict-transport-security
# strict-transport-security: max-age=31536000; includeSubDomains; preload
Une fois HSTS stable sur 1 an, soumettre le domaine à
hstspreload.org pour inclusion dans la liste préchargée des navigateurs.
Attention : opération irréversible — le retour à HTTP plain est
impossible sans casser tous les clients.
Audit régulier
| Fréquence | Action | Commande / outil |
|---|---|---|
| Quotidien | Revue logs nginx 4xx / 5xx anormaux | monolog channel security |
| Hebdomadaire | Liste des paquets à mettre à jour | apt list --upgradable |
| Hebdomadaire | Audit dépendances Composer | composer audit |
| Mensuel | Audit système complet | sudo lynis audit system |
| Mensuel | Scan TLS et headers | observatory.mozilla.org, ssllabs.com |
| Trimestriel | Revue des comptes ROLE_SUPER_ADMIN et ROLE_ADMIN |
cf. doc dev — utilisateurs |
| Annuel | Rotation secrets et clés Ed25519 | secrets:rotate + procédure interne |
# Canal Monolog dédié sécurité (config/packages/prod/monolog.yaml)
monolog:
channels: [security]
handlers:
security:
type: rotating_file
path: "%kernel.logs_dir%/security.log"
level: info
channels: [security]
max_files: 90
Anti-patterns — à bannir absolument
- SSH port 22 ouvert au monde sans fail2ban — invitation directe aux bots chinois et russes.
- phpMyAdmin / Adminer exposé sur Internet — JAMAIS. Tunnel SSH ou Wireguard uniquement.
- Profiler Symfony accessible en prod — leak total (env, sessions, requêtes SQL avec paramètres).
- Backup DB en clair stocké chez un cloud tiers — utiliser
ageougpgavantrclone. Cf. backup & restauration. chmod -R 777pour résoudre une erreur de droits — corriger l'owner, jamais le mode. 777 = porte ouverte aux uploads malveillants.APP_DEBUG=1en prod « juste pour debug » — toute exception expose la stack trace, les variables et la config DB.display_errors = Ondansphp.ini— idem ci-dessus côté PHP.- Mot de passe DB partagé entre prod et staging — un staging compromis devient un pivot vers la prod.
- Clé SSH commune entre admins — chaque admin a sa clé Ed25519 nominative, révocable individuellement.
- Symfony secrets vault sans backup hors-site — perdre la clé de déchiffrement = perdre l'accès à toute la prod après un crash disque.
Checklist hardening — récapitulatif opérationnel
- Debian/Ubuntu LTS à jour,
unattended-upgradesactivé pour security. ufwdeny par défaut ; 80/443 ouverts ; 22 restreint Wireguard ou IP fixe.- SSH : clés Ed25519,
PermitRootLogin no,PasswordAuthentication no,AllowUsersnominatif. - fail2ban actif sur
sshd,nginx-http-auth,nginx-noscript,nginx-badbots. - nginx : HTTPS only, TLS 1.3+1.2, HSTS preload, CSP stricte, rate limiting
/loginet/api/,server_tokens off. - PHP-FPM : user
kohinos-webdédié,expose_php=0,disable_functionsappliquée,open_basedirbornée. - Filesystem :
750sur dirs,640sur fichiers,600sur.env.local, jamais777. - Symfony prod :
APP_ENV=prod,APP_DEBUG=0, profiler désactivé,TRUSTED_HOSTSen regex stricte. - Secrets : vault Symfony chiffré, backup séparé des backups DB, rotation annuelle.
- Audit :
lynismensuel,composer audithebdo, revue trimestrielle des comptes admin, scan TLS Mozilla Observatory.