Skip to main content

pretix_webhook/
builder.rs

1use std::{
2    collections::BTreeSet,
3    fmt::{Debug, Display, Formatter},
4};
5
6use base64::{Engine as _, engine::general_purpose::STANDARD};
7use http::{HeaderMap, header};
8use pretix_webhook_events::WebhookEvent;
9use sha2::{Digest, Sha256};
10use subtle::{Choice, ConstantTimeEq};
11
12use crate::service::{DEFAULT_BODY_LIMIT, WebhookService};
13
14/// A username/password pair accepted by HTTP Basic authentication.
15#[derive(Clone)]
16pub struct BasicAuthCredential {
17    digest: [u8; 32],
18}
19
20impl BasicAuthCredential {
21    /// Creates a credential from the exact username and password bytes.
22    ///
23    /// HTTP Basic authentication uses the first colon as the username/password
24    /// separator, so usernames should not contain `:`. Passwords may contain
25    /// colons. Serve authenticated endpoints only through HTTPS or trusted TLS
26    /// termination because HTTP Basic credentials are not encrypted.
27    #[must_use]
28    pub fn new(username: impl AsRef<str>, password: impl AsRef<str>) -> Self {
29        let mut hasher = Sha256::new();
30        hasher.update(username.as_ref().as_bytes());
31        hasher.update(b":");
32        hasher.update(password.as_ref().as_bytes());
33        Self {
34            digest: hasher.finalize().into(),
35        }
36    }
37}
38
39impl Debug for BasicAuthCredential {
40    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
41        formatter.write_str("BasicAuthCredential(REDACTED)")
42    }
43}
44
45/// Configures a [`WebhookService`].
46#[derive(Clone)]
47pub struct WebhookServiceBuilder {
48    organizers: BTreeSet<String>,
49    events: BTreeSet<String>,
50    credentials: Vec<BasicAuthCredential>,
51    body_limit: usize,
52}
53
54/// Reports how much policy is configured without disclosing any of it.
55///
56/// Configured slugs are policy, not payload data, so they are redacted for the
57/// same reason [`BasicAuthCredential`] and [`WebhookFilterError`] are: a
58/// derived `Debug` would place them in any diagnostic that renders a
59/// configuration.
60impl Debug for WebhookServiceBuilder {
61    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
62        formatter
63            .debug_struct("WebhookServiceBuilder")
64            .field("organizers", &Redacted(self.organizers.len()))
65            .field("events", &Redacted(self.events.len()))
66            .field("credentials", &Redacted(self.credentials.len()))
67            .field("body_limit", &self.body_limit)
68            .finish()
69    }
70}
71
72struct Redacted(usize);
73
74impl Debug for Redacted {
75    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
76        write!(formatter, "<{} REDACTED>", self.0)
77    }
78}
79
80/// An invalid organizer or event filter value.
81///
82/// The rejected value is never included in the message so that diagnostics can
83/// be reported without disclosing configured policy.
84#[derive(Clone, Debug, Eq, PartialEq)]
85pub struct WebhookFilterError {
86    message: String,
87}
88
89impl Display for WebhookFilterError {
90    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
91        formatter.write_str(&self.message)
92    }
93}
94
95impl std::error::Error for WebhookFilterError {}
96
97impl WebhookServiceBuilder {
98    /// Creates a builder with no filters or authentication requirement.
99    #[must_use]
100    pub fn new() -> Self {
101        Self::default()
102    }
103
104    /// Allows payloads from one organizer slug.
105    ///
106    /// # Errors
107    ///
108    /// Returns [`WebhookFilterError`] when `organizer` is empty or has leading
109    /// or trailing whitespace.
110    pub fn allow_organizer(
111        mut self,
112        organizer: impl Into<String>,
113    ) -> Result<Self, WebhookFilterError> {
114        self.organizers
115            .insert(validate_filter("organizer", organizer.into())?);
116        Ok(self)
117    }
118
119    /// Allows payloads for one event slug, independently of organizer filters.
120    ///
121    /// # Errors
122    ///
123    /// Returns [`WebhookFilterError`] when `event` is empty or has leading or
124    /// trailing whitespace.
125    pub fn allow_event(mut self, event: impl Into<String>) -> Result<Self, WebhookFilterError> {
126        self.events.insert(validate_filter("event", event.into())?);
127        Ok(self)
128    }
129
130    /// Requires any one of the supplied credentials.
131    ///
132    /// Passing an empty iterator disables authentication.
133    #[must_use]
134    pub fn require_basic_auth(
135        mut self,
136        credentials: impl IntoIterator<Item = BasicAuthCredential>,
137    ) -> Self {
138        self.credentials = credentials.into_iter().collect();
139        self
140    }
141
142    /// Sets the maximum request body size in bytes.
143    ///
144    /// The default is [`DEFAULT_BODY_LIMIT`]. A request that exceeds the limit
145    /// receives `413 Payload Too Large` without reaching the handler.
146    #[must_use]
147    pub fn body_limit(mut self, body_limit: usize) -> Self {
148        self.body_limit = body_limit;
149        self
150    }
151
152    /// Builds an HTTP webhook service around an event handler.
153    pub fn build<H>(self, handler: H) -> WebhookService<H> {
154        WebhookService::new(handler, self)
155    }
156
157    pub(super) fn allows(&self, event: &WebhookEvent) -> bool {
158        (self.organizers.is_empty()
159            || event
160                .organizer_slug()
161                .is_some_and(|organizer| self.organizers.contains(organizer)))
162            && (self.events.is_empty()
163                || !event.is_event_level()
164                || event
165                    .event_slug()
166                    .is_some_and(|event| self.events.contains(event)))
167    }
168
169    pub(super) fn authenticates(&self, headers: &HeaderMap) -> bool {
170        if self.credentials.is_empty() {
171            return true;
172        }
173
174        let Some(encoded) = headers
175            .get(header::AUTHORIZATION)
176            .and_then(|value| value.to_str().ok())
177            .and_then(|value| value.split_once(' '))
178            .filter(|(scheme, _)| scheme.eq_ignore_ascii_case("basic"))
179            .map(|(_, encoded)| encoded)
180        else {
181            return false;
182        };
183        let Ok(presented) = STANDARD.decode(encoded) else {
184            return false;
185        };
186        let digest: [u8; 32] = Sha256::digest(presented).into();
187
188        bool::from(
189            self.credentials
190                .iter()
191                .fold(Choice::from(0), |matched, credential| {
192                    matched | credential.digest.ct_eq(&digest)
193                }),
194        )
195    }
196
197    pub(super) fn body_limit_bytes(&self) -> usize {
198        self.body_limit
199    }
200}
201
202impl Default for WebhookServiceBuilder {
203    fn default() -> Self {
204        Self {
205            organizers: BTreeSet::new(),
206            events: BTreeSet::new(),
207            credentials: Vec::new(),
208            body_limit: DEFAULT_BODY_LIMIT,
209        }
210    }
211}
212
213fn validate_filter(kind: &str, value: String) -> Result<String, WebhookFilterError> {
214    if value.is_empty() {
215        return Err(WebhookFilterError {
216            message: format!("invalid {kind} slug: it must not be empty"),
217        });
218    }
219
220    if value.trim() != value {
221        return Err(WebhookFilterError {
222            message: format!(
223                "invalid {kind} slug: leading and trailing whitespace are not allowed"
224            ),
225        });
226    }
227
228    Ok(value)
229}