mirror of
https://codeberg.org/Toasterson/solstice-ci.git
synced 2026-04-11 05:40:41 +00:00
342 lines
11 KiB
Rust
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())
|
|
}
|