Ce document décrit la politique de sécurité de dsfr-data, la manière de signaler une vulnérabilité, et l'ensemble des outils qui contrôlent en permanence l'état de sécurité du projet.
Merci de ne pas ouvrir d'issue publique pour signaler une vulnérabilité. Utilisez à la place l'un de ces deux canaux privés :
- GitHub Security Advisories (préférence) — Report a vulnerability depuis l'onglet Security du repo. C'est le canal le plus rapide, il crée automatiquement un espace de discussion privé avec les mainteneurs.
- Email — envoyer un rapport à l'auteur du repo (bmatge).
Merci d'inclure dans votre signalement :
- Une description du problème et de son impact potentiel
- Les étapes de reproduction ou un proof-of-concept minimal
- La version concernée de
dsfr-data(tag ou commit SHA) - Vos coordonnées si vous souhaitez être recontacté
Nous nous engageons à accuser réception sous 72 heures, à évaluer l'impact et proposer un correctif ou un plan de mitigation sous 7 jours pour les vulnérabilités significatives.
Ce projet est en développement actif. Seule la branche main et la dernière version publiée sur npm reçoivent des correctifs de sécurité.
| Version | Supportée |
|---|---|
main (dev) |
✅ |
| Dernière release npm | ✅ |
| Releases antérieures | ❌ |
La CI exécute 10 jobs de sécurité sur chaque pull request, et plusieurs workflows périodiques pour rattraper les CVE qui arrivent après un build :
| Brique | Outil | Job / Workflow | Sévérité bloquante |
|---|---|---|---|
| SCA — dépendances (bloquant) | npm audit --omit=dev (root, ce qui est livré) · npm audit (mcp-server) |
quality (root) + sca (mcp-server) |
HIGH/CRITICAL |
| SCA — advisory (non-bloquant) | npm audit --audit-level=moderate, dépendances de développement comprises |
sca-advisory |
aucune (information) |
| SCA — lockfiles | trivy fs |
sca (root + mcp-server + Cargo) |
HIGH/CRITICAL (fixable) |
| Misconfig — Dockerfiles | trivy config |
sca |
HIGH/CRITICAL |
| Secrets | gitleaks |
secrets + Husky pre-commit |
toute détection |
| SAST | semgrep |
sast |
ERROR/WARNING (rulesets curated) |
| SAST — data flow | CodeQL |
codeql.yml |
security-and-quality |
| Lint sécurité | eslint-plugin-security |
quality |
toute erreur |
| SRI — intégrité CDN | script custom check:sri |
quality |
tout tag <script>/<link> pointant vers un CDN sans integrity valide |
| Images Docker | trivy image |
docker-scan.yml (matrix sur 2 images) |
CRITICAL |
| DAST — app live | OWASP ZAP baseline |
dast.yml (stack MariaDB+Express+nginx bootée dans le runner) |
non bloquant (advisory) |
Les scans périodiques :
- CodeQL : hebdomadaire, lundi 06:00 UTC
- Trivy image : hebdomadaire, lundi 07:00 UTC
- ZAP DAST : hebdomadaire, lundi 08:00 UTC (+
workflow_dispatchmanuel)
Côté Express, trois couches empilées couvrent les surfaces auth + API :
| Couche | Mécanisme | Activé sur | Raison |
|---|---|---|---|
| Session | Cookie httpOnly gw-auth-token (JWT 7j, SameSite=Strict) |
toutes les routes /api/* via authMiddleware |
Authentifie l'utilisateur sans exposer le token au JS côté navigateur |
| CSRF | Double-submit cookie (csrf-csrf v4) — header X-CSRF-Token + cookie gw-csrf, liés à userId (ou IP si anonyme) |
POST/PUT/PATCH/DELETE sauf auth-bootstrap (login, register, reset-password, verify-email) |
Empêche qu'une form tierce déclenche une mutation via le cookie d'auth de la victime (cf. issue #92) |
| Rate limiting | express-rate-limit 10/15min sur auth, 300/min safety-net sur /api/* |
auth + global | Absorbe l'énumération / brute force avant qu'il ne tape l'application |
Chiffrement au repos : les connections.api_key_encrypted sont chiffrées en AES-256-GCM (clé ENCRYPTION_KEY, 64 hex). Le secret CSRF dérive de la même env var par défaut ou est spécifié séparément via CSRF_SECRET.
Deux routes relaient vers une cible que le client désigne, dans l'en-tête X-Target-URL : /cors-proxy (sources use-proxy, explorateur d'API de l'app Sources, Studio IA) et /ia-proxy (Studio IA, clé de l'usager). Toutes les autres routes de proxy ont une cible fixe, écrite dans la configuration. Une cible choisie par le client doit être bornée : sans cela le serveur relaie vers n'importe quelle adresse, y compris celles qu'il est seul à pouvoir joindre.
Elle s'applique avant tout appel amont, à l'identique dans les quatre endroits qui portent ces routes : docker/nginx.conf, docker/nginx-db.conf, proxy/nginx/nginx.conf (proxy autonome) et le serveur de développement (vite.config.ts). La source de vérité est scripts/lib/garde-proxy.cjs ; les map nginx (docker/garde-proxy.conf) en recopient les motifs.
| Borne | /cors-proxy |
/ia-proxy |
|---|---|---|
| Cible | https:// + nom DNS public, sans identifiants ni port |
idem |
| Méthodes | GET, POST (+ OPTIONS pour le preflight) |
GET, POST |
| Corps de requête | 1 Mo | 1 Mo |
| Débit par client | 10 req/s, rafale de 40 | 5 req/s, rafale de 20 |
| Débit global, partagé par les deux routes | 50 req/s, rafale de 200 | idem |
| CORS | Access-Control-Allow-Origin: * |
aucun en-tête CORS |
| Certificat de l'amont | vérifié | vérifié |
| Redirection de l'amont | rendue telle quelle, jamais suivie | idem |
| Cookies de l'instance | jamais transmis à l'amont | idem |
Sont refusés (403) : http:// et tout autre schéma ; toute adresse IP littérale, quelle que soit sa notation (pointée, décimale, hexadécimale, octale, IPv6 entre crochets, IPv6 mappée) — donc la boucle locale, les plages privées, le lien local, le CGNAT ; localhost et tout nom sans point, ce qui couvre les noms de services Docker ; les suffixes réservés aux réseaux locaux (.localhost, .local, .internal, .lan, .home, .corp, .arpa, .test…) ; une URL portant user:pass@ ; un port, même :443 ; le domaine par lequel l'instance est appelée. Une méthode hors liste reçoit 405, un en-tête absent 400, un corps trop volumineux 413, un dépassement de débit 429.
La forme admise est une liste positive : ce qui n'est pas https://nom.public[/chemin] est refusé, sans qu'il faille énumérer les notations d'adresse.
- Un nom public qui résout vers une adresse interne. nginx filtre le nom qu'il lit, pas l'adresse que le résolveur lui rend. Trois verrous réduisent ce reste sans le fermer : seul le port 443 est joignable ; l'échange est en TLS et le certificat de l'amont est vérifié contre les autorités publiques, pour le nom demandé — un service interne ne présente pas de certificat valide à ce nom ; le résolveur configuré est public, donc les noms internes (services Docker compris) ne résolvent pas. Reste qu'une tentative de connexion vers le port 443 d'une adresse interne a bien lieu, et que le relais atteint tout service qui présente un certificat publiquement valide — y compris les autres sites servis par le même hôte, vus alors depuis une adresse du serveur. Ne jamais accorder de confiance à une adresse source interne.
- Le relais reste ouvert à tout hôte public en
https. C'est son usage : l'explorateur d'API interroge des API quelconques, une liste blanche d'hôtes le rendrait inutilisable. Le débit est plafonné, pas la destination. Access-Control-Allow-Origin: *sur/cors-proxy. Un widget embarqué sur un site tiers (use-proxyavecproxy-url, ouVITE_PROXY_URL_EMBED) appelle cette route depuis une autre origine : la restreindre à l'origine de l'instance casserait ces widgets.- La taille de la réponse de l'amont n'est pas plafonnée.
- La clé de débit par client est
X-Forwarded-Forquand il existe. Sans reverse proxy devant nginx, un client peut forger cet en-tête ; le plafond global tient dans tous les cas. - Le serveur de développement applique la règle de cible, les méthodes et la taille, pas la limite de débit.
Le filtrage par nom ne remplace pas une isolation réseau. Selon le déploiement, par ordre d'effet :
- Sortir le relais générique du conteneur applicatif :
proxy/nginx/est un proxy autonome, à déployer sur un réseau qui ne voit ni la base ni les autres services, puis à router sur/cors-proxypar le reverse proxy (Scénario C). - Règles de sortie sur l'hôte : interdire au conteneur web les destinations privées (RFC 1918, lien local
169.254.0.0/16, CGNAT100.64.0.0/10) en dehors de sa base de données. - Résolveur dédié qui écarte les réponses privées (
private-addressd'unbound,stop-dns-rebindde dnsmasq), désigné par la directiveresolverdes deux blocs.
Aucune n'est imposée par le dépôt : elles dépassent la configuration nginx. La procédure de vérification pour un auto-hébergeur est dans DEPLOYMENT.md.
tests/proxy/garde-proxy.test.ts— le module et son middleware, sur un serveur local, émetteur amont injecté.tests/proxy/garde-proxy-coherence.test.ts— test-garde : les motifs desmapnginx sont ceux du module, les blocslocationsont identiques d'un fichier à l'autre, et la table de cas (tests/proxy/cas-garde-proxy.ts) est évaluée sur lesmaprelues.tests/proxy/garde-proxy-nginx.test.ts— job CIproxy-nginx:nginx -tsur les trois configurations, puis la même table rejouée par un vrai nginx dans un conteneur sans réseau.
Le relais cachable (ADR-155) est une route qu'un site intégrateur fournit pour que les données de ses dataviz passent par son domaine : <relais>/<hôte>/<chemin>?<requête>. Il n'est pas déployé sur l'instance publique, et ce n'est pas le proxy générique ci-dessus. Son contrat, règle par règle, est dans RELAY.md ; cette section dit ce qui le distingue et ce qu'il expose.
Proxy générique (/cors-proxy) |
Relais cachable | |
|---|---|---|
| Qui choisit la destination | le client, dans X-Target-URL — tout hôte public en https |
l'opérateur du relais : liste blanche exacte d'hôtes, et de préfixes de chemin par hôte |
| Ce qui borne | la forme de la cible (pas d'adresse, pas de port, pas de nom local) et le débit | l'appartenance à la liste ; un hôte hors liste est refusé sans contacter personne |
| Méthodes | GET, POST |
GET, HEAD, OPTIONS : lecture seule, aucun corps transmis |
| En-têtes du visiteur | transmis à l'amont, Authorization compris (cookies de l'instance exceptés) |
aucun n'est transmis |
| Clé d'API | celle de l'usager, envoyée par son navigateur, pour lui seul | celle de l'opérateur, détenue par le relais, la même pour tout le monde |
| Réponse | propre à l'appelant, jamais mise en cache | publique et mise en cache, sous l'URL seule |
| Redirection de l'amont | rendue telle quelle | jamais rendue au navigateur |
La conséquence va dans les deux sens. Le relais n'est pas un relais ouvert : la question du proxy générique — « vers où le serveur peut-il être envoyé ? » — y est fermée par la liste blanche. Mais il pose une question que le proxy générique ne pose pas : qu'est-ce que la clé du relais donne à lire à n'importe qui ?
Le relais répond sans authentification, à toute origine (Access-Control-Allow-Origin: *), et met ses réponses en cache. Il ne distingue pas la dataviz de la page d'un inconnu qui écrit l'URL à la main.
Une clé confiée au relais rend public tout ce qu'elle sait lire sur l'hôte autorisé, dans les préfixes de chemin autorisés. Y compris ce que l'amont dit privé : une réponse portant
Set-Cookie,Cache-Control: privateouno-storeest serviepubliccomme les autres.
Trois obligations en découlent, pour l'opérateur du relais :
- Clé en lecture seule, dédiée au relais, au périmètre le plus étroit que le portail délivre. Le relais ne transmet aucune écriture, mais une clé qui en a le droit reste une clé qui fuit plus cher.
- Préfixes de chemin pour tout hôte à clé : seuls les jeux destinés à être publics. Le relais Node refuse de démarrer sans ; avec l'extrait nginx, c'est une ligne de la
locationde l'hôte, qu'il faut écrire. - La clé hors du dépôt et hors des pages : variable d'environnement pour le relais Node, fichier de configuration lisible par root seul pour nginx. Elle n'apparaît dans aucune réponse ni aucun journal du relais.
Si une donnée ne doit pas être publique, elle ne passe pas par un relais.
Monté sur l'origine du site (le cas recommandé), le relais sert des réponses venues d'un tiers sous le domaine du site. D'où : jamais de Set-Cookie de l'amont ; types de contenu en liste fermée (JSON, GeoJSON, CSV), jamais de HTML, de SVG ni de script ; X-Content-Type-Options: nosniff et Content-Security-Policy: default-src 'none'; sandbox sur toute réponse ; aucune redirection de l'amont rendue au navigateur.
Le relais Node de référence tient le contrat en entier, dont deux défenses que nginx seul n'a pas : il vérifie l'adresse vers laquelle résout un hôte autorisé (jamais une adresse privée, et il se connecte à l'adresse qu'il a vérifiée), et il plafonne la taille d'une réponse. L'extrait nginx a quatre limites face à la suite de conformance, et quelques autres que la suite ne voit pas (liste noire d'en-têtes de réponse, pas de délai global, journal d'erreurs de nginx) : elles sont écrites dans proxy/relay/nginx/README.md et dans RELAY.md §10. Aucun des deux ne remplace l'isolation réseau : comme pour le proxy générique, ne jamais accorder de confiance à une adresse source interne.
Deux règles viennent de la relecture de sécurité du relais (#1254) et concernent tout opérateur :
- C-DOS-5 — derrière un mandataire, l'adresse du visiteur se lit dans un en-tête que le mandataire pose, jamais dans un en-tête qu'il transmet. Avec nginx :
proxy_set_header X-Forwarded-For $remote_addr;. Unproxy_passnu laisse chaque requête forger son adresse, donc son quota. - C-CACHE-6 — une 401, 403, 404 ou 410 de l'amont purge l'entrée du cache : sans cela, la panne suivante resservirait en « périmé » la donnée que le portail vient de dépublier.
Les journaux du relais contiennent les URL, donc ce que l'usager a tapé dans une recherche déléguée : le relais Node ne journalise pas la requête par défaut ; la durée de conservation est à fixer par l'opérateur.
tests/relay/conformance.test.mjs— la suite de conformance du contrat (140 tests), jouée contre le relais Node parnpm run test:run, et contre un vrai nginx chargé de l'extrait par le job CIrelais-nginx.tests/relay/reference/— ce qui ne s'observe pas de l'extérieur, sur le relais Node : adresse privée après résolution, rebond DNS, connecteur TLS, cache borné, purge, journaux, mandataire.tests/relay/nginx/— le banc de l'extrait nginx ne diffère de la production que par l'adresse de l'amont ; la liste des tests rouges contre nginx doit être exactement celle des limites documentées.
Côté client, quatre couches protègent contre l'injection et le détournement de scripts :
| Couche | Mécanisme | Portée |
|---|---|---|
| SRI | Attribut integrity="sha384-…" sur tous les <script>/<link> pointant vers un CDN (jsDelivr, unpkg, etc.) |
330 tags sur 49 fichiers, généré par scripts/generate-sri.ts, vérifié en CI par check:sri |
| CSP | Content-Security-Policy par helmet() Express + header add_header nginx avec default-src 'self', allowlist serrée de CDN et endpoints IA |
toutes les réponses servies par nginx et /api/* |
| Headers hardening | X-Frame-Options: SAMEORIGIN, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy (caméra/micro/géo/etc. désactivés) |
snippet docker/security-headers.conf inclus dans chaque location nginx |
| Server fingerprinting | server_tokens off + bannière nginx masquée |
toutes les réponses nginx |
La CSP et les headers sont validés sur chaque URL crawlée par le job ZAP DAST. Le snippet d'en-têtes est inclus dans chaque location nginx (pas au niveau server) car nginx n'hérite pas les add_header dès qu'une location en déclare un — cette architecture est documentée dans docker/security-headers.conf.
- nginx non-root : l'image prod utilise
nginxinc/nginx-unprivileged:alpineet écoute sur8080(pas 80). Finding TrivyDS-0002(container running as root) éliminé. - MariaDB healthcheck : le conteneur DB expose un healthcheck
mysqladmin pingattendu par le conteneur app avant de démarrer — évite qu'Express attaque une DB non prête (et ne pollue les logs de démarrage). - Volumes nommés + chown idempotent :
deploy-server.shapplique un chown one-shot root sur les volumes persistants (html/public,beacon-logs) avant ledocker compose uppour éviter les crashloop après un switch root → non-root (les permissions d'un volume nommé pré-existant priment sur celles du Dockerfile).
- Alertes Dependabot : activées, visibles dans l'onglet Security → Dependabot.
- Correctifs automatiques : Dependabot ouvre automatiquement des PRs quand une advisory impacte nos dépendances (setting
dependabot_security_updatesactivé). - Secret scanning : activé avec push protection — un push contenant un secret connu d'un provider est bloqué avant même d'atteindre le serveur.
Tous nos gates SCA actuels bloquent sur HIGH/CRITICAL. Les advisories MODERATE sont visibles (via Dependabot + le job non-bloquant sca-advisory + les rapports Trivy) mais non bloquantes — la remédiation passe par Dependabot et la revue humaine des alertes, pas par un blocage du pipeline. Le rationale complet (double seuil, défense en profondeur, patterns CodeQL réutilisables) est formalisé dans ADR-004 — Politique de sévérité SCA et défense en profondeur (accepted, 2026-04-15) et tracé dans l'issue #73.
Aucun masquage silencieux. Toute exclusion de règle ou CVE est documentée avec :
- Quoi : règle ou CVE ignorée
- Pourquoi : justification technique
- Suivi : issue de follow-up si un fix est prévu
- Revue : date après laquelle l'entrée doit être réévaluée
Les fichiers d'exclusion à jour :
.trivyignore.semgrepignore.gitleaks.toml.zap/rules.tsv— faux positifs DAST (ZAP baseline)- Exclusions inline (
// nosemgrep:,// eslint-disable-next-line security/…) toujours avec un commentaire en ligne adjacente
- Fuite de secret détectée → security-incident-response.md — runbook avec priorité révocation/rotation, options de réécriture d'historique, tableau des secrets manipulés.
- Lancer les scans en local → security-dev-guide.md — commandes Docker-only pour Trivy, Semgrep, Gitleaks.
- Réutiliser cette baseline sur un autre projet → security-baseline.md — template générique avec snippets prêts à copier-coller pour les 8 briques.
La baseline définie dans l'issue #65 a été complétée à 100%. Chaque brique est traçable à une PR mergée :
| Brique | PR |
|---|---|
| Trivy SCA (lockfiles) | #67 |
| Gitleaks secrets | #68 |
| Semgrep SAST + fix prototype pollution | #70 |
| CodeQL | #71 |
eslint-plugin-security |
#72 |
| Trivy image Docker | #74 |
SECURITY.md + consolidation |
#75 |
| Baseline réutilisable (template) | #79 |
ADR-004 + job sca-advisory non-bloquant |
#99 |
| DAST OWASP ZAP | #107 |
| SRI sur tous les tags CDN | #108 |
| Security headers nginx (include snippet) | #109 |
| CSRF double-submit cookie | #110 |
| Express 4 → 5 | #112 |
| nginx non-root | #113 |
no-explicit-any (109 → 0 warnings) |
#114–#120 |
Rate limiting /logout + global /api/* safety net |
#96 |
Dette // nosemgrep SARIF upload |
#121 |