Back to flin
flin

75 tests de sécurité : comment nous avons tout vérifié

Comment nous avons écrit 75 tests de sécurité pour FLIN couvrant le hachage de mots de passe, les jetons JWT, la limitation de débit, les gardes, le CSRF, la validation des entrées et la gestion de session -- garantissant que chaque fonctionnalité de sécurité fonctionne correctement.

Thales & Claude | March 30, 2026 8 min flin
EN/ FR/ ES
flinsecuritytestingverification

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 tests

Chaque 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, &params);
    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, &params);
    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=4

Les 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

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles