Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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<Webhook>
    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

#![allow(unused)]
fn main() {
// 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.

#![allow(unused)]
fn main() {
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

#![allow(unused)]
fn main() {
// 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

#![allow(unused)]
fn main() {
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

#![allow(unused)]
fn main() {
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 :

#![allow(unused)]
fn main() {
// 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 :

#![allow(unused)]
fn main() {
.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