From 4c5a8567a457be7eb9d6031d65adf927b3685aa953a8077b208094342b1f4f55 Mon Sep 17 00:00:00 2001 From: Till Wegmueller Date: Sun, 25 Jan 2026 22:16:11 +0100 Subject: [PATCH] Add `webhook` crate for extensible signature validation and integration - Introduce a new `webhook` crate to centralize signature validation for GitHub, Hookdeck, and Forgejo webhooks. - Enable `github-integration` to perform unified webhook signature verification using the `webhook` crate. - Refactor `github-integration`: replace legacy HMAC verification with the reusable `webhook` structure. - Extend Podman configuration for Hookdeck webhook signature handling and improve documentation. - Clean up unused dependencies by migrating to the new implementation. Signed-off-by: Till Wegmueller --- .idea/solstice-ci.iml | 1 + crates/github-integration/Cargo.toml | 5 +- crates/github-integration/src/main.rs | 119 ++++----- crates/webhook/Cargo.toml | 15 ++ crates/webhook/src/lib.rs | 331 ++++++++++++++++++++++++++ deploy/podman/.env.sample | 4 +- deploy/podman/README.md | 1 + deploy/podman/compose.yml | 6 +- 8 files changed, 419 insertions(+), 63 deletions(-) create mode 100644 crates/webhook/Cargo.toml create mode 100644 crates/webhook/src/lib.rs diff --git a/.idea/solstice-ci.iml b/.idea/solstice-ci.iml index 928b227..eac4bb5 100644 --- a/.idea/solstice-ci.iml +++ b/.idea/solstice-ci.iml @@ -12,6 +12,7 @@ + diff --git a/crates/github-integration/Cargo.toml b/crates/github-integration/Cargo.toml index 0c0851e..6308abb 100644 --- a/crates/github-integration/Cargo.toml +++ b/crates/github-integration/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" [dependencies] common = { path = "../common" } +webhook = { path = "../webhook" } clap = { version = "4", features = ["derive", "env"] } miette = { version = "7", features = ["fancy"] } tracing = "0.1" @@ -14,10 +15,6 @@ axum = { version = "0.8", features = ["macros"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -# Signature verification -hmac = "0.12" -sha2 = "0.10" -hex = "0.4" # GitHub App auth jsonwebtoken = "9" time = { version = "0.3", features = ["formatting"] } diff --git a/crates/github-integration/src/main.rs b/crates/github-integration/src/main.rs index e70edd1..cf17439 100644 --- a/crates/github-integration/src/main.rs +++ b/crates/github-integration/src/main.rs @@ -13,12 +13,11 @@ use axum::{ use base64::Engine; use clap::{Parser, Subcommand}; use futures_util::StreamExt; -use hmac::{Hmac, Mac}; use jsonwebtoken::{EncodingKey, Header}; use miette::{IntoDiagnostic, Result}; use serde::{Deserialize, Serialize}; -use sha2::Sha256; use tracing::{error, info, warn}; +use webhook::{SignatureCheck, SignaturePolicy, WebhookError, WebhookInfo}; #[derive(Subcommand, Debug)] enum Cmd { @@ -55,6 +54,10 @@ struct Opts { #[arg(long, env = "GITHUB_WEBHOOK_SECRET")] webhook_secret: Option, + /// Hookdeck signing secret (proxy signature) + #[arg(long, env = "HOOKDECK_SIGNING_SECRET")] + hookdeck_signing_secret: Option, + /// GitHub API base (e.g., https://api.github.com) #[arg(long, env = "GITHUB_API_BASE", default_value = "https://api.github.com")] github_api_base: String, @@ -125,6 +128,7 @@ struct Opts { struct AppState { mq_cfg: common::MqConfig, webhook_secret: Option, + hookdeck_signing_secret: Option, github_api_base: String, app_id: Option, app_key_pem: Option, @@ -137,8 +141,6 @@ struct AppState { runs_on_map: std::collections::HashMap, // key: owner/repo } -type HmacSha256 = Hmac; - #[tokio::main(flavor = "multi_thread")] async fn main() -> Result<()> { // Load internal config (preloads KDL -> env, then reads env) @@ -180,10 +182,11 @@ async fn main() -> Result<()> { let webhook_secret = opts .webhook_secret .or_else(|| std::env::var("WEBHOOK_SECRET").ok()); - if webhook_secret.is_none() { - warn!( - "GITHUB_WEBHOOK_SECRET is not set — accepting webhooks without signature validation (dev mode)" - ); + let hookdeck_signing_secret = opts + .hookdeck_signing_secret + .or_else(|| std::env::var("HOOKDECK_SECRET").ok()); + if webhook_secret.is_none() && hookdeck_signing_secret.is_none() { + warn!("GITHUB_WEBHOOK_SECRET and HOOKDECK_SIGNING_SECRET are not set — accepting webhooks without signature validation (dev mode)"); } let app_key_pem = match (&opts.app_key_pem, &opts.app_key_path) { @@ -202,6 +205,7 @@ async fn main() -> Result<()> { let state = Arc::new(AppState { mq_cfg, webhook_secret, + hookdeck_signing_secret, github_api_base: opts.github_api_base, app_id: opts.app_id, app_key_pem, @@ -455,56 +459,69 @@ async fn handle_webhook( headers: HeaderMap, body: Bytes, ) -> impl IntoResponse { - // Signature validation (X-Hub-Signature-256) - if let Some(secret) = state.webhook_secret.as_ref() { - if let Some(sig_hdr) = headers.get("X-Hub-Signature-256") { - let sig = match sig_hdr.to_str() { - Ok(s) => s.trim(), - Err(_) => return StatusCode::UNAUTHORIZED, - }; - if !verify_github_hmac(sig, secret.as_bytes(), &body) { - warn!("invalid webhook signature"); - return StatusCode::UNAUTHORIZED; - } - } else { - warn!("missing signature header"); - return StatusCode::UNAUTHORIZED; - } - } - - let event = headers - .get("X-GitHub-Event") - .and_then(|v| v.to_str().ok()) + let info = WebhookInfo::from_headers(&headers); + let checks = signature_checks(&state); + let event = info + .github + .event + .as_deref() .unwrap_or("") - .to_lowercase(); + .to_ascii_lowercase(); match event.as_str() { - "push" => handle_push(state, body).await, - "pull_request" => handle_pull_request(state, body).await, - "ping" => StatusCode::OK, + "push" => match webhook::extract_json::( + &headers, + body.as_ref(), + &checks, + SignaturePolicy::Any, + ) { + Ok(extract) => handle_push(state, extract.payload).await, + Err(err) => map_webhook_error(err), + }, + "pull_request" => match webhook::extract_json::( + &headers, + body.as_ref(), + &checks, + SignaturePolicy::Any, + ) { + Ok(extract) => handle_pull_request(state, extract.payload).await, + Err(err) => map_webhook_error(err), + }, + "ping" => match webhook::verify_signatures( + &headers, + body.as_ref(), + &checks, + SignaturePolicy::Any, + ) { + Ok(_) => StatusCode::OK, + Err(err) => map_webhook_error(WebhookError::Signature(err)), + }, _ => StatusCode::NO_CONTENT, } } -fn verify_github_hmac(sig_hdr: &str, secret: &[u8], body: &[u8]) -> bool { - let sig = sig_hdr.strip_prefix("sha256=").unwrap_or(sig_hdr); - let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC can take key of any size"); - mac.update(body); - match hex::decode(sig) { - Ok(sig) => mac.verify_slice(&sig).is_ok(), - Err(_) => false, +fn signature_checks(state: &AppState) -> Vec { + let mut checks = Vec::new(); + if let Some(secret) = state.webhook_secret.as_deref() { + checks.push(SignatureCheck::github(secret)); } + if let Some(secret) = state.hookdeck_signing_secret.as_deref() { + checks.push(SignatureCheck::hookdeck(secret)); + } + checks } -async fn handle_push(state: Arc, body: Bytes) -> StatusCode { - let payload: PushPayload = match serde_json::from_slice(&body) { - Ok(p) => p, - Err(e) => { - warn!(error = %e, "failed to parse push payload"); - return StatusCode::BAD_REQUEST; - } +fn map_webhook_error(err: WebhookError) -> StatusCode { + let (status, message) = match &err { + WebhookError::Signature(_) => (StatusCode::UNAUTHORIZED, "webhook signature check failed"), + WebhookError::Json(_) => (StatusCode::BAD_REQUEST, "failed to parse webhook payload"), }; + let report = miette::Report::new(err); + warn!(error = %report, "{message}"); + status +} +async fn handle_push(state: Arc, payload: PushPayload) -> StatusCode { // Ignore delete events (after = all zeros) let is_delete = payload.after.chars().all(|c| c == '0'); if is_delete { @@ -561,15 +578,7 @@ async fn handle_push(state: Arc, body: Bytes) -> StatusCode { } } -async fn handle_pull_request(state: Arc, body: Bytes) -> StatusCode { - let payload: PrPayload = match serde_json::from_slice(&body) { - Ok(p) => p, - Err(e) => { - warn!(error = %e, "failed to parse pull_request payload"); - return StatusCode::BAD_REQUEST; - } - }; - +async fn handle_pull_request(state: Arc, payload: PrPayload) -> StatusCode { // Only act on opened/synchronize/reopened match payload.action.as_str() { "opened" | "synchronize" | "reopened" => {} diff --git a/crates/webhook/Cargo.toml b/crates/webhook/Cargo.toml new file mode 100644 index 0000000..740b5ea --- /dev/null +++ b/crates/webhook/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "webhook" +version = "0.1.0" +edition = "2024" + +[dependencies] +base64 = "0.22" +hex = "0.4" +hmac = "0.12" +http = "1" +miette = { version = "7", features = ["fancy"] } +serde = "1" +serde_json = "1" +sha2 = "0.10" +thiserror = "2" diff --git a/crates/webhook/src/lib.rs b/crates/webhook/src/lib.rs new file mode 100644 index 0000000..38c82ea --- /dev/null +++ b/crates/webhook/src/lib.rs @@ -0,0 +1,331 @@ +use std::net::IpAddr; +use base64::Engine; +use hmac::{Hmac, Mac}; +use http::HeaderMap; +use serde::de::DeserializeOwned; +use sha2::Sha256; +use miette::Diagnostic; +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()) +} diff --git a/deploy/podman/.env.sample b/deploy/podman/.env.sample index 07511b8..c36d88d 100644 --- a/deploy/podman/.env.sample +++ b/deploy/podman/.env.sample @@ -75,10 +75,10 @@ FORGEJO_BASE_URL= # GitHub Integration secrets (set per deployment) # Shared secret used to validate GitHub webhooks (X-Hub-Signature-256) GITHUB_WEBHOOK_SECRET= +# Hookdeck signing secret (when webhooks are proxied via Hookdeck) +HOOKDECK_SIGNING_SECRET= # GitHub App ID (numeric) GITHUB_APP_ID= -# GitHub App private key (PEM) or a filesystem path to the PEM -GITHUB_APP_KEY= GITHUB_APP_KEY_PATH= # Optional: override GitHub API base (GitHub Enterprise) GITHUB_API_BASE= diff --git a/deploy/podman/README.md b/deploy/podman/README.md index 067c79a..977c46c 100644 --- a/deploy/podman/README.md +++ b/deploy/podman/README.md @@ -166,6 +166,7 @@ Forge integration configuration GitHub integration configuration - Set GITHUB_WEBHOOK_SECRET in deploy/podman/.env to validate webhook signatures (X-Hub-Signature-256). If unset, webhooks are accepted without validation (dev mode). +- If you proxy webhooks through Hookdeck, set HOOKDECK_SIGNING_SECRET to validate Hookdeck's signature header (X-Hookdeck-Signature). Either GitHub or Hookdeck signatures can satisfy verification. - To enable check runs and workflow fetches, configure a GitHub App and set GITHUB_APP_ID plus either GITHUB_APP_KEY (PEM contents) or GITHUB_APP_KEY_PATH (path inside the container). - Optional overrides: GITHUB_API_BASE for GitHub Enterprise and GITHUB_CHECK_NAME to customize the check run title. - The compose file passes these variables to the container. After editing .env, run: podman compose up -d github-integration diff --git a/deploy/podman/compose.yml b/deploy/podman/compose.yml index acd1bf5..11dc5e3 100644 --- a/deploy/podman/compose.yml +++ b/deploy/podman/compose.yml @@ -320,13 +320,15 @@ services: HTTP_ADDR: 0.0.0.0:8082 WEBHOOK_PATH: /webhooks/github GITHUB_WEBHOOK_SECRET: ${GITHUB_WEBHOOK_SECRET} + HOOKDECK_SIGNING_SECRET: ${HOOKDECK_SIGNING_SECRET} GITHUB_APP_ID: ${GITHUB_APP_ID} - GITHUB_APP_KEY_PATH: ${GITHUB_APP_KEY_PATH} - GITHUB_APP_KEY: ${GITHUB_APP_KEY} + GITHUB_APP_KEY_PATH: /app/github-app-key.pem GITHUB_API_BASE: ${GITHUB_API_BASE:-https://api.github.com} GITHUB_CHECK_NAME: ${GITHUB_CHECK_NAME:-Solstice CI} # URL where logs-service is exposed (used for check-run links) LOGS_BASE_URL: https://logs.${ENV}.${DOMAIN} + volumes: + - ${GITHUB_APP_KEY_PATH}:/app/github-app-key.pem depends_on: rabbitmq: condition: service_healthy