Implémenter des webhooks dans gitrust

Ce guide couvre l'ajout d'un nouveau déclencheur webhook : modèle de données, service de dispatch, signature HMAC, configuration UI par dépôt, et rotation de secret.

Pré-requis

Vue d'ensemble

sequenceDiagram
    participant Push as git push
    participant Hook as receive-pack handler
    participant Svc as WebhookService
    participant Disp as WebhookDispatcher
    participant Ext as Endpoint HTTP distant

    Push->>Hook: réception des objets
    Hook->>Svc: list_by_repo_and_event(repo_id, "push")
    Svc-->>Hook: Vec
    Hook->>Disp: deliver(webhook, "push", payload)
    Disp->>Ext: POST url (payload signé HMAC-SHA256)
    Ext-->>Disp: 200 OK
    Disp->>Svc: record_delivery(webhook_id, status, duration)

Modèle de données

La table webhooks stocke la configuration par dépôt :

CREATE TABLE webhooks (
    id           UUID PRIMARY KEY,
    repository_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
    url          TEXT NOT NULL,
    secret_hash  TEXT NOT NULL,        -- HMAC secret haché SHA-256 (jamais en clair)
    events       TEXT NOT NULL,        -- JSON array : ["push","pull_request","issues"]
    is_active    BOOLEAN NOT NULL DEFAULT true,
    created_at   TIMESTAMPTZ NOT NULL,
    updated_at   TIMESTAMPTZ NOT NULL
);

CREATE INDEX webhooks_repo_active_idx ON webhooks(repository_id, is_active);

La table webhook_deliveries trace chaque envoi :

CREATE TABLE webhook_deliveries (
    id           UUID PRIMARY KEY,
    webhook_id   UUID NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE,
    event        TEXT NOT NULL,
    payload      TEXT NOT NULL,
    response_status INTEGER NULL,
    response_body   TEXT NULL,
    duration_ms  INTEGER NULL,
    success      BOOLEAN NOT NULL,
    created_at   TIMESTAMPTZ NOT NULL
);

Créez les deux migrations correspondantes (voir ajouter-migration-db.md).

WebhookService — méthodes publiques

// crates/gitrust-core/src/services/webhook_service.rs
pub struct WebhookService;

impl WebhookService {
    /// Crée un webhook pour un dépôt.
    pub async fn create(
        db: &DatabaseConnection,
        repo_id: Uuid,
        url: String,
        secret: &str,          // secret en clair — haché avant stockage
        events: Vec<String>,
    ) -> Result<webhook::Model, GitrustError>;

    /// Liste les webhooks actifs pour un dépôt et un événement donné.
    pub async fn list_by_repo_and_event(
        db: &DatabaseConnection,
        repo_id: Uuid,
        event: &str,
    ) -> Result<Vec<webhook::Model>, GitrustError>;

    /// Met à jour l'URL, le secret ou les événements.
    pub async fn update(
        db: &DatabaseConnection,
        webhook_id: Uuid,
        repo_id: Uuid,           // anti-IDOR : vérifie l'appartenance
        url: Option<String>,
        new_secret: Option<&str>,
        events: Option<Vec<String>>,
    ) -> Result<webhook::Model, GitrustError>;

    /// Désactive / active un webhook.
    pub async fn set_active(
        db: &DatabaseConnection,
        webhook_id: Uuid,
        repo_id: Uuid,
        active: bool,
    ) -> Result<(), GitrustError>;

    /// Supprime un webhook et ses deliveries en cascade.
    pub async fn delete(
        db: &DatabaseConnection,
        webhook_id: Uuid,
        repo_id: Uuid,
    ) -> Result<(), GitrustError>;

    /// Enregistre le résultat d'une livraison.
    pub async fn record_delivery(
        db: &DatabaseConnection,
        webhook_id: Uuid,
        event: &str,
        payload: &str,
        response_status: Option<i32>,
        response_body: Option<&str>,
        duration_ms: i32,
        success: bool,
    ) -> Result<(), GitrustError>;

    /// Liste les dernières deliveries d'un webhook (pagination).
    pub async fn list_deliveries(
        db: &DatabaseConnection,
        webhook_id: Uuid,
        repo_id: Uuid,
        page: u64,
        per_page: u64,
    ) -> Result<Vec<webhook_delivery::Model>, GitrustError>;
}

Sécurité — stockage du secret : le secret ne doit jamais être stocké en clair. Lors de la création ou mise à jour, hachez-le avec SHA-256 avant insertion. La signature HMAC utilise le secret en clair fourni par l'utilisateur au moment de la création (retourné une seule fois) ou récupéré depuis un secret manager.

fn hash_secret(secret: &str) -> String {
    use sha2::{Digest, Sha256};
    let mut hasher = Sha256::new();
    hasher.update(secret.as_bytes());
    format!("{:x}", hasher.finalize())
}

WebhookDispatcher — envoi avec retry et signature HMAC

