use base64::Engine; use hmac::{Hmac, Mac}; use http::HeaderMap; use miette::Diagnostic; use serde::de::DeserializeOwned; use sha2::Sha256; use std::net::IpAddr; use thiserror::Error; type HmacSha256 = Hmac; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SignatureSource { Github, Hookdeck, Forgejo, } #[derive(Debug, Clone, Copy)] pub enum SignatureFormat { Hex { prefix: Option<&'static str> }, Base64, } #[derive(Debug, Clone)] pub struct SignatureCheck { source: SignatureSource, headers: &'static [&'static str], format: SignatureFormat, secret: Vec, } impl SignatureCheck { pub fn github(secret: impl AsRef<[u8]>) -> Self { Self { source: SignatureSource::Github, headers: &["X-Hub-Signature-256"], format: SignatureFormat::Hex { prefix: Some("sha256="), }, secret: secret.as_ref().to_vec(), } } pub fn hookdeck(secret: impl AsRef<[u8]>) -> Self { Self { source: SignatureSource::Hookdeck, headers: &["X-Hookdeck-Signature"], format: SignatureFormat::Base64, secret: secret.as_ref().to_vec(), } } pub fn forgejo(secret: impl AsRef<[u8]>) -> Self { Self { source: SignatureSource::Forgejo, headers: &["X-Gitea-Signature", "X-Forgejo-Signature"], format: SignatureFormat::Hex { prefix: None }, secret: secret.as_ref().to_vec(), } } fn verify(&self, headers: &HeaderMap, body: &[u8]) -> Result<(), SignatureCheckError> { let mut saw_header = false; for name in self.headers { if let Some(value) = headers.get(*name) { saw_header = true; let sig = value.to_str().map_err(|_| SignatureCheckError::Invalid)?; let ok = match self.format { SignatureFormat::Hex { prefix } => { verify_hmac_hex(sig, prefix, &self.secret, body) } SignatureFormat::Base64 => verify_hmac_base64(sig, &self.secret, body), }; if ok { return Ok(()); } } } if saw_header { Err(SignatureCheckError::Invalid) } else { Err(SignatureCheckError::Missing) } } } #[derive(Debug, Clone, Copy)] pub enum SignaturePolicy { Any, All, } #[derive(Debug, Clone)] pub enum SignatureStatus { Verified(Vec), Skipped, } #[derive(Debug, Error, Diagnostic)] pub enum SignatureError { #[error("missing signature headers for {0:?}")] #[diagnostic( code("webhook.signature.missing"), help( "Ensure the webhook sender or proxy includes the expected signature header(s) for one of the enabled signature sources." ) )] Missing(Vec), #[error("invalid signature for {0:?}")] #[diagnostic( code("webhook.signature.invalid"), help( "Check that the signing secret matches the sender/proxy configuration and that the request body is unmodified." ) )] Invalid(Vec), } #[derive(Debug, Error, Diagnostic)] enum SignatureCheckError { #[error("missing signature header")] #[diagnostic( code("webhook.signature.header_missing"), help("Configure the webhook sender or proxy to include the expected signature header(s).") )] Missing, #[error("signature verification failed")] #[diagnostic( code("webhook.signature.verify_failed"), help( "Confirm the signing secret and ensure the request body is not modified by intermediaries." ) )] Invalid, } pub fn verify_signatures( headers: &HeaderMap, body: &[u8], checks: &[SignatureCheck], policy: SignaturePolicy, ) -> Result { if checks.is_empty() { return Ok(SignatureStatus::Skipped); } let mut verified = Vec::new(); let mut missing = Vec::new(); let mut invalid = Vec::new(); for check in checks { match check.verify(headers, body) { Ok(()) => verified.push(check.source), Err(SignatureCheckError::Missing) => missing.push(check.source), Err(SignatureCheckError::Invalid) => invalid.push(check.source), } } match policy { SignaturePolicy::Any => { if let Some(first) = verified.first().copied() { return Ok(SignatureStatus::Verified(vec![first])); } if !invalid.is_empty() { return Err(SignatureError::Invalid(invalid)); } Err(SignatureError::Missing(missing)) } SignaturePolicy::All => { if invalid.is_empty() && missing.is_empty() { return Ok(SignatureStatus::Verified(verified)); } if !invalid.is_empty() { return Err(SignatureError::Invalid(invalid)); } Err(SignatureError::Missing(missing)) } } } #[derive(Debug, Clone, Default)] pub struct GithubInfo { pub event: Option, pub delivery: Option, pub hook_id: Option, pub installation_target_id: Option, pub installation_target_type: Option, pub user_agent: Option, } #[derive(Debug, Clone, Default)] pub struct HookdeckInfo { pub event_id: Option, pub request_id: Option, pub idempotency_key: Option, pub original_ip: Option, pub verified: Option, pub event_url: Option, pub source_name: Option, pub connection_name: Option, pub destination_name: Option, pub attempt_count: Option, pub attempt_trigger: Option, pub will_retry_after: Option, } #[derive(Debug, Clone, Default)] pub struct WebhookInfo { pub github: GithubInfo, pub hookdeck: HookdeckInfo, pub content_type: Option, pub content_length: Option, } impl WebhookInfo { pub fn from_headers(headers: &HeaderMap) -> Self { let github = GithubInfo { event: header_string(headers, "X-GitHub-Event"), delivery: header_string(headers, "X-GitHub-Delivery"), hook_id: header_u64(headers, "X-GitHub-Hook-Id"), installation_target_id: header_u64(headers, "X-GitHub-Hook-Installation-Target-Id"), installation_target_type: header_string( headers, "X-GitHub-Hook-Installation-Target-Type", ), user_agent: header_string(headers, "User-Agent"), }; let hookdeck = HookdeckInfo { event_id: header_string(headers, "X-Hookdeck-EventId"), request_id: header_string(headers, "X-Hookdeck-RequestId"), idempotency_key: header_string(headers, "Idempotency-Key"), original_ip: header_ip(headers, "X-Hookdeck-Original-Ip"), verified: header_bool(headers, "X-Hookdeck-Verified"), event_url: header_string(headers, "X-Hookdeck-Event-Url"), source_name: header_string(headers, "X-Hookdeck-Source-Name"), connection_name: header_string(headers, "X-Hookdeck-Connection-Name"), destination_name: header_string(headers, "X-Hookdeck-Destination-Name"), attempt_count: header_u32(headers, "X-Hookdeck-Attempt-Count"), attempt_trigger: header_string(headers, "X-Hookdeck-Attempt-Trigger"), will_retry_after: header_string(headers, "X-Hookdeck-Will-Retry-After") .filter(|v| v.to_ascii_lowercase() != "empty"), }; Self { github, hookdeck, content_type: header_string(headers, "Content-Type"), content_length: header_u64(headers, "Content-Length"), } } } #[derive(Debug)] pub struct WebhookExtraction { pub payload: T, pub info: WebhookInfo, pub signature: SignatureStatus, } #[derive(Debug, Error, Diagnostic)] pub enum WebhookError { #[error("signature verification failed")] #[diagnostic( code("webhook.signature.failed"), help( "Provide a valid signature header or disable verification only in trusted development environments." ) )] Signature(#[from] SignatureError), #[error("failed to parse json payload")] #[diagnostic( code("webhook.payload.json_invalid"), help("Verify the webhook payload is valid JSON and matches the expected event schema.") )] Json(#[from] serde_json::Error), } pub fn extract_json( headers: &HeaderMap, body: &[u8], checks: &[SignatureCheck], policy: SignaturePolicy, ) -> Result, WebhookError> { let info = WebhookInfo::from_headers(headers); let signature = verify_signatures(headers, body, checks, policy)?; let payload = serde_json::from_slice(body)?; Ok(WebhookExtraction { payload, info, signature, }) } fn verify_hmac_hex(sig: &str, prefix: Option<&'static str>, secret: &[u8], body: &[u8]) -> bool { let sig = sig.trim(); let sig = prefix.and_then(|p| sig.strip_prefix(p)).unwrap_or(sig); let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC can take key of any size"); mac.update(body); match hex::decode(sig) { Ok(decoded) => mac.verify_slice(&decoded).is_ok(), Err(_) => false, } } fn verify_hmac_base64(sig: &str, secret: &[u8], body: &[u8]) -> bool { let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC can take key of any size"); mac.update(body); match base64::engine::general_purpose::STANDARD.decode(sig.trim()) { Ok(decoded) => mac.verify_slice(&decoded).is_ok(), Err(_) => false, } } fn header_string(headers: &HeaderMap, name: &str) -> Option { headers .get(name) .and_then(|v| v.to_str().ok()) .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) } fn header_u64(headers: &HeaderMap, name: &str) -> Option { header_string(headers, name).and_then(|v| v.parse().ok()) } fn header_u32(headers: &HeaderMap, name: &str) -> Option { header_string(headers, name).and_then(|v| v.parse().ok()) } fn header_bool(headers: &HeaderMap, name: &str) -> Option { header_string(headers, name).and_then(|v| match v.to_ascii_lowercase().as_str() { "true" => Some(true), "false" => Some(false), _ => None, }) } fn header_ip(headers: &HeaderMap, name: &str) -> Option { header_string(headers, name).and_then(|v| v.parse().ok()) }