Les principes de secure coding sont les règles d'ingénierie qui préviennent mécaniquement les vulnérabilités à la source, indépendamment du langage. Cet article détaille 12 principes opérationnels illustrés par du code Python, TypeScript, Java et Go — en allant au-delà des exemples évidents (injection SQL paramétrée, XSS avec encodage HTML) pour couvrir les patterns où les développeurs expérimentés échouent le plus souvent : comparaison timing-safe de secrets, TOCTOU / race conditions, désérialisation sûre, SSRF en allowlist, mass assignment ORM, prototype pollution côté Node.js, logs sans PII ni secrets, TOCTOU. Les principes sont alignés OWASP ASVS v4.0.3 (sections V5 Validation, V6 Cryptography, V7 Error Handling, V8 Data Protection, V10 Malicious Code), NIST SP 800-218 SSDF (Secure Software Development Framework), NIST SP 800-53 SI-10, et les CWE Top 25 2023-2024. Un codebase qui applique rigoureusement ces 12 principes prévient 80 à 90 % des vulnérabilités du Top 10 OWASP 2021 sans avoir à les énumérer une par une. Pour la version parcours d'apprentissage structuré, voir Roadmap secure coding ; pour le catalogue des vulnérabilités classiques, Introduction OWASP Top 10.
1. Fail Closed / Secure by Default
Un système doit refuser l'accès en cas de doute plutôt que l'autoriser. Principe aligné CWE-636 (Not Failing Securely) et ASVS V1.4.1. L'erreur la plus fréquente : écrire une fonction d'autorisation qui retourne true par défaut ou qui retourne l'autorisation sur exception au lieu de la refuser.
Contre-exemple classique (Python)
def can_user_access(user, resource) -> bool:
try:
policy = load_policy(resource)
return policy.allows(user)
except Exception as e:
logger.warning(f"Policy load failed: {e}")
return True # ❌ fail open : accès accordé sur erreurVersion correcte
def can_user_access(user, resource) -> bool:
try:
policy = load_policy(resource)
except Exception as e:
logger.error(
"policy_load_failed",
extra={"resource": resource.id, "user": user.id, "err": str(e)}
)
return False # ✅ fail closed
return policy.allows(user)2. Valider à la Trust Boundary, pas partout
Le modèle Trust Boundary OWASP définit que la validation se fait à l'entrée du périmètre de confiance (HTTP handler, message queue consumer, CLI args, import file), puis les données validées circulent sous forme de types qui portent l'invariant. Éparpiller la validation partout produit du code bruité et des invariants inconsistants.
Pattern parse-don't-validate (TypeScript avec Zod)
import { z } from "zod";
// Schéma qui PARSE et TYPE la donnée à la frontière
const CreateOrderInput = z.object({
customerId: z.string().uuid(),
items: z.array(z.object({
sku: z.string().regex(/^[A-Z0-9-]{3,20}$/),
quantity: z.number().int().min(1).max(1000),
})).min(1).max(50),
shippingAddress: z.object({
country: z.enum(["FR", "BE", "CH", "LU", "MC"]),
postalCode: z.string().regex(/^\d{5}$/),
}),
});
type CreateOrderInput = z.infer<typeof CreateOrderInput>;
// HTTP handler = seule frontière où on valide
app.post("/api/orders", async (req, res) => {
const parsed = CreateOrderInput.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ ok: false, error: "invalid_input" });
}
// À partir d'ici, le type CreateOrderInput PORTE l'invariant.
// Aucune re-validation nécessaire dans createOrder, enqueueFulfillment, etc.
await createOrder(parsed.data);
res.status(201).json({ ok: true });
});Cette approche, théorisée par Alexis King (2019), est devenue standard dans les codebases TypeScript, Rust et Go modernes. Elle supprime la catégorie entière des bugs de validation manquée à mi-pipeline.
3. Encoder selon le contexte de sortie
L'encodage HTML n'est PAS universel. Une donnée utilisateur injectée dans du JS inline, dans un attribut HTML, dans une URL ou dans du CSS nécessite 4 encodages différents. Aligné OWASP Cheat Sheet: Cross Site Scripting Prevention et ASVS V5.3.
| Contexte de sortie | Encodage correct | Piège |
|---|---|---|
Texte HTML (<div>{x}</div>) | htmlEncode : &, <, >, ", ' | Suffit ici seulement |
Attribut HTML (<a title="{x}">) | htmlAttributeEncode, guillemets obligatoires | onclick="..." interdit d'y injecter |
JS inline (<script>var x = "{x}";</script>) | JSON.stringify (échappe \, ", </script>) | htmlEncode SEUL est faux |
URL parameter (?q={x}) | encodeURIComponent / urllib.parse.quote | htmlEncode SEUL est faux |
CSS value (background: url({x})) | CSS.escape ou valeur restreinte | Souvent non supporté, préférer rejeter |
Exemple TypeScript — ne jamais faire ceci
// ❌ XSS via attribut JS : htmlEncode ne protège pas ici
res.send(`<button onclick="show('${htmlEncode(userInput)}')">Click</button>`);
// ✅ Éviter complètement le JS inline, ou utiliser JSON.stringify
const safeJson = JSON.stringify(userInput).replace(/</g, "\\u003c");
res.send(`
<button id="btn">Click</button>
<script>
const payload = ${safeJson};
document.getElementById("btn").onclick = () => show(payload);
</script>
`);4. Comparer les secrets en temps constant
Les comparaisons de tokens, HMAC, API keys ou mots de passe hashés ne doivent jamais utiliser ==, ===, strcmp, Arrays.equals. Ces opérations court-circuitent au premier caractère différent, exposant la longueur du préfixe correct via le timing réseau. CWE-208 (Observable Timing Discrepancy), violation détectée dans ~50 % des codebases auditées 2024 (observations ESN cyber + rapports bug bounty HackerOne 2023-2024).
Exemple multi-langages
# Python
import hmac
def verify_token(provided: bytes, expected: bytes) -> bool:
return hmac.compare_digest(provided, expected)// Node.js
import { timingSafeEqual } from "crypto";
function verifyToken(provided: Buffer, expected: Buffer): boolean {
if (provided.length !== expected.length) return false;
return timingSafeEqual(provided, expected);
}// Java
import java.security.MessageDigest;
public static boolean verifyToken(byte[] provided, byte[] expected) {
return MessageDigest.isEqual(provided, expected);
}// Go
import "crypto/subtle"
func VerifyToken(provided, expected []byte) bool {
return subtle.ConstantTimeCompare(provided, expected) == 1
}5. Autorisation côté serveur non-négociable
Un ID ressource reçu du client n'implique jamais d'autorisation. Le serveur doit systématiquement vérifier : « cet utilisateur est-il autorisé à accéder à cette ressource ? ». OWASP Top 10 A01:2021 Broken Access Control, CWE-285, CWE-639 (IDOR). C'est la classe de vulnérabilité la plus fréquente en bug bounty 2024 (source : HackerOne Hacker-Powered Security Report 2024, 33 % des critical bounties).
Exemple Python / FastAPI
# ❌ IDOR classique : on fait confiance à l'ID d'URL
@router.get("/invoices/{invoice_id}")
async def get_invoice(invoice_id: UUID, user: User = Depends(current_user)):
invoice = await Invoice.get(invoice_id)
if not invoice:
raise HTTPException(404)
return invoice # ❌ tout utilisateur authentifié voit toute facture
# ✅ Vérification d'ownership / authorization explicite
@router.get("/invoices/{invoice_id}")
async def get_invoice(invoice_id: UUID, user: User = Depends(current_user)):
invoice = await Invoice.get(invoice_id)
if not invoice:
raise HTTPException(404)
# Autorisation centralisée, pas dispersée dans chaque route
if not can_user_read_invoice(user, invoice):
# Retourner 404 plutôt que 403 pour ne pas divulguer l'existence
raise HTTPException(404)
return invoiceRègle structurante
Externaliser les décisions d'autorisation dans une policy layer testable (OPA/Rego, oso, Casbin) ou un module applicatif isolé. Jamais de règle d'autorisation inline dispersée sur des centaines de routes — coût de review explosant, couverture tests nulle.
6. Cryptographie haute-niveau, jamais roll your own
N'implémentez jamais d'AES, de CBC, de padding, de HMAC à la main. Utilisez des APIs haute-niveau qui gèrent mode + nonce + auth ensemble. Aligné ASVS V6.2 et NIST SP 800-175B.
| Choix à éviter | Remplacement recommandé 2025 |
|---|---|
| AES-CBC + HMAC manuel | AES-GCM ou ChaCha20-Poly1305 (AEAD) |
| MD5, SHA-1 (cassés) | SHA-256 minimum, SHA-3 pour nouveaux designs |
| MD5/SHA-256 pour mots de passe | argon2id (défaut 2025), sinon bcrypt/scrypt |
| Math.random(), rand() | Sources crypto : secrets (Python), crypto.randomBytes (Node), crypto/rand (Go) |
| RSA < 3072 bits | RSA 3072+ minimum, Ed25519 préférable |
| TLS 1.0/1.1 | TLS 1.2 minimum, TLS 1.3 cible |
Exemple Python : chiffrement AEAD avec PyNaCl (libsodium)
from nacl.secret import SecretBox
from nacl.utils import random
# Clé 32 octets, stockée dans secret manager (KMS, Vault)
key = random(SecretBox.KEY_SIZE) # en prod : load_from_vault("app-encryption-key")
box = SecretBox(key)
# Chiffrement : nonce automatique unique, auth tag intégré
ciphertext = box.encrypt(b"donnee sensible")
# Déchiffrement : vérification auth automatique, lève CryptoError si tampered
plaintext = box.decrypt(ciphertext)Pas de nonce manuel à générer, pas de HMAC à coller, pas de padding à gérer. L'API fait ce qu'il faut, ce qui élimine 90 % des bugs crypto applicatifs observés en pentest.
7. Éviter les TOCTOU (Time-Of-Check / Time-Of-Use)
Vérifier une condition puis l'utiliser plus tard expose à une race condition si l'état change entre les deux. CWE-367. Classe de bug sous-diagnostiquée car difficilement détectable par les scans statiques traditionnels.
Contre-exemple Go — vérification séparée de l'écriture
// ❌ TOCTOU : fichier peut être remplacé par un symlink entre Lstat et Open
info, err := os.Lstat(path)
if err != nil { return err }
if info.Mode().IsRegular() {
f, err := os.Open(path) // ❌ peut ouvrir un symlink créé entre-temps
// ...
}Version correcte — opération atomique
// ✅ Open avec O_NOFOLLOW : refuse les symlinks atomiquement
f, err := os.OpenFile(path, os.O_RDONLY | syscall.O_NOFOLLOW, 0)
if err != nil { return err }
defer f.Close()
// On vérifie le type APRÈS ouverture, sur le fd, pas sur le path
info, err := f.Stat()
if err != nil { return err }
if !info.Mode().IsRegular() {
return errors.New("not a regular file")
}TOCTOU métier : le double-spend
# ❌ Race condition sur le solde : deux requêtes simultanées passent la vérif
def transfer(account_id: UUID, amount: Decimal):
balance = Account.get(account_id).balance
if balance < amount:
raise InsufficientFunds()
Account.debit(account_id, amount) # deux requêtes peuvent débiter
# ✅ Décrément conditionnel atomique en base, UPDATE ... WHERE balance >= amount
def transfer(account_id: UUID, amount: Decimal):
updated = db.execute(
"UPDATE accounts SET balance = balance - :amt "
"WHERE id = :id AND balance >= :amt",
{"id": account_id, "amt": amount}
)
if updated.rowcount == 0:
raise InsufficientFunds()8. Désérialisation sûre
La désérialisation de formats codant du comportement (Python pickle, Java ObjectInputStream, Ruby Marshal, PHP unserialize, .NET BinaryFormatter) peut conduire à une RCE si le contenu est sous contrôle attaquant. OWASP Top 10 A08:2021 Software and Data Integrity Failures, CWE-502. Classe responsable de CVE critiques récurrentes (Log4Shell CVE-2021-44228 de 2021 en est un descendant, Spring4Shell CVE-2022-22965 en 2022, exploitations Java persistantes 2023-2024).
Règles pratiques
| Format | Règle |
|---|---|
| Python pickle / Ruby Marshal / PHP unserialize | Ne jamais désérialiser de donnée non-trusted. Utiliser JSON ou CBOR. |
| Java ObjectInputStream | Déprécier. Utiliser Jackson JSON + validation Bean. Si inévitable : ObjectInputFilter JEP-290. |
| .NET BinaryFormatter | Déprécié Microsoft depuis .NET 5. Utiliser System.Text.Json. |
| YAML | yaml.safe_load() en Python, jamais yaml.load nu. |
| XML | Désactiver entités externes (XXE) et DTD : etree.parse(..., parser=XMLParser(resolve_entities=False)). |
Exemple Python safe YAML
import yaml
# ❌ yaml.load accepte des constructeurs Python arbitraires → RCE
config = yaml.load(user_upload, Loader=yaml.Loader) # NE JAMAIS FAIRE
# ✅ safe_load interdit les types non-primitifs
config = yaml.safe_load(user_upload)9. SSRF en allowlist, pas denylist
Les denylists d'IP privées (169.254.0.0/16 metadata, 10.0.0.0/8, 127.0.0.0/8) sont systématiquement contournables : DNS rebinding, redirections HTTP multi-hop, IPv6 équivalents, encodages URL variants (127.1, 0177.0.0.1, 2130706433 décimal), IPs publiques cloud pointant en interne. OWASP Top 10 A10:2021 SSRF, CWE-918.
Pattern robuste TypeScript
import dns from "node:dns/promises";
import { URL } from "node:url";
import net from "node:net";
const ALLOWED_HOSTS = new Set([
"api.partner1.com",
"webhook.partner2.io",
]);
async function safeFetch(rawUrl: string): Promise<Response> {
const url = new URL(rawUrl);
// 1. Allowlist stricte de host
if (!ALLOWED_HOSTS.has(url.hostname)) {
throw new Error("host_not_allowed");
}
// 2. Scheme restreint
if (url.protocol !== "https:") {
throw new Error("scheme_not_allowed");
}
// 3. Résolution DNS côté serveur + validation de l'IP finale
const { address } = await dns.lookup(url.hostname);
if (isPrivateIp(address)) {
throw new Error("resolved_to_private_ip");
}
// 4. Fetch avec redirections désactivées
return fetch(url, { redirect: "error" });
}
function isPrivateIp(ip: string): boolean {
if (net.isIPv4(ip)) {
const [a, b] = ip.split(".").map(Number);
return (
a === 10 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
a === 127 ||
(a === 169 && b === 254) ||
a === 0
);
}
if (net.isIPv6(ip)) {
return ip.startsWith("::1") || ip.startsWith("fc") || ip.startsWith("fd") || ip.startsWith("fe80");
}
return false;
}La protection robuste combine allowlist hostname + scheme fixé + résolution DNS serveur + validation IP finale + interdiction de redirections. Un seul de ces contrôles pris isolément est contournable.
10. Logs sécurisés — jamais de secrets ni de PII brute
Les logs sont souvent indexés (SIEM, stack ELK, Loki) et consultés par des équipes larges. Logguer des secrets (tokens, mots de passe, clés), des PII (numéro CNI, carte bancaire, email brut sur certains contextes RGPD) ou des données de santé crée des fuites indirectes. CWE-532, violation détectée régulièrement lors des audits post-incident.
Pattern Python : redaction automatique par filtre
import logging
import re
SECRETS_PATTERNS = [
re.compile(r"(?i)(authorization|cookie|password|token|api[_-]?key)\s*[=:]\s*\S+"),
re.compile(r"Bearer\s+[A-Za-z0-9._-]+"),
re.compile(r"\b\d{13,19}\b"), # PAN carte bancaire naïf
]
class RedactFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
msg = record.getMessage()
for pattern in SECRETS_PATTERNS:
msg = pattern.sub("[REDACTED]", msg)
record.msg = msg
record.args = ()
return True
logger = logging.getLogger("app")
logger.addFilter(RedactFilter())
# ✅ Pour les identifiants utilisateurs, préférer un hash / surrogate ID
logger.info("user_login", extra={
"user_id_hash": sha256(user.email.encode()).hexdigest()[:12],
"ip": anonymize_ip(request.remote_addr),
})Règles opérationnelles
- Jamais de header
Authorization,Cookie,X-API-Keyen log brut. - Jamais de body de requête si non échantillonné et non filtré.
- Jamais de stack trace renvoyée au client (fuite d'architecture + CWE-209).
- Identifiants utilisateurs loggués en forme pseudonymisée si les logs sortent du périmètre régulé (RGPD article 4).
11. Prévenir la prototype pollution (écosystème JS / Node.js)
La prototype pollution est une classe spécifique à JavaScript où un attaquant modifie Object.prototype via une clé __proto__ ou constructor.prototype, affectant tous les objets créés après. CWE-1321. CVE fréquentes 2022-2024 sur lodash, minimist, merge, hoek, set-value.
Contre-exemple et fix
// ❌ Merge naïf : copie la clé __proto__
function merge(target: any, source: any): any {
for (const key in source) {
if (typeof source[key] === "object") {
target[key] = merge(target[key] || {}, source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
// Entrée malicieuse
merge({}, JSON.parse('{"__proto__": {"isAdmin": true}}'));
console.log(({} as any).isAdmin); // true → pollution globale
// ✅ Fix : filtrer les clés dangereuses + Object.hasOwn
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
function safeMerge(target: Record<string, unknown>, source: Record<string, unknown>) {
for (const key of Object.keys(source)) {
if (FORBIDDEN_KEYS.has(key)) continue;
if (!Object.hasOwn(source, key)) continue;
const value = source[key];
if (value && typeof value === "object" && !Array.isArray(value)) {
target[key] = safeMerge(
(target[key] as Record<string, unknown>) ?? Object.create(null),
value as Record<string, unknown>
);
} else {
target[key] = value;
}
}
return target;
}Alternative structurante : préférer Map pour les dictionnaires de clés dynamiques (new Map() n'a pas de prototype exploitable), ou Object.create(null) pour des objets sans prototype.
12. Mass assignment : verrouiller les champs modifiables
Les ORM et frameworks modernes permettent de construire des objets depuis un body JSON d'un coup. Si tous les champs sont assignables, un attaquant peut injecter {"is_admin": true} dans un PATCH /profile. OWASP API Top 10 2023 API6, CWE-915. Classe de bug majeure sur Ruby on Rails historiquement (CVE GitHub 2012), persistante sur Django, Spring, TypeORM.
Exemple TypeScript / TypeORM
// ❌ Mass assignment : tous les champs body écrasent l'entity
app.patch("/profile", async (req, res) => {
const user = await userRepo.findOneByOrFail({ id: req.user.id });
Object.assign(user, req.body); // ❌ body contient "role": "admin"
await userRepo.save(user);
res.json({ ok: true });
});
// ✅ Allowlist explicite des champs modifiables
const UpdateProfileInput = z.object({
displayName: z.string().min(1).max(80),
avatarUrl: z.string().url().optional(),
bio: z.string().max(500).optional(),
});
app.patch("/profile", async (req, res) => {
const input = UpdateProfileInput.safeParse(req.body);
if (!input.success) return res.status(400).json({ ok: false });
await userRepo.update(
{ id: req.user.id },
input.data // ✅ seuls les 3 champs whitelisted
);
res.json({ ok: true });
});Règle générale
Définir des DTOs d'entrée distincts des entités. L'entité base de données contient role, tenant_id, created_at ; le DTO PATCH profil ne contient que les 3-4 champs utilisateur-modifiables. Zod, class-validator, Pydantic, Bean Validation assurent le filtrage sans code manuel.
Points clés à retenir
- Fail Closed : refuser par défaut sur erreur, jamais laisser passer « parce que la policy n'a pas chargé ».
- Trust Boundary : valider une fois à l'entrée avec types qui portent l'invariant (parse-don't-validate), pas re-valider à chaque fonction.
- Context-aware encoding : htmlEncode, JSON.stringify, encodeURIComponent, CSS.escape — quatre contextes, quatre encodages.
- Timing-safe compare : jamais
==sur un token, HMAC ou API key —hmac.compare_digest,timingSafeEqual,MessageDigest.isEqual,subtle.ConstantTimeCompare. - Server-side authorization : politique centralisée, 404 plutôt que 403, ID URL jamais trusté.
- Crypto haute-niveau : AES-GCM / ChaCha20-Poly1305 AEAD, argon2id pour mots de passe, Ed25519 pour signatures, sources aléatoires crypto.
- TOCTOU : atomicité via APIs système (O_NOFOLLOW) ou UPDATE conditionnel en base.
- Désérialisation : jamais de pickle / ObjectInputStream / BinaryFormatter sur input non-trusté ; JSON + schéma sinon.
- SSRF : allowlist hostname + scheme + résolution DNS serveur + IP finale vérifiée + redirections interdites.
- Logs : filtre de redaction systématique, pseudonymisation des user IDs, jamais de stack trace au client.
- Prototype pollution : filtrer
__proto__/constructor/prototype, préférerMapouObject.create(null). - Mass assignment : DTOs d'entrée et de sortie distincts de l'entité, allowlist explicite.
Pour consolider la pratique, voir Roadmap secure coding (parcours progressif 4 phases) et Roadmap AppSec Engineer. Pour le pendant validation offensive, OWASP Testing Guide expliqué et Méthodologie pentest web.







