Skip to content

How-to : Verrou distribué

Référence FT : FT288 (NENE2-FT/distlocklog) — Verrou distribué : contrainte DB UNIQUE(resource), vérification de propriétaire, expiration basée sur TTL, re-acquisition de verrou expiré par conception, enum ReleaseResult (Released/NotFound/Forbidden), 403 sur incompatibilité de propriétaire, 16 tests / 27 assertions PASS.

Évaluation ATK : ATK-01 à ATK-12 inclus à la fin de ce document.

Ce guide montre comment implémenter une API de verrou distribué — empêcher les opérations concurrentes sur la même ressource en émettant des verrous avec bail.

Qu'est-ce qu'un verrou distribué ?

Quand plusieurs processus ont besoin d'un accès exclusif à une ressource partagée (ex. un paiement, un fichier, un job de file), un verrou distribué garantit qu'un seul processus procède à la fois. Les verrous ont un TTL pour qu'ils expirent automatiquement si le détenteur plante.

Schéma

sql
CREATE TABLE distributed_locks (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    resource    TEXT    NOT NULL UNIQUE,
    owner       TEXT    NOT NULL,
    expires_at  TEXT    NOT NULL,
    acquired_at TEXT    NOT NULL
);

resource TEXT UNIQUE — une ligne par ressource. L'acquisition insère ou met à jour cette ligne.

Endpoints

MéthodeCheminDescription
POST/locks/{resource}Acquérir le verrou
GET/locks/{resource}Obtenir le statut du verrou
DELETE/locks/{resource}Libérer le verrou
POST/locks/{resource}/renewÉtendre le TTL

Logique d'acquisition

php
public function acquire(string $resource, string $owner, string $expiresAt, string $now): ?LockRecord
{
    $existing = $this->findByResource($resource);

    if ($existing === null) {
        // Pas de verrou — INSERT (la contrainte UNIQUE gère les races)
        try {
            $this->executor->execute('INSERT INTO distributed_locks ...', [...]);
        } catch (\RuntimeException) {
            return null;  // Race : un autre processus a inséré en concurrence
        }
        return $this->findByResource($resource);
    }

    if ($existing->isExpired($now) || $existing->owner === $owner) {
        // Expiré → re-acquérir (UPDATE remplace l'ancienne ligne)
        // Même propriétaire → re-acquérir (étendre ou re-verrouiller)
        $this->executor->execute('UPDATE distributed_locks SET owner = ?, expires_at = ?, acquired_at = ? WHERE resource = ?', ...);
        return $this->findByResource($resource);
    }

    // Détenu par un autre propriétaire, pas expiré → impossible d'acquérir
    return null;
}

Libération avec vérification de propriétaire

php
$result = $this->repo->release($resource, $owner, $now);

return match ($result) {
    ReleaseResult::Released  => $this->json->create([], 204),
    ReleaseResult::NotFound  => $this->problems->create($request, 'not-found', 'Lock not found.', 404),
    ReleaseResult::Forbidden => $this->problems->create($request, 'forbidden', 'Owner mismatch.', 403),
};

Seul le propriétaire du verrou peut le libérer. Mauvais owner → 403 Forbidden.

Enum ReleaseResult

php
enum ReleaseResult
{
    case Released;   // Verrou trouvé, propriétaire correspondant, ligne supprimée
    case NotFound;   // Verrou introuvable ou déjà expiré
    case Forbidden;  // Verrou trouvé, mais le propriétaire ne correspond pas
}

Utiliser un enum (pas des chaînes magiques) garantit une gestion exhaustive dans match.

Réponse d'acquisition

php
// Succès :
{ "acquired": true, "lock": { "resource": "...", "owner": "...", "expires_at": "...", "acquired_at": "..." } }

// Échec (détenu par un autre) :
{ "acquired": false, "resource": "payment:42" }

acquired: false n'est pas une erreur — cela signifie "réessayez plus tard." Pas de statut 4xx ; l'appelant devrait réessayer.


ATK Assessment — Cracker-Mindset Attack Test

ATK-01 — Acquérir un verrou détenu par un autre propriétaire 🚫 BLOCKED

Attack: L'attaquant essaie d'acquérir locks/payment:42 pendant qu'un autre processus le détient. Result: BLOCKED — le repository vérifie existing.owner === $caller_owner. Propriétaire différent + pas expiré → retourne null{ acquired: false }. Pas d'erreur, pas de crash — l'attaquant ne reçoit simplement pas le verrou.


ATK-02 — Libérer un verrou appartenant à un autre 🚫 BLOCKED

Attack: L'attaquant envoie DELETE /locks/payment:42 avec { "owner": "attacker" } pour libérer de force un verrou. Result: BLOCKED — le repository vérifie lock.owner === $body_owner. Incompatibilité → ReleaseResult::Forbidden → 403.


ATK-03 — Voler le verrou après expiration 🚫 BLOCKED (par conception)

Attack: L'attaquant attend l'expiration du verrou, puis l'acquiert. Result: BLOCKED (par conception) — les verrous expirés peuvent être re-acquis par n'importe quel propriétaire. C'est le comportement prévu : l'expiration basée sur TTL est comment les détenteurs plantés perdent leurs verrous. Réduire les attaques basées sur TTL nécessite une coordination (renouvellement par heartbeat).


ATK-04 — Renouveler un verrou appartenant à un autre 🚫 BLOCKED

Attack: L'attaquant envoie POST /locks/payment:42/renew avec { "owner": "attacker", "ttl_seconds": 3600 }. Result: BLOCKED — le renouvellement vérifie lock.owner === $body_owner. Incompatibilité → 403 Forbidden.


