Skip to main content

meritocrab_api/
extractors.rs

1use crate::{error::ApiError, state::AppState};
2use axum::{
3    extract::{FromRequest, Request},
4    http::header::HeaderMap,
5};
6use hmac::{Hmac, Mac};
7use sha2::Sha256;
8use subtle::ConstantTimeEq;
9
10type HmacSha256 = Hmac<Sha256>;
11
12/// Verified webhook payload extractor that works with AppState
13///
14/// This extractor validates the HMAC-SHA256 signature from GitHub webhooks.
15/// It extracts the `X-Hub-Signature-256` header and validates it against the request body.
16#[derive(Debug)]
17pub struct VerifiedWebhookPayload(pub Vec<u8>);
18
19impl FromRequest<AppState> for VerifiedWebhookPayload {
20    type Rejection = ApiError;
21
22    async fn from_request(req: Request, state: &AppState) -> Result<Self, Self::Rejection> {
23        let (parts, body) = req.into_parts();
24
25        // Extract signature from header
26        let signature = extract_signature(&parts.headers)?;
27
28        // Read body bytes
29        let body_bytes = axum::body::to_bytes(body, usize::MAX)
30            .await
31            .map_err(|e| ApiError::Internal(format!("Failed to read request body: {}", e)))?
32            .to_vec();
33
34        // Verify HMAC using webhook secret from app state
35        verify_signature(&body_bytes, &signature, state.webhook_secret.expose())?;
36
37        Ok(VerifiedWebhookPayload(body_bytes))
38    }
39}
40
41/// Extract signature from X-Hub-Signature-256 header
42fn extract_signature(headers: &HeaderMap) -> Result<Vec<u8>, ApiError> {
43    let signature_header = headers
44        .get("X-Hub-Signature-256")
45        .ok_or_else(|| {
46            ApiError::InvalidSignature("X-Hub-Signature-256 header not found".to_string())
47        })?
48        .to_str()
49        .map_err(|e| ApiError::InvalidSignature(format!("Invalid header encoding: {}", e)))?;
50
51    // GitHub sends signature as "sha256=<hex>"
52    let signature_hex = signature_header.strip_prefix("sha256=").ok_or_else(|| {
53        ApiError::InvalidSignature("Signature must start with 'sha256='".to_string())
54    })?;
55
56    // Decode hex to bytes
57    hex::decode(signature_hex)
58        .map_err(|e| ApiError::InvalidSignature(format!("Invalid hex encoding: {}", e)))
59}
60
61/// Verify HMAC-SHA256 signature using constant-time comparison
62fn verify_signature(body: &[u8], signature: &[u8], secret: &str) -> Result<(), ApiError> {
63    let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
64        .map_err(|e| ApiError::Internal(format!("HMAC initialization failed: {}", e)))?;
65
66    mac.update(body);
67    let expected = mac.finalize().into_bytes();
68
69    // Constant-time comparison to prevent timing attacks
70    if expected.ct_eq(signature).into() {
71        Ok(())
72    } else {
73        Err(ApiError::InvalidSignature("Signature mismatch".to_string()))
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn test_extract_signature_valid() {
83        let mut headers = HeaderMap::new();
84        headers.insert(
85            "X-Hub-Signature-256",
86            "sha256=0123456789abcdef".parse().unwrap(),
87        );
88
89        let result = extract_signature(&headers);
90        assert!(result.is_ok());
91    }
92
93    #[test]
94    fn test_extract_signature_missing() {
95        let headers = HeaderMap::new();
96        let result = extract_signature(&headers);
97        assert!(result.is_err());
98    }
99
100    #[test]
101    fn test_extract_signature_invalid_format() {
102        let mut headers = HeaderMap::new();
103        headers.insert("X-Hub-Signature-256", "invalid-format".parse().unwrap());
104
105        let result = extract_signature(&headers);
106        assert!(result.is_err());
107    }
108
109    #[test]
110    fn test_verify_signature_valid() {
111        let body = b"test body";
112        let secret = "test-secret";
113        let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
114        mac.update(body);
115        let signature = mac.finalize().into_bytes();
116
117        let result = verify_signature(body, &signature, secret);
118        assert!(result.is_ok());
119    }
120
121    #[test]
122    fn test_verify_signature_invalid() {
123        let body = b"test body";
124        let secret = "test-secret";
125        let wrong_signature = [0u8; 32];
126
127        let result = verify_signature(body, &wrong_signature, secret);
128        assert!(result.is_err());
129    }
130}