Aller au contenu

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 :

openssl s_client -connect urbafix.fr:443 -tls1_3


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 :

openssl rand -hex 32
# → Exemple : a3f7c2e1d9b4a8f0e6c5d2b1a9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f0

Ajouter dans .env :

JWT_SECRET=<résultat openssl rand -hex 32>


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
Ajouté le 2026-09-18 (précédemment absent — mentionné comme "future hardening" dans une version antérieure de cette doc alors que le code ne l'appliquait pas encore).


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) :

echo "$(openssl rand -hex 32)" | docker secret create jwt_secret -


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)

Flux de connexion admin avec second facteur WebAuthn

É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 :

location ^~ /backend/old/ {
    deny all;
    return 403;
}

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é)

Flux d'authentification FranceConnect Agent (OIDC + PKCE)

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 (integ01 bac à 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) depuis logout.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

# docker-compose.yml — service web
ports:
  - "127.0.0.1:${WEB_PORT:-8080}:80"

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.php backoffice (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"}'

Références ANSSI