ATK-05 — TTL zéro ou négatif pour créer un verrou déjà expiré 🚫 BLOCKED

Attack: Envoyer { "ttl_seconds": 0 } ou { "ttl_seconds": -100 } pour créer un verrou qui expire instantanément. Result: BLOCKED — if ($ttlSeconds === null || $ttlSeconds < 1) → erreur de validation 422.


ATK-06 — Injection SQL via le paramètre de chemin resource 🚫 BLOCKED

Attack: Utiliser locks/resource'; DROP TABLE distributed_locks; -- comme nom de ressource. Result: BLOCKED — toutes les requêtes utilisent des instructions paramétrisées (WHERE resource = ?). La chaîne injectée est traitée comme un identifiant de ressource littéral.


ATK-07 — Propriétaire vide pour contourner la vérification de propriété 🚫 BLOCKED

Attack: Envoyer { "owner": "" } ou { "owner": " " } pour libérer ou renouveler sans propriété valide. Result: BLOCKED — $owner = trim(...); if ($owner === '') → erreur de validation 422.


ATK-08 — TTL non entier pour contourner la validation de type 🚫 BLOCKED

Attack: Envoyer { "ttl_seconds": "3600" } (chaîne) ou { "ttl_seconds": 60.5 } (float). Result: BLOCKED — is_int($body['ttl_seconds']) rejette les chaînes et les floats. Seul le type entier JSON est accepté.


ATK-09 — Acquérir plusieurs fois avec le même propriétaire 🚫 BLOCKED (par conception)

Attack: Le même propriétaire re-acquiert un verrou qu'il détient pour l'étendre sans utiliser /renew. Result: AUTORISÉ (par conception) — $existing->owner === $owner → UPDATE (re-acquisition/extension). La re-acquisition par le même propriétaire est idempotente et sûre ; elle met à jour expires_at et acquired_at.


ATK-10 — Race condition : deux propriétaires acquièrent en concurrence 🚫 BLOCKED

Attack: Deux processus voient tous les deux qu'il n'y a pas de verrou et tentent INSERT simultanément. Result: BLOCKED — la contrainte UNIQUE(resource) garantit qu'un seul INSERT réussit. Le perdant capture \RuntimeException et retourne null{ acquired: false }. Un seul propriétaire gagne.


ATK-11 — GET d'un verrou inexistant ou expiré 🚫 BLOCKED

Attack: Appeler GET /locks/nonexistent ou attendre l'expiration du verrou puis appeler GET. Result: BLOCKED — if ($lock === null || $lock->isExpired($now)) return 404. Les verrous expirés retournent 404 (pas les données du verrou périmé).


ATK-12 — Nom de ressource extrêmement long pour causer un DoS 🚫 BLOCKED (note de conception)

Attack: Envoyer { "resource": "<chaîne 10Mo>" } comme paramètre de chemin resource. Result: PARTIELLEMENT BLOQUÉ — la ressource vient du chemin URL, limité par la longueur de chemin du serveur web (typiquement 8Ko). Pas de validation explicite de longueur au niveau applicatif dans ce FT. En production, ajouter if (strlen($resource) > 255) → 422. La DB stocke tout ce que l'application passe.


ATK Summary

IDAttackResult
ATK-01Acquérir le verrou détenu par un autre🚫 BLOCKED
ATK-02Libérer le verrou appartenant à un autre🚫 BLOCKED
ATK-03Voler le verrou après expiration TTL🚫 BLOCKED (par conception)
ATK-04Renouveler le verrou appartenant à un autre🚫 BLOCKED
ATK-05TTL zéro/négatif🚫 BLOCKED
ATK-06Injection SQL via chemin resource🚫 BLOCKED
ATK-07Contournement par propriétaire vide🚫 BLOCKED
ATK-08Contournement de type TTL non entier🚫 BLOCKED
ATK-09Re-acquisition par même propriétaire🚫 BLOCKED (intentionnel)
ATK-10Race condition sur acquisition concurrente🚫 BLOCKED
ATK-11GET d'un verrou expiré/inexistant🚫 BLOCKED
ATK-12Nom de ressource extrêmement long⚠️ NOTE DE CONCEPTION

11 BLOCKED, 1 NOTE DE CONCEPTION, 0 EXPOSED La vérification de propriétaire, la protection contre les races UNIQUE(resource), la validation TTL et les requêtes paramétrisées préviennent tous les vecteurs d'attaque critiques.


Ce qu'il ne faut PAS faire

Anti-patternRisque
Pas de contrainte UNIQUE(resource)Race condition : deux propriétaires acquièrent tous les deux ; vulnérabilité TOCTOU
Libération sans vérification de propriétaireN'importe quel processus peut libérer n'importe quel verrou ; aucune garantie d'exclusivité
Pas de TTL sur les verrousLe verrou du détenteur planté persiste indéfiniment ; deadlock système
Accepter un TTL de 0 ou négatifLe verrou est déjà expiré à la création ; immédiatement re-acquérable
Retourner 404 sur incompatibilité de propriétaire (libération)L'attaquant ne peut pas distinguer "le verrou n'existe pas" de "mauvais propriétaire" ; utiliser 403
Accepter chaîne/float comme TTL"3600" semble valide mais is_int échoue ; vérification de type stricte prévient les bugs subtils
Stocker le propriétaire sans validationUn propriétaire vide contourne la propriété ; toujours valider non vide
Pas de limite de longueur sur resourceLa limite de chemin du serveur web est la seule garde ; ajouter une validation explicite
Renouveler les verrous expirésUn verrou expiré n'appartient à personne ; re-acquérir plutôt que renouveler

Publié sous licence MIT.