Construire des fonctionnalités de sécurité, c'est la moitié de la bataille. Prouver qu'elles fonctionnent, c'est l'autre moitié. Une fonction de hachage qui retourne silencieusement une chaîne vide. Un limiteur de débit qui se réinitialise à chaque requête. Un garde qui passe quand il devrait échouer. Un vérificateur JWT qui accepte des jetons expirés. Chacun de ces bugs est invisible en fonctionnement normal et catastrophique dans un scénario d'attaque.
Nous avons écrit 75 tests orientés sécurité pour le runtime FLIN, organisés en dix catégories. Chaque test est une fonction Rust #[test] qui exerce un comportement de sécurité spécifique et affirme qu'il fonctionne correctement. Pas de mocking. Pas de stubs. De vraies opérations cryptographiques, une vraie génération de jetons, de vrais compteurs de limitation de débit.
Cet article documente les catégories de tests, montre des tests représentatifs de chacune et explique la philosophie de test qui nous a guidés.
Organisation des tests
Les 75 tests sont organisés en dix catégories :
tests/security/
password_hashing.rs -- 12 tests
jwt_tokens.rs -- 10 tests
rate_limiting.rs -- 8 tests
guards.rs -- 10 tests
csrf.rs -- 5 tests
input_validation.rs -- 12 tests
session_security.rs -- 6 tests
security_headers.rs -- 4 tests
file_upload_security.rs -- 4 tests
cryptographic_safety.rs -- 4 testsChaque fichier de test se concentre sur un domaine de sécurité et teste à la fois le chemin normal (la fonctionnalité fonctionne correctement) et le chemin d'attaque (la fonctionnalité résiste aux abus).
Catégorie 1 : Hachage de mots de passe (12 tests)
Les tests de hachage de mots de passe vérifient le comportement d'Argon2id de bout en bout :
rust#[test]
fn test_hash_produces_argon2id_format() {
let hash = hash_password("test-password").unwrap();
assert!(hash.starts_with("$argon2id$"));
}
#[test]
fn test_correct_password_verifies() {
let hash = hash_password("correct-horse-battery-staple").unwrap();
assert!(verify_password("correct-horse-battery-staple", &hash).unwrap());
}
#[test]
fn test_wrong_password_fails() {
let hash = hash_password("correct-password").unwrap();
assert!(!verify_password("wrong-password", &hash).unwrap());
}
#[test]
fn test_same_password_different_hashes() {
let hash1 = hash_password("same-password").unwrap();
let hash2 = hash_password("same-password").unwrap();
assert_ne!(hash1, hash2); // Sels différents -> hachages différents
}
#[test]
fn test_empty_password_hashes() {
// Les mots de passe vides doivent quand même être hachés (certaines apps les permettent pour les utilisateurs OAuth)
let hash = hash_password("").unwrap();
assert!(verify_password("", &hash).unwrap());
assert!(!verify_password("not-empty", &hash).unwrap());
}
#[test]
fn test_unicode_password() {
let hash = hash_password("mot de passe avec des caracteres speciaux eeeacu").unwrap();
assert!(verify_password("mot de passe avec des caracteres speciaux eeeacu", &hash).unwrap());
}Le test pour des hachages différents à partir du même mot de passe est critique -- il vérifie que le salage fonctionne. Si deux hachages de "password" sont identiques, la génération du sel est cassée et les attaques par table arc-en-ciel sont possibles.
Catégorie 2 : Jetons JWT (10 tests)
Les tests JWT vérifient la création, la vérification, l'expiration et la détection de falsification :
rust#[test]
fn test_create_and_verify_token() {
let user = mock_user(42, "admin");
let token = create_token(&user, &TokenOptions {
expires: Duration::hours(1),
claims: HashMap::new(),
}).unwrap();
let claims = verify_token(&token, &TEST_SECRET).unwrap();
assert_eq!(claims.sub, "42");
}
#[test]
fn test_expired_token_rejected() {
let user = mock_user(42, "admin");
let token = create_token(&user, &TokenOptions {
expires: Duration::seconds(-1), // Déjà expiré
claims: HashMap::new(),
}).unwrap();
assert!(verify_token(&token, &TEST_SECRET).is_none());
}
#[test]
fn test_tampered_token_rejected() {
let user = mock_user(42, "admin");
let token = create_token(&user, &TokenOptions::default()).unwrap();
// Falsifier le payload
let parts: Vec<&str> = token.splitn(3, '.').collect();
let tampered = format!("{}.{}{}.{}", parts[0], parts[1], "tampered", parts[2]);
assert!(verify_token(&tampered, &TEST_SECRET).is_none());
}
#[test]
fn test_wrong_secret_rejected() {
let user = mock_user(42, "admin");
let token = create_token(&user, &TokenOptions::default()).unwrap();
assert!(verify_token(&token, b"wrong-secret").is_none());
}
#[test]
fn test_custom_claims_preserved() {
let user = mock_user(42, "admin");
let mut custom = HashMap::new();
custom.insert("role".into(), Value::String("admin".into()));
custom.insert("org_id".into(), Value::Int(7));
let token = create_token(&user, &TokenOptions {
expires: Duration::hours(1),
claims: custom,
}).unwrap();
let claims = verify_token(&token, &TEST_SECRET).unwrap();
assert_eq!(claims.get("role"), Some(&Value::String("admin".into())));
assert_eq!(claims.get("org_id"), Some(&Value::Int(7)));
}Le test de jeton falsifié est particulièrement important. Il vérifie que changer ne serait-ce qu'un seul caractère dans le payload invalide la signature.
Catégorie 3 : Limitation de débit (8 tests)
Les tests de limitation de débit vérifient le comptage, le comportement de fenêtre et la logique de réinitialisation :
rust#[test]
fn test_allows_within_limit() {
let mut limiter = RateLimiter::new();
for _ in 0..5 {
assert!(limiter.check("test-ip", 5, 60).is_allowed());
}
}
#[test]
fn test_blocks_over_limit() {
let mut limiter = RateLimiter::new();
for _ in 0..5 {
limiter.check("test-ip", 5, 60);
}
let result = limiter.check("test-ip", 5, 60);
assert!(!result.is_allowed());
assert!(result.retry_after() > 0);
}
#[test]
fn test_different_keys_independent() {
let mut limiter = RateLimiter::new();
for _ in 0..5 {
limiter.check("ip-a", 5, 60);
}
// ip-b devrait encore être autorisé
assert!(limiter.check("ip-b", 5, 60).is_allowed());
}
#[test]
fn test_remaining_count_decreases() {
let mut limiter = RateLimiter::new();
let r1 = limiter.check("ip", 10, 60);
assert_eq!(r1.remaining(), 9);
let r2 = limiter.check("ip", 10, 60);
assert_eq!(r2.remaining(), 8);
}Catégorie 4 : Gardes (10 tests)
Les tests de gardes vérifient que chaque type de garde autorise ou rejette correctement les requêtes :
rust#[test]
fn test_auth_guard_rejects_unauthenticated() {
let ctx = RequestContext::anonymous();
let result = guard_auth(&ctx, &[]);
assert!(matches!(result, GuardResult::Fail(_)));
}
#[test]
fn test_auth_guard_accepts_session() {
let mut ctx = RequestContext::anonymous();
ctx.session.set("user", "[email protected]");
let result = guard_auth(&ctx, &[]);
assert!(matches!(result, GuardResult::Pass));
}
#[test]
fn test_role_guard_rejects_wrong_role() {
let mut ctx = RequestContext::authenticated("user");
ctx.set_role("viewer");
let params = vec![Value::String("admin".into())];
let result = guard_role(&ctx, ¶ms);
assert!(matches!(result, GuardResult::Fail(_)));
}
#[test]
fn test_role_guard_accepts_matching_role() {
let mut ctx = RequestContext::authenticated("admin");
ctx.set_role("admin");
let params = vec![Value::String("admin".into()), Value::String("superadmin".into())];
let result = guard_role(&ctx, ¶ms);
assert!(matches!(result, GuardResult::Pass));
}Catégorie 5 : Protection CSRF (5 tests)
rust#[test]
fn test_csrf_token_unique_per_session() {
let session1 = Session::new();
let session2 = Session::new();
let token1 = generate_csrf_token(&session1);
let token2 = generate_csrf_token(&session2);
assert_ne!(token1, token2);
}
#[test]
fn test_csrf_validation_rejects_wrong_token() {
let session = Session::new();
let _token = generate_csrf_token(&session);
assert!(!validate_csrf(&session, "wrong-token"));
}Catégorie 6 : Validation des entrées (12 tests)
rust#[test]
fn test_required_field_rejects_empty() {
let fields = vec![ValidateField::new("name", FieldType::Text).required()];
let body = json!({});
let result = validate_body(&body, &fields);
assert!(result.is_err());
assert!(result.unwrap_err().fields.contains_key("name"));
}
#[test]
fn test_email_validator_rejects_invalid() {
let fields = vec![ValidateField::new("email", FieldType::Text).email()];
let body = json!({"email": "not-an-email"});
let result = validate_body(&body, &fields);
assert!(result.is_err());
}
#[test]
fn test_email_validator_accepts_valid() {
let fields = vec![ValidateField::new("email", FieldType::Text).email()];
let body = json!({"email": "[email protected]"});
let result = validate_body(&body, &fields);
assert!(result.is_ok());
}
#[test]
fn test_min_length_validator() {
let fields = vec![ValidateField::new("name", FieldType::Text).min_length(3)];
let body = json!({"name": "AB"});
let result = validate_body(&body, &fields);
assert!(result.is_err());
let body = json!({"name": "ABC"});
let result = validate_body(&body, &fields);
assert!(result.is_ok());
}
#[test]
fn test_type_coercion_int_from_string() {
let fields = vec![ValidateField::new("age", FieldType::Int).min(0.0).max(150.0)];
let body = json!({"age": "25"});
let result = validate_body(&body, &fields).unwrap();
assert_eq!(result.get("age"), Some(&Value::Int(25)));
}Catégories 7-10 : Sessions, en-têtes, téléversements, crypto
Les catégories restantes couvrent le chiffrement de session, la présence des en-têtes de sécurité, la prévention de la traversée de répertoires et la comparaison en temps constant :
rust#[test]
fn test_session_cookie_is_encrypted() {
let session = Session::new();
session.set("secret", "sensitive-data");
let cookie = session.to_cookie(&ENCRYPTION_KEY);
// Le cookie ne devrait pas contenir de texte en clair
assert!(!cookie.contains("sensitive-data"));
}
#[test]
fn test_security_headers_present_in_production() {
let response = make_request_in_production_mode("/api/test");
assert_eq!(response.header("X-Frame-Options"), Some("DENY"));
assert_eq!(response.header("X-Content-Type-Options"), Some("nosniff"));
assert!(response.header("Content-Security-Policy").is_some());
}
#[test]
fn test_directory_traversal_blocked() {
let result = serve_static("../../etc/passwd", Path::new("/app/public"));
assert!(result.is_none()); // Traversée de chemin bloquée
}
#[test]
fn test_constant_time_eq_same_length() {
// Ce test vérifie que la fonction existe et fonctionne
// Il ne peut pas tester les propriétés temporelles dans un test unitaire
assert!(constant_time_eq(b"hello", b"hello"));
assert!(!constant_time_eq(b"hello", b"world"));
}La philosophie de test
Trois principes ont guidé nos tests de sécurité :
Tester le chemin d'échec. La plupart des tests vérifient que les entrées invalides sont rejetées. C'est contre-intuitif -- les développeurs testent habituellement que les entrées valides fonctionnent. Mais la sécurité concerne ce qui se passe quand les choses vont mal. Un jeton expiré doit être rejeté. Un payload falsifié doit être détecté. Un client limité en débit doit être bloqué.
Pas de mocking pour les opérations cryptographiques. Les tests de hachage de mots de passe utilisent le vrai Argon2id. Les tests JWT utilisent le vrai HMAC-SHA256. Mocker une fonction cryptographique annule le but du test. Si l'implémentation réelle a un bug, le mock ne le détectera pas.
Indépendance des tests. Chaque test crée son propre état, exécute son assertion et nettoie. Les tests peuvent s'exécuter dans n'importe quel ordre, en parallèle, sans s'affecter mutuellement. Un test de sécurité instable est pire que pas de test du tout.
Exécuter les tests
bashcargo test --test security -- --test-threads=4Les 75 tests se complètent en moins de 10 secondes. Les tests de hachage de mots de passe sont les plus lents (Argon2id avec 64 Mo de mémoire prend environ 200 ms par hachage), mais même ceux-ci se complètent assez rapidement pour que la suite de tests s'exécute à chaque commit.
Les tests de sécurité ne sont pas optionnels. Ils s'exécutent en CI aux côtés des tests unitaires et des tests d'intégration. Une régression de sécurité casse le build.
Dans le prochain article, nous couvrons les gardes personnalisés et le middleware de sécurité -- comment les développeurs étendent la sécurité intégrée de FLIN avec une logique de contrôle d'accès spécifique à l'application.
Ceci est la partie 114 de la série « Comment nous avons construit FLIN », documentant comment un CEO à Abidjan et un CTO IA ont conçu et construit un langage de programmation à partir de zéro.
Navigation de la série : - [113] Validateurs de corps de requête - [114] 75 tests de sécurité : comment nous avons tout vérifié (vous êtes ici) - [115] Gardes personnalisés et middleware de sécurité - [116] Le moteur d'intentions : requêtes base de données en langage naturel