Skip to main content

vgi_forge_github/
webhook.rs

1//! Webhook verification and translation (§5.6).
2//!
3//! Every delivery is authenticated before a byte of it is parsed: GitHub
4//! signs the raw body with HMAC-SHA256 under the App's webhook secret and
5//! sends the tag in `X-Hub-Signature-256`. The tag is checked in constant
6//! time, over the exact bytes received — never over re-serialised JSON.
7//!
8//! What this cannot check is freshness: the signature covers no timestamp,
9//! so a captured delivery replays cleanly. [`ForgeEvent::delivery_id`] is
10//! carried through so the core can drop repeats, and every event here is a
11//! prompt to `inspect`, not a statement of state to apply — a replayed
12//! "ruleset edited" costs one read.
13
14use aws_lc_rs::hmac;
15use http::HeaderMap;
16use serde_json::Value;
17use vgi_forge::{
18    ForgeAccount, ForgeError, ForgeEvent, ForgeEventKind, InstallationChange, MemberChange,
19    Resource, Result, Visibility,
20};
21
22use crate::secret::Secret;
23
24/// Verify `X-Hub-Signature-256` over `body`.
25pub fn verify_signature(secret: &Secret, headers: &HeaderMap, body: &[u8]) -> Result<()> {
26    let header = headers
27        .get("x-hub-signature-256")
28        .ok_or_else(|| ForgeError::Webhook("missing X-Hub-Signature-256".into()))?
29        .to_str()
30        .map_err(|_| ForgeError::Webhook("X-Hub-Signature-256 is not ASCII".into()))?;
31    let hex_tag = header
32        .strip_prefix("sha256=")
33        .ok_or_else(|| ForgeError::Webhook("X-Hub-Signature-256 is not `sha256=<hex>`".into()))?;
34    let tag = hex::decode(hex_tag)
35        .map_err(|_| ForgeError::Webhook("X-Hub-Signature-256 is not hex".into()))?;
36    let key = hmac::Key::new(hmac::HMAC_SHA256, secret.expose().as_bytes());
37    // `hmac::verify` recomputes the tag and compares in constant time.
38    hmac::verify(&key, body, &tag)
39        .map_err(|_| ForgeError::Webhook("signature does not match the body".into()))
40}
41
42/// Compute the `X-Hub-Signature-256` value GitHub would send. For tests and
43/// for replaying captured deliveries against a local bridge.
44pub fn sign_body(secret: &Secret, body: &[u8]) -> String {
45    let key = hmac::Key::new(hmac::HMAC_SHA256, secret.expose().as_bytes());
46    format!("sha256={}", hex::encode(hmac::sign(&key, body).as_ref()))
47}
48
49/// Verify, then translate. `host` is the forge host resources are built on.
50pub fn parse(
51    secret: &Secret,
52    host: &str,
53    headers: &HeaderMap,
54    body: &[u8],
55) -> Result<Option<ForgeEvent>> {
56    verify_signature(secret, headers, body)?;
57
58    let event = header_str(headers, "x-github-event")?
59        .ok_or_else(|| ForgeError::Webhook("missing X-GitHub-Event".into()))?;
60    let delivery = header_str(headers, "x-github-delivery")?.map(str::to_string);
61    let payload: Value = serde_json::from_slice(body)
62        .map_err(|e| ForgeError::Webhook(format!("body is not JSON: {e}")))?;
63    let action = payload.get("action").and_then(Value::as_str).unwrap_or("");
64
65    let kind = match event {
66        "repository" => repository_event(host, action, &payload)?,
67        "member" => {
68            let change = match action {
69                "added" => MemberChange::Added,
70                "removed" => MemberChange::Removed,
71                "edited" => MemberChange::Edited,
72                _ => return Ok(None),
73            };
74            let (repo, forge_id) = repository(host, &payload)?;
75            Some(ForgeEventKind::CollaboratorChanged {
76                repo,
77                forge_id,
78                account: account(&payload["member"])?,
79                change,
80            })
81        }
82        // `membership` is *team* membership; `organization` carries joining
83        // and leaving the org itself.
84        "membership" => {
85            let change = match action {
86                "added" => MemberChange::Added,
87                "removed" => MemberChange::Removed,
88                _ => return Ok(None),
89            };
90            Some(ForgeEventKind::TeamMembershipChanged {
91                namespace: namespace(host, &payload["organization"])?,
92                team: str_at(payload.get("team").unwrap_or(&Value::Null), &["slug"])?.to_string(),
93                account: account(&payload["member"])?,
94                change,
95            })
96        }
97        "organization" => {
98            let change = match action {
99                "member_added" => MemberChange::Added,
100                "member_removed" => MemberChange::Removed,
101                _ => return Ok(None),
102            };
103            Some(ForgeEventKind::OrgMembershipChanged {
104                namespace: namespace(host, &payload["organization"])?,
105                account: account(&payload["membership"]["user"])?,
106                change,
107            })
108        }
109        "repository_ruleset" | "branch_protection_rule" => {
110            let repo = match payload.get("repository") {
111                Some(r) if !r.is_null() => Some(repository(host, &payload)?.0),
112                _ => None,
113            };
114            let namespace = match &repo {
115                Some(r) => r.namespace(),
116                None => namespace(host, &payload["organization"])?,
117            };
118            Some(ForgeEventKind::ProtectionChanged {
119                repo,
120                namespace,
121                action: action.to_string(),
122            })
123        }
124        "installation" => {
125            let change = match action {
126                "created" => InstallationChange::Created,
127                "deleted" => InstallationChange::Deleted,
128                "suspend" => InstallationChange::Suspended,
129                "unsuspend" => InstallationChange::Unsuspended,
130                "new_permissions_accepted" => InstallationChange::PermissionsAccepted,
131                _ => return Ok(None),
132            };
133            let installation = &payload["installation"];
134            Some(ForgeEventKind::InstallationChanged {
135                namespace: namespace(host, &installation["account"])?,
136                installation_id: u64_field(installation, "id")?,
137                change,
138            })
139        }
140        _ => None,
141    };
142    Ok(kind.map(|k| ForgeEvent::new(delivery, k)))
143}
144
145fn repository_event(host: &str, action: &str, payload: &Value) -> Result<Option<ForgeEventKind>> {
146    let (repo, forge_id) = repository(host, payload)?;
147    Ok(Some(match action {
148        "created" => ForgeEventKind::RepoCreated { repo, forge_id },
149        "deleted" => ForgeEventKind::RepoDeleted { repo, forge_id },
150        "archived" | "unarchived" => ForgeEventKind::RepoArchived {
151            repo,
152            forge_id,
153            archived: action == "archived",
154        },
155        "publicized" | "privatized" => ForgeEventKind::RepoVisibilityChanged {
156            repo,
157            forge_id,
158            visibility: if action == "publicized" {
159                Visibility::Public
160            } else {
161                Visibility::Private
162            },
163        },
164        "renamed" => {
165            let old = str_at(payload, &["changes", "repository", "name", "from"])?;
166            let from = repo.namespace().join(old)?;
167            ForgeEventKind::RepoRenamed {
168                forge_id,
169                from,
170                to: repo,
171            }
172        }
173        "transferred" => {
174            let owner = &payload["changes"]["owner"]["from"];
175            let from_account = if owner["organization"].is_object() {
176                &owner["organization"]
177            } else {
178                &owner["user"]
179            };
180            ForgeEventKind::RepoTransferred {
181                forge_id,
182                from_namespace: namespace(host, from_account).ok(),
183                to: repo,
184            }
185        }
186        _ => return Ok(None),
187    }))
188}
189
190fn header_str<'a>(headers: &'a HeaderMap, name: &str) -> Result<Option<&'a str>> {
191    headers
192        .get(name)
193        .map(|v| {
194            v.to_str()
195                .map_err(|_| ForgeError::Webhook(format!("{name} is not ASCII")))
196        })
197        .transpose()
198}
199
200fn repository(host: &str, payload: &Value) -> Result<(Resource, u64)> {
201    let repo = &payload["repository"];
202    let full_name = str_at(repo, &["full_name"])?;
203    let resource = Resource::parse_owner_repo(&format!("{host}/{full_name}"))?;
204    Ok((resource, u64_field(repo, "id")?))
205}
206
207fn namespace(host: &str, account: &Value) -> Result<Resource> {
208    Resource::namespace_of(host, str_at(account, &["login"])?)
209}
210
211fn account(v: &Value) -> Result<ForgeAccount> {
212    Ok(ForgeAccount::new(
213        u64_field(v, "id")?,
214        str_at(v, &["login"])?,
215    ))
216}
217
218fn u64_field(v: &Value, key: &str) -> Result<u64> {
219    v.get(key)
220        .and_then(Value::as_u64)
221        .ok_or_else(|| ForgeError::Webhook(format!("payload is missing numeric `{key}`")))
222}
223
224fn str_at<'a>(v: &'a Value, path: &[&str]) -> Result<&'a str> {
225    path.iter()
226        .try_fold(v, |v, k| v.get(k))
227        .and_then(Value::as_str)
228        .ok_or_else(|| ForgeError::Webhook(format!("payload is missing `{}`", path.join("."))))
229}