documentation
Kohinos v3 Doc générée le commit 8d60b1904

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.

⚠ À lire avant l'installation

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 :

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)

ComposantActionPaquet / 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

⚠ SSH est le point d'entrée critique

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 / fichierValeur prodPourquoi
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

⛔ Secrets — règles non négociables
  • Jamais de secret dans Git. Hook pre-commit git-secrets actif côté trésorier et mainteneur.
  • Utiliser le vault Symfony chiffré (secrets:set) plutôt qu'un .env.local nu — 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équenceActionCommande / 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

⛔ Si vous voyez l'un de ces points sur une instance Kohinos, corrigez immédiatement
  • 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 age ou gpg avant rclone. Cf. backup & restauration.
  • chmod -R 777 pour résoudre une erreur de droits — corriger l'owner, jamais le mode. 777 = porte ouverte aux uploads malveillants.
  • APP_DEBUG=1 en prod « juste pour debug » — toute exception expose la stack trace, les variables et la config DB.
  • display_errors = On dans php.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

  1. Debian/Ubuntu LTS à jour, unattended-upgrades activé pour security.
  2. ufw deny par défaut ; 80/443 ouverts ; 22 restreint Wireguard ou IP fixe.
  3. SSH : clés Ed25519, PermitRootLogin no, PasswordAuthentication no, AllowUsers nominatif.
  4. fail2ban actif sur sshd, nginx-http-auth, nginx-noscript, nginx-badbots.
  5. nginx : HTTPS only, TLS 1.3+1.2, HSTS preload, CSP stricte, rate limiting /login et /api/, server_tokens off.
  6. PHP-FPM : user kohinos-web dédié, expose_php=0, disable_functions appliquée, open_basedir bornée.
  7. Filesystem : 750 sur dirs, 640 sur fichiers, 600 sur .env.local, jamais 777.
  8. Symfony prod : APP_ENV=prod, APP_DEBUG=0, profiler désactivé, TRUSTED_HOSTS en regex stricte.
  9. Secrets : vault Symfony chiffré, backup séparé des backups DB, rotation annuelle.
  10. Audit : lynis mensuel, composer audit hebdo, revue trimestrielle des comptes admin, scan TLS Mozilla Observatory.