use hmac::{Hmac, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;
use tracing::warn;
type HmacSha256 = Hmac<Sha256>;
#[derive(Clone)]
pub enum WebhookAuth {
None,
Header {
name: String,
expected: String,
},
HmacSha256 {
header: String,
secret: String,
},
}
impl std::fmt::Debug for WebhookAuth {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::None => write!(f, "WebhookAuth::None"),
Self::Header { name, .. } => f
.debug_struct("WebhookAuth::Header")
.field("name", name)
.field("expected", &"[REDACTED]")
.finish(),
Self::HmacSha256 { header, .. } => f
.debug_struct("WebhookAuth::HmacSha256")
.field("header", header)
.field("secret", &"[REDACTED]")
.finish(),
}
}
}
impl WebhookAuth {
pub fn none() -> Self {
Self::None
}
pub fn header(name: &str, expected: &str) -> Self {
if expected.is_empty() {
warn!(
"WebhookAuth::header created with an empty expected value - any request with an empty header will be accepted"
);
}
Self::Header {
name: name.to_lowercase(),
expected: expected.to_string(),
}
}
pub fn gitlab(secret: &str) -> Self {
if secret.is_empty() {
warn!(
"WebhookAuth::gitlab created with an empty token - any request with an empty X-Gitlab-Token header will be accepted"
);
}
Self::Header {
name: "x-gitlab-token".to_string(),
expected: secret.to_string(),
}
}
pub fn github(secret: &str) -> Self {
if secret.is_empty() {
warn!(
"WebhookAuth::github created with an empty secret - HMAC verification will be trivially bypassable"
);
}
Self::HmacSha256 {
header: "x-hub-signature-256".to_string(),
secret: secret.to_string(),
}
}
pub fn verify(&self, headers: &axum::http::HeaderMap, body: &[u8]) -> bool {
match self {
Self::None => true,
Self::Header { name, expected } => headers
.get(name)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.as_bytes().ct_eq(expected.as_bytes()).into()),
Self::HmacSha256 { header, secret } => {
let Some(signature) = headers.get(header).and_then(|v| v.to_str().ok()) else {
return false;
};
let Some(signature) = signature.strip_prefix("sha256=") else {
return false;
};
let Ok(sig_bytes) = hex::decode(signature) else {
return false;
};
let Ok(mut mac) = HmacSha256::new_from_slice(secret.as_bytes()) else {
return false;
};
mac.update(body);
mac.verify_slice(&sig_bytes).is_ok()
}
}
}
}
#[cfg(test)]
pub(crate) fn compute_test_hmac(secret: &[u8], body: &[u8]) -> String {
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC key rejected");
mac.update(body);
format!("sha256={}", hex::encode(mac.finalize().into_bytes()))
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderMap;
#[test]
fn none_always_returns_true() {
let auth = WebhookAuth::none();
let headers = HeaderMap::new();
assert!(auth.verify(&headers, b"anything"));
}
#[test]
fn none_empty_body_returns_true() {
let auth = WebhookAuth::none();
let headers = HeaderMap::new();
assert!(auth.verify(&headers, b""));
}
#[test]
fn none_empty_headers_returns_true() {
let auth = WebhookAuth::none();
let headers = HeaderMap::new();
assert!(auth.verify(&headers, b"payload"));
}
#[test]
fn header_correct_value_returns_true() {
let auth = WebhookAuth::header("x-api-key", "secret123");
let mut headers = HeaderMap::new();
headers.insert("x-api-key", "secret123".parse().unwrap());
assert!(auth.verify(&headers, b""));
}
#[test]
fn header_wrong_value_returns_false() {
let auth = WebhookAuth::header("x-api-key", "secret123");
let mut headers = HeaderMap::new();
headers.insert("x-api-key", "wrong".parse().unwrap());
assert!(!auth.verify(&headers, b""));
}
#[test]
fn header_missing_returns_false() {
let auth = WebhookAuth::header("x-api-key", "secret123");
let headers = HeaderMap::new();
assert!(!auth.verify(&headers, b""));
}
#[test]
fn header_name_lookup_is_case_insensitive() {
let auth = WebhookAuth::header("X-Api-Key", "secret123");
let mut headers = HeaderMap::new();
headers.insert("x-api-key", "secret123".parse().unwrap());
assert!(auth.verify(&headers, b""));
}
#[test]
fn header_value_comparison_is_case_sensitive() {
let auth = WebhookAuth::header("x-api-key", "Secret123");
let mut headers = HeaderMap::new();
headers.insert("x-api-key", "secret123".parse().unwrap());
assert!(!auth.verify(&headers, b""));
}
#[test]
fn header_empty_expected_with_empty_value_returns_true() {
let auth = WebhookAuth::header("x-api-key", "");
let mut headers = HeaderMap::new();
headers.insert("x-api-key", "".parse().unwrap());
assert!(auth.verify(&headers, b""));
}
#[test]
fn gitlab_correct_token_returns_true() {
let auth = WebhookAuth::gitlab("gl-token-abc");
let mut headers = HeaderMap::new();
headers.insert("x-gitlab-token", "gl-token-abc".parse().unwrap());
assert!(auth.verify(&headers, b""));
}
#[test]
fn gitlab_wrong_token_returns_false() {
let auth = WebhookAuth::gitlab("gl-token-abc");
let mut headers = HeaderMap::new();
headers.insert("x-gitlab-token", "wrong".parse().unwrap());
assert!(!auth.verify(&headers, b""));
}
#[test]
fn gitlab_missing_header_returns_false() {
let auth = WebhookAuth::gitlab("gl-token-abc");
let headers = HeaderMap::new();
assert!(!auth.verify(&headers, b""));
}
#[test]
fn github_valid_hmac_returns_true() {
let secret = "gh-secret";
let body = b"payload body";
let auth = WebhookAuth::github(secret);
let sig = compute_test_hmac(secret.as_bytes(), body);
let mut headers = HeaderMap::new();
headers.insert("x-hub-signature-256", sig.parse().unwrap());
assert!(auth.verify(&headers, body));
}
#[test]
fn github_invalid_signature_returns_false() {
let auth = WebhookAuth::github("gh-secret");
let mut headers = HeaderMap::new();
headers.insert(
"x-hub-signature-256",
"sha256=0000000000000000000000000000000000000000000000000000000000000000"
.parse()
.unwrap(),
);
assert!(!auth.verify(&headers, b"payload"));
}
#[test]
fn github_missing_header_returns_false() {
let auth = WebhookAuth::github("gh-secret");
let headers = HeaderMap::new();
assert!(!auth.verify(&headers, b"payload"));
}
#[test]
fn hmac_valid_signature_verifies() {
let secret = "my-secret";
let body = b"request body";
let auth = WebhookAuth::HmacSha256 {
header: "x-signature".to_string(),
secret: secret.to_string(),
};
let sig = compute_test_hmac(secret.as_bytes(), body);
let mut headers = HeaderMap::new();
headers.insert("x-signature", sig.parse().unwrap());
assert!(auth.verify(&headers, body));
}
#[test]
fn hmac_tampered_signature_returns_false() {
let secret = "my-secret";
let body = b"request body";
let auth = WebhookAuth::HmacSha256 {
header: "x-signature".to_string(),
secret: secret.to_string(),
};
let mut sig = compute_test_hmac(secret.as_bytes(), body);
sig.pop();
sig.push('0');
let mut headers = HeaderMap::new();
headers.insert("x-signature", sig.parse().unwrap());
assert!(!auth.verify(&headers, body));
}
#[test]
fn hmac_missing_header_returns_false() {
let auth = WebhookAuth::HmacSha256 {
header: "x-signature".to_string(),
secret: "my-secret".to_string(),
};
let headers = HeaderMap::new();
assert!(!auth.verify(&headers, b"body"));
}
#[test]
fn hmac_no_sha256_prefix_returns_false() {
let auth = WebhookAuth::HmacSha256 {
header: "x-signature".to_string(),
secret: "my-secret".to_string(),
};
let mut headers = HeaderMap::new();
headers.insert(
"x-signature",
"abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
.parse()
.unwrap(),
);
assert!(!auth.verify(&headers, b"body"));
}
#[test]
fn hmac_invalid_hex_returns_false() {
let auth = WebhookAuth::HmacSha256 {
header: "x-signature".to_string(),
secret: "my-secret".to_string(),
};
let mut headers = HeaderMap::new();
headers.insert("x-signature", "sha256=not-valid-hex!".parse().unwrap());
assert!(!auth.verify(&headers, b"body"));
}
#[test]
fn hmac_wrong_secret_returns_false() {
let body = b"request body";
let sig = compute_test_hmac(b"correct-secret", body);
let auth = WebhookAuth::HmacSha256 {
header: "x-signature".to_string(),
secret: "wrong-secret".to_string(),
};
let mut headers = HeaderMap::new();
headers.insert("x-signature", sig.parse().unwrap());
assert!(!auth.verify(&headers, body));
}
#[test]
fn hmac_empty_body_verifies() {
let secret = "my-secret";
let body = b"";
let auth = WebhookAuth::HmacSha256 {
header: "x-signature".to_string(),
secret: secret.to_string(),
};
let sig = compute_test_hmac(secret.as_bytes(), body);
let mut headers = HeaderMap::new();
headers.insert("x-signature", sig.parse().unwrap());
assert!(auth.verify(&headers, body));
}
#[test]
fn hmac_body_tampered_returns_false() {
let secret = "my-secret";
let auth = WebhookAuth::HmacSha256 {
header: "x-signature".to_string(),
secret: secret.to_string(),
};
let sig = compute_test_hmac(secret.as_bytes(), b"original body");
let mut headers = HeaderMap::new();
headers.insert("x-signature", sig.parse().unwrap());
assert!(!auth.verify(&headers, b"tampered body"));
}
#[test]
fn hmac_empty_secret_still_works() {
let secret = "";
let body = b"some body";
let auth = WebhookAuth::HmacSha256 {
header: "x-signature".to_string(),
secret: secret.to_string(),
};
let sig = compute_test_hmac(secret.as_bytes(), body);
let mut headers = HeaderMap::new();
headers.insert("x-signature", sig.parse().unwrap());
assert!(auth.verify(&headers, body));
}
#[test]
fn debug_redacts_header_secret() {
let auth = WebhookAuth::header("x-api-key", "super-secret");
let debug = format!("{:?}", auth);
assert!(debug.contains("[REDACTED]"));
assert!(!debug.contains("super-secret"));
}
#[test]
fn debug_redacts_hmac_secret() {
let auth = WebhookAuth::github("my-secret-key");
let debug = format!("{:?}", auth);
assert!(debug.contains("[REDACTED]"));
assert!(!debug.contains("my-secret-key"));
}
#[test]
fn debug_none_format() {
let auth = WebhookAuth::none();
let debug = format!("{:?}", auth);
assert_eq!(debug, "WebhookAuth::None");
}
#[test]
fn hmac_rfc4231_test_vector() {
let key_bytes = hex::decode("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b").unwrap();
let body = b"Hi There";
let expected_mac = "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7";
let mut mac = HmacSha256::new_from_slice(&key_bytes).unwrap();
mac.update(body);
let computed = hex::encode(mac.finalize().into_bytes());
assert_eq!(computed, expected_mac);
let secret_str = String::from_utf8(key_bytes).unwrap();
let auth = WebhookAuth::HmacSha256 {
header: "x-signature".to_string(),
secret: secret_str,
};
let sig = format!("sha256={}", expected_mac);
let mut headers = HeaderMap::new();
headers.insert("x-signature", sig.parse().unwrap());
assert!(auth.verify(&headers, body));
}
}