1use hmac::{Hmac, Mac};
9use sha2::Sha256;
10use subtle::ConstantTimeEq;
11use tracing::warn;
12
13type HmacSha256 = Hmac<Sha256>;
14
15#[derive(Clone)]
41pub enum WebhookAuth {
42 None,
44 Header {
49 name: String,
51 expected: String,
53 },
54 HmacSha256 {
60 header: String,
62 secret: String,
64 },
65}
66
67impl std::fmt::Debug for WebhookAuth {
68 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 match self {
70 Self::None => write!(f, "WebhookAuth::None"),
71 Self::Header { name, .. } => f
72 .debug_struct("WebhookAuth::Header")
73 .field("name", name)
74 .field("expected", &"[REDACTED]")
75 .finish(),
76 Self::HmacSha256 { header, .. } => f
77 .debug_struct("WebhookAuth::HmacSha256")
78 .field("header", header)
79 .field("secret", &"[REDACTED]")
80 .finish(),
81 }
82 }
83}
84
85impl WebhookAuth {
86 pub fn none() -> Self {
96 Self::None
97 }
98
99 pub fn header(name: &str, expected: &str) -> Self {
112 if expected.is_empty() {
113 warn!(
114 "WebhookAuth::header created with an empty expected value - any request with an empty header will be accepted"
115 );
116 }
117 Self::Header {
118 name: name.to_lowercase(),
119 expected: expected.to_string(),
120 }
121 }
122
123 pub fn gitlab(secret: &str) -> Self {
137 if secret.is_empty() {
138 warn!(
139 "WebhookAuth::gitlab created with an empty token - any request with an empty X-Gitlab-Token header will be accepted"
140 );
141 }
142 Self::Header {
143 name: "x-gitlab-token".to_string(),
144 expected: secret.to_string(),
145 }
146 }
147
148 pub fn github(secret: &str) -> Self {
163 if secret.is_empty() {
164 warn!(
165 "WebhookAuth::github created with an empty secret - HMAC verification will be trivially bypassable"
166 );
167 }
168 Self::HmacSha256 {
169 header: "x-hub-signature-256".to_string(),
170 secret: secret.to_string(),
171 }
172 }
173
174 pub fn verify(&self, headers: &axum::http::HeaderMap, body: &[u8]) -> bool {
197 match self {
198 Self::None => true,
199 Self::Header { name, expected } => headers
200 .get(name)
201 .and_then(|v| v.to_str().ok())
202 .is_some_and(|v| v.as_bytes().ct_eq(expected.as_bytes()).into()),
203 Self::HmacSha256 { header, secret } => {
204 let Some(signature) = headers.get(header).and_then(|v| v.to_str().ok()) else {
205 return false;
206 };
207 let Some(signature) = signature.strip_prefix("sha256=") else {
208 return false;
209 };
210 let Ok(sig_bytes) = hex::decode(signature) else {
211 return false;
212 };
213 let Ok(mut mac) = HmacSha256::new_from_slice(secret.as_bytes()) else {
214 return false;
215 };
216 mac.update(body);
217 mac.verify_slice(&sig_bytes).is_ok()
218 }
219 }
220 }
221}
222
223#[cfg(test)]
227pub(crate) fn compute_test_hmac(secret: &[u8], body: &[u8]) -> String {
228 let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC key rejected");
229 mac.update(body);
230 format!("sha256={}", hex::encode(mac.finalize().into_bytes()))
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236 use axum::http::HeaderMap;
237
238 #[test]
241 fn none_always_returns_true() {
242 let auth = WebhookAuth::none();
243 let headers = HeaderMap::new();
244 assert!(auth.verify(&headers, b"anything"));
245 }
246
247 #[test]
248 fn none_empty_body_returns_true() {
249 let auth = WebhookAuth::none();
250 let headers = HeaderMap::new();
251 assert!(auth.verify(&headers, b""));
252 }
253
254 #[test]
255 fn none_empty_headers_returns_true() {
256 let auth = WebhookAuth::none();
257 let headers = HeaderMap::new();
258 assert!(auth.verify(&headers, b"payload"));
259 }
260
261 #[test]
264 fn header_correct_value_returns_true() {
265 let auth = WebhookAuth::header("x-api-key", "secret123");
266 let mut headers = HeaderMap::new();
267 headers.insert("x-api-key", "secret123".parse().unwrap());
268 assert!(auth.verify(&headers, b""));
269 }
270
271 #[test]
272 fn header_wrong_value_returns_false() {
273 let auth = WebhookAuth::header("x-api-key", "secret123");
274 let mut headers = HeaderMap::new();
275 headers.insert("x-api-key", "wrong".parse().unwrap());
276 assert!(!auth.verify(&headers, b""));
277 }
278
279 #[test]
280 fn header_missing_returns_false() {
281 let auth = WebhookAuth::header("x-api-key", "secret123");
282 let headers = HeaderMap::new();
283 assert!(!auth.verify(&headers, b""));
284 }
285
286 #[test]
287 fn header_name_lookup_is_case_insensitive() {
288 let auth = WebhookAuth::header("X-Api-Key", "secret123");
289 let mut headers = HeaderMap::new();
290 headers.insert("x-api-key", "secret123".parse().unwrap());
291 assert!(auth.verify(&headers, b""));
292 }
293
294 #[test]
295 fn header_value_comparison_is_case_sensitive() {
296 let auth = WebhookAuth::header("x-api-key", "Secret123");
297 let mut headers = HeaderMap::new();
298 headers.insert("x-api-key", "secret123".parse().unwrap());
299 assert!(!auth.verify(&headers, b""));
300 }
301
302 #[test]
303 fn header_empty_expected_with_empty_value_returns_true() {
304 let auth = WebhookAuth::header("x-api-key", "");
305 let mut headers = HeaderMap::new();
306 headers.insert("x-api-key", "".parse().unwrap());
307 assert!(auth.verify(&headers, b""));
308 }
309
310 #[test]
313 fn gitlab_correct_token_returns_true() {
314 let auth = WebhookAuth::gitlab("gl-token-abc");
315 let mut headers = HeaderMap::new();
316 headers.insert("x-gitlab-token", "gl-token-abc".parse().unwrap());
317 assert!(auth.verify(&headers, b""));
318 }
319
320 #[test]
321 fn gitlab_wrong_token_returns_false() {
322 let auth = WebhookAuth::gitlab("gl-token-abc");
323 let mut headers = HeaderMap::new();
324 headers.insert("x-gitlab-token", "wrong".parse().unwrap());
325 assert!(!auth.verify(&headers, b""));
326 }
327
328 #[test]
329 fn gitlab_missing_header_returns_false() {
330 let auth = WebhookAuth::gitlab("gl-token-abc");
331 let headers = HeaderMap::new();
332 assert!(!auth.verify(&headers, b""));
333 }
334
335 #[test]
338 fn github_valid_hmac_returns_true() {
339 let secret = "gh-secret";
340 let body = b"payload body";
341 let auth = WebhookAuth::github(secret);
342 let sig = compute_test_hmac(secret.as_bytes(), body);
343 let mut headers = HeaderMap::new();
344 headers.insert("x-hub-signature-256", sig.parse().unwrap());
345 assert!(auth.verify(&headers, body));
346 }
347
348 #[test]
349 fn github_invalid_signature_returns_false() {
350 let auth = WebhookAuth::github("gh-secret");
351 let mut headers = HeaderMap::new();
352 headers.insert(
353 "x-hub-signature-256",
354 "sha256=0000000000000000000000000000000000000000000000000000000000000000"
355 .parse()
356 .unwrap(),
357 );
358 assert!(!auth.verify(&headers, b"payload"));
359 }
360
361 #[test]
362 fn github_missing_header_returns_false() {
363 let auth = WebhookAuth::github("gh-secret");
364 let headers = HeaderMap::new();
365 assert!(!auth.verify(&headers, b"payload"));
366 }
367
368 #[test]
371 fn hmac_valid_signature_verifies() {
372 let secret = "my-secret";
373 let body = b"request body";
374 let auth = WebhookAuth::HmacSha256 {
375 header: "x-signature".to_string(),
376 secret: secret.to_string(),
377 };
378 let sig = compute_test_hmac(secret.as_bytes(), body);
379 let mut headers = HeaderMap::new();
380 headers.insert("x-signature", sig.parse().unwrap());
381 assert!(auth.verify(&headers, body));
382 }
383
384 #[test]
385 fn hmac_tampered_signature_returns_false() {
386 let secret = "my-secret";
387 let body = b"request body";
388 let auth = WebhookAuth::HmacSha256 {
389 header: "x-signature".to_string(),
390 secret: secret.to_string(),
391 };
392 let mut sig = compute_test_hmac(secret.as_bytes(), body);
393 sig.pop();
395 sig.push('0');
396 let mut headers = HeaderMap::new();
397 headers.insert("x-signature", sig.parse().unwrap());
398 assert!(!auth.verify(&headers, body));
399 }
400
401 #[test]
402 fn hmac_missing_header_returns_false() {
403 let auth = WebhookAuth::HmacSha256 {
404 header: "x-signature".to_string(),
405 secret: "my-secret".to_string(),
406 };
407 let headers = HeaderMap::new();
408 assert!(!auth.verify(&headers, b"body"));
409 }
410
411 #[test]
412 fn hmac_no_sha256_prefix_returns_false() {
413 let auth = WebhookAuth::HmacSha256 {
414 header: "x-signature".to_string(),
415 secret: "my-secret".to_string(),
416 };
417 let mut headers = HeaderMap::new();
418 headers.insert(
419 "x-signature",
420 "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
421 .parse()
422 .unwrap(),
423 );
424 assert!(!auth.verify(&headers, b"body"));
425 }
426
427 #[test]
428 fn hmac_invalid_hex_returns_false() {
429 let auth = WebhookAuth::HmacSha256 {
430 header: "x-signature".to_string(),
431 secret: "my-secret".to_string(),
432 };
433 let mut headers = HeaderMap::new();
434 headers.insert("x-signature", "sha256=not-valid-hex!".parse().unwrap());
435 assert!(!auth.verify(&headers, b"body"));
436 }
437
438 #[test]
439 fn hmac_wrong_secret_returns_false() {
440 let body = b"request body";
441 let sig = compute_test_hmac(b"correct-secret", body);
442 let auth = WebhookAuth::HmacSha256 {
443 header: "x-signature".to_string(),
444 secret: "wrong-secret".to_string(),
445 };
446 let mut headers = HeaderMap::new();
447 headers.insert("x-signature", sig.parse().unwrap());
448 assert!(!auth.verify(&headers, body));
449 }
450
451 #[test]
452 fn hmac_empty_body_verifies() {
453 let secret = "my-secret";
454 let body = b"";
455 let auth = WebhookAuth::HmacSha256 {
456 header: "x-signature".to_string(),
457 secret: secret.to_string(),
458 };
459 let sig = compute_test_hmac(secret.as_bytes(), body);
460 let mut headers = HeaderMap::new();
461 headers.insert("x-signature", sig.parse().unwrap());
462 assert!(auth.verify(&headers, body));
463 }
464
465 #[test]
466 fn hmac_body_tampered_returns_false() {
467 let secret = "my-secret";
468 let auth = WebhookAuth::HmacSha256 {
469 header: "x-signature".to_string(),
470 secret: secret.to_string(),
471 };
472 let sig = compute_test_hmac(secret.as_bytes(), b"original body");
473 let mut headers = HeaderMap::new();
474 headers.insert("x-signature", sig.parse().unwrap());
475 assert!(!auth.verify(&headers, b"tampered body"));
476 }
477
478 #[test]
479 fn hmac_empty_secret_still_works() {
480 let secret = "";
481 let body = b"some body";
482 let auth = WebhookAuth::HmacSha256 {
483 header: "x-signature".to_string(),
484 secret: secret.to_string(),
485 };
486 let sig = compute_test_hmac(secret.as_bytes(), body);
487 let mut headers = HeaderMap::new();
488 headers.insert("x-signature", sig.parse().unwrap());
489 assert!(auth.verify(&headers, body));
490 }
491
492 #[test]
493 fn debug_redacts_header_secret() {
494 let auth = WebhookAuth::header("x-api-key", "super-secret");
495 let debug = format!("{:?}", auth);
496 assert!(debug.contains("[REDACTED]"));
497 assert!(!debug.contains("super-secret"));
498 }
499
500 #[test]
501 fn debug_redacts_hmac_secret() {
502 let auth = WebhookAuth::github("my-secret-key");
503 let debug = format!("{:?}", auth);
504 assert!(debug.contains("[REDACTED]"));
505 assert!(!debug.contains("my-secret-key"));
506 }
507
508 #[test]
509 fn debug_none_format() {
510 let auth = WebhookAuth::none();
511 let debug = format!("{:?}", auth);
512 assert_eq!(debug, "WebhookAuth::None");
513 }
514
515 #[test]
516 fn hmac_rfc4231_test_vector() {
517 let key_bytes = hex::decode("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b").unwrap();
519 let body = b"Hi There";
520 let expected_mac = "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7";
521
522 let mut mac = HmacSha256::new_from_slice(&key_bytes).unwrap();
524 mac.update(body);
525 let computed = hex::encode(mac.finalize().into_bytes());
526 assert_eq!(computed, expected_mac);
527
528 let secret_str = String::from_utf8(key_bytes).unwrap();
539 let auth = WebhookAuth::HmacSha256 {
540 header: "x-signature".to_string(),
541 secret: secret_str,
542 };
543 let sig = format!("sha256={}", expected_mac);
544 let mut headers = HeaderMap::new();
545 headers.insert("x-signature", sig.parse().unwrap());
546 assert!(auth.verify(&headers, body));
547 }
548}