Esc
 Naviguer  Ouvrir Esc Fermer
Aller au contenu

12.3 — Sécurité backend

🎯 Objectif : protéger ton backend contre les attaques qui n’ont rien à voir avec le navigateur — injections, manipulation d’IDs, fuite de secrets, brute-force.

À l'issue de cet axe, tu sauras :

  • Prévenir SQL/NoSQL/Command injection
  • Implémenter une vérification d'autorisation contre IDOR
  • Hasher un mot de passe avec argon2id
  • Stocker des secrets dans un secret manager
  • Rate-limiter les endpoints sensibles
  • Bloquer les SSRF en filtrant les URLs

Confirmé 10 min prérequis : axes 8 et 11 lus

// ❌
db.query(`SELECT * FROM users WHERE email = '${email}'`);
// ✅
db.query('SELECT * FROM users WHERE email = $1', [email]);

ORMs (Prisma, Drizzle, SQLAlchemy, Eloquent, Doctrine) paramétrisent automatiquement — c’est une raison majeure de les utiliser.

// ❌
db.users.find({ email: req.body.email, password: req.body.password });
// Si body.password = { $ne: null } → MongoDB matche tout password différent de null
// ✅
db.users.find({ email: String(req.body.email), password: String(req.body.password) });
// Plus mieux : valider avec Zod
const { email, password } = LoginSchema.parse(req.body);
// ❌
import { exec } from 'node:child_process';
exec(`convert ${userFilename} output.png`);
// Si filename = "input.jpg; rm -rf /"
// ✅ execFile + arguments séparés (jamais concaténés en string)
import { execFile } from 'node:child_process';
execFile('convert', [userFilename, 'output.png']);

Règle absolue : NEVER construire un shell command avec interpolation de string. Toujours execFile / spawn avec array d’arguments.

L’attaque la plus simple à tester : changer un ID dans l’URL et voir si on accède aux données d’un autre.

// ❌
app.get('/orders/:id', async (req, res) => {
const order = await db.order.findById(req.params.id);
res.json(order);
});

Tu peux tester GET /orders/1, /orders/2, /orders/3 et tout récupérer.

// ✅
app.get('/orders/:id', requireAuth, async (req, res) => {
const order = await db.order.findOne({
where: { id: req.params.id, userId: req.user.id },
});
if (!order) return res.status(404).json({ error: 'Not found' });
res.json(order);
});

Pourquoi 404 et pas 403 : 403 confirme que la ressource existe → l’attaquant peut énumérer.

Même si tu oublies le check applicatif, PostgreSQL RLS empêche l’accès :

ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY user_sees_own_orders ON orders
FOR SELECT USING (user_id = auth.uid());

Utiliser des UUIDs v4 ou slugs random plutôt que des entiers séquentiels rend l’énumération beaucoup plus dure :

/orders/8f3a9c2e-4b1d-... au lieu de /orders/42

Pas une protection (juste de l’obscurité), mais défense en profondeur.

Un hash de mot de passe doit être lent et adapté aux GPU.

AlgoAvis
argon2idRecommandation OWASP 2026
bcrypt✅ Toujours acceptable (legacy mais éprouvé)
scrypt✅ Alternative valide
PBKDF2🟡 Acceptable mais moins moderne
AlgoPourquoi
MD5Cassé depuis 2005, hashable à 100 GH/s sur GPU
SHA-1Cassé en 2017
SHA-256 / SHA-512Trop rapide → brute-force massif
Plain text(sans commentaire)
import { hash, verify } from '@node-rs/argon2';
const hashed = await hash('myPassword');
const valid = await verify(hashed, 'myPassword');
from passlib.context import CryptContext
pwd = CryptContext(schemes=['argon2'], deprecated='auto')
hashed = pwd.hash('myPassword')
valid = pwd.verify('myPassword', hashed)
$hashed = password_hash('myPassword', PASSWORD_ARGON2ID);
$valid = password_verify('myPassword', $hashed);

Configuration du hasher dans config/hashing.php (Laravel) ou security.yaml (Symfony) → automatique partout.