// crates/gitrust-core/src/services/webhook_dispatcher.rs
pub struct WebhookDispatcher;

impl WebhookDispatcher {
    /// Envoie le payload à l'URL du webhook avec signature HMAC-SHA256.
    /// Retry exponentiel : 3 tentatives (0s, 5s, 25s).
    pub async fn deliver(
        webhook: &webhook::Model,
        event: &str,
        payload: serde_json::Value,
        secret_plain: &str,     // secret en clair pour la signature
    ) -> DeliveryResult;
}

pub struct DeliveryResult {
    pub success: bool,
    pub response_status: Option<i32>,
    pub response_body: Option<String>,
    pub duration_ms: i32,
    pub attempts: u32,
}

Calcul de la signature HMAC

use hmac::{Hmac, Mac};
use sha2::Sha256;

fn compute_hmac_signature(secret: &str, body: &str) -> String {
    let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
        .expect("HMAC accepte toutes les longueurs de clé");
    mac.update(body.as_bytes());
    let result = mac.finalize();
    format!("sha256={}", hex::encode(result.into_bytes()))
}

Le header HTTP envoyé avec chaque livraison :

X-Gitrust-Signature-256: sha256=<hmac_hex>
X-Gitrust-Event: push
X-Gitrust-Delivery: <uuid de la delivery>
Content-Type: application/json

Retry exponentiel

const RETRY_DELAYS: [u64; 3] = [0, 5, 25]; // secondes

for (attempt, delay) in RETRY_DELAYS.iter().enumerate() {
    if *delay > 0 {
        tokio::time::sleep(Duration::from_secs(*delay)).await;
    }
    match client.post(&webhook.url).send().await {
        Ok(resp) if resp.status().is_success() => return Ok(DeliveryResult { success: true, attempts: attempt + 1, .. }),
        Ok(resp) => last_error = format!("HTTP {}", resp.status()),
        Err(e) => last_error = e.to_string(),
    }
}

Règle SSRF : l'URL du webhook est validée à la création. Les adresses RFC1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) et localhost sont interdites en production.

Injecter le dispatch dans le hook post-receive

Dans le handler receive_pack (crates/gitrust-web/src/handlers/git_http.rs), après la réception des objets Git :

// Déclencher les webhooks "push"
let payload = serde_json::json!({
    "repository": { "id": repo.id, "slug": repo.slug, "owner": owner.username },
    "pusher": { "username": user.username },
    "ref": pushed_ref,
    "commits": commit_summaries,
});

let webhooks = WebhookService::list_by_repo_and_event(&db, repo.id, "push").await?;
for webhook in webhooks {
    // Le dispatch est non-bloquant : on ne veut pas bloquer le push si le
    // serveur distant est lent.
    let db_clone = db.clone();
    let payload_clone = payload.clone();
    tokio::spawn(async move {
        let result = WebhookDispatcher::deliver(&webhook, "push", payload_clone, &secret_plain).await;
        let _ = WebhookService::record_delivery(
            &db_clone,
            webhook.id,
            "push",
            &payload_clone.to_string(),
            result.response_status,
            result.response_body.as_deref(),
            result.duration_ms,
            result.success,
        ).await;
    });
}

UI de configuration par dépôt

Ajoutez les routes dans routes.rs :

.route(
    "/{owner}/{repo}/settings/webhooks",
    get(handlers::webhooks::list_webhooks).post(handlers::webhooks::create_webhook),
)
.route(
    "/{owner}/{repo}/settings/webhooks/{id}",
    get(handlers::webhooks::webhook_detail),
)
.route(
    "/{owner}/{repo}/settings/webhooks/{id}/edit",
    post(handlers::webhooks::edit_webhook),
)
.route(
    "/{owner}/{repo}/settings/webhooks/{id}/delete",
    post(handlers::webhooks::delete_webhook),
)
.route(
    "/{owner}/{repo}/settings/webhooks/{id}/test",
    post(handlers::webhooks::test_webhook),
)

Rotation du secret

Quand un utilisateur régénère le secret via l'UI, le nouveau secret est :

  1. Affiché une seule fois à l'utilisateur (jamais re-lisible depuis l'UI).
  2. Haché SHA-256 et stocké dans webhooks.secret_hash.
  3. Utilisé immédiatement pour les livraisons suivantes.

Les livraisons en cours avec l'ancien secret ne sont pas réessayées — informez l'utilisateur dans l'UI.

Checklist de test

  • [ ] Créer un webhook avec URL + secret + événements → visible dans la liste.
  • [ ] Push sur le dépôt → delivery créée avec statut success ou failed.
  • [ ] Désactiver le webhook → push ne déclenche aucune livraison.
  • [ ] URL RFC1918 → rejet à la création avec message d'erreur.
  • [ ] Bouton « Test » → delivery avec événement ping créée.
  • [ ] Rotation du secret → livraisons suivantes signées avec le nouveau secret.

Voir aussi