use actix_web::{
FromRequest, HttpRequest, HttpResponse,
dev::Payload,
error::ResponseError,
http::StatusCode,
web::Bytes,
};
use serde::de::DeserializeOwned;
use std::{fmt, future::Future, pin::Pin, sync::Arc};
use webhooksmith::signing;
const MAX_BODY_BYTES: usize = 1_048_576; const TIMESTAMP_TOLERANCE_SECS: i64 = 300;
#[derive(Clone)]
pub struct WebhookSecret(pub(crate) Arc<String>);
impl WebhookSecret {
pub fn new(secret: impl Into<String>) -> Self {
Self(Arc::new(secret.into()))
}
}
#[derive(Debug, Clone)]
pub struct WebhookPayload {
pub event_type: String,
pub event_id: Option<String>,
pub timestamp: i64,
pub body: Bytes,
}
#[derive(Debug)]
pub enum WebhookError {
MissingSignature,
MissingTimestamp,
InvalidTimestamp,
StaleTimestamp,
InvalidSignature,
PayloadTooLarge,
BodyNotJson,
SecretNotConfigured,
BodyReadError(String),
}
impl fmt::Display for WebhookError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingSignature => write!(f, "missing signature"),
Self::MissingTimestamp => write!(f, "missing timestamp"),
Self::InvalidTimestamp => write!(f, "invalid timestamp"),
Self::StaleTimestamp => write!(f, "stale timestamp"),
Self::InvalidSignature => write!(f, "invalid signature"),
Self::PayloadTooLarge => write!(f, "payload too large"),
Self::BodyNotJson => write!(f, "body must be JSON"),
Self::SecretNotConfigured => write!(f, "webhook secret not configured"),
Self::BodyReadError(e) => write!(f, "body read error: {e}"),
}
}
}
impl ResponseError for WebhookError {
fn status_code(&self) -> StatusCode {
match self {
Self::MissingSignature => StatusCode::UNAUTHORIZED,
Self::MissingTimestamp => StatusCode::BAD_REQUEST,
Self::InvalidTimestamp => StatusCode::BAD_REQUEST,
Self::StaleTimestamp => StatusCode::UNAUTHORIZED,
Self::InvalidSignature => StatusCode::UNAUTHORIZED,
Self::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
Self::BodyNotJson => StatusCode::UNPROCESSABLE_ENTITY,
Self::SecretNotConfigured => StatusCode::INTERNAL_SERVER_ERROR,
Self::BodyReadError(_) => StatusCode::BAD_REQUEST,
}
}
fn error_response(&self) -> HttpResponse {
let body = serde_json::json!({"error": self.to_string()});
HttpResponse::build(self.status_code())
.content_type("application/json")
.json(body)
}
}
async fn extract_and_verify(
req: &HttpRequest,
payload: &mut Payload,
) -> Result<WebhookPayload, WebhookError> {
let secret = req
.app_data::<WebhookSecret>()
.ok_or(WebhookError::SecretNotConfigured)?
.0
.clone();
let sig = req
.headers()
.get("x-hooksmith-signature")
.and_then(|v| v.to_str().ok())
.map(str::to_owned)
.ok_or(WebhookError::MissingSignature)?;
let ts_str = req
.headers()
.get("x-hooksmith-timestamp")
.and_then(|v| v.to_str().ok())
.map(str::to_owned)
.ok_or(WebhookError::MissingTimestamp)?;
let timestamp: i64 = ts_str.parse().map_err(|_| WebhookError::InvalidTimestamp)?;
let now = chrono::Utc::now().timestamp();
if (now - timestamp).abs() > TIMESTAMP_TOLERANCE_SECS {
return Err(WebhookError::StaleTimestamp);
}
use futures::StreamExt;
let mut chunks: Vec<u8> = Vec::new();
while let Some(chunk) = payload.next().await {
let chunk = chunk.map_err(|e| WebhookError::BodyReadError(e.to_string()))?;
if chunks.len() + chunk.len() > MAX_BODY_BYTES {
return Err(WebhookError::PayloadTooLarge);
}
chunks.extend_from_slice(&chunk);
}
let body = Bytes::from(chunks);
if serde_json::from_slice::<serde_json::Value>(&body).is_err() {
return Err(WebhookError::BodyNotJson);
}
if !signing::verify(&secret, timestamp, &body, &sig) {
return Err(WebhookError::InvalidSignature);
}
let event_type = req
.headers()
.get("x-hooksmith-event-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_owned();
let event_id = req
.headers()
.get("x-hooksmith-event-id")
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
Ok(WebhookPayload { event_type, event_id, timestamp, body })
}
pub struct VerifiedWebhook(pub WebhookPayload);
impl std::ops::Deref for VerifiedWebhook {
type Target = WebhookPayload;
fn deref(&self) -> &Self::Target { &self.0 }
}
impl FromRequest for VerifiedWebhook {
type Error = WebhookError;
type Future = Pin<Box<dyn Future<Output = Result<Self, Self::Error>>>>;
fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future {
let req = req.clone();
let mut payload = payload.take();
Box::pin(async move {
let verified = extract_and_verify(&req, &mut payload).await?;
Ok(VerifiedWebhook(verified))
})
}
}
pub struct TypedWebhook<T> {
pub payload: T,
pub meta: WebhookPayload,
}
impl<T: DeserializeOwned + 'static> FromRequest for TypedWebhook<T> {
type Error = WebhookError;
type Future = Pin<Box<dyn Future<Output = Result<Self, Self::Error>>>>;
fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future {
let req = req.clone();
let mut payload = payload.take();
Box::pin(async move {
let meta = extract_and_verify(&req, &mut payload).await?;
let typed: T = serde_json::from_slice(&meta.body)
.map_err(|_| WebhookError::BodyNotJson)?;
Ok(TypedWebhook { payload: typed, meta })
})
}
}