Skip to main content

rust_web_server/webhook/
mod.rs

1//! Webhook signature verification for common providers, gated behind the
2//! `webhook` Cargo feature (`hmac` + `sha2` — the same RustCrypto crates
3//! already used by [`crate::auth`] and [`crate::crypto`]).
4//!
5//! Every webhook-receiving handler needs to verify that a request actually
6//! came from the provider, not an attacker who guessed (or found leaked) the
7//! endpoint URL. All three schemes below are HMAC-SHA256-based, but the
8//! header format, signed-payload construction, and encoding differ per
9//! provider — [`verify_webhook_signature`] and its per-provider siblings
10//! encode the exact convention each one uses, so handlers don't have to
11//! re-derive it from provider docs.
12//!
13//! # Example
14//!
15//! ```rust,no_run
16//! # #[cfg(feature = "webhook")]
17//! # fn example() {
18//! use rust_web_server::webhook::{verify_webhook_signature, WebhookProvider};
19//!
20//! fn handle_github_webhook(body: &[u8], signature_header: &str, secret: &[u8]) -> bool {
21//!     verify_webhook_signature(WebhookProvider::GitHub, body, secret, signature_header)
22//! }
23//! # }
24//! ```
25//!
26//! # Which header to read
27//!
28//! | Provider | Header | Format |
29//! |---|---|---|
30//! | GitHub | `X-Hub-Signature-256` | `sha256=<hex>` |
31//! | Shopify | `X-Shopify-Hmac-Sha256` | `<base64>` |
32//! | Stripe | `Stripe-Signature` | `t=<unix_ts>,v1=<hex>[,v1=<hex>...]` |
33
34#[cfg(test)]
35mod tests;
36
37use std::time::{SystemTime, UNIX_EPOCH};
38
39use hmac::{Hmac, Mac};
40use sha2::Sha256;
41
42type HmacSha256 = Hmac<Sha256>;
43
44/// Default timestamp tolerance for [`verify_stripe_signature`] — 300 seconds
45/// (5 minutes), matching Stripe's own recommended default. Requests signed
46/// further in the past (or future, guarding against clock skew abuse) than
47/// this are rejected as a replay-protection measure.
48pub const STRIPE_DEFAULT_TOLERANCE_SECS: u64 = 300;
49
50/// Which provider's signature convention to verify a webhook request against.
51///
52/// Each variant corresponds to one specific header format, HMAC payload
53/// construction, and encoding — see the module docs table and the
54/// per-provider function docs for the exact convention.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum WebhookProvider {
57    /// GitHub — see [`verify_github_signature`].
58    GitHub,
59    /// Shopify — see [`verify_shopify_signature`].
60    Shopify,
61    /// Stripe — see [`verify_stripe_signature`]. Uses the default
62    /// [`STRIPE_DEFAULT_TOLERANCE_SECS`] timestamp tolerance; call
63    /// [`verify_stripe_signature_with_tolerance`] directly to customize it.
64    Stripe,
65}
66
67/// Verifies `header_value` against `body` and `secret` using `provider`'s
68/// signature convention. Returns `false` on any failure: wrong secret,
69/// tampered body, malformed header, or (Stripe only) a timestamp outside the
70/// default tolerance window.
71///
72/// `body` must be the exact raw request bytes — re-serializing a parsed JSON
73/// body before verifying will not reproduce the byte sequence the provider
74/// signed and will always fail verification.
75pub fn verify_webhook_signature(
76    provider: WebhookProvider,
77    body: &[u8],
78    secret: &[u8],
79    header_value: &str,
80) -> bool {
81    match provider {
82        WebhookProvider::GitHub => verify_github_signature(body, secret, header_value),
83        WebhookProvider::Shopify => verify_shopify_signature(body, secret, header_value),
84        WebhookProvider::Stripe => verify_stripe_signature(body, secret, header_value),
85    }
86}
87
88/// Verifies a GitHub webhook's `X-Hub-Signature-256` header value —
89/// `"sha256=<hex-encoded-hmac-sha256-of-body>"`, keyed by the webhook's
90/// configured secret.
91///
92/// GitHub also sends the older `X-Hub-Signature` header (HMAC-**SHA1**) for
93/// backward compatibility. This function deliberately does not support it —
94/// SHA-1 is cryptographically broken, this crate has no SHA-1 dependency,
95/// and GitHub recommends `X-Hub-Signature-256` for all new integrations.
96pub fn verify_github_signature(body: &[u8], secret: &[u8], header_value: &str) -> bool {
97    let Some(hex_sig) = header_value.strip_prefix("sha256=") else {
98        return false;
99    };
100    let Some(expected) = from_hex(hex_sig) else {
101        return false;
102    };
103    hmac_verify(secret, body, &expected)
104}
105
106/// Verifies a Shopify webhook's `X-Shopify-Hmac-Sha256` header value — the
107/// standard base64 encoding of an HMAC-SHA256 digest of the raw body, keyed
108/// by the app's configured secret.
109pub fn verify_shopify_signature(body: &[u8], secret: &[u8], header_value: &str) -> bool {
110    let Some(expected) = base64_decode(header_value) else {
111        return false;
112    };
113    hmac_verify(secret, body, &expected)
114}
115
116/// Verifies a Stripe webhook's `Stripe-Signature` header using the default
117/// [`STRIPE_DEFAULT_TOLERANCE_SECS`] timestamp tolerance. See
118/// [`verify_stripe_signature_with_tolerance`] for the full format and to
119/// customize the tolerance window.
120pub fn verify_stripe_signature(body: &[u8], secret: &[u8], header_value: &str) -> bool {
121    verify_stripe_signature_with_tolerance(body, secret, header_value, STRIPE_DEFAULT_TOLERANCE_SECS)
122}
123
124/// Verifies a Stripe webhook's `Stripe-Signature` header value:
125/// `"t=<unix_timestamp>,v1=<hex_hmac_sha256>[,v1=<hex_hmac_sha256>...]"`.
126///
127/// The signed payload Stripe actually hashes is `"{timestamp}.{body}"`
128/// (timestamp as a decimal string, a literal `.`, then the raw body bytes),
129/// HMAC-SHA256 keyed by the webhook's signing secret.
130///
131/// Stripe may send multiple `v1=` entries while a secret rotation is in
132/// progress — this accepts the header if *any* entry matches. Rejects the
133/// header if `timestamp` is more than `tolerance_secs` away (in either
134/// direction) from the current system time, which guards against replaying
135/// a captured request long after the fact.
136pub fn verify_stripe_signature_with_tolerance(
137    body: &[u8],
138    secret: &[u8],
139    header_value: &str,
140    tolerance_secs: u64,
141) -> bool {
142    let mut timestamp: Option<u64> = None;
143    let mut signatures: Vec<&str> = Vec::new();
144
145    for part in header_value.split(',') {
146        let part = part.trim();
147        if let Some(t) = part.strip_prefix("t=") {
148            timestamp = t.parse().ok();
149        } else if let Some(v1) = part.strip_prefix("v1=") {
150            signatures.push(v1);
151        }
152    }
153
154    let Some(timestamp) = timestamp else {
155        return false;
156    };
157    if signatures.is_empty() {
158        return false;
159    }
160
161    let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
162        return false;
163    };
164    if now.as_secs().abs_diff(timestamp) > tolerance_secs {
165        return false;
166    }
167
168    let signed_payload = [timestamp.to_string().as_bytes(), b".", body].concat();
169
170    signatures
171        .iter()
172        .any(|hex_sig| from_hex(hex_sig).is_some_and(|expected| hmac_verify(secret, &signed_payload, &expected)))
173}
174
175fn hmac_verify(secret: &[u8], message: &[u8], expected: &[u8]) -> bool {
176    let Ok(mut mac) = <HmacSha256 as Mac>::new_from_slice(secret) else {
177        return false;
178    };
179    mac.update(message);
180    mac.verify_slice(expected).is_ok()
181}
182
183fn from_hex(s: &str) -> Option<Vec<u8>> {
184    if s.is_empty() || s.len() % 2 != 0 {
185        return None;
186    }
187    (0..s.len())
188        .step_by(2)
189        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
190        .collect()
191}
192
193// Decodes standard base64 (RFC 4648, `+`/`/` alphabet). Padding characters
194// ('=') are stripped before decoding; Shopify emits padded output but this
195// tolerates unpadded input too.
196fn base64_decode(input: &str) -> Option<Vec<u8>> {
197    crate::core::base64::Decoder::STANDARD.decode(input).ok().filter(|b| !b.is_empty())
198}