Skip to main content

meritocrab_github/
webhook.rs

1use axum::{
2    extract::{FromRequest, Request},
3    http::{StatusCode, header::HeaderMap},
4    response::{IntoResponse, Response},
5};
6use hmac::{Hmac, Mac};
7use sha2::Sha256;
8use subtle::ConstantTimeEq;
9
10type HmacSha256 = Hmac<Sha256>;
11
12/// Webhook secret for HMAC verification
13#[derive(Clone)]
14pub struct WebhookSecret(String);
15
16impl WebhookSecret {
17    pub fn new(secret: String) -> Self {
18        Self(secret)
19    }
20
21    pub fn expose(&self) -> &str {
22        &self.0
23    }
24}
25
26/// Verified webhook payload extractor
27///
28/// This extractor validates the HMAC-SHA256 signature from GitHub webhooks.
29/// It extracts the `X-Hub-Signature-256` header and validates it against the request body.
30///
31/// Usage:
32/// ```ignore
33/// async fn webhook_handler(
34///     VerifiedWebhook(body): VerifiedWebhook,
35/// ) -> impl IntoResponse {
36///     // body is verified and can be parsed safely
37/// }
38/// ```
39#[derive(Debug)]
40pub struct VerifiedWebhook(pub Vec<u8>);
41
42impl FromRequest<WebhookSecret> for VerifiedWebhook {
43    type Rejection = WebhookError;
44
45    async fn from_request(req: Request, state: &WebhookSecret) -> Result<Self, Self::Rejection> {
46        let (parts, body) = req.into_parts();
47
48        // Extract signature from header
49        let signature = extract_signature(&parts.headers)?;
50
51        // Read body bytes
52        let body_bytes = axum::body::to_bytes(body, usize::MAX)
53            .await
54            .map_err(|e| WebhookError::BodyReadError(e.to_string()))?
55            .to_vec();
56
57        // Verify HMAC
58        verify_signature(&body_bytes, &signature, state.expose())?;
59
60        Ok(VerifiedWebhook(body_bytes))
61    }
62}
63
64/// Extract signature from X-Hub-Signature-256 header
65fn extract_signature(headers: &HeaderMap) -> Result<Vec<u8>, WebhookError> {
66    let signature_header = headers
67        .get("X-Hub-Signature-256")
68        .ok_or_else(|| {
69            WebhookError::MissingHeader("X-Hub-Signature-256 header not found".to_string())
70        })?
71        .to_str()
72        .map_err(|e| WebhookError::InvalidSignature(format!("Invalid header encoding: {}", e)))?;
73
74    // GitHub sends signature as "sha256=<hex>"
75    let signature_hex = signature_header.strip_prefix("sha256=").ok_or_else(|| {
76        WebhookError::InvalidSignature("Signature must start with 'sha256='".to_string())
77    })?;
78
79    // Decode hex to bytes
80    hex::decode(signature_hex)
81        .map_err(|e| WebhookError::InvalidSignature(format!("Invalid hex encoding: {}", e)))
82}
83
84/// Verify HMAC-SHA256 signature using constant-time comparison
85fn verify_signature(body: &[u8], signature: &[u8], secret: &str) -> Result<(), WebhookError> {
86    let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
87        .map_err(|e| WebhookError::HmacError(format!("HMAC initialization failed: {}", e)))?;
88
89    mac.update(body);
90    let expected = mac.finalize().into_bytes();
91
92    // Constant-time comparison to prevent timing attacks
93    if expected.ct_eq(signature).into() {
94        Ok(())
95    } else {
96        Err(WebhookError::VerificationFailed(
97            "Signature mismatch".to_string(),
98        ))
99    }
100}
101
102/// Webhook verification error
103#[derive(Debug)]
104pub enum WebhookError {
105    MissingHeader(String),
106    InvalidSignature(String),
107    HmacError(String),
108    VerificationFailed(String),
109    BodyReadError(String),
110}
111
112impl IntoResponse for WebhookError {
113    fn into_response(self) -> Response {
114        let (status, message) = match self {
115            WebhookError::MissingHeader(msg) => (StatusCode::BAD_REQUEST, msg),
116            WebhookError::InvalidSignature(msg) => (StatusCode::BAD_REQUEST, msg),
117            WebhookError::HmacError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
118            WebhookError::VerificationFailed(msg) => (StatusCode::UNAUTHORIZED, msg),
119            WebhookError::BodyReadError(msg) => (StatusCode::BAD_REQUEST, msg),
120        };
121
122        (status, message).into_response()
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use axum::{body::Body, http::Request};
130
131    fn compute_signature(body: &[u8], secret: &str) -> String {
132        let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
133        mac.update(body);
134        let result = mac.finalize();
135        format!("sha256={}", hex::encode(result.into_bytes()))
136    }
137
138    #[tokio::test]
139    async fn test_valid_signature() {
140        let secret = WebhookSecret::new("test-secret".to_string());
141        let body = b"test body";
142        let signature = compute_signature(body, "test-secret");
143
144        let req = Request::builder()
145            .header("X-Hub-Signature-256", signature)
146            .body(Body::from(body.to_vec()))
147            .unwrap();
148
149        let result = VerifiedWebhook::from_request(req, &secret).await;
150        assert!(result.is_ok());
151        let verified = result.unwrap();
152        assert_eq!(verified.0, body);
153    }
154
155    #[tokio::test]
156    async fn test_invalid_signature() {
157        let secret = WebhookSecret::new("test-secret".to_string());
158        let body = b"test body";
159        let wrong_signature =
160            "sha256=0000000000000000000000000000000000000000000000000000000000000000";
161
162        let req = Request::builder()
163            .header("X-Hub-Signature-256", wrong_signature)
164            .body(Body::from(body.to_vec()))
165            .unwrap();
166
167        let result = VerifiedWebhook::from_request(req, &secret).await;
168        assert!(result.is_err());
169        let err = result.unwrap_err();
170        matches!(err, WebhookError::VerificationFailed(_));
171    }
172
173    #[tokio::test]
174    async fn test_missing_signature_header() {
175        let secret = WebhookSecret::new("test-secret".to_string());
176        let body = b"test body";
177
178        let req = Request::builder().body(Body::from(body.to_vec())).unwrap();
179
180        let result = VerifiedWebhook::from_request(req, &secret).await;
181        assert!(result.is_err());
182        let err = result.unwrap_err();
183        matches!(err, WebhookError::MissingHeader(_));
184    }
185
186    #[tokio::test]
187    async fn test_empty_body() {
188        let secret = WebhookSecret::new("test-secret".to_string());
189        let body = b"";
190        let signature = compute_signature(body, "test-secret");
191
192        let req = Request::builder()
193            .header("X-Hub-Signature-256", signature)
194            .body(Body::from(body.to_vec()))
195            .unwrap();
196
197        let result = VerifiedWebhook::from_request(req, &secret).await;
198        assert!(result.is_ok());
199        let verified = result.unwrap();
200        assert_eq!(verified.0, body);
201    }
202
203    #[tokio::test]
204    async fn test_invalid_signature_format() {
205        let secret = WebhookSecret::new("test-secret".to_string());
206        let body = b"test body";
207
208        let req = Request::builder()
209            .header("X-Hub-Signature-256", "not-a-valid-signature")
210            .body(Body::from(body.to_vec()))
211            .unwrap();
212
213        let result = VerifiedWebhook::from_request(req, &secret).await;
214        assert!(result.is_err());
215        matches!(result.unwrap_err(), WebhookError::InvalidSignature(_));
216    }
217
218    #[tokio::test]
219    async fn test_signature_without_prefix() {
220        let secret = WebhookSecret::new("test-secret".to_string());
221        let body = b"test body";
222
223        let req = Request::builder()
224            .header("X-Hub-Signature-256", "0123456789abcdef")
225            .body(Body::from(body.to_vec()))
226            .unwrap();
227
228        let result = VerifiedWebhook::from_request(req, &secret).await;
229        assert!(result.is_err());
230        matches!(result.unwrap_err(), WebhookError::InvalidSignature(_));
231    }
232}