SecretSi fuit
Service role DB / SUPABASE_SERVICE_ROLE_KEYCompromission totale de la DB
STRIPE_SECRET_KEYCharges frauduleuses
JWT_SECRETTous les tokens forgés par l’attaquant
Tokens API tiers (OpenAI, Resend, …)Vol de quota / spam
Cookies sessionImpersonation utilisateur
PlateformeOutil
Vercel / NetlifyVariables d’env du dashboard
AWSSecrets Manager (rotation automatique possible)
GCPSecret Manager
KubernetesSealed Secrets, External Secrets Operator
Hashicorp VaultSelf-host enterprise
DopplerSaaS, sync vers tous les envs
.env (PAS dans Git)
.env.example (DANS Git, sans les vraies valeurs)
.gitignore : .env, .env.*.local
  1. RÉVOQUER la clé chez le provider (Stripe, AWS, etc.) — pas négociable.
  2. Rotation : générer une nouvelle clé.
  3. Nettoyer l’historique avec git filter-repo.
  4. Force-push (avec accord équipe).

Outils pour détecter avant : gitleaks, trufflehog, GitHub Secret Scanning (auto sur les repos publics).

Sans rate-limit, un attaquant peut tester 1 million de mots de passe en quelques minutes.

  • /login, /register, /reset-password — par IP
  • API publique — par token
  • Endpoints coûteux (recherche, IA) — par user
import { rateLimiter } from 'hono-rate-limiter';
app.use('/auth/login', rateLimiter({
windowMs: 15 * 60 * 1000, // 15 min
limit: 5, // 5 tentatives par IP
message: 'Too many attempts, try again later',
}));
Fenêtre de terminal
uv add slowapi
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
@app.post('/login')
@limiter.limit('5/15minutes')
async def login(...): ...
routes/api.php
Route::post('/login', LoginController::class)->middleware('throttle:5,1');
// 5 tentatives par minute

Pour une app multi-instances, le compteur doit être partagé :

import { Redis } from 'ioredis';
const redis = new Redis();
async function checkRateLimit(ip: string): Promise<boolean> {
const count = await redis.incr(`rate:login:${ip}`);
if (count === 1) await redis.expire(`rate:login:${ip}`, 900);
return count <= 5;
}

Quand un IP dépasse N tentatives, exige un CAPTCHA (hCaptcha, Cloudflare Turnstile) avant la prochaine.

Cas typique : ton serveur fetch une URL fournie par l’utilisateur (génération PDF, preview de lien…).

// ❌
const html = await fetch(req.body.url).then(r => r.text());

L’attaquant envoie :

  • http://169.254.169.254/latest/meta-data → credentials AWS instance.
  • http://localhost:5432 → exfiltration DB locale.
  • http://internal-api/admin → accès services internes.
import dns from 'node:dns/promises';
import net from 'node:net';
async function fetchSafe(url: string) {
const parsed = new URL(url);
// 1. Whitelist de protocoles
if (!['https:', 'http:'].includes(parsed.protocol)) {
throw new Error('Unsupported protocol');
}
// 2. Résoudre le hostname → IP
const { address } = await dns.lookup(parsed.hostname);
// 3. Bloquer les IPs privées et localhost
if (isPrivateIp(address)) {
throw new Error('Internal IPs not allowed');
}
// 4. Fetch avec timeout
return fetch(url, { signal: AbortSignal.timeout(5000) });
}
function isPrivateIp(ip: string): boolean {
if (ip.startsWith('127.') || ip.startsWith('10.') || ip.startsWith('192.168.')) return true;
if (ip === '::1' || ip.startsWith('fe80:')) return true;
// 172.16.0.0/12
const parts = ip.split('.').map(Number);
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
return false;
}

Bibliothèques : ssrf-req-filter (Node), équivalents par langage.

Particulièrement risqué en PHP (unserialize), Java (ObjectInputStream), Python (pickle).

// ❌
$data = unserialize($_POST['data']);
// L'attaquant peut envoyer un objet malicieux qui exécute du code à la désérialisation
# ❌ pickle est dangereux avec des inputs externes
import pickle
data = pickle.loads(request.data)

Règle : pour échanger des données, utilise JSON uniquement avec validation de schéma.

// Logger les actions sensibles
logger.info({
event: 'user_role_change',
actor: req.user.id,
target: targetUserId,
oldRole,
newRole,
ip: req.ip,
timestamp: new Date(),
});

Stocker dans une table audit_log ou un système dédié (Datadog, Loki). Ne jamais y mettre password ou token.

Tu vois `db.query("SELECT * FROM users WHERE id = " + req.params.id)`. Risque immédiat ?
Tu hashes les mots de passe avec SHA-256 + sel. Avis 2026 ?
Tu fais `fetch(req.body.url)` côté serveur pour générer une preview. Risque ?

Suite : 12.4 — Pratiques continues — Dependabot, SAST/DAST, RGPD.