Conformité ANSSI¶
⬇️ Télécharger cette page en Markdown
Dernière mise à jour
Durcissement complet appliqué le 2026-06-03 — audit et corrections de 10 catégories de vulnérabilités.
Mise à jour 2026-06-18 — CSP étendue aux CDN frontend (Tailwind, Google Fonts, Leaflet/unpkg, OSM) ; correction du chemin d'upload photos (uploads/incidents/ hors public/).
Mise à jour 2026-08-14 — isolation conteneur (cap_drop, no-new-privileges) suite à l'audit du risque d'évasion Docker, voir section 14.
Mise à jour 2026-08-25 — ajout frame-src https://www.openstreetmap.org à la CSP (la carte embarquée sur incident_detail.php était bloquée silencieusement, faute de directive explicite, fallback sur default-src 'self').
Mise à jour 2026-08-25 — connexion admin par clé de sécurité (WebAuthn/FIDO2), voir section 15.
Mise à jour 2026-08-25 — intégration FranceConnect Agent préparée mais désactivée (pas d'autorisation DINUM), voir section 16.
Mise à jour 2026-09-16 — XSS stocké corrigé sur submit_incident.php (adresse/description jamais sanitisées), voir section 17 ; contournement du rate limiting via en-têtes client falsifiés corrigé, voir section 18.
Mise à jour 2026-09-18 — port du conteneur web rebindé sur 127.0.0.1 (accès HTTP direct hors TLS supprimé), cookie de session admin sécurisé (Secure + session_set_cookie_params() sur le flux de connexion), fusion des deux implémentations Auth.php backoffice, anti-fixation de session (session_regenerate_id()), voir section 19.
1. Protocoles de Chiffrement¶
TLS 1.3¶
Configuration reverse proxy :
ssl_protocols TLSv1.3;
ssl_ciphers 'TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256';
ssl_prefer_server_ciphers on;
Vérification :
2. En-têtes HTTP de Sécurité¶
Configuration nginx (nginx.conf)¶
Tous les en-têtes de sécurité ANSSI sont appliqués au niveau Nginx — propagés sur toutes les réponses y compris les erreurs (always) :
server_tokens off; # Masquer la version Nginx
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header Permissions-Policy "camera=(), microphone=(), payment=(), usb=()" always;
add_header Content-Security-Policy
"default-src 'self';
img-src 'self' data: blob: https://*.tile.openstreetmap.org;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://unpkg.com;
script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com https://unpkg.com;
font-src 'self' https://fonts.gstatic.com;
connect-src 'self' https://*.tile.openstreetmap.org;
frame-src https://www.openstreetmap.org;
frame-ancestors 'none';
base-uri 'self';
form-action 'self'" always;
# Page carte publique : hérite de la CSP ci-dessus
location = /carte-france.html {
try_files $uri =404;
}
| En-tête | Valeur | Rôle |
|---|---|---|
X-Content-Type-Options |
nosniff |
Bloque le MIME sniffing |
X-Frame-Options |
SAMEORIGIN |
Anti-clickjacking |
Referrer-Policy |
strict-origin-when-cross-origin |
Limite la fuite de l'URL |
HSTS |
max-age=63072000 (2 ans) |
Force HTTPS + preload |
Permissions-Policy |
camera/micro/payment désactivés | Réduit la surface d'attaque |
CSP |
default-src 'self' + CDN whitelist |
Bloque les ressources non autorisées ; autorise les CDN frontend (Tailwind, Google Fonts, Leaflet, OSM) |
frame-ancestors 'none' |
(dans CSP) | Anti-clickjacking renforcé |
Exécution PHP bloquée dans les uploads¶
location ^~ /uploads/ {
alias /var/www/html/uploads/;
location ~* \.php$ {
deny all; # Bloque l'exécution de tout fichier .php uploadé
}
}
3. CORS Whitelist Stricte¶
Principe¶
Recommandation ANSSI : Pas de wildcard Access-Control-Allow-Origin: * sur les endpoints authentifiés.
Tous les endpoints API utilisent SecurityHelpers::setCorsHeaders() qui applique une whitelist explicite :
// public/api/SecurityHelpers.php
private static function getAllowedOrigins(): array {
$origins = [
'https://urbafix.fr',
'https://www.urbafix.fr',
'android://com.example.monquartierkotlin',
'capacitor://localhost',
];
// localhost inclus uniquement hors production
if (getenv('APP_ENV') !== 'production') {
$origins[] = 'http://localhost:3000';
$origins[] = 'http://localhost:8080';
}
return $origins;
}
Usage dans chaque endpoint :
header('Content-Type: application/json; charset=utf-8');
SecurityHelpers::setCorsHeaders(); // ← jamais header('Access-Control-Allow-Origin: *')
Variable d'environnement obligatoire
En production, définir APP_ENV=production dans .env pour exclure automatiquement les origines localhost de la whitelist.
4. JWT — Secret Obligatoire¶
Règle¶
La variable JWT_SECRET doit être définie dans .env. Aucun fallback sur une valeur par défaut n'est accepté :
// api/agents/auth/login.php, refresh.php, _auth_middleware.php
$secret = getenv('JWT_SECRET');
if (!$secret) {
error_log('CRITICAL: JWT_SECRET non défini dans les variables d\'environnement');
http_response_code(500);
echo json_encode(['success' => false, 'message' => 'Erreur de configuration serveur']);
exit;
}
Génération d'un secret fort :
Ajouter dans .env :
5. Permissions Fichiers¶
Recommandation ANSSI : Jamais de 0777
Appliqué (Dockerfile) :
RUN chown -R www-data:www-data /var/www/html \
&& chmod -R 755 /var/www/html \
&& mkdir -p /var/www/html/uploads/incidents \
&& mkdir -p /var/www/html/uploads/videos \
&& chmod -R 755 /var/www/html/uploads
| Ressource | Permission | Raison |
|---|---|---|
| Répertoires uploads | 0755 |
www-data peut écrire, autres peuvent lire |
| Fichiers uploadés | 0644 |
www-data seul peut modifier |
| Code source | 0755/0644 |
Lecture seule pour l'application |
Code :
// Création répertoire
mkdir($uploadDir, 0755, true);
// Vérification du type MIME par magic bytes (pas la déclaration client)
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$detectedMime = finfo_buffer($finfo, $imageData);
finfo_close($finfo);
$allowedMimes = ['image/jpeg', 'image/png', 'image/webp'];
if (!in_array($detectedMime, $allowedMimes, true)) {
continue; // Rejet silencieux
}
6. Protection Path Traversal¶
Validation realpath() sur les photos¶
// api/get_photo.php
$uploadsBase = realpath(__DIR__ . '/../uploads');
$rawPath = __DIR__ . '/..' . $photo['filepath'];
$filepath = realpath($rawPath);
// Vérifier que le fichier est bien dans le répertoire uploads
if ($filepath === false
|| $uploadsBase === false
|| strpos($filepath, $uploadsBase . DIRECTORY_SEPARATOR) !== 0) {
http_response_code(404);
echo json_encode(['error' => 'Photo non trouvée']);
exit;
}
Principe : realpath() résout les .. et liens symboliques. Si le chemin résolu ne commence pas par le répertoire uploads/, la requête est rejetée avec une 404 générique (pas de fuite de chemin interne).
7. Injection SQL¶
Prepared Statements Obligatoires¶
Recommandation ANSSI : Jamais de concaténation SQL
✅ Correct :
$db->fetchAll(
"SELECT * FROM incidents WHERE mairie_id = ? AND type_id = ?",
[$mairieId, $typeId]
);
Mapping statique pour les noms de colonnes¶
Quand le nom de colonne doit varier, utiliser un mapping statique (jamais interpoler $field directement) :
// backend/api/update_incident.php
$allowedFields = ['statut' => 'statut', 'priorite' => 'priorite'];
if (!isset($allowedFields[$field])) {
echo json_encode(['error' => 'Champ non autorisé']);
exit;
}
$safeField = $allowedFields[$field];
$db->execute(
"UPDATE incidents SET $safeField = ?, updated_at = NOW() WHERE id = ?",
[$value, $incidentId]
);
❌ Interdit :
// Ne JAMAIS faire ça même avec in_array()
$db->execute("UPDATE incidents SET $field = ? WHERE id = ?", [$value, $id]);
8. Configuration PHP Sécurisée¶
php.ini de sécurité (Dockerfile)¶
# /usr/local/etc/php/conf.d/security.ini
display_errors = Off # Pas d'erreurs vers le client
display_startup_errors = Off
expose_php = Off # Masquer la version PHP dans les headers
log_errors = On # Logger côté serveur
error_reporting = E_ALL # Tout logger serveur
session.cookie_httponly = 1 # Cookies non accessibles en JS
session.use_only_cookies = 1 # Sessions uniquement par cookies
session.use_strict_mode = 1 # Rejeter les IDs de session non initialisés
session.cookie_samesite = Strict # Anti-CSRF
allow_url_fopen = Off # Bloquer les wrappers URL
allow_url_include = Off # Bloquer les includes distants
9. Gestion des Erreurs¶
Principe : messages génériques vers le client¶
Recommandation ANSSI : Ne jamais exposer de détails techniques dans les réponses API.
✅ Correct :
} catch (Exception $e) {
error_log('submit_incident.php error: ' . $e->getMessage()); // Log serveur
http_response_code(500);
echo json_encode(['error' => 'Erreur serveur interne']); // Message générique client
}
❌ Interdit :
} catch (Exception $e) {
echo json_encode(['error' => 'Erreur serveur: ' . $e->getMessage()]); // Fuite interne
}
Méthode utilitaire (SecurityHelpers::safeError()) :
public static function safeError(string $context, \Throwable $e, int $code = 500): void {
error_log("[$context] " . $e->getMessage() . " in " . $e->getFile() . ':' . $e->getLine());
http_response_code($code);
echo json_encode(['success' => false, 'error' => 'Erreur serveur interne']);
exit;
}
10. Sessions Sécurisées¶
// public/src/Auth.php (constructeur)
session_set_cookie_params([
'lifetime' => $config['session_lifetime'] * 60,
'path' => '/',
'domain' => '',
'secure' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on',
'httponly' => true, // Pas d'accès JavaScript
'samesite' => 'Strict' // Anti-CSRF
]);
Trouvaille du 2026-09-18
Ce session_set_cookie_params() n'existait pas avant cette date sur le flux de connexion admin (admin-login.html → public/src/Auth.php) : le constructeur faisait un session_start() nu. Combiné à l'absence de session.cookie_secure dans le Dockerfile (voir section 5 pour le reste du durcissement Dockerfile) et à l'exposition réseau décrite en section 19, le cookie de session admin pouvait circuler sans le flag Secure. Voir RAPPORT_SECURITE_ADMIN_LOGIN.md pour le constat complet. Les deux corrigés ensemble.
Anti-fixation de session :
// public/src/Auth.php — _establishSession(), point commun aux 3 chemins
// de connexion (mot de passe, WebAuthn, FranceConnect Agent)
session_regenerate_id(true); // détruit aussi l'ancien fichier de session
11. Stockage Mots de Passe¶
// bcrypt cost ≥ 10 (recommandation ANSSI)
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
// ❌ Interdit
$hash = md5($password);
$hash = sha1($password);
12. Journalisation¶
// SecurityHelpers::logSecurityEvent()
$log_entry = [
'timestamp' => date('Y-m-d H:i:s'),
'event' => $event,
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown',
'fingerprint' => $_SERVER['HTTP_X_FINGERPRINT'] ?? null,
'data' => $data
];
error_log('SECURITY: ' . json_encode($log_entry));
Règle : Logger le détail côté serveur, retourner un message générique au client.
13. Secrets et Credentials¶
// ✅ Correct — variables d'environnement
$dbPassword = getenv('DB_PASSWORD');
$jwtSecret = getenv('JWT_SECRET');
// ❌ Interdit — valeur en dur ou fallback faible
$jwtSecret = getenv('JWT_SECRET') ?: 'your-secret-key-here';
Docker Secrets (production recommandée) :
14. Isolation Conteneur¶
Contexte¶
Audit du risque d'évasion de conteneur suite à zerone-cyber.org/docker-container-evasion, qui documente l'exploitation de --privileged, du montage docker.sock, de capabilities Linux excessives (CAP_SYS_ADMIN, CAP_SYS_MODULE) et des CVE runc/containerd CVE-2019-5736, CVE-2019-14271, CVE-2020-15257.
Constat : aucun de ces vecteurs n'était présent (pas de --privileged, pas de docker.sock, runc/Docker à jour). Durcissement en défense en profondeur appliqué le 2026-08-14.
Capabilities minimales (docker-compose.yml)¶
web:
cap_drop: [ALL]
cap_add: [CHOWN, FOWNER, SETUID, SETGID, NET_BIND_SERVICE]
security_opt: [no-new-privileges:true]
db: # + redis, phpmyadmin
security_opt: [no-new-privileges:true]
| Capability | Nécessaire pour |
|---|---|
CHOWN |
entrypoint.sh : chown -R www-data:www-data sur les dossiers uploads à chaque démarrage |
FOWNER |
chmod -R sur des fichiers déjà présents non détenus par le process courant |
SETUID/SETGID |
nginx et php-fpm : master root → workers www-data |
NET_BIND_SERVICE |
bind du port 80 sans capacité root complète |
no-new-privileges |
bloque toute élévation via binaire setuid, sur les 4 services |
Validation : capabilities déterminées empiriquement sur une pile de test isolée (réseau/volume/port dédiés) en partant de cap_drop: ALL et en réintroduisant une capability à la fois jusqu'à disparition des erreurs Operation not permitted.
Code applicatif en lecture seule¶
web:
volumes:
- ./:/var/www/html:ro
- ./uploads:/var/www/html/uploads
- ./public/backend/uploads:/var/www/html/public/backend/uploads
- ./public/uploads:/var/www/html/public/uploads
Une compromission applicative (upload malveillant, RCE) ne peut plus écrire dans le code source — seuls les volumes uploads explicites restent en écriture.
Prérequis levé le 2026-08-14 : tasks.php et cron/cleanup_alertes_audio.php écrivaient hors de uploads/ (public/tasks.csv, public/tasks_admin_config.json, public/cron/cleanup_alertes.log). Ces écritures ont été migrées vers public/backend/uploads/tasks/ et public/backend/uploads/logs/ (volume RW déjà existant), sans perte de données historiques. Détail complet : infrastructure/docker.md.
15. Connexion par clé de sécurité (WebAuthn/FIDO2)¶
Second facteur optionnel, par agent, sur la connexion admin (admin-login.html). Remplace un champ « Yubikey OTP » qui existait côté UI depuis longtemps mais n'était jamais vérifié côté serveur (api/admin_login.php ignorait totalement le paramètre yubikeyOtp envoyé par le formulaire).
Choix : WebAuthn plutôt que l'API cloud Yubico OTP — pas de secret partagé, pas d'appel réseau vers un tiers à chaque connexion, résistant au phishing par construction (la signature est liée à l'origine exacte). Fonctionne avec toute clé FIDO2 (Yubikey, Titan, etc.), pas seulement Yubico.
Librairie vendue sans Composer¶
Le projet n'a aucune dépendance Composer à ce jour. lbuchs/WebAuthn (MIT, zéro dépendance PHP externe) est vendue directement dans src/vendor/lbuchs-webauthn/ plutôt qu'introduire Composer pour une seule lib :
src/vendor/lbuchs-webauthn/
├── LICENSE
└── src/
├── WebAuthn.php # point d'entrée, require_once en cascade (relatifs à son propre dossier)
├── WebAuthnException.php
├── Binary/ByteBuffer.php
├── CBOR/CborDecoder.php
└── Attestation/...
src/WebAuthnConfig.php fournit createWebAuthnInstance() (rpName fixe, rpId = domaine courant sans port) et currentRpId().
rpId = domaine, une clé n'est valide que sur son domaine d'enregistrement
L'app est servie sur plusieurs domaines (urbafix.fr, monquartier.fr). Une clé enregistrée sous urbafix.fr ne fonctionnera pas pour se connecter via monquartier.fr — contrainte du protocole, pas un bug. webauthn_credentials.rp_id trace le domaine d'enregistrement.
Flux de connexion (3 étapes)¶
Étape 2 : jamais de paramètre username dans la requête — évite toute énumération, l'identité est déjà établie par le mot de passe à l'étape 1. Étape 3 : le chemin « clé valide » établit la session avec le même code que le chemin immédiat, factorisé dans Auth::_establishSession().
Auth::login() retourne désormais ['success' => bool, 'webauthn_required' => bool] au lieu d'un simple booléen — seul appelant vivant : api/admin_login.php (mis à jour en conséquence).
Enregistrement d'une clé — securite.php¶
Self-service, accessible à tout utilisateur authentifié (pas réservé aux admins) : GET /api/webauthn/register_options.php → navigator.credentials.create() → POST /api/webauthn/register_verify.php. Liste/suppression des clés enregistrées sur la même page.
Table webauthn_credentials¶
CREATE TABLE webauthn_credentials (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
credential_id VARCHAR(512) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
public_key TEXT NOT NULL, -- PEM
sign_counter INT UNSIGNED NOT NULL DEFAULT 0,
nom VARCHAR(100) NULL,
rp_id VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMP NULL,
CONSTRAINT fk_webauthn_credentials_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY uq_webauthn_credential_id (credential_id),
KEY idx_webauthn_credentials_user_rp (user_id, rp_id)
);
credential_id stocké en hexadécimal (bin2hex), pas en binaire brut — plus simple à indexer/déboguer. sign_counter est revérifié et mis à jour à chaque connexion (protection anti-clonage d'authenticator standard WebAuthn).
Fermeture de backend/old/ (trouvaille annexe)¶
En modifiant Auth::login(), découverte d'une ancienne copie complète de l'admin dans public/backend/old/ (login.php, run_migration_communes.php, debug_session.php, etc.) — publiquement accessible (aucune règle Nginx ne la couvrait), aucune de ces pages n'appliquant Auth::requireAuth(). run_migration_communes.php en particulier exécute du SQL sans authentification (non fonctionnel aujourd'hui faute de vendor/autoload.php, mais reste un point d'entrée à fermer). Bloquée entièrement :
16. FranceConnect Agent — DÉSACTIVÉ en attente d'autorisation DINUM¶
Ne pas activer sans autorisation DINUM confirmée
FRANCECONNECT_AGENT_ENABLED doit rester absent ou false tant que l'instance n'a pas l'autorisation officielle de la DINUM. Le code est prêt (flux OIDC complet, testé avec une paire de clés RSA de test — signature valide acceptée, signature falsifiée rejetée), mais aucun identifiant réel n'a été configuré ni testé contre un vrai fournisseur (pas d'accès au bac à sable DINUM au moment de l'implémentation, 2026-08-25).
Pourquoi OIDC + PKCE, comme le vrai FranceConnect Agent / ProConnect¶
Authorization Code Flow avec PKCE (code_challenge/code_verifier, RFC 7636) — protège contre l'interception du code d'autorisation même sur un canal de redirection non garanti. state (anti-CSRF) et nonce (anti-rejeu de l'id_token) systématiques, comparés avec hash_equals().
Flux (3 fichiers, tous sous garde FranceConnectAgentConfig::isEnabled() → 404 si désactivé)¶
GET /api/franceconnect/status.php (public, sans info sensible — juste {"enabled": bool}) permet à admin-login.html d'afficher ou masquer le bouton « Se connecter avec FranceConnect Agent » sans dupliquer la logique d'activation côté client.
Vérification JWT — librairie vendue sans Composer¶
firebase/php-jwt (rebaptisé googleapis/php-jwt, BSD-3-Clause) vendue dans src/vendor/firebase-jwt/, même approche que lbuchs/WebAuthn (§15) — le projet n'a pas de Composer. CachedKeySet.php volontairement omis (dépend de psr/cache/psr/http-client, absents) ; le JWKS est récupéré directement via file_get_contents() à chaque callback (pas de cache pour l'instant — TODO si la volumétrie le justifie un jour).
Testé (paire de clés RSA générée à la volée, hors tout fournisseur réel) :
- id_token signé avec la bonne clé + JWKS correspondant → décodage réussi, claims lisibles.
- id_token signé avec une clé ≠ celle du JWKS (signature falsifiée/mismatch) → SignatureInvalidException, rejeté.
Configuration (.env, tout vide par défaut)¶
FRANCECONNECT_AGENT_ENABLED=false
FRANCECONNECT_AGENT_CLIENT_ID=
FRANCECONNECT_AGENT_CLIENT_SECRET=
FRANCECONNECT_AGENT_AUTHORIZE_URL=
FRANCECONNECT_AGENT_TOKEN_URL=
FRANCECONNECT_AGENT_USERINFO_URL=
FRANCECONNECT_AGENT_JWKS_URL=
FRANCECONNECT_AGENT_LOGOUT_URL=
FRANCECONNECT_AGENT_REDIRECT_URI= # optionnel, calculé depuis le domaine courant sinon
FranceConnectAgentConfig::isEnabled() exige ENABLED=true et CLIENT_ID/AUTHORIZE_URL non vides — une activation à moitié configurée reste désactivée plutôt que de planter.
Restant à faire avant activation en production¶
- Obtenir l'autorisation + les identifiants (client_id/secret) auprès de la DINUM, et les URLs exactes du fournisseur (
integ01bac à sable puis production). - Tester le flux complet contre le vrai fournisseur (jamais fait à ce stade — seule la crypto JWT a été testée avec des clés de test).
- Appeler l'endpoint de déconnexion FranceConnect (
FRANCECONNECT_AGENT_LOGOUT_URL) depuislogout.php— non fait, sinon la session SSO côté fournisseur reste active après déconnexion côté MonQuartier. - Décider d'une politique de cache du JWKS si le volume de connexions le justifie.
17. Sanitisation des Entrées (XSS)¶
Principe¶
Recommandation ANSSI : neutraliser tout HTML/JS dans les entrées utilisateur avant stockage, pas seulement à l'affichage. Les champs texte libre sont réaffichés via innerHTML sans échappement à plusieurs endroits (app.html : liste publique des incidents, "mes signalements", détail d'incident) — une sanitisation à la source protège d'un coup tous les points de rendu, présents et futurs.
SecurityHelpers::sanitizeInput()¶
public static function sanitizeInput($input) {
if (is_array($input)) {
return array_map([self::class, 'sanitizeInput'], $input);
}
return htmlspecialchars(trim($input), ENT_QUOTES, 'UTF-8');
}
Appliqué à :
| Endpoint | Champs sanitisés |
|---|---|
submit_incident.php |
adresse, description |
submit_bug_report.php |
title, message, app_version, os_version, device_model, browser_info, url, network_state, user_action, user_comment |
Trouvaille du 2026-09-16
submit_incident.php n'appliquait aucune sanitisation avant cette date : adresse/description étaient stockées brutes puis réaffichées sans échappement — une faille XSS stockée exploitable par tout citoyen non authentifié (le signalement d'incident ne requiert qu'un fingerprint auto-déclaré, pas de compte). Corrigé en appliquant sanitizeInput() immédiatement après la validation des champs requis, avant toute utilisation des valeurs (requête de détection de doublon, insertion en base).
18. Intégrité de l'Identifiant de Rate Limiting¶
Problème corrigé¶
SecurityHelpers::getRateLimitIdentifier() utilisait en priorité le header X-Fingerprint (device ID auto-déclaré côté mobile, non signé) puis, à défaut, le premier maillon de X-Forwarded-For — deux valeurs qu'un client peut définir lui-même avant d'atteindre le reverse proxy. Un attaquant pouvait donc obtenir un nouveau quota de rate limit à volonté sur tous les endpoints publics en faisant varier ces en-têtes à chaque appel.
Correction : dernier maillon de X-Forwarded-For¶
L'infrastructure passe par un reverse proxy configuré pour ajouter (et non remplacer) la vraie IP du client en dernier maillon de X-Forwarded-For — comportement par défaut de la plupart des reverse proxies lorsqu'ils forwardent cet en-tête, les maillons précédents pouvant avoir été falsifiés par le client lui-même avant d'atteindre le reverse proxy :
private static function getClientIp() {
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$hops = array_map('trim', explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']));
$lastHop = end($hops);
if (filter_var($lastHop, FILTER_VALIDATE_IP)) {
$ip = $lastHop;
}
}
return $ip;
}
Le fingerprint, quand présent, est désormais combiné à cette IP plutôt qu'utilisé seul (fingerprint:$fingerprint:$ip) — il reste utile pour distinguer plusieurs utilisateurs derrière un même NAT/wifi partagé, sans offrir de bypass gratuit.
Trouvaille du 2026-09-16
Le même contournement existait indépendamment dans submit_bug_report.php ($fingerprint ?: getRateLimitIdentifier(), qui ignorait totalement la logique IP du helper dès qu'un fingerprint était fourni). Corrigé pour repasser systématiquement par le helper partagé.
19. Exposition réseau du conteneur web¶
Problème corrigé¶
Le port du conteneur web était publié avec la syntaxe courte de docker-compose.yml ("${WEB_PORT:-8080}:80"), qui bind toujours sur 0.0.0.0 — combiné à une règle de pare-feu hôte autorisant explicitement ce port, http://<IP publique>:8080/admin-login.html et /api/admin_login.php étaient atteignables en HTTP pur, hors TLS et hors reverse proxy. Un identifiant/mot de passe mairie soumis par ce chemin transitait en clair sur le réseau.
Le reverse proxy n'a jamais eu besoin de ce port publié : il atteint le conteneur via le réseau Docker interne (nom de service monquartier_web, port 80), pas via le port hôte.
Correction¶
Le port reste utilisable localement (debug, health checks internes) mais n'est plus routable depuis l'extérieur, indépendamment de l'état du pare-feu.
Trouvaille du 2026-09-17, corrigée le 2026-09-18
Constat détaillé dans RAPPORT_SECURITE_ADMIN_LOGIN.md (à la racine de backend/). Vérifié avant/après depuis une machine externe : curl http://<IP publique>:8080/admin-login.html passait de 200 à une absence totale de réponse après le correctif, tandis que https://urbafix.fr/admin-login.html (via reverse proxy) continuait de répondre normalement.
Constat annexe traité au même moment — duplication d'Auth.php¶
Le même rapport relevait la coexistence de deux implémentations de Auth pour l'authentification backoffice (session PHP) : backend/src/Auth.php (obsolète, sans WebAuthn, 15 consommateurs — pages/API backoffice uniquement) et backend/public/src/Auth.php (utilisée par admin_login.php, avec WebAuthn, 49 consommateurs). Diff préalable : les deux ne différaient que sur login() (bool vs tableau {success, webauthn_required}) ; aucun des 15 consommateurs de la version obsolète n'appelait login(), donc la fusion ne changeait aucun comportement. Les 15 fichiers redirigés vers public/src/Auth.php, backend/src/Auth.php supprimé.
Sans rapport avec l'authentification citoyenne (public/backend/src/Auth.php, fingerprint) ni avec l'app mobile agents (JWT, aucune dépendance à une classe Auth) — confirmé avant intervention, voir Backoffice Agents.
Checklist Conformité ANSSI¶
Niveau 1 — Appliqué ✅¶
- TLS 1.3 uniquement (reverse proxy)
- En-têtes sécurité Nginx (CSP, HSTS, X-Frame, X-Content-Type, Referrer, Permissions)
-
server_tokens off— version Nginx masquée - PHP
expose_php = Off— version PHP masquée - PHP
display_errors = Off— erreurs non exposées - Permissions fichiers 0755/0644 (plus de 0777)
- Sessions sécurisées (HttpOnly, SameSite, strict mode)
- Prepared statements SQL dans toute l'application
- Mapping statique pour noms de colonnes SQL
- Password hashing bcrypt (cost 12)
- CORS whitelist stricte — plus de wildcard
* - JWT_SECRET obligatoire — plus de fallback faible
- Messages d'erreur génériques en production
- Path traversal protégé (realpath validation)
- Validation MIME par magic bytes pour les uploads
- Exécution PHP bloquée dans
/uploads/ -
allow_url_fopen = Off,allow_url_include = Off - Logs sans données sensibles exposées au client
- Isolation conteneur :
cap_drop: ALL+ capabilities minimales,no-new-privileges, code applicatif:ro(section 14) - Connexion admin par clé de sécurité WebAuthn/FIDO2, optionnelle par agent (section 15)
-
backend/old/(ancienne admin non authentifiée, publiquement accessible) fermé (section 15) - Sanitisation des entrées texte libre avant stockage (
submit_incident.php,submit_bug_report.php) (section 17) - Identifiant de rate limiting non falsifiable via en-têtes client (section 18)
- Port du conteneur web non exposé publiquement (bind
127.0.0.1) (section 19) - Cookie de session admin avec flag
Secure(section 10, section 19) - Fusion des deux implémentations
Auth.phpbackoffice (section 19) - Anti-fixation de session (
session_regenerate_id(true)après connexion) (section 10)
Niveau 2 — Recommandé¶
- WAF (Web Application Firewall — ModSecurity)
- IDS/IPS
- SIEM centralisé
- Rate limiting login (brute force) → base Redis requise
- Rotation automatique des secrets (JWT_SECRET, DB_PASSWORD)
- FranceConnect Agent — code prêt et testé (crypto JWT), désactivé en attente d'autorisation DINUM (section 16)
- Audits de sécurité trimestriels
- Pentests annuels
Niveau 3 — Critique¶
- Certification ISO 27001
- Homologation RGS (Référentiel Général de Sécurité)
Tests de Sécurité¶
Headers¶
curl -sI https://urbafix.fr | grep -E "X-Frame|Content-Security|Strict-Transport|X-Content|Referrer"
CORS¶
# Origine non autorisée → pas de Access-Control-Allow-Origin dans la réponse
curl -H "Origin: https://evil.com" -I https://urbafix.fr/api/get_types.php?code_postal=06500
# Origine autorisée → header présent
curl -H "Origin: https://urbafix.fr" -I https://urbafix.fr/api/get_types.php?code_postal=06500
Injection SQL¶
# Doit retourner 400 ou tableau vide — jamais d'erreur SQL exposée
curl "https://urbafix.fr/api/get_types.php?code_postal=06000%27+OR+%271%27%3D%271"
Path Traversal¶
# Doit retourner 404 — pas de chemin interne dans la réponse
curl "https://urbafix.fr/api/get_photo.php?id=1/../../../etc/passwd"
Erreurs internes masquées¶
# Doit retourner {"error":"Erreur serveur interne"} — pas de stack trace
curl -X POST https://urbafix.fr/api/submit_incident.php \
-H "Content-Type: application/json" \
-d '{"invalid":"data"}'