Skip to main content

kasapay_core/
webhook.rs

1//! What a provider posts when a payment finishes without us asking.
2
3use std::fmt;
4
5use crate::error::Error;
6use crate::id::{EventId, PaymentId};
7use crate::money::Money;
8use crate::provider::{ProviderId, async_trait};
9use crate::raw::Raw;
10
11/// A header a signature depends on arrived more than once.
12///
13/// [`Delivery::signed_header`] answers this rather than picking one of them.
14#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
15#[error("`{name}` arrived {count} times; a signed delivery carries it once")]
16pub struct RepeatedHeader {
17    /// The header that was asked for.
18    pub name: Box<str>,
19    /// How many times it arrived.
20    pub count: usize,
21}
22
23/// One delivery from a provider, exactly as it arrived.
24///
25/// Headers and bytes, and nothing parsed: **a signature is over the bytes the
26/// provider sent**, and a body that has been through a JSON parser and back is
27/// a different sequence of bytes that will not verify. A web framework hands
28/// the body over as `&[u8]` before anything else touches it, and that is what
29/// belongs here.
30///
31/// Header names are matched without regard to case, because HTTP/2 lowercases
32/// them and HTTP/1.1 does not.
33#[derive(Debug, Clone, Copy)]
34pub struct Delivery<'a> {
35    headers: &'a [(&'a str, &'a str)],
36    body: &'a [u8],
37}
38
39impl<'a> Delivery<'a> {
40    /// Holds a delivery's headers and its body.
41    #[must_use]
42    pub const fn new(headers: &'a [(&'a str, &'a str)], body: &'a [u8]) -> Self {
43        Self { headers, body }
44    }
45
46    /// The first header with this name, ignoring case.
47    ///
48    /// For anything a signature depends on, use
49    /// [`Delivery::signed_header`] instead: this one answers the first of two
50    /// and says nothing about the second.
51    #[must_use]
52    pub fn header(&self, name: &str) -> Option<&'a str> {
53        self.headers
54            .iter()
55            .find(|(key, _)| key.eq_ignore_ascii_case(name))
56            .map(|(_, value)| *value)
57    }
58
59    /// The header with this name, refusing a delivery that carries two.
60    ///
61    /// `Ok(None)` is a delivery that carries none, which is the caller's own
62    /// error to phrase — a header that is merely absent and one that is
63    /// contradicted are different failures and deserve different words.
64    ///
65    /// # Why this exists
66    ///
67    /// A signature is a claim about one delivery, and two headers making that
68    /// claim are two claims. Whichever a verifier picks, something in front of
69    /// it — a proxy, a load balancer, whatever wrote the second — picked
70    /// differently, and the pair of them no longer agree about what was
71    /// signed. That disagreement is the whole of a header-smuggling attack,
72    /// and the only safe reading of it is that this delivery cannot be
73    /// trusted.
74    ///
75    /// It costs nothing to refuse: no provider here sends a signature header
76    /// twice, so a delivery that carries two did not come from them intact.
77    pub fn signed_header(&self, name: &str) -> Result<Option<&'a str>, RepeatedHeader> {
78        let mut found = self
79            .headers
80            .iter()
81            .filter(|(key, _)| key.eq_ignore_ascii_case(name));
82        let Some((_, value)) = found.next() else {
83            return Ok(None);
84        };
85        let count = 1 + found.count();
86        if count > 1 {
87            return Err(RepeatedHeader {
88                name: name.into(),
89                count,
90            });
91        }
92        Ok(Some(value))
93    }
94
95    /// Every header, in the order they arrived.
96    #[must_use]
97    pub const fn headers(&self) -> &'a [(&'a str, &'a str)] {
98        self.headers
99    }
100
101    /// The body as it arrived.
102    #[must_use]
103    pub const fn body(&self) -> &'a [u8] {
104        self.body
105    }
106
107    /// The body as text, for a provider that posts a form or JSON.
108    ///
109    /// `None` for a body that is not UTF-8. Nothing lossy: a body that is not
110    /// what the provider documents is one to refuse rather than repair.
111    #[must_use]
112    pub fn body_str(&self) -> Option<&'a str> {
113        std::str::from_utf8(self.body).ok()
114    }
115}
116
117/// What a delivery says happened.
118///
119/// `Other` is not an error and must never become one. A provider adding an
120/// event type is normal, and a handler that answers an error for one it does
121/// not know drives the provider into a redelivery loop that can run for days
122/// — for something nobody wanted in the first place.
123#[derive(Debug, Clone, PartialEq, Eq, Hash)]
124#[non_exhaustive]
125pub enum EventKind {
126    /// Funds are held and not taken.
127    Authorized,
128    /// Funds are taken.
129    Captured,
130    /// Money has gone back.
131    Refunded,
132    /// The payment was refused, and will not proceed.
133    Failed,
134    /// The payment was withdrawn before it completed.
135    Canceled,
136    /// Something else, named as the provider named it.
137    Other(Box<str>),
138}
139
140/// What a provider told us happened, once it has been shown to be theirs.
141///
142/// Every field is public and the struct is open, for the same reason
143/// [`Charge`](crate::Charge) is: an adapter in someone else's repository has
144/// to be able to build one.
145///
146/// # Why this is not an enum of [`Charge`](crate::Charge) and [`Refund`](crate::Refund)
147///
148/// It was the other candidate, and it asks each adapter to build a whole
149/// charge out of a delivery. Stripe's webhook carries a PaymentIntent and can;
150/// PayTR's notice signs three fields and cannot say what currency they are in;
151/// Mollie's carries an identifier and nothing else at all. The shape that
152/// survives all three is this one — what happened, to which payment, and the
153/// body for everything else — and what the caller does next is read the
154/// payment back, which is a call they already have.
155#[derive(Debug, Clone)]
156pub struct Event {
157    /// What the caller writes into a unique index before acting on this.
158    ///
159    /// The second delivery of an event collides with the first instead of
160    /// shipping the order twice, which is the whole reason this field is not
161    /// optional — a delivery nothing identifies is one nothing can deduplicate.
162    ///
163    /// **Read [`Id::source`](crate::Id::source) before trusting it.** Stripe
164    /// and PayPal issue an identifier for the delivery itself; PayTR and
165    /// Mollie issue none, so kasapay composes one out of the fields the
166    /// provider did send and says which they were. A composed key is unique
167    /// exactly as far as those fields are, and no further — two deliveries
168    /// that differ only in a field the provider did not sign are one key.
169    pub id: EventId,
170    /// What happened.
171    pub kind: EventKind,
172    /// The payment it happened to, where the delivery names one.
173    ///
174    /// `None` for a delivery about something that is not a payment — a dispute
175    /// opening, a payout landing — which arrives as
176    /// [`EventKind::Other`] and is a normal thing to receive.
177    pub payment: Option<PaymentId>,
178    /// The amount the delivery carried, where it carried one that can be
179    /// trusted.
180    ///
181    /// `None` is not "zero" and not "the provider sent nothing". PayTR's
182    /// notice carries an amount whose currency is **outside the hash**, so the
183    /// figure is a number without a unit and is left here rather than guessed
184    /// at; it is on [`Event::raw`] with everything else.
185    pub amount: Option<Money>,
186    /// Which provider sent it.
187    pub provider: ProviderId,
188    /// The delivery's own body, kept whole.
189    pub raw: Raw,
190}
191
192/// Checks that a delivery is the provider's, and says what it means.
193///
194/// Separate from [`Provider`](crate::Provider) because it is a separate thing
195/// to hold: verification needs a webhook secret the API credentials do not
196/// carry, and a process that takes payments does not always handle the
197/// callbacks for them.
198///
199/// # Verification is not one mechanism
200///
201/// `verify` is `async` because for two of the four providers that implement it
202/// here, checking a delivery is a network call rather than a hash:
203///
204/// | | how a delivery is shown to be theirs |
205/// |---|---|
206/// | Stripe | HMAC-SHA256 over `timestamp.body`, with a tolerance window |
207/// | PayTR | HMAC-SHA256 over three of the notice's fields |
208/// | Mollie | **nothing is signed** — the delivery carries an identifier, and the payment is read back over the merchant's own authenticated connection |
209/// | PayPal | PayPal verifies it, at `/v1/notifications/verify-webhook-signature` |
210///
211/// A trait that took only `(headers, body) -> Result<Event, Error>`
212/// synchronously would fit the first two and force the other two to lie.
213///
214/// # Answering the provider is not this trait's business
215///
216/// An `Err` here says **do not act on this**. It does not say what to answer:
217/// PayTR retries any reply that is not exactly `OK` for days, so a handler
218/// that turns [`ErrorKind::Untrusted`](crate::ErrorKind::Untrusted) into a 500
219/// has arranged for a forged notice to be delivered again every hour. Answer
220/// the provider what the provider documents, and act only on `Ok`.
221#[async_trait]
222pub trait Webhook: fmt::Debug + Send + Sync {
223    /// Which provider this verifies deliveries from.
224    fn provider(&self) -> ProviderId;
225
226    /// Shows that a delivery is the provider's, and reads what it says.
227    ///
228    /// # Errors
229    ///
230    /// [`ErrorKind::Untrusted`](crate::ErrorKind::Untrusted) for a delivery
231    /// that cannot be shown to be theirs — a signature that does not match,
232    /// one that is missing where the provider always sends one, or a timestamp
233    /// outside the tolerance a replay would fall outside. Nothing has been
234    /// read out of the body at that point, and nothing should be.
235    ///
236    /// [`ErrorKind::Malformed`](crate::ErrorKind::Malformed) for one that
237    /// verifies and is then not the shape the provider documents. An event
238    /// *type* this crate does not know is not that: it is
239    /// [`EventKind::Other`], and it is `Ok`.
240    async fn verify(&self, delivery: &Delivery<'_>) -> Result<Event, Error>;
241}
242
243#[cfg(test)]
244mod tests {
245    use super::Delivery;
246
247    #[test]
248    fn a_header_is_found_whatever_case_it_arrived_in() {
249        let headers = [("Stripe-Signature", "t=1,v1=abc"), ("Accept", "*/*")];
250        let delivery = Delivery::new(&headers, b"{}");
251        assert_eq!(delivery.header("stripe-signature"), Some("t=1,v1=abc"));
252        assert_eq!(delivery.header("STRIPE-SIGNATURE"), Some("t=1,v1=abc"));
253        assert_eq!(delivery.header("x-nothing"), None);
254    }
255
256    #[test]
257    fn a_signed_header_that_arrived_twice_is_refused_rather_than_chosen_between() {
258        let headers = [
259            ("Stripe-Signature", "t=1,v1=abc"),
260            ("Accept", "*/*"),
261            ("stripe-signature", "t=1,v1=forged"),
262        ];
263        let delivery = Delivery::new(&headers, b"{}");
264        // The lenient reader picks one and cannot say the other was there.
265        assert_eq!(delivery.header("Stripe-Signature"), Some("t=1,v1=abc"));
266
267        let repeated = delivery
268            .signed_header("Stripe-Signature")
269            .expect_err("two claims about one delivery are not one claim");
270        assert_eq!(repeated.count, 2);
271        assert!(repeated.to_string().contains("arrived 2 times"));
272    }
273
274    #[test]
275    fn a_header_that_never_arrived_is_not_the_same_failure_as_one_that_arrived_twice() {
276        let headers = [("Accept", "*/*")];
277        let delivery = Delivery::new(&headers, b"{}");
278        assert_eq!(
279            delivery
280                .signed_header("Stripe-Signature")
281                .expect("absent is not ambiguous"),
282            None
283        );
284    }
285
286    #[test]
287    fn a_body_that_is_not_utf8_is_kept_as_bytes_and_read_as_nothing() {
288        let delivery = Delivery::new(&[], &[0xff, 0xfe]);
289        assert_eq!(delivery.body(), &[0xff, 0xfe]);
290        assert!(delivery.body_str().is_none());
291    }
292}