1#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
14
15use std::collections::HashMap;
16use std::future::Future;
17use std::pin::Pin;
18use std::sync::{Arc, Mutex};
19use std::time::{Duration, SystemTime, UNIX_EPOCH};
20
21use axum::extract::FromRequest;
22use bytes::Bytes;
23use http::{HeaderMap, StatusCode};
24use serde::Deserialize;
25use thiserror::Error;
26
27pub use crate::security::config::hmac_sha256_hex;
28
29const DEFAULT_TIMESTAMP_TOLERANCE_SECS: u64 = 300;
30const DEFAULT_REPLAY_WINDOW_SECS: u64 = 24 * 60 * 60;
31const DEFAULT_MAX_BODY_BYTES: usize = 1024 * 1024;
32const IN_MEMORY_REPLAY_CLEANUP_INTERVAL: usize = 128;
33const IN_MEMORY_REPLAY_CLEANUP_HIGH_WATER: usize = 16 * 1024;
34
35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Deserialize)]
37#[serde(rename_all = "lowercase")]
38pub enum WebhookProvider {
39 Stripe,
41 Github,
43 Slack,
45 #[default]
47 Generic,
48}
49
50impl WebhookProvider {
51 #[must_use]
53 pub const fn as_str(self) -> &'static str {
54 match self {
55 Self::Stripe => "stripe",
56 Self::Github => "github",
57 Self::Slack => "slack",
58 Self::Generic => "generic",
59 }
60 }
61}
62
63impl std::fmt::Display for WebhookProvider {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 f.write_str(self.as_str())
66 }
67}
68
69#[derive(Debug, Clone, Default, Deserialize)]
71pub struct WebhookConfig {
72 #[serde(default)]
74 pub replay: WebhookReplayConfig,
75 #[serde(default)]
77 pub endpoints: Vec<WebhookEndpointConfig>,
78}
79
80impl WebhookConfig {
81 pub(crate) fn apply_env_overrides_with_env(&mut self, env: &dyn crate::config::Env) {
83 self.replay.apply_env_overrides_with_env(env);
84 self.resolve_secret_envs_with_env(env);
85 }
86
87 fn resolve_secret_envs_with_env(&mut self, env: &dyn crate::config::Env) {
90 for endpoint in &mut self.endpoints {
91 if endpoint.secret.is_none()
92 && let Some(env_name) = endpoint.secret_env.as_deref()
93 && let Ok(secret) = env.var(env_name)
94 {
95 endpoint.secret = Some(secret);
96 }
97
98 for env_name in &endpoint.previous_secret_envs {
99 if let Ok(secret) = env.var(env_name)
100 && !secret.is_empty()
101 {
102 endpoint.previous_secrets.push(secret);
103 }
104 }
105 }
106 }
107
108 pub fn validate(&self, is_production: bool) -> Result<(), WebhookConfigError> {
115 for endpoint in &self.endpoints {
116 endpoint.validate(is_production)?;
117 }
118 validate_unique_endpoint_paths(&self.endpoints)?;
119 if self
120 .endpoints
121 .iter()
122 .any(|endpoint| endpoint.replay_protection)
123 {
124 self.replay.validate(is_production)?;
125 }
126 Ok(())
127 }
128}
129
130#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
132#[serde(rename_all = "lowercase")]
133pub enum WebhookReplayBackend {
134 #[serde(alias = "local", alias = "in_memory")]
137 #[default]
138 Memory,
139 Redis,
141}
142
143impl WebhookReplayBackend {
144 fn from_env_value(value: &str) -> Option<Self> {
145 match value.trim().to_ascii_lowercase().as_str() {
146 "memory" | "local" | "in_memory" | "in-memory" => Some(Self::Memory),
147 "redis" => Some(Self::Redis),
148 _ => None,
149 }
150 }
151}
152
153#[derive(Debug, Clone, Deserialize)]
155pub struct WebhookReplayConfig {
156 #[serde(default)]
158 pub backend: WebhookReplayBackend,
159 #[serde(default)]
161 pub allow_memory_in_production: bool,
162 #[serde(default)]
164 pub redis: WebhookReplayRedisConfig,
165}
166
167impl Default for WebhookReplayConfig {
168 fn default() -> Self {
169 Self {
170 backend: WebhookReplayBackend::Memory,
171 allow_memory_in_production: false,
172 redis: WebhookReplayRedisConfig::default(),
173 }
174 }
175}
176
177impl WebhookReplayConfig {
178 fn apply_env_overrides_with_env(&mut self, env: &dyn crate::config::Env) {
179 if let Ok(value) = env.var("AUTUMN_SECURITY__WEBHOOKS__REPLAY__BACKEND") {
180 if let Some(backend) = WebhookReplayBackend::from_env_value(&value) {
181 self.backend = backend;
182 } else {
183 eprintln!(
184 "Warning: AUTUMN_SECURITY__WEBHOOKS__REPLAY__BACKEND={value:?} is not valid \
185 (expected memory or redis), ignoring"
186 );
187 }
188 }
189 if let Ok(value) = env.var("AUTUMN_SECURITY__WEBHOOKS__REPLAY__ALLOW_MEMORY_IN_PRODUCTION")
190 {
191 match value.trim().parse::<bool>() {
192 Ok(value) => self.allow_memory_in_production = value,
193 Err(error) => eprintln!(
194 "Warning: AUTUMN_SECURITY__WEBHOOKS__REPLAY__ALLOW_MEMORY_IN_PRODUCTION \
195 could not be parsed as bool: {error}"
196 ),
197 }
198 }
199 if let Ok(value) = env.var("AUTUMN_SECURITY__WEBHOOKS__REPLAY__REDIS__URL") {
200 let value = value.trim();
201 self.redis.url = if value.is_empty() {
202 None
203 } else {
204 Some(value.to_owned())
205 };
206 }
207 if let Ok(value) = env.var("AUTUMN_SECURITY__WEBHOOKS__REPLAY__REDIS__KEY_PREFIX")
208 && !value.trim().is_empty()
209 {
210 self.redis.key_prefix = value;
211 }
212 }
213
214 fn validate(&self, is_production: bool) -> Result<(), WebhookConfigError> {
215 match self.backend {
216 WebhookReplayBackend::Memory => {
217 if is_production && !self.allow_memory_in_production {
218 return Err(WebhookConfigError::MemoryReplayInProduction);
219 }
220 Ok(())
221 }
222 WebhookReplayBackend::Redis => validate_redis_replay_config(&self.redis),
223 }
224 }
225}
226
227#[derive(Debug, Clone, Deserialize)]
229pub struct WebhookReplayRedisConfig {
230 #[serde(default)]
232 pub url: Option<String>,
233 #[serde(default = "default_replay_redis_key_prefix")]
235 pub key_prefix: String,
236}
237
238impl Default for WebhookReplayRedisConfig {
239 fn default() -> Self {
240 Self {
241 url: None,
242 key_prefix: default_replay_redis_key_prefix(),
243 }
244 }
245}
246
247#[derive(Debug, Clone, Deserialize)]
249pub struct WebhookEndpointConfig {
250 pub name: String,
252 pub path: String,
254 #[serde(default)]
256 pub provider: WebhookProvider,
257 #[serde(default)]
259 pub secret: Option<String>,
260 #[serde(default)]
262 pub secret_env: Option<String>,
263 #[serde(default)]
265 pub previous_secrets: Vec<String>,
266 #[serde(default)]
268 pub previous_secret_envs: Vec<String>,
269 #[serde(default = "default_timestamp_tolerance_secs")]
271 pub timestamp_tolerance_secs: u64,
272 #[serde(default = "default_replay_window_secs")]
274 pub replay_window_secs: u64,
275 #[serde(default = "default_true")]
277 pub replay_protection: bool,
278 #[serde(default)]
281 pub signature_header: Option<String>,
282 #[serde(default)]
284 pub signature_prefix: Option<String>,
285 #[serde(default)]
287 pub timestamp_header: Option<String>,
288 #[serde(default)]
290 pub delivery_id_header: Option<String>,
291 #[serde(default)]
293 pub event_type_header: Option<String>,
294 #[serde(default = "default_max_body_bytes")]
296 pub max_body_bytes: usize,
297}
298
299impl Default for WebhookEndpointConfig {
300 fn default() -> Self {
301 Self::provider_defaults(WebhookProvider::Generic)
302 }
303}
304
305impl WebhookEndpointConfig {
306 #[must_use]
308 pub fn new(
309 name: impl Into<String>,
310 path: impl Into<String>,
311 provider: WebhookProvider,
312 secret: impl Into<String>,
313 ) -> Self {
314 let mut config = Self::provider_defaults(provider);
315 config.name = name.into();
316 config.path = path.into();
317 config.secret = Some(secret.into());
318 config
319 }
320
321 #[must_use]
323 pub fn stripe(
324 name: impl Into<String>,
325 path: impl Into<String>,
326 secret: impl Into<String>,
327 ) -> Self {
328 Self::new(name, path, WebhookProvider::Stripe, secret)
329 }
330
331 #[must_use]
333 pub fn github(
334 name: impl Into<String>,
335 path: impl Into<String>,
336 secret: impl Into<String>,
337 ) -> Self {
338 Self::new(name, path, WebhookProvider::Github, secret)
339 }
340
341 #[must_use]
343 pub fn slack(
344 name: impl Into<String>,
345 path: impl Into<String>,
346 secret: impl Into<String>,
347 ) -> Self {
348 Self::new(name, path, WebhookProvider::Slack, secret)
349 }
350
351 #[must_use]
353 pub fn generic(
354 name: impl Into<String>,
355 path: impl Into<String>,
356 secret: impl Into<String>,
357 ) -> Self {
358 Self::new(name, path, WebhookProvider::Generic, secret)
359 }
360
361 #[must_use]
363 pub fn with_previous_secret(mut self, secret: impl Into<String>) -> Self {
364 self.previous_secrets.push(secret.into());
365 self
366 }
367
368 #[must_use]
370 pub const fn with_timestamp_tolerance_secs(mut self, secs: u64) -> Self {
371 self.timestamp_tolerance_secs = secs;
372 self
373 }
374
375 #[must_use]
377 pub const fn with_replay_window_secs(mut self, secs: u64) -> Self {
378 self.replay_window_secs = secs;
379 self
380 }
381
382 #[must_use]
384 pub const fn without_replay_protection(mut self) -> Self {
385 self.replay_protection = false;
386 self
387 }
388
389 fn provider_defaults(provider: WebhookProvider) -> Self {
390 let mut config = Self {
391 name: String::new(),
392 path: String::new(),
393 provider,
394 secret: None,
395 secret_env: None,
396 previous_secrets: Vec::new(),
397 previous_secret_envs: Vec::new(),
398 timestamp_tolerance_secs: DEFAULT_TIMESTAMP_TOLERANCE_SECS,
399 replay_window_secs: DEFAULT_REPLAY_WINDOW_SECS,
400 replay_protection: true,
401 signature_header: None,
402 signature_prefix: None,
403 timestamp_header: None,
404 delivery_id_header: None,
405 event_type_header: None,
406 max_body_bytes: DEFAULT_MAX_BODY_BYTES,
407 };
408
409 match provider {
410 WebhookProvider::Stripe => {
411 config.signature_header = Some("Stripe-Signature".to_owned());
412 }
413 WebhookProvider::Github => {
414 config.signature_header = Some("X-Hub-Signature-256".to_owned());
415 config.signature_prefix = Some("sha256=".to_owned());
416 config.delivery_id_header = Some("X-GitHub-Delivery".to_owned());
417 config.event_type_header = Some("X-GitHub-Event".to_owned());
418 }
419 WebhookProvider::Slack => {
420 config.signature_header = Some("X-Slack-Signature".to_owned());
421 config.signature_prefix = Some("v0=".to_owned());
422 config.timestamp_header = Some("X-Slack-Request-Timestamp".to_owned());
423 }
424 WebhookProvider::Generic => {
425 config.signature_header = Some("X-Webhook-Signature".to_owned());
426 config.signature_prefix = Some("sha256=".to_owned());
427 config.delivery_id_header = Some("X-Webhook-Delivery".to_owned());
428 config.event_type_header = Some("X-Webhook-Event".to_owned());
429 }
430 }
431
432 config
433 }
434
435 fn validate(&self, is_production: bool) -> Result<(), WebhookConfigError> {
436 if self.name.trim().is_empty() {
437 return Err(WebhookConfigError::InvalidEndpoint {
438 name: self.name.clone(),
439 message: "name must not be empty".to_owned(),
440 });
441 }
442 if !self.path.starts_with('/') || self.path.trim() == "/" || self.path.trim().is_empty() {
443 return Err(WebhookConfigError::InvalidEndpoint {
444 name: self.name.clone(),
445 message: format!("path {:?} must start with '/' and not be root", self.path),
446 });
447 }
448 let Some(secret) = self.secret.as_deref().filter(|value| !value.is_empty()) else {
449 return Err(WebhookConfigError::MissingSecret {
450 name: self.name.clone(),
451 path: self.path.clone(),
452 });
453 };
454
455 if is_production {
456 crate::security::config::validate_signing_secret(Some(secret), true).map_err(
457 |reason| WebhookConfigError::InvalidSecret {
458 name: self.name.clone(),
459 reason,
460 },
461 )?;
462 for (index, previous) in self.previous_secrets.iter().enumerate() {
463 crate::security::config::validate_signing_secret(Some(previous), true).map_err(
464 |reason| WebhookConfigError::InvalidPreviousSecret {
465 name: self.name.clone(),
466 index,
467 reason,
468 },
469 )?;
470 }
471 }
472
473 Ok(())
474 }
475
476 fn apply_provider_defaults(&mut self) {
477 let defaults = Self::provider_defaults(self.provider);
478 if self.signature_header.is_none() {
479 self.signature_header = defaults.signature_header;
480 }
481 if self.signature_prefix.is_none() {
482 self.signature_prefix = defaults.signature_prefix;
483 }
484 if self.timestamp_header.is_none() {
485 self.timestamp_header = defaults.timestamp_header;
486 }
487 if self.delivery_id_header.is_none() {
488 self.delivery_id_header = defaults.delivery_id_header;
489 }
490 if self.event_type_header.is_none() {
491 self.event_type_header = defaults.event_type_header;
492 }
493 }
494}
495
496#[derive(Debug, Clone, PartialEq, Eq, Error)]
498pub enum WebhookConfigError {
499 #[error("webhook endpoint {name:?} at {path:?} is missing a secret")]
501 MissingSecret {
502 name: String,
504 path: String,
506 },
507 #[error("webhook endpoint {name:?} is invalid: {message}")]
509 InvalidEndpoint {
510 name: String,
512 message: String,
514 },
515 #[error(
517 "duplicate webhook endpoint path {path:?}: endpoints {first_name:?} and \
518 {duplicate_name:?} would shadow each other"
519 )]
520 DuplicatePath {
521 path: String,
523 first_name: String,
525 duplicate_name: String,
527 },
528 #[error("webhook endpoint {name:?} has invalid secret: {reason}")]
530 InvalidSecret {
531 name: String,
533 reason: crate::security::config::SigningSecretError,
535 },
536 #[error("webhook endpoint {name:?} has invalid previous secret {index}: {reason}")]
538 InvalidPreviousSecret {
539 name: String,
541 index: usize,
543 reason: crate::security::config::SigningSecretError,
545 },
546 #[error(
548 "webhook replay backend memory is not allowed in production; set \
549 security.webhooks.replay.backend = \"redis\" or explicitly set \
550 security.webhooks.replay.allow_memory_in_production = true"
551 )]
552 MemoryReplayInProduction,
553 #[error("webhook redis replay backend requires security.webhooks.replay.redis.url")]
555 RedisReplayMissingUrl,
556 #[error("webhook redis replay backend URL is invalid: {0}")]
558 RedisReplayInvalidUrl(String),
559 #[error("webhook redis replay backend requires the autumn-web redis feature")]
561 RedisReplayFeatureDisabled,
562}
563
564#[derive(Debug)]
565struct ResolvedWebhookEndpoint {
566 config: WebhookEndpointConfig,
567 keys: crate::security::config::ResolvedSigningKeys,
568}
569
570#[derive(Clone, Debug)]
572pub struct WebhookRegistry {
573 endpoints_by_path: Arc<HashMap<String, Arc<ResolvedWebhookEndpoint>>>,
574 replay_store: Arc<dyn WebhookReplayStore>,
575}
576
577impl WebhookRegistry {
578 pub fn from_config(config: &WebhookConfig) -> Result<Self, WebhookConfigError> {
584 let replay_store = if config
585 .endpoints
586 .iter()
587 .any(|endpoint| endpoint.replay_protection)
588 {
589 replay_store_from_config(&config.replay)?
590 } else {
591 Arc::new(InMemoryWebhookReplayStore::default())
592 };
593 Self::from_config_with_shared_replay_store(config, replay_store)
594 }
595
596 pub fn from_config_with_replay_store(
602 config: &WebhookConfig,
603 replay_store: impl WebhookReplayStore + 'static,
604 ) -> Result<Self, WebhookConfigError> {
605 Self::from_config_with_shared_replay_store(config, Arc::new(replay_store))
606 }
607
608 pub fn from_config_with_shared_replay_store(
617 config: &WebhookConfig,
618 replay_store: Arc<dyn WebhookReplayStore>,
619 ) -> Result<Self, WebhookConfigError> {
620 validate_unique_endpoint_paths(&config.endpoints)?;
621 let mut endpoints_by_path = HashMap::new();
622 for endpoint in &config.endpoints {
623 let mut endpoint = endpoint.clone();
624 endpoint.apply_provider_defaults();
625 endpoint.validate(false)?;
626 let Some(secret) = endpoint.secret.as_ref() else {
627 return Err(WebhookConfigError::MissingSecret {
628 name: endpoint.name.clone(),
629 path: endpoint.path.clone(),
630 });
631 };
632 let current = secret.as_bytes().to_vec();
633 let previous = endpoint
634 .previous_secrets
635 .iter()
636 .map(|secret| secret.as_bytes().to_vec())
637 .collect();
638 endpoints_by_path.insert(
639 endpoint.path.clone(),
640 Arc::new(ResolvedWebhookEndpoint {
641 config: endpoint,
642 keys: crate::security::config::ResolvedSigningKeys::new(current, previous),
643 }),
644 );
645 }
646
647 Ok(Self {
648 endpoints_by_path: Arc::new(endpoints_by_path),
649 replay_store,
650 })
651 }
652
653 fn endpoint_for_path(&self, path: &str) -> Option<Arc<ResolvedWebhookEndpoint>> {
654 self.endpoints_by_path.get(path).cloned()
655 }
656}
657
658fn validate_unique_endpoint_paths(
659 endpoints: &[WebhookEndpointConfig],
660) -> Result<(), WebhookConfigError> {
661 let mut seen_paths = HashMap::new();
662 for endpoint in endpoints {
663 if let Some(first_name) = seen_paths.insert(endpoint.path.as_str(), endpoint.name.as_str())
664 {
665 return Err(WebhookConfigError::DuplicatePath {
666 path: endpoint.path.clone(),
667 first_name: first_name.to_owned(),
668 duplicate_name: endpoint.name.clone(),
669 });
670 }
671 }
672 Ok(())
673}
674
675pub type WebhookReplayFuture<'a> =
677 Pin<Box<dyn Future<Output = Result<bool, WebhookReplayStoreError>> + Send + 'a>>;
678
679pub trait WebhookReplayStore: Send + Sync + std::fmt::Debug {
681 fn check_and_insert<'a>(
684 &'a self,
685 key: &'a str,
686 received_at: SystemTime,
687 window: Duration,
688 ) -> WebhookReplayFuture<'a>;
689
690 fn remove<'a>(&'a self, key: &'a str) -> WebhookReplayFuture<'a>;
692}
693
694impl<T> WebhookReplayStore for Arc<T>
695where
696 T: WebhookReplayStore + ?Sized,
697{
698 fn check_and_insert<'a>(
699 &'a self,
700 key: &'a str,
701 received_at: SystemTime,
702 window: Duration,
703 ) -> WebhookReplayFuture<'a> {
704 self.as_ref().check_and_insert(key, received_at, window)
705 }
706
707 fn remove<'a>(&'a self, key: &'a str) -> WebhookReplayFuture<'a> {
708 self.as_ref().remove(key)
709 }
710}
711
712#[derive(Debug, Clone, Error)]
714#[error("{message}")]
715pub struct WebhookReplayStoreError {
716 message: String,
717}
718
719impl WebhookReplayStoreError {
720 #[must_use]
727 pub fn new(message: impl Into<String>) -> Self {
728 Self {
729 message: message.into(),
730 }
731 }
732}
733
734#[cfg(feature = "redis")]
735impl WebhookReplayStoreError {
736 fn backend(operation: &'static str, error: impl std::fmt::Display) -> Self {
737 Self::new(format!("webhook replay store {operation} failed: {error}"))
738 }
739}
740
741#[derive(Debug, Default)]
746pub struct InMemoryWebhookReplayStore {
747 state: Mutex<InMemoryWebhookReplayState>,
748}
749
750#[derive(Debug, Default)]
751struct InMemoryWebhookReplayState {
752 seen: HashMap<String, SystemTime>,
753 checks_since_cleanup: usize,
754}
755
756impl InMemoryWebhookReplayStore {
757 fn check_and_insert_sync(&self, key: &str, received_at: SystemTime, window: Duration) -> bool {
758 {
759 let mut state = self
760 .state
761 .lock()
762 .expect("webhook replay store lock poisoned");
763 state.checks_since_cleanup = state.checks_since_cleanup.saturating_add(1);
764
765 if let Some(expires_at) = state.seen.get(key).copied() {
766 if expires_at.duration_since(received_at).is_ok() {
767 Self::cleanup_if_due(&mut state, received_at);
768 drop(state);
769 return false;
770 }
771 state.seen.remove(key);
772 }
773
774 let expires_at = received_at.checked_add(window).unwrap_or(received_at);
775 state.seen.insert(key.to_owned(), expires_at);
776 Self::cleanup_if_due(&mut state, received_at);
777 drop(state);
778 }
779 true
780 }
781
782 fn cleanup_if_due(state: &mut InMemoryWebhookReplayState, received_at: SystemTime) {
783 if state.checks_since_cleanup < IN_MEMORY_REPLAY_CLEANUP_INTERVAL
784 && state.seen.len() <= IN_MEMORY_REPLAY_CLEANUP_HIGH_WATER
785 {
786 return;
787 }
788
789 state.checks_since_cleanup = 0;
790 state
791 .seen
792 .retain(|_, expires_at| expires_at.duration_since(received_at).is_ok());
793 }
794}
795
796impl WebhookReplayStore for InMemoryWebhookReplayStore {
797 fn check_and_insert<'a>(
798 &'a self,
799 key: &'a str,
800 received_at: SystemTime,
801 window: Duration,
802 ) -> WebhookReplayFuture<'a> {
803 Box::pin(async move { Ok(self.check_and_insert_sync(key, received_at, window)) })
804 }
805
806 fn remove<'a>(&'a self, key: &'a str) -> WebhookReplayFuture<'a> {
807 self.state
808 .lock()
809 .expect("webhook replay store lock poisoned")
810 .seen
811 .remove(key);
812 Box::pin(async move { Ok(true) })
813 }
814}
815
816#[cfg(feature = "redis")]
818#[derive(Clone, Debug)]
819pub struct RedisWebhookReplayStore {
820 connection: redis::aio::ConnectionManager,
821 key_prefix: String,
822}
823
824#[cfg(feature = "redis")]
825impl RedisWebhookReplayStore {
826 pub fn from_config(config: &WebhookReplayRedisConfig) -> Result<Self, WebhookConfigError> {
832 let url = config
833 .url
834 .as_deref()
835 .filter(|url| !url.trim().is_empty())
836 .ok_or(WebhookConfigError::RedisReplayMissingUrl)?;
837 let client = crate::redis_tls::open_client(url)
838 .map_err(|error| WebhookConfigError::RedisReplayInvalidUrl(error.to_string()))?;
839 let connection = redis::aio::ConnectionManager::new_lazy_with_config(
840 client,
841 redis::aio::ConnectionManagerConfig::new(),
842 )
843 .map_err(|error| WebhookConfigError::RedisReplayInvalidUrl(error.to_string()))?;
844 Ok(Self {
845 connection,
846 key_prefix: config.key_prefix.clone(),
847 })
848 }
849
850 fn key_for(&self, replay_key: &str) -> String {
851 format!("{}:{replay_key}", self.key_prefix)
852 }
853}
854
855#[cfg(feature = "redis")]
856impl WebhookReplayStore for RedisWebhookReplayStore {
857 fn check_and_insert<'a>(
858 &'a self,
859 key: &'a str,
860 received_at: SystemTime,
861 window: Duration,
862 ) -> WebhookReplayFuture<'a> {
863 Box::pin(async move {
864 let mut connection = self.connection.clone();
865 let key = self.key_for(key);
866 let ttl_secs = window.as_secs().max(1);
867 let received_unix = received_at
868 .duration_since(UNIX_EPOCH)
869 .map_err(|error| WebhookReplayStoreError::backend("timestamp", error))?
870 .as_secs()
871 .to_string();
872 let inserted: Option<String> = redis::cmd("SET")
873 .arg(&key)
874 .arg(received_unix)
875 .arg("NX")
876 .arg("EX")
877 .arg(ttl_secs)
878 .query_async(&mut connection)
879 .await
880 .map_err(|error| WebhookReplayStoreError::backend("insert", error))?;
881 Ok(inserted.is_some())
882 })
883 }
884
885 fn remove<'a>(&'a self, key: &'a str) -> WebhookReplayFuture<'a> {
886 Box::pin(async move {
887 let mut connection = self.connection.clone();
888 let key = self.key_for(key);
889 let _: () = redis::cmd("DEL")
890 .arg(&key)
891 .query_async(&mut connection)
892 .await
893 .map_err(|error| WebhookReplayStoreError::backend("delete", error))?;
894 Ok(true)
895 })
896 }
897}
898
899#[derive(Debug, Clone)]
901pub struct SignedWebhook {
902 provider: WebhookProvider,
903 endpoint: String,
904 delivery_id: Option<String>,
905 event_type: Option<String>,
906 received_at: SystemTime,
907 raw_body: Bytes,
908}
909
910impl SignedWebhook {
911 #[must_use]
913 pub const fn provider(&self) -> &'static str {
914 self.provider.as_str()
915 }
916
917 #[must_use]
919 pub fn endpoint(&self) -> &str {
920 &self.endpoint
921 }
922
923 #[must_use]
925 pub fn delivery_id(&self) -> Option<&str> {
926 self.delivery_id.as_deref()
927 }
928
929 #[must_use]
931 pub fn event_type(&self) -> Option<&str> {
932 self.event_type.as_deref()
933 }
934
935 #[must_use]
937 pub const fn received_at(&self) -> SystemTime {
938 self.received_at
939 }
940
941 #[must_use]
943 pub fn raw_body(&self) -> &[u8] {
944 &self.raw_body
945 }
946
947 pub fn json<T: serde::de::DeserializeOwned>(&self) -> Result<T, serde_json::Error> {
954 serde_json::from_slice(&self.raw_body)
955 }
956}
957
958impl FromRequest<crate::AppState> for SignedWebhook {
959 type Rejection = crate::AutumnError;
960
961 async fn from_request(
962 req: axum::extract::Request,
963 state: &crate::AppState,
964 ) -> Result<Self, Self::Rejection> {
965 let (parts, body) = req.into_parts();
966 let path = parts.uri.path().to_owned();
967 let registry = state
968 .extension::<WebhookRegistry>()
969 .ok_or_else(|| WebhookVerifyError::RegistryMissing.into_autumn_error())?;
970 let endpoint = registry
971 .endpoint_for_path(&path)
972 .ok_or_else(|| WebhookVerifyError::EndpointMissing(path.clone()).into_autumn_error())?;
973 let body = axum::body::to_bytes(body, endpoint.config.max_body_bytes)
974 .await
975 .map_err(|err| {
976 crate::AutumnError::bad_request_msg(format!(
977 "webhook body could not be read: {err}"
978 ))
979 })?;
980 let received_at = crate::time::ambient_system_time();
985 verify_request(®istry, &endpoint, &parts.headers, body, received_at)
986 .await
987 .map_err(WebhookVerifyError::into_autumn_error)
988 }
989}
990
991#[derive(Debug, Error)]
992enum WebhookVerifyError {
993 #[error("signed webhook registry is not installed")]
994 RegistryMissing,
995 #[error("no signed webhook endpoint is configured for path {0}")]
996 EndpointMissing(String),
997 #[error("missing required webhook header {0}")]
998 MissingHeader(String),
999 #[error("malformed webhook signature")]
1000 MalformedSignature,
1001 #[error("malformed webhook timestamp")]
1002 MalformedTimestamp,
1003 #[error("webhook timestamp is outside the accepted tolerance")]
1004 StaleTimestamp,
1005 #[error("webhook signature mismatch")]
1006 SignatureMismatch,
1007 #[error("missing webhook delivery ID")]
1008 MissingDeliveryId,
1009 #[error("duplicate webhook delivery")]
1010 DuplicateDelivery,
1011 #[error("webhook replay store unavailable: {0}")]
1012 ReplayStoreUnavailable(String),
1013}
1014
1015impl WebhookVerifyError {
1016 const fn status(&self) -> StatusCode {
1017 match self {
1018 Self::RegistryMissing | Self::EndpointMissing(_) => StatusCode::INTERNAL_SERVER_ERROR,
1019 Self::MissingHeader(_)
1020 | Self::MalformedSignature
1021 | Self::MalformedTimestamp
1022 | Self::MissingDeliveryId => StatusCode::BAD_REQUEST,
1023 Self::StaleTimestamp | Self::SignatureMismatch => StatusCode::UNAUTHORIZED,
1024 Self::DuplicateDelivery => StatusCode::CONFLICT,
1025 Self::ReplayStoreUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
1026 }
1027 }
1028
1029 fn into_autumn_error(self) -> crate::AutumnError {
1030 crate::AutumnError::bad_request_msg(self.to_string()).with_status(self.status())
1031 }
1032}
1033
1034pub(crate) type ReplayStoreCell =
1035 std::sync::Arc<std::sync::Mutex<Option<(std::sync::Arc<dyn WebhookReplayStore>, Vec<String>)>>>;
1036
1037tokio::task_local! {
1038 pub static WEBHOOK_REPLAY_KEY: ReplayStoreCell;
1039}
1040
1041pub(crate) struct ReplayKeyGuard {
1055 cell: ReplayStoreCell,
1056 completed: bool,
1057}
1058
1059impl Drop for ReplayKeyGuard {
1060 fn drop(&mut self) {
1061 if !self.completed {
1062 let to_remove = match self.cell.lock() {
1063 Ok(mut guard) => guard.take(),
1064 Err(poisoned) => poisoned.into_inner().take(),
1065 };
1066 if let Some((store, keys)) = to_remove {
1067 tokio::spawn(async move {
1068 for key in keys {
1069 tracing::debug!(key = %key, "Releasing webhook replay key after handler panic or deadline cancellation");
1070 let _ = store.remove(&key).await;
1071 }
1072 });
1073 }
1074 }
1075 }
1076}
1077
1078pub async fn webhook_replay_cleanup_middleware(
1080 req: axum::extract::Request,
1081 next: axum::middleware::Next,
1082) -> axum::response::Response {
1083 let cell: ReplayStoreCell = std::sync::Arc::new(std::sync::Mutex::new(None));
1084 let cell_cloned = cell.clone();
1085
1086 let mut guard = ReplayKeyGuard {
1087 cell: cell_cloned.clone(),
1088 completed: false,
1089 };
1090
1091 let response = WEBHOOK_REPLAY_KEY
1092 .scope(cell, async move { next.run(req).await })
1093 .await;
1094
1095 guard.completed = true;
1096
1097 if let Some(cleanup) = release_keys_on_server_error(&cell_cloned, response.status()) {
1098 cleanup.await;
1099 }
1100
1101 response
1102}
1103
1104fn release_keys_on_server_error(
1111 cell: &ReplayStoreCell,
1112 status: axum::http::StatusCode,
1113) -> Option<impl std::future::Future<Output = ()> + Send + 'static> {
1114 if !status.is_server_error() {
1115 return None;
1116 }
1117 let (store, keys) = match cell.lock() {
1118 Ok(mut guard) => guard.take(),
1119 Err(poisoned) => poisoned.into_inner().take(),
1120 }?;
1121 Some(async move {
1122 for key in keys {
1123 tracing::debug!(key = %key, "Releasing webhook replay key due to 5xx server error");
1124 let _ = store.remove(&key).await;
1125 }
1126 })
1127}
1128
1129#[derive(Clone, Copy, Debug)]
1142pub(crate) struct WebhookReplayCleanupLayer;
1143
1144impl<S> tower::Layer<S> for WebhookReplayCleanupLayer {
1145 type Service = WebhookReplayCleanupService<S>;
1146
1147 fn layer(&self, inner: S) -> Self::Service {
1148 WebhookReplayCleanupService { inner }
1149 }
1150}
1151
1152#[derive(Clone, Debug)]
1154pub(crate) struct WebhookReplayCleanupService<S> {
1155 inner: S,
1156}
1157
1158impl<S, ReqBody> tower::Service<axum::http::Request<ReqBody>> for WebhookReplayCleanupService<S>
1159where
1160 S: tower::Service<axum::http::Request<ReqBody>, Response = axum::response::Response>,
1161{
1162 type Response = S::Response;
1163 type Error = S::Error;
1164 type Future = WebhookReplayCleanupFuture<S::Future>;
1165
1166 fn poll_ready(
1167 &mut self,
1168 cx: &mut std::task::Context<'_>,
1169 ) -> std::task::Poll<Result<(), Self::Error>> {
1170 self.inner.poll_ready(cx)
1171 }
1172
1173 fn call(&mut self, req: axum::http::Request<ReqBody>) -> Self::Future {
1174 let cell: ReplayStoreCell = std::sync::Arc::new(std::sync::Mutex::new(None));
1175 let inner = WEBHOOK_REPLAY_KEY.sync_scope(cell.clone(), || self.inner.call(req));
1180 WebhookReplayCleanupFuture::Serving {
1181 inner: WEBHOOK_REPLAY_KEY.scope(cell.clone(), inner),
1182 guard: ReplayKeyGuard {
1183 cell: cell.clone(),
1184 completed: false,
1185 },
1186 cell,
1187 }
1188 }
1189}
1190
1191pin_project_lite::pin_project! {
1192 #[project = WebhookReplayCleanupFutureProj]
1200 pub(crate) enum WebhookReplayCleanupFuture<F> {
1201 Serving {
1202 #[pin]
1203 inner: tokio::task::futures::TaskLocalFuture<ReplayStoreCell, F>,
1204 cell: ReplayStoreCell,
1205 guard: ReplayKeyGuard,
1206 },
1207 Releasing {
1210 #[pin]
1211 cleanup: std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>,
1212 response: Option<axum::response::Response>,
1213 },
1214 }
1215}
1216
1217impl<F, E> std::future::Future for WebhookReplayCleanupFuture<F>
1218where
1219 F: std::future::Future<Output = Result<axum::response::Response, E>>,
1220{
1221 type Output = Result<axum::response::Response, E>;
1222
1223 #[allow(
1224 clippy::expect_used,
1225 reason = "unreachable: future not polled after Ready"
1226 )]
1227 fn poll(
1228 mut self: std::pin::Pin<&mut Self>,
1229 cx: &mut std::task::Context<'_>,
1230 ) -> std::task::Poll<Self::Output> {
1231 loop {
1232 let next = match self.as_mut().project() {
1233 WebhookReplayCleanupFutureProj::Serving { inner, cell, guard } => {
1234 let response = match std::task::ready!(inner.poll(cx)) {
1235 Ok(response) => response,
1236 Err(error) => {
1237 return std::task::Poll::Ready(Err(error));
1241 }
1242 };
1243 guard.completed = true;
1246 match release_keys_on_server_error(cell, response.status()) {
1247 None => return std::task::Poll::Ready(Ok(response)),
1248 Some(cleanup) => Self::Releasing {
1249 cleanup: Box::pin(cleanup),
1250 response: Some(response),
1251 },
1252 }
1253 }
1254 WebhookReplayCleanupFutureProj::Releasing { cleanup, response } => {
1255 std::task::ready!(cleanup.poll(cx));
1256 return std::task::Poll::Ready(Ok(response
1257 .take()
1258 .expect("WebhookReplayCleanupFuture polled after completion")));
1259 }
1260 };
1261 self.as_mut().set(next);
1262 }
1263 }
1264}
1265
1266async fn verify_request(
1267 registry: &WebhookRegistry,
1268 endpoint: &ResolvedWebhookEndpoint,
1269 headers: &HeaderMap,
1270 body: Bytes,
1271 received_at: SystemTime,
1272) -> Result<SignedWebhook, WebhookVerifyError> {
1273 match endpoint.config.provider {
1274 WebhookProvider::Stripe => verify_stripe(endpoint, headers, &body, received_at)?,
1275 WebhookProvider::Github | WebhookProvider::Generic => {
1276 verify_body_hmac(endpoint, headers, &body, None, received_at)?;
1277 }
1278 WebhookProvider::Slack => verify_slack(endpoint, headers, &body, received_at)?,
1279 }
1280
1281 let json_body = serde_json::from_slice::<serde_json::Value>(&body).ok();
1282 let delivery_id = resolve_delivery_id(&endpoint.config, headers, json_body.as_ref());
1283 if endpoint.config.replay_protection {
1284 let delivery_id = delivery_id
1285 .as_deref()
1286 .ok_or(WebhookVerifyError::MissingDeliveryId)?;
1287
1288 let mut keys_to_check = vec![format!(
1289 "{}:{}:id:{delivery_id}",
1290 endpoint.config.provider.as_str(),
1291 endpoint.config.name
1292 )];
1293
1294 if matches!(
1295 endpoint.config.provider,
1296 WebhookProvider::Github | WebhookProvider::Generic
1297 ) {
1298 let sig_hdr = signature_header(endpoint);
1299 if let Some(sig_val) = headers.get(sig_hdr).and_then(|v| v.to_str().ok()) {
1300 use sha2::{Digest, Sha256};
1301 let mut hasher = Sha256::new();
1302 hasher.update(sig_val.as_bytes());
1303 let sig_hash = hex::encode(hasher.finalize());
1304 keys_to_check.push(format!(
1305 "{}:{}:sig:{sig_hash}",
1306 endpoint.config.provider.as_str(),
1307 endpoint.config.name
1308 ));
1309 }
1310 }
1311
1312 let window = Duration::from_secs(endpoint.config.replay_window_secs);
1313 let mut inserted_keys = Vec::new();
1314 for key in keys_to_check {
1315 match registry
1316 .replay_store
1317 .check_and_insert(&key, received_at, window)
1318 .await
1319 {
1320 Ok(true) => {
1321 inserted_keys.push(key);
1322 }
1323 Ok(false) => {
1324 for inserted in inserted_keys {
1326 let _ = registry.replay_store.remove(&inserted).await;
1327 }
1328 return Err(WebhookVerifyError::DuplicateDelivery);
1329 }
1330 Err(error) => {
1331 for inserted in inserted_keys {
1333 let _ = registry.replay_store.remove(&inserted).await;
1334 }
1335 return Err(WebhookVerifyError::ReplayStoreUnavailable(
1336 error.to_string(),
1337 ));
1338 }
1339 }
1340 }
1341
1342 let _ = WEBHOOK_REPLAY_KEY.try_with(|cell| {
1343 let guard = match cell.lock() {
1344 Ok(g) => Some(g),
1345 Err(poisoned) => Some(poisoned.into_inner()),
1346 };
1347 if let Some(mut guard) = guard {
1348 *guard = Some((std::sync::Arc::clone(®istry.replay_store), inserted_keys));
1349 }
1350 });
1351 }
1352
1353 Ok(SignedWebhook {
1354 provider: endpoint.config.provider,
1355 endpoint: endpoint.config.name.clone(),
1356 delivery_id,
1357 event_type: resolve_event_type(&endpoint.config, headers, json_body.as_ref()),
1358 received_at,
1359 raw_body: body,
1360 })
1361}
1362
1363fn verify_stripe(
1364 endpoint: &ResolvedWebhookEndpoint,
1365 headers: &HeaderMap,
1366 body: &[u8],
1367 received_at: SystemTime,
1368) -> Result<(), WebhookVerifyError> {
1369 let header = required_header(headers, signature_header(endpoint))?;
1370 let (timestamp, signatures) = parse_stripe_signature(header)?;
1371 verify_timestamp(
1372 timestamp,
1373 received_at,
1374 endpoint.config.timestamp_tolerance_secs,
1375 )?;
1376
1377 let timestamp = timestamp.to_string();
1378 let mut signed_payload = Vec::with_capacity(timestamp.len() + 1 + body.len());
1379 signed_payload.extend_from_slice(timestamp.as_bytes());
1380 signed_payload.push(b'.');
1381 signed_payload.extend_from_slice(body);
1382
1383 if signatures
1384 .iter()
1385 .any(|signature| endpoint.keys.verify(&signed_payload, signature))
1386 {
1387 Ok(())
1388 } else {
1389 Err(WebhookVerifyError::SignatureMismatch)
1390 }
1391}
1392
1393fn verify_slack(
1394 endpoint: &ResolvedWebhookEndpoint,
1395 headers: &HeaderMap,
1396 body: &[u8],
1397 received_at: SystemTime,
1398) -> Result<(), WebhookVerifyError> {
1399 let timestamp_header = endpoint
1400 .config
1401 .timestamp_header
1402 .as_deref()
1403 .ok_or(WebhookVerifyError::MalformedTimestamp)?;
1404 let timestamp = required_header(headers, timestamp_header)?
1405 .parse::<i64>()
1406 .map_err(|_| WebhookVerifyError::MalformedTimestamp)?;
1407 verify_timestamp(
1408 timestamp,
1409 received_at,
1410 endpoint.config.timestamp_tolerance_secs,
1411 )?;
1412
1413 let timestamp = timestamp.to_string();
1414 let mut signed_payload = Vec::with_capacity(3 + timestamp.len() + 1 + body.len());
1415 signed_payload.extend_from_slice(b"v0:");
1416 signed_payload.extend_from_slice(timestamp.as_bytes());
1417 signed_payload.push(b':');
1418 signed_payload.extend_from_slice(body);
1419 verify_body_hmac(
1420 endpoint,
1421 headers,
1422 &signed_payload,
1423 endpoint.config.signature_prefix.as_deref(),
1424 received_at,
1425 )
1426}
1427
1428fn verify_body_hmac(
1429 endpoint: &ResolvedWebhookEndpoint,
1430 headers: &HeaderMap,
1431 body_or_base: &[u8],
1432 explicit_prefix: Option<&str>,
1433 received_at: SystemTime,
1434) -> Result<(), WebhookVerifyError> {
1435 if let Some(timestamp_header) = endpoint.config.timestamp_header.as_deref()
1436 && endpoint.config.provider != WebhookProvider::Slack
1437 {
1438 let timestamp = required_header(headers, timestamp_header)?
1439 .parse::<i64>()
1440 .map_err(|_| WebhookVerifyError::MalformedTimestamp)?;
1441 verify_timestamp(
1442 timestamp,
1443 received_at,
1444 endpoint.config.timestamp_tolerance_secs,
1445 )?;
1446 }
1447
1448 let mut signature = required_header(headers, signature_header(endpoint))?;
1449 let prefix = explicit_prefix.or(endpoint.config.signature_prefix.as_deref());
1450 if let Some(prefix) = prefix {
1451 signature = signature
1452 .strip_prefix(prefix)
1453 .ok_or(WebhookVerifyError::MalformedSignature)?;
1454 }
1455
1456 if endpoint.keys.verify(body_or_base, signature) {
1457 Ok(())
1458 } else {
1459 Err(WebhookVerifyError::SignatureMismatch)
1460 }
1461}
1462
1463fn signature_header(endpoint: &ResolvedWebhookEndpoint) -> &str {
1464 endpoint
1465 .config
1466 .signature_header
1467 .as_deref()
1468 .unwrap_or("X-Webhook-Signature")
1469}
1470
1471fn required_header<'a>(headers: &'a HeaderMap, name: &str) -> Result<&'a str, WebhookVerifyError> {
1472 headers
1473 .get(name)
1474 .ok_or_else(|| WebhookVerifyError::MissingHeader(name.to_owned()))?
1475 .to_str()
1476 .map_err(|_| WebhookVerifyError::MalformedSignature)
1477}
1478
1479fn parse_stripe_signature(header: &str) -> Result<(i64, Vec<&str>), WebhookVerifyError> {
1480 let mut timestamp = None;
1481 let mut signatures = Vec::new();
1482
1483 for part in header.split(',') {
1484 let Some((key, value)) = part.split_once('=') else {
1485 return Err(WebhookVerifyError::MalformedSignature);
1486 };
1487 match key.trim() {
1488 "t" => {
1489 timestamp = Some(
1490 value
1491 .trim()
1492 .parse::<i64>()
1493 .map_err(|_| WebhookVerifyError::MalformedTimestamp)?,
1494 );
1495 }
1496 "v1" => signatures.push(value.trim()),
1497 _ => {}
1498 }
1499 }
1500
1501 let timestamp = timestamp.ok_or(WebhookVerifyError::MalformedTimestamp)?;
1502 if signatures.is_empty() {
1503 return Err(WebhookVerifyError::MalformedSignature);
1504 }
1505 Ok((timestamp, signatures))
1506}
1507
1508fn verify_timestamp(
1509 timestamp: i64,
1510 received_at: SystemTime,
1511 tolerance_secs: u64,
1512) -> Result<(), WebhookVerifyError> {
1513 let now = i64::try_from(
1514 received_at
1515 .duration_since(UNIX_EPOCH)
1516 .map_err(|_| WebhookVerifyError::MalformedTimestamp)?
1517 .as_secs(),
1518 )
1519 .map_err(|_| WebhookVerifyError::MalformedTimestamp)?;
1520 let skew = now.abs_diff(timestamp);
1521 if skew > tolerance_secs {
1522 return Err(WebhookVerifyError::StaleTimestamp);
1523 }
1524 Ok(())
1525}
1526
1527fn resolve_delivery_id(
1528 config: &WebhookEndpointConfig,
1529 headers: &HeaderMap,
1530 json_body: Option<&serde_json::Value>,
1531) -> Option<String> {
1532 let header = config
1533 .delivery_id_header
1534 .as_deref()
1535 .and_then(|header| optional_header(headers, header));
1536
1537 match config.provider {
1538 WebhookProvider::Slack => header
1539 .or_else(|| slack_delivery_id(json_body))
1540 .or_else(|| json_string_field(json_body, "id")),
1541 _ => header.or_else(|| json_string_field(json_body, "id")),
1542 }
1543}
1544
1545fn resolve_event_type(
1546 config: &WebhookEndpointConfig,
1547 headers: &HeaderMap,
1548 json_body: Option<&serde_json::Value>,
1549) -> Option<String> {
1550 config
1551 .event_type_header
1552 .as_deref()
1553 .and_then(|header| optional_header(headers, header))
1554 .or_else(|| json_string_field(json_body, "type"))
1555 .or_else(|| nested_json_string_field(json_body, "event", "type"))
1556}
1557
1558fn optional_header(headers: &HeaderMap, name: &str) -> Option<String> {
1559 headers
1560 .get(name)
1561 .and_then(|value| value.to_str().ok())
1562 .filter(|value| !value.trim().is_empty())
1563 .map(str::to_owned)
1564}
1565
1566fn slack_delivery_id(json_body: Option<&serde_json::Value>) -> Option<String> {
1567 json_string_field(json_body, "event_id").or_else(|| {
1568 let value = json_body?;
1569 if value.get("type").and_then(serde_json::Value::as_str) == Some("url_verification") {
1570 value
1571 .get("challenge")
1572 .and_then(serde_json::Value::as_str)
1573 .map(str::to_owned)
1574 } else {
1575 None
1576 }
1577 })
1578}
1579
1580fn json_string_field(value: Option<&serde_json::Value>, field: &str) -> Option<String> {
1581 let value = value?;
1582 value
1583 .get(field)
1584 .and_then(serde_json::Value::as_str)
1585 .map(str::to_owned)
1586}
1587
1588fn nested_json_string_field(
1589 value: Option<&serde_json::Value>,
1590 parent: &str,
1591 field: &str,
1592) -> Option<String> {
1593 let value = value?;
1594 value
1595 .get(parent)
1596 .and_then(|parent_value| parent_value.get(field))
1597 .and_then(serde_json::Value::as_str)
1598 .map(str::to_owned)
1599}
1600
1601const fn default_timestamp_tolerance_secs() -> u64 {
1602 DEFAULT_TIMESTAMP_TOLERANCE_SECS
1603}
1604
1605const fn default_replay_window_secs() -> u64 {
1606 DEFAULT_REPLAY_WINDOW_SECS
1607}
1608
1609const fn default_max_body_bytes() -> usize {
1610 DEFAULT_MAX_BODY_BYTES
1611}
1612
1613const fn default_true() -> bool {
1614 true
1615}
1616
1617fn default_replay_redis_key_prefix() -> String {
1618 "autumn:webhooks:replay".to_owned()
1619}
1620
1621fn validate_redis_replay_config(
1622 config: &WebhookReplayRedisConfig,
1623) -> Result<(), WebhookConfigError> {
1624 let url = config
1625 .url
1626 .as_deref()
1627 .filter(|url| !url.trim().is_empty())
1628 .ok_or(WebhookConfigError::RedisReplayMissingUrl)?;
1629
1630 #[cfg(feature = "redis")]
1631 {
1632 crate::redis_tls::open_client(url)
1633 .map_err(|error| WebhookConfigError::RedisReplayInvalidUrl(error.to_string()))?;
1634 Ok(())
1635 }
1636
1637 #[cfg(not(feature = "redis"))]
1638 {
1639 let _ = url;
1640 Err(WebhookConfigError::RedisReplayFeatureDisabled)
1641 }
1642}
1643
1644fn replay_store_from_config(
1645 config: &WebhookReplayConfig,
1646) -> Result<Arc<dyn WebhookReplayStore>, WebhookConfigError> {
1647 match config.backend {
1648 WebhookReplayBackend::Memory => Ok(Arc::new(InMemoryWebhookReplayStore::default())),
1649 WebhookReplayBackend::Redis => {
1650 #[cfg(feature = "redis")]
1651 {
1652 Ok(Arc::new(RedisWebhookReplayStore::from_config(
1653 &config.redis,
1654 )?))
1655 }
1656
1657 #[cfg(not(feature = "redis"))]
1658 {
1659 Err(WebhookConfigError::RedisReplayFeatureDisabled)
1660 }
1661 }
1662 }
1663}
1664
1665pub(crate) fn install_registry_from_config(
1666 state: &crate::AppState,
1667 config: &WebhookConfig,
1668) -> Result<(), WebhookConfigError> {
1669 if config.endpoints.is_empty() {
1670 return Ok(());
1671 }
1672 let registry = WebhookRegistry::from_config(config)?;
1673 state.insert_extension(registry);
1674 Ok(())
1675}
1676
1677#[cfg(test)]
1678mod tests {
1679 use super::*;
1680
1681 #[test]
1682 fn test_parse_stripe_signature_valid() {
1683 let header = "t=1614556800,v1=signature1,v1=signature2,v0=ignored";
1684 let (timestamp, signatures) = parse_stripe_signature(header).unwrap();
1685 assert_eq!(timestamp, 1_614_556_800);
1686 assert_eq!(signatures, vec!["signature1", "signature2"]);
1687 }
1688
1689 #[test]
1690 fn test_parse_stripe_signature_missing_timestamp() {
1691 let header = "v1=signature1";
1692 let err = parse_stripe_signature(header).unwrap_err();
1693 assert!(matches!(err, WebhookVerifyError::MalformedTimestamp));
1694 }
1695
1696 #[test]
1697 fn test_parse_stripe_signature_missing_signatures() {
1698 let header = "t=1614556800";
1699 let err = parse_stripe_signature(header).unwrap_err();
1700 assert!(matches!(err, WebhookVerifyError::MalformedSignature));
1701 }
1702
1703 #[test]
1704 fn test_parse_stripe_signature_malformed_parts() {
1705 let header = "t=1614556800,v1";
1706 let err = parse_stripe_signature(header).unwrap_err();
1707 assert!(matches!(err, WebhookVerifyError::MalformedSignature));
1708 }
1709
1710 #[test]
1711 fn test_parse_stripe_signature_malformed_timestamp() {
1712 let header = "t=not_a_number,v1=signature1";
1713 let err = parse_stripe_signature(header).unwrap_err();
1714 assert!(matches!(err, WebhookVerifyError::MalformedTimestamp));
1715 }
1716
1717 #[test]
1718 fn test_verify_timestamp_valid() {
1719 let received_at = UNIX_EPOCH + Duration::from_secs(1_614_556_800);
1720 assert!(verify_timestamp(1_614_556_800, received_at, 300).is_ok());
1721 assert!(verify_timestamp(1_614_556_800 + 300, received_at, 300).is_ok());
1722 assert!(verify_timestamp(1_614_556_800 - 300, received_at, 300).is_ok());
1723 }
1724
1725 #[test]
1726 fn test_verify_timestamp_stale() {
1727 let received_at = UNIX_EPOCH + Duration::from_secs(1_614_556_800);
1728 let err = verify_timestamp(1_614_556_800 + 301, received_at, 300).unwrap_err();
1729 assert!(matches!(err, WebhookVerifyError::StaleTimestamp));
1730
1731 let err2 = verify_timestamp(1_614_556_800 - 301, received_at, 300).unwrap_err();
1732 assert!(matches!(err2, WebhookVerifyError::StaleTimestamp));
1733 }
1734}
1735
1736#[cfg(test)]
1746mod replay_cleanup_service_tests {
1747 use super::{
1748 ReplayStoreCell, WEBHOOK_REPLAY_KEY, WebhookReplayCleanupLayer, WebhookReplayFuture,
1749 WebhookReplayStore,
1750 };
1751 use std::sync::atomic::{AtomicUsize, Ordering};
1752 use std::sync::{Arc, Mutex};
1753 use std::time::{Duration, SystemTime};
1754
1755 use axum::body::Body;
1756 use axum::http::{Request, Response, StatusCode};
1757 use tower::{Layer, Service, ServiceExt};
1758
1759 #[derive(Debug, Default)]
1761 struct RecordingStore {
1762 removed: Mutex<Vec<String>>,
1763 remove_calls: AtomicUsize,
1764 }
1765
1766 impl WebhookReplayStore for RecordingStore {
1767 fn check_and_insert<'a>(
1768 &'a self,
1769 _key: &'a str,
1770 _received_at: SystemTime,
1771 _window: Duration,
1772 ) -> WebhookReplayFuture<'a> {
1773 Box::pin(async { Ok(true) })
1774 }
1775
1776 fn remove<'a>(&'a self, key: &'a str) -> WebhookReplayFuture<'a> {
1777 self.remove_calls.fetch_add(1, Ordering::SeqCst);
1778 let key = key.to_owned();
1779 Box::pin(async move {
1780 self.removed.lock().expect("lock").push(key);
1781 Ok(true)
1782 })
1783 }
1784 }
1785
1786 fn delivery_handler(
1789 store: Arc<RecordingStore>,
1790 keys: Vec<String>,
1791 status: StatusCode,
1792 ) -> impl Service<Request<Body>, Response = Response<Body>, Error = std::convert::Infallible> + Clone
1793 {
1794 tower::service_fn(move |_req: Request<Body>| {
1795 let store = Arc::clone(&store);
1796 let keys = keys.clone();
1797 async move {
1798 WEBHOOK_REPLAY_KEY.with(|cell: &ReplayStoreCell| {
1799 *cell.lock().expect("lock") =
1800 Some((store as Arc<dyn WebhookReplayStore>, keys.clone()));
1801 });
1802 Ok::<_, std::convert::Infallible>(
1803 Response::builder()
1804 .status(status)
1805 .body(Body::empty())
1806 .expect("response builds"),
1807 )
1808 }
1809 })
1810 }
1811
1812 #[allow(clippy::future_not_send)]
1816 async fn drive(store: &Arc<RecordingStore>, status: StatusCode) -> StatusCode {
1817 let service = WebhookReplayCleanupLayer.layer(delivery_handler(
1818 Arc::clone(store),
1819 vec!["evt_1".to_owned(), "evt_2".to_owned()],
1820 status,
1821 ));
1822 service
1823 .oneshot(Request::builder().body(Body::empty()).expect("request"))
1824 .await
1825 .expect("infallible")
1826 .status()
1827 }
1828
1829 #[tokio::test]
1832 async fn the_replay_key_scope_is_installed_for_the_inner_service() {
1833 let store = Arc::new(RecordingStore::default());
1834 assert_eq!(drive(&store, StatusCode::OK).await, StatusCode::OK);
1835 }
1836
1837 #[tokio::test]
1840 async fn a_2xx_never_releases_replay_keys() {
1841 let store = Arc::new(RecordingStore::default());
1842 drive(&store, StatusCode::OK).await;
1843 assert_eq!(store.remove_calls.load(Ordering::SeqCst), 0);
1844 assert!(store.removed.lock().expect("lock").is_empty());
1845 }
1846
1847 #[tokio::test]
1850 async fn a_5xx_releases_every_registered_replay_key_and_still_returns_the_response() {
1851 let store = Arc::new(RecordingStore::default());
1852 let status = drive(&store, StatusCode::INTERNAL_SERVER_ERROR).await;
1853 assert_eq!(
1854 status,
1855 StatusCode::INTERNAL_SERVER_ERROR,
1856 "the release branch must not swallow or rewrite the response"
1857 );
1858 let mut removed = store.removed.lock().expect("lock").clone();
1859 removed.sort();
1860 assert_eq!(removed, vec!["evt_1".to_owned(), "evt_2".to_owned()]);
1861 }
1862
1863 #[tokio::test]
1866 async fn a_4xx_does_not_release_replay_keys() {
1867 let store = Arc::new(RecordingStore::default());
1868 drive(&store, StatusCode::BAD_REQUEST).await;
1869 assert_eq!(store.remove_calls.load(Ordering::SeqCst), 0);
1870 }
1871
1872 #[tokio::test]
1875 async fn a_5xx_with_no_registered_keys_releases_nothing() {
1876 let store = Arc::new(RecordingStore::default());
1877 let service =
1878 WebhookReplayCleanupLayer.layer(tower::service_fn(|_req: Request<Body>| async move {
1879 Ok::<_, std::convert::Infallible>(
1880 Response::builder()
1881 .status(StatusCode::BAD_GATEWAY)
1882 .body(Body::empty())
1883 .expect("response builds"),
1884 )
1885 }));
1886 let response = service
1887 .oneshot(Request::builder().body(Body::empty()).expect("request"))
1888 .await
1889 .expect("infallible");
1890 assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
1891 assert_eq!(store.remove_calls.load(Ordering::SeqCst), 0);
1892 }
1893
1894 #[tokio::test]
1900 async fn dropping_the_future_mid_flight_still_releases_the_keys() {
1901 let store = Arc::new(RecordingStore::default());
1902 let store_for_handler = Arc::clone(&store);
1903 let service =
1904 WebhookReplayCleanupLayer.layer(tower::service_fn(move |_req: Request<Body>| {
1905 let store = Arc::clone(&store_for_handler);
1906 async move {
1907 WEBHOOK_REPLAY_KEY.with(|cell: &ReplayStoreCell| {
1908 *cell.lock().expect("lock") = Some((
1909 store as Arc<dyn WebhookReplayStore>,
1910 vec!["evt_cancelled".to_owned()],
1911 ));
1912 });
1913 std::future::pending::<Result<Response<Body>, std::convert::Infallible>>().await
1915 }
1916 }));
1917
1918 let handle = tokio::spawn(async move {
1919 let _ = service
1920 .oneshot(Request::builder().body(Body::empty()).expect("request"))
1921 .await;
1922 });
1923 tokio::task::yield_now().await;
1925 handle.abort();
1926 let _ = handle.await;
1927 for _ in 0..8 {
1929 tokio::task::yield_now().await;
1930 }
1931
1932 assert_eq!(
1933 store.removed.lock().expect("lock").as_slice(),
1934 ["evt_cancelled".to_owned()],
1935 "a cancelled webhook delivery must release the replay keys it \
1936 registered, so the provider's redelivery is accepted"
1937 );
1938 }
1939}
1940
1941#[cfg(test)]
1942mod payload_extractor_tests {
1943 use super::*;
1944 use serde_json::json;
1945
1946 #[test]
1947 fn should_extract_slack_delivery_id_from_event_id() {
1948 let payload = json!({
1949 "event_id": "Ev12345",
1950 "type": "event_callback"
1951 });
1952 assert_eq!(
1953 slack_delivery_id(Some(&payload)),
1954 Some("Ev12345".to_owned())
1955 );
1956 }
1957
1958 #[test]
1959 fn should_extract_slack_delivery_id_from_url_verification_challenge() {
1960 let payload = json!({
1961 "type": "url_verification",
1962 "challenge": "Ch9876"
1963 });
1964 assert_eq!(slack_delivery_id(Some(&payload)), Some("Ch9876".to_owned()));
1965 }
1966
1967 #[test]
1968 fn should_return_none_when_slack_payload_missing_identifiers() {
1969 let payload = json!({
1970 "type": "event_callback",
1971 "text": "hello"
1972 });
1973 assert_eq!(slack_delivery_id(Some(&payload)), None);
1974 }
1975
1976 #[test]
1977 fn should_return_none_when_slack_payload_is_none() {
1978 assert_eq!(slack_delivery_id(None), None);
1979 }
1980
1981 #[test]
1982 fn should_extract_json_string_field() {
1983 let payload = json!({
1984 "id": "123",
1985 "count": 45
1986 });
1987 assert_eq!(
1988 json_string_field(Some(&payload), "id"),
1989 Some("123".to_owned())
1990 );
1991 assert_eq!(json_string_field(Some(&payload), "count"), None); assert_eq!(json_string_field(Some(&payload), "missing"), None);
1993 assert_eq!(json_string_field(None, "id"), None);
1994 }
1995
1996 #[test]
1997 fn should_extract_nested_json_string_field() {
1998 let payload = json!({
1999 "event": {
2000 "type": "message",
2001 "count": 1
2002 },
2003 "other": "value"
2004 });
2005 assert_eq!(
2006 nested_json_string_field(Some(&payload), "event", "type"),
2007 Some("message".to_owned())
2008 );
2009 assert_eq!(
2010 nested_json_string_field(Some(&payload), "event", "count"),
2011 None
2012 ); assert_eq!(
2014 nested_json_string_field(Some(&payload), "event", "missing"),
2015 None
2016 );
2017 assert_eq!(
2018 nested_json_string_field(Some(&payload), "missing", "type"),
2019 None
2020 );
2021 assert_eq!(nested_json_string_field(None, "event", "type"), None);
2022 }
2023}