solstice-ci/crates/webhook/src/lib.rs
Till Wegmueller ac81dedf82
Improve case-insensitive comparison for X-Hookdeck-Will-Retry-After header
Signed-off-by: Till Wegmueller <toasterson@gmail.com>
2026-01-25 23:28:36 +01:00

342 lines
11 KiB
Rust

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<Sha256>;
#[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<u8>,
}
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<SignatureSource>),
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<SignatureSource>),
#[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<SignatureSource>),
}
#[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<SignatureStatus, SignatureError> {
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<String>,
pub delivery: Option<String>,
pub hook_id: Option<u64>,
pub installation_target_id: Option<u64>,
pub installation_target_type: Option<String>,
pub user_agent: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct HookdeckInfo {
pub event_id: Option<String>,
pub request_id: Option<String>,
pub idempotency_key: Option<String>,
pub original_ip: Option<IpAddr>,
pub verified: Option<bool>,
pub event_url: Option<String>,
pub source_name: Option<String>,
pub connection_name: Option<String>,
pub destination_name: Option<String>,
pub attempt_count: Option<u32>,
pub attempt_trigger: Option<String>,
pub will_retry_after: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct WebhookInfo {
pub github: GithubInfo,
pub hookdeck: HookdeckInfo,
pub content_type: Option<String>,
pub content_length: Option<u64>,
}
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.eq_ignore_ascii_case("empty")),
};
Self {
github,
hookdeck,
content_type: header_string(headers, "Content-Type"),
content_length: header_u64(headers, "Content-Length"),
}
}
}
#[derive(Debug)]
pub struct WebhookExtraction<T> {
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<T: DeserializeOwned>(
headers: &HeaderMap,
body: &[u8],
checks: &[SignatureCheck],
policy: SignaturePolicy,
) -> Result<WebhookExtraction<T>, 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<String> {
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<u64> {
header_string(headers, name).and_then(|v| v.parse().ok())
}
fn header_u32(headers: &HeaderMap, name: &str) -> Option<u32> {
header_string(headers, name).and_then(|v| v.parse().ok())
}
fn header_bool(headers: &HeaderMap, name: &str) -> Option<bool> {
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<IpAddr> {
header_string(headers, name).and_then(|v| v.parse().ok())
}