diff --git a/server/src/chat/transport/whatsapp/mod.rs b/server/src/chat/transport/whatsapp/mod.rs index 2976ef98..e90f76d4 100644 --- a/server/src/chat/transport/whatsapp/mod.rs +++ b/server/src/chat/transport/whatsapp/mod.rs @@ -181,8 +181,9 @@ pub async fn webhook_verify( /// Both providers expect a `200 OK` response, even on parse errors. /// /// For the `"meta"` provider, the `X-Hub-Signature-256` header is verified -/// against the configured app secret (HMAC-SHA256). Requests with a missing -/// or invalid signature are rejected with `401`/`403` respectively. +/// against the configured app secret (HMAC-SHA256). For Twilio, the +/// `X-Twilio-Signature` header is verified against the auth token. +/// Requests with a missing or invalid signature are rejected with `403 Forbidden`. #[handler] pub async fn webhook_receive( req: &Request, @@ -205,7 +206,7 @@ pub async fn webhook_receive( if signature.is_empty() { slog!("[whatsapp] Missing X-Hub-Signature-256 header; rejecting request"); return Response::builder() - .status(StatusCode::UNAUTHORIZED) + .status(StatusCode::FORBIDDEN) .body("Missing signature"); } if !verify::verify_meta_signature(&ctx.app_secret, &bytes, signature) { @@ -222,7 +223,7 @@ pub async fn webhook_receive( if signature.is_empty() { slog!("[whatsapp/twilio] Missing X-Twilio-Signature header; rejecting request"); return Response::builder() - .status(StatusCode::UNAUTHORIZED) + .status(StatusCode::FORBIDDEN) .body("Missing X-Twilio-Signature"); } let url = reconstruct_request_url(req); @@ -294,6 +295,173 @@ fn reconstruct_request_url(req: &Request) -> String { mod tests { use super::*; + // ── webhook_receive signature-verification handler tests ────────── + + use crate::chat::transport::whatsapp::history::MessagingWindowTracker; + use poem::EndpointExt; + use std::fmt::Write as FmtWrite; + use std::sync::Arc; + + struct NullTransport; + + #[async_trait::async_trait] + impl crate::chat::ChatTransport for NullTransport { + async fn send_message( + &self, + _: &str, + _: &str, + _: &str, + ) -> Result { + Ok(String::new()) + } + async fn edit_message(&self, _: &str, _: &str, _: &str, _: &str) -> Result<(), String> { + Ok(()) + } + async fn send_typing(&self, _: &str, _: bool) -> Result<(), String> { + Ok(()) + } + } + + fn make_ctx( + provider: &str, + app_secret: &str, + twilio_auth_token: &str, + ) -> Arc { + let tmp = tempfile::tempdir().unwrap(); + let services = + crate::services::Services::new_test(tmp.path().to_path_buf(), "Bot".to_string()); + Arc::new(WhatsAppWebhookContext { + services, + verify_token: "tok".to_string(), + provider: provider.to_string(), + transport: Arc::new(NullTransport), + history: Arc::new(tokio::sync::Mutex::new(Default::default())), + history_size: 20, + window_tracker: Arc::new(MessagingWindowTracker::new()), + allowed_phones: vec![], + app_secret: app_secret.to_string(), + twilio_auth_token: twilio_auth_token.to_string(), + }) + } + + fn handler_app( + provider: &str, + app_secret: &str, + twilio_auth_token: &str, + ) -> impl poem::Endpoint { + let ctx = make_ctx(provider, app_secret, twilio_auth_token); + poem::Route::new() + .at("/webhook", poem::post(webhook_receive)) + .data(ctx) + } + + fn make_meta_sig(secret: &str, body: &[u8]) -> String { + use hmac::{Hmac, KeyInit, Mac}; + use sha2::Sha256; + let mut mac = Hmac::::new_from_slice(secret.as_bytes()).unwrap(); + mac.update(body); + let result = mac.finalize().into_bytes(); + let mut hex = String::from("sha256="); + for b in &result { + write!(hex, "{b:02x}").unwrap(); + } + hex + } + + #[tokio::test] + async fn meta_missing_signature_returns_403() { + let app = handler_app("meta", "secret", ""); + let cli = poem::test::TestClient::new(app); + let resp = cli + .post("/webhook") + .body(b"{\"entry\":[]}".to_vec()) + .send() + .await; + assert_eq!(resp.0.status(), poem::http::StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn meta_invalid_signature_returns_403() { + let app = handler_app("meta", "secret", ""); + let cli = poem::test::TestClient::new(app); + let resp = cli + .post("/webhook") + .header( + "X-Hub-Signature-256", + "sha256=0000000000000000000000000000000000000000000000000000000000000000", + ) + .body(b"{\"entry\":[]}".to_vec()) + .send() + .await; + assert_eq!(resp.0.status(), poem::http::StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn meta_valid_signature_passes() { + let body = b"{\"entry\":[]}"; + let sig = make_meta_sig("secret", body); + let app = handler_app("meta", "secret", ""); + let cli = poem::test::TestClient::new(app); + let resp = cli + .post("/webhook") + .header("X-Hub-Signature-256", sig) + .body(body.to_vec()) + .send() + .await; + assert_eq!(resp.0.status(), poem::http::StatusCode::OK); + } + + #[tokio::test] + async fn meta_no_secret_configured_skips_verification() { + // When app_secret is empty, signature checking is disabled (backwards-compat). + let app = handler_app("meta", "", ""); + let cli = poem::test::TestClient::new(app); + let resp = cli + .post("/webhook") + .body(b"{\"entry\":[]}".to_vec()) + .send() + .await; + assert_eq!(resp.0.status(), poem::http::StatusCode::OK); + } + + #[tokio::test] + async fn twilio_missing_signature_returns_403() { + let app = handler_app("twilio", "", "twilio-token"); + let cli = poem::test::TestClient::new(app); + let resp = cli + .post("/webhook") + .body(b"Body=hello&From=whatsapp%3A%2B15551234567".to_vec()) + .send() + .await; + assert_eq!(resp.0.status(), poem::http::StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn twilio_invalid_signature_returns_403() { + let app = handler_app("twilio", "", "twilio-token"); + let cli = poem::test::TestClient::new(app); + let resp = cli + .post("/webhook") + .header("X-Twilio-Signature", "invalidsig==") + .body(b"Body=hello&From=whatsapp%3A%2B15551234567".to_vec()) + .send() + .await; + assert_eq!(resp.0.status(), poem::http::StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn twilio_no_auth_token_configured_skips_verification() { + // When twilio_auth_token is empty, signature checking is disabled (backwards-compat). + let app = handler_app("twilio", "", ""); + let cli = poem::test::TestClient::new(app); + let resp = cli + .post("/webhook") + .body(b"Body=hello&From=whatsapp%3A%2B15551234567".to_vec()) + .send() + .await; + assert_eq!(resp.0.status(), poem::http::StatusCode::OK); + } + // ── Existing webhook / transport tests ──────────────────────────── #[test]