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
223const DELIVERY_ID_HEADERS: &[(&str, &str)] = &[
229 ("x-github-delivery", "github"),
230 ("x-gitlab-event-uuid", "gitlab"),
231];
232
233pub const MAX_IDEMPOTENCY_KEY_LEN: usize = 255;
246
247pub fn extract_delivery_id(headers: &axum::http::HeaderMap) -> Option<String> {
267 for (header, provider) in DELIVERY_ID_HEADERS {
268 let Some(raw) = headers.get(*header).and_then(|v| v.to_str().ok()) else {
269 continue;
270 };
271 if raw.is_empty() || !raw.bytes().all(|b| b.is_ascii_graphic()) {
272 warn!(header = %header, "delivery id is not printable ASCII, ignoring");
273 continue;
274 }
275
276 let key = format!("{provider}:{raw}");
277 if key.len() > MAX_IDEMPOTENCY_KEY_LEN {
278 warn!(header = %header, "delivery id is too long for an idempotency key, ignoring");
279 continue;
280 }
281 return Some(key);
282 }
283 None
284}
285
286#[cfg(test)]
288pub(crate) fn compute_test_hmac(secret: &[u8], body: &[u8]) -> String {
289 let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC key rejected");
290 mac.update(body);
291 format!("sha256={}", hex::encode(mac.finalize().into_bytes()))
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297 use axum::http::HeaderMap;
298
299 #[test]
302 fn none_always_returns_true() {
303 let auth = WebhookAuth::none();
304 let headers = HeaderMap::new();
305 assert!(auth.verify(&headers, b"anything"));
306 }
307
308 #[test]
309 fn none_empty_body_returns_true() {
310 let auth = WebhookAuth::none();
311 let headers = HeaderMap::new();
312 assert!(auth.verify(&headers, b""));
313 }
314
315 #[test]
316 fn none_empty_headers_returns_true() {
317 let auth = WebhookAuth::none();
318 let headers = HeaderMap::new();
319 assert!(auth.verify(&headers, b"payload"));
320 }
321
322 #[test]
325 fn header_correct_value_returns_true() {
326 let auth = WebhookAuth::header("x-api-key", "secret123");
327 let mut headers = HeaderMap::new();
328 headers.insert("x-api-key", "secret123".parse().unwrap());
329 assert!(auth.verify(&headers, b""));
330 }
331
332 #[test]
333 fn header_wrong_value_returns_false() {
334 let auth = WebhookAuth::header("x-api-key", "secret123");
335 let mut headers = HeaderMap::new();
336 headers.insert("x-api-key", "wrong".parse().unwrap());
337 assert!(!auth.verify(&headers, b""));
338 }
339
340 #[test]
341 fn header_missing_returns_false() {
342 let auth = WebhookAuth::header("x-api-key", "secret123");
343 let headers = HeaderMap::new();
344 assert!(!auth.verify(&headers, b""));
345 }
346
347 #[test]
348 fn header_name_lookup_is_case_insensitive() {
349 let auth = WebhookAuth::header("X-Api-Key", "secret123");
350 let mut headers = HeaderMap::new();
351 headers.insert("x-api-key", "secret123".parse().unwrap());
352 assert!(auth.verify(&headers, b""));
353 }
354
355 #[test]
356 fn header_value_comparison_is_case_sensitive() {
357 let auth = WebhookAuth::header("x-api-key", "Secret123");
358 let mut headers = HeaderMap::new();
359 headers.insert("x-api-key", "secret123".parse().unwrap());
360 assert!(!auth.verify(&headers, b""));
361 }
362
363 #[test]
364 fn header_empty_expected_with_empty_value_returns_true() {
365 let auth = WebhookAuth::header("x-api-key", "");
366 let mut headers = HeaderMap::new();
367 headers.insert("x-api-key", "".parse().unwrap());
368 assert!(auth.verify(&headers, b""));
369 }
370
371 #[test]
374 fn gitlab_correct_token_returns_true() {
375 let auth = WebhookAuth::gitlab("gl-token-abc");
376 let mut headers = HeaderMap::new();
377 headers.insert("x-gitlab-token", "gl-token-abc".parse().unwrap());
378 assert!(auth.verify(&headers, b""));
379 }
380
381 #[test]
382 fn gitlab_wrong_token_returns_false() {
383 let auth = WebhookAuth::gitlab("gl-token-abc");
384 let mut headers = HeaderMap::new();
385 headers.insert("x-gitlab-token", "wrong".parse().unwrap());
386 assert!(!auth.verify(&headers, b""));
387 }
388
389 #[test]
390 fn gitlab_missing_header_returns_false() {
391 let auth = WebhookAuth::gitlab("gl-token-abc");
392 let headers = HeaderMap::new();
393 assert!(!auth.verify(&headers, b""));
394 }
395
396 #[test]
399 fn github_valid_hmac_returns_true() {
400 let secret = "gh-secret";
401 let body = b"payload body";
402 let auth = WebhookAuth::github(secret);
403 let sig = compute_test_hmac(secret.as_bytes(), body);
404 let mut headers = HeaderMap::new();
405 headers.insert("x-hub-signature-256", sig.parse().unwrap());
406 assert!(auth.verify(&headers, body));
407 }
408
409 #[test]
410 fn github_invalid_signature_returns_false() {
411 let auth = WebhookAuth::github("gh-secret");
412 let mut headers = HeaderMap::new();
413 headers.insert(
414 "x-hub-signature-256",
415 "sha256=0000000000000000000000000000000000000000000000000000000000000000"
416 .parse()
417 .unwrap(),
418 );
419 assert!(!auth.verify(&headers, b"payload"));
420 }
421
422 #[test]
423 fn github_missing_header_returns_false() {
424 let auth = WebhookAuth::github("gh-secret");
425 let headers = HeaderMap::new();
426 assert!(!auth.verify(&headers, b"payload"));
427 }
428
429 #[test]
432 fn hmac_valid_signature_verifies() {
433 let secret = "my-secret";
434 let body = b"request body";
435 let auth = WebhookAuth::HmacSha256 {
436 header: "x-signature".to_string(),
437 secret: secret.to_string(),
438 };
439 let sig = compute_test_hmac(secret.as_bytes(), body);
440 let mut headers = HeaderMap::new();
441 headers.insert("x-signature", sig.parse().unwrap());
442 assert!(auth.verify(&headers, body));
443 }
444
445 #[test]
446 fn hmac_tampered_signature_returns_false() {
447 let secret = "my-secret";
448 let body = b"request body";
449 let auth = WebhookAuth::HmacSha256 {
450 header: "x-signature".to_string(),
451 secret: secret.to_string(),
452 };
453 let mut sig = compute_test_hmac(secret.as_bytes(), body);
454 sig.pop();
456 sig.push('0');
457 let mut headers = HeaderMap::new();
458 headers.insert("x-signature", sig.parse().unwrap());
459 assert!(!auth.verify(&headers, body));
460 }
461
462 #[test]
463 fn hmac_missing_header_returns_false() {
464 let auth = WebhookAuth::HmacSha256 {
465 header: "x-signature".to_string(),
466 secret: "my-secret".to_string(),
467 };
468 let headers = HeaderMap::new();
469 assert!(!auth.verify(&headers, b"body"));
470 }
471
472 #[test]
473 fn hmac_no_sha256_prefix_returns_false() {
474 let auth = WebhookAuth::HmacSha256 {
475 header: "x-signature".to_string(),
476 secret: "my-secret".to_string(),
477 };
478 let mut headers = HeaderMap::new();
479 headers.insert(
480 "x-signature",
481 "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
482 .parse()
483 .unwrap(),
484 );
485 assert!(!auth.verify(&headers, b"body"));
486 }
487
488 #[test]
489 fn hmac_invalid_hex_returns_false() {
490 let auth = WebhookAuth::HmacSha256 {
491 header: "x-signature".to_string(),
492 secret: "my-secret".to_string(),
493 };
494 let mut headers = HeaderMap::new();
495 headers.insert("x-signature", "sha256=not-valid-hex!".parse().unwrap());
496 assert!(!auth.verify(&headers, b"body"));
497 }
498
499 #[test]
500 fn hmac_wrong_secret_returns_false() {
501 let body = b"request body";
502 let sig = compute_test_hmac(b"correct-secret", body);
503 let auth = WebhookAuth::HmacSha256 {
504 header: "x-signature".to_string(),
505 secret: "wrong-secret".to_string(),
506 };
507 let mut headers = HeaderMap::new();
508 headers.insert("x-signature", sig.parse().unwrap());
509 assert!(!auth.verify(&headers, body));
510 }
511
512 #[test]
513 fn hmac_empty_body_verifies() {
514 let secret = "my-secret";
515 let body = b"";
516 let auth = WebhookAuth::HmacSha256 {
517 header: "x-signature".to_string(),
518 secret: secret.to_string(),
519 };
520 let sig = compute_test_hmac(secret.as_bytes(), body);
521 let mut headers = HeaderMap::new();
522 headers.insert("x-signature", sig.parse().unwrap());
523 assert!(auth.verify(&headers, body));
524 }
525
526 #[test]
527 fn hmac_body_tampered_returns_false() {
528 let secret = "my-secret";
529 let auth = WebhookAuth::HmacSha256 {
530 header: "x-signature".to_string(),
531 secret: secret.to_string(),
532 };
533 let sig = compute_test_hmac(secret.as_bytes(), b"original body");
534 let mut headers = HeaderMap::new();
535 headers.insert("x-signature", sig.parse().unwrap());
536 assert!(!auth.verify(&headers, b"tampered body"));
537 }
538
539 #[test]
540 fn hmac_empty_secret_still_works() {
541 let secret = "";
542 let body = b"some body";
543 let auth = WebhookAuth::HmacSha256 {
544 header: "x-signature".to_string(),
545 secret: secret.to_string(),
546 };
547 let sig = compute_test_hmac(secret.as_bytes(), body);
548 let mut headers = HeaderMap::new();
549 headers.insert("x-signature", sig.parse().unwrap());
550 assert!(auth.verify(&headers, body));
551 }
552
553 #[test]
554 fn debug_redacts_header_secret() {
555 let auth = WebhookAuth::header("x-api-key", "super-secret");
556 let debug = format!("{:?}", auth);
557 assert!(debug.contains("[REDACTED]"));
558 assert!(!debug.contains("super-secret"));
559 }
560
561 #[test]
562 fn debug_redacts_hmac_secret() {
563 let auth = WebhookAuth::github("my-secret-key");
564 let debug = format!("{:?}", auth);
565 assert!(debug.contains("[REDACTED]"));
566 assert!(!debug.contains("my-secret-key"));
567 }
568
569 #[test]
570 fn debug_none_format() {
571 let auth = WebhookAuth::none();
572 let debug = format!("{:?}", auth);
573 assert_eq!(debug, "WebhookAuth::None");
574 }
575
576 #[test]
577 fn hmac_rfc4231_test_vector() {
578 let key_bytes = hex::decode("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b").unwrap();
580 let body = b"Hi There";
581 let expected_mac = "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7";
582
583 let mut mac = HmacSha256::new_from_slice(&key_bytes).unwrap();
585 mac.update(body);
586 let computed = hex::encode(mac.finalize().into_bytes());
587 assert_eq!(computed, expected_mac);
588
589 let secret_str = String::from_utf8(key_bytes).unwrap();
600 let auth = WebhookAuth::HmacSha256 {
601 header: "x-signature".to_string(),
602 secret: secret_str,
603 };
604 let sig = format!("sha256={}", expected_mac);
605 let mut headers = HeaderMap::new();
606 headers.insert("x-signature", sig.parse().unwrap());
607 assert!(auth.verify(&headers, body));
608 }
609
610 #[test]
613 fn github_delivery_id_is_prefixed() {
614 let mut headers = HeaderMap::new();
615 headers.insert("x-github-delivery", "abc-123".parse().unwrap());
616 assert_eq!(
617 extract_delivery_id(&headers),
618 Some("github:abc-123".to_string())
619 );
620 }
621
622 #[test]
623 fn gitlab_event_uuid_is_prefixed() {
624 let mut headers = HeaderMap::new();
625 headers.insert("x-gitlab-event-uuid", "def-456".parse().unwrap());
626 assert_eq!(
627 extract_delivery_id(&headers),
628 Some("gitlab:def-456".to_string())
629 );
630 }
631
632 #[test]
633 fn no_provider_header_yields_none() {
634 let headers = HeaderMap::new();
635 assert_eq!(extract_delivery_id(&headers), None);
636 }
637
638 #[test]
639 fn unrelated_headers_yield_none() {
640 let mut headers = HeaderMap::new();
641 headers.insert("x-request-id", "abc".parse().unwrap());
642 assert_eq!(extract_delivery_id(&headers), None);
643 }
644
645 #[test]
646 fn github_wins_over_gitlab_when_both_are_present() {
647 let mut headers = HeaderMap::new();
648 headers.insert("x-github-delivery", "gh".parse().unwrap());
649 headers.insert("x-gitlab-event-uuid", "gl".parse().unwrap());
650 assert_eq!(extract_delivery_id(&headers), Some("github:gh".to_string()));
651 }
652
653 #[test]
654 fn empty_delivery_id_is_ignored() {
655 let mut headers = HeaderMap::new();
656 headers.insert("x-github-delivery", "".parse().unwrap());
657 assert_eq!(extract_delivery_id(&headers), None);
658 }
659
660 #[test]
661 fn delivery_id_with_a_space_is_ignored() {
662 let mut headers = HeaderMap::new();
663 headers.insert("x-github-delivery", "abc 123".parse().unwrap());
664 assert_eq!(extract_delivery_id(&headers), None);
665 }
666
667 #[test]
668 fn overlong_delivery_id_is_ignored() {
669 let mut headers = HeaderMap::new();
670 let raw = "a".repeat(MAX_IDEMPOTENCY_KEY_LEN);
671 headers.insert("x-github-delivery", raw.parse().unwrap());
672 assert_eq!(extract_delivery_id(&headers), None);
673 }
674
675 #[test]
676 fn delivery_id_at_the_length_limit_is_accepted() {
677 let mut headers = HeaderMap::new();
678 let raw = "a".repeat(MAX_IDEMPOTENCY_KEY_LEN - 7);
680 headers.insert("x-github-delivery", raw.parse().unwrap());
681 let key = extract_delivery_id(&headers).expect("key accepted");
682 assert_eq!(key.len(), MAX_IDEMPOTENCY_KEY_LEN);
683 }
684
685 #[test]
686 fn gitlab_is_used_when_github_header_is_invalid() {
687 let mut headers = HeaderMap::new();
688 headers.insert("x-github-delivery", "".parse().unwrap());
689 headers.insert("x-gitlab-event-uuid", "def".parse().unwrap());
690 assert_eq!(
691 extract_delivery_id(&headers),
692 Some("gitlab:def".to_string())
693 );
694 }
695}