meritocrab_github/
webhook.rs1use 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#[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#[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 let signature = extract_signature(&parts.headers)?;
50
51 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_signature(&body_bytes, &signature, state.expose())?;
59
60 Ok(VerifiedWebhook(body_bytes))
61 }
62}
63
64fn 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 let signature_hex = signature_header.strip_prefix("sha256=").ok_or_else(|| {
76 WebhookError::InvalidSignature("Signature must start with 'sha256='".to_string())
77 })?;
78
79 hex::decode(signature_hex)
81 .map_err(|e| WebhookError::InvalidSignature(format!("Invalid hex encoding: {}", e)))
82}
83
84fn 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 if expected.ct_eq(signature).into() {
94 Ok(())
95 } else {
96 Err(WebhookError::VerificationFailed(
97 "Signature mismatch".to_string(),
98 ))
99 }
100}
101
102#[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}