Skip to main content

vgi_forge_forgejo/
webhook.rs

1//! Webhook verification and translation (§5.6).
2//!
3//! Forgejo signs the raw body with HMAC-SHA256 under the hook's secret and
4//! sends the tag as bare hex in `X-Forgejo-Signature` (and, for Gitea
5//! compatibility, the same tag in `X-Gitea-Signature`). The tag is checked
6//! in constant time over the exact bytes received, before a byte is parsed.
7//! When `X-Forgejo-Signature` is present it is the one checked — a bad one
8//! is not rescued by a good `X-Gitea-Signature`.
9//!
10//! The org webhook subscribes to `repository` only (created, deleted): that
11//! is the one drift Forgejo announces. It sends no event for collaborator,
12//! branch-protection, rename or archive changes, so those are found by the
13//! scheduled `inspect` sweep — which is why the adapter reports
14//! `webhooks: false`.
15//!
16//! As on GitHub, the signature covers no timestamp: a captured delivery
17//! replays. [`ForgeEvent::delivery_id`] carries `X-Forgejo-Delivery` for the
18//! core to drop repeats, and every event is a prompt to `inspect`.
19
20use aws_lc_rs::hmac;
21use http::HeaderMap;
22use serde_json::Value;
23use vgi_forge::{ForgeError, ForgeEvent, ForgeEventKind, Resource, Result};
24
25use crate::secret::Secret;
26
27/// The events the org webhook subscribes to.
28pub const HOOK_EVENTS: [&str; 1] = ["repository"];
29
30/// Verify `X-Forgejo-Signature` (or, failing that header, `X-Gitea-Signature`)
31/// over `body`.
32pub fn verify_signature(secret: &Secret, headers: &HeaderMap, body: &[u8]) -> Result<()> {
33    let (name, value) = ["x-forgejo-signature", "x-gitea-signature"]
34        .into_iter()
35        .find_map(|n| headers.get(n).map(|v| (n, v)))
36        .ok_or_else(|| {
37            ForgeError::Webhook("missing X-Forgejo-Signature (or X-Gitea-Signature)".into())
38        })?;
39    let text = value
40        .to_str()
41        .map_err(|_| ForgeError::Webhook(format!("{name} is not ASCII")))?;
42    let tag =
43        hex::decode(text.trim()).map_err(|_| ForgeError::Webhook(format!("{name} is not hex")))?;
44    let key = hmac::Key::new(hmac::HMAC_SHA256, secret.expose().as_bytes());
45    // `hmac::verify` recomputes the tag and compares in constant time.
46    hmac::verify(&key, body, &tag)
47        .map_err(|_| ForgeError::Webhook("signature does not match the body".into()))
48}
49
50/// The signature header value Forgejo would send. For tests and for
51/// replaying captured deliveries against a local bridge.
52pub fn sign_body(secret: &Secret, body: &[u8]) -> String {
53    let key = hmac::Key::new(hmac::HMAC_SHA256, secret.expose().as_bytes());
54    hex::encode(hmac::sign(&key, body).as_ref())
55}
56
57/// Verify, then translate. `host` is the forge host resources are built on.
58pub fn parse(
59    secret: &Secret,
60    host: &str,
61    headers: &HeaderMap,
62    body: &[u8],
63) -> Result<Option<ForgeEvent>> {
64    verify_signature(secret, headers, body)?;
65
66    let event = header_str(headers, "x-forgejo-event")?
67        .or(header_str(headers, "x-gitea-event")?)
68        .ok_or_else(|| ForgeError::Webhook("missing X-Forgejo-Event".into()))?;
69    let delivery = header_str(headers, "x-forgejo-delivery")?
70        .or(header_str(headers, "x-gitea-delivery")?)
71        .map(str::to_string);
72    let payload: Value = serde_json::from_slice(body)
73        .map_err(|e| ForgeError::Webhook(format!("body is not JSON: {e}")))?;
74    let action = payload.get("action").and_then(Value::as_str).unwrap_or("");
75
76    let kind = match (event, action) {
77        ("repository", "created") => {
78            let (repo, forge_id) = repository(host, &payload)?;
79            Some(ForgeEventKind::RepoCreated { repo, forge_id })
80        }
81        ("repository", "deleted") => {
82            let (repo, forge_id) = repository(host, &payload)?;
83            Some(ForgeEventKind::RepoDeleted { repo, forge_id })
84        }
85        _ => None,
86    };
87    Ok(kind.map(|k| ForgeEvent::new(delivery, k)))
88}
89
90fn header_str<'a>(headers: &'a HeaderMap, name: &str) -> Result<Option<&'a str>> {
91    headers
92        .get(name)
93        .map(|v| {
94            v.to_str()
95                .map_err(|_| ForgeError::Webhook(format!("{name} is not ASCII")))
96        })
97        .transpose()
98}
99
100fn repository(host: &str, payload: &Value) -> Result<(Resource, u64)> {
101    let repo = &payload["repository"];
102    let full_name = repo
103        .get("full_name")
104        .and_then(Value::as_str)
105        .ok_or_else(|| ForgeError::Webhook("payload is missing `repository.full_name`".into()))?;
106    let resource = Resource::parse_owner_repo(&format!("{host}/{full_name}"))?;
107    resource
108        .require_owner_repo()
109        .map_err(|_| ForgeError::Webhook(format!("`{full_name}` is not an owner/repo name")))?;
110    let id = repo
111        .get("id")
112        .and_then(Value::as_u64)
113        .ok_or_else(|| ForgeError::Webhook("payload is missing numeric `repository.id`".into()))?;
114    Ok((resource, id))
115}