huskies: merge 936
This commit is contained in:
@@ -181,8 +181,9 @@ pub async fn webhook_verify(
|
|||||||
/// Both providers expect a `200 OK` response, even on parse errors.
|
/// Both providers expect a `200 OK` response, even on parse errors.
|
||||||
///
|
///
|
||||||
/// For the `"meta"` provider, the `X-Hub-Signature-256` header is verified
|
/// For the `"meta"` provider, the `X-Hub-Signature-256` header is verified
|
||||||
/// against the configured app secret (HMAC-SHA256). Requests with a missing
|
/// against the configured app secret (HMAC-SHA256). For Twilio, the
|
||||||
/// or invalid signature are rejected with `401`/`403` respectively.
|
/// `X-Twilio-Signature` header is verified against the auth token.
|
||||||
|
/// Requests with a missing or invalid signature are rejected with `403 Forbidden`.
|
||||||
#[handler]
|
#[handler]
|
||||||
pub async fn webhook_receive(
|
pub async fn webhook_receive(
|
||||||
req: &Request,
|
req: &Request,
|
||||||
@@ -205,7 +206,7 @@ pub async fn webhook_receive(
|
|||||||
if signature.is_empty() {
|
if signature.is_empty() {
|
||||||
slog!("[whatsapp] Missing X-Hub-Signature-256 header; rejecting request");
|
slog!("[whatsapp] Missing X-Hub-Signature-256 header; rejecting request");
|
||||||
return Response::builder()
|
return Response::builder()
|
||||||
.status(StatusCode::UNAUTHORIZED)
|
.status(StatusCode::FORBIDDEN)
|
||||||
.body("Missing signature");
|
.body("Missing signature");
|
||||||
}
|
}
|
||||||
if !verify::verify_meta_signature(&ctx.app_secret, &bytes, signature) {
|
if !verify::verify_meta_signature(&ctx.app_secret, &bytes, signature) {
|
||||||
@@ -222,7 +223,7 @@ pub async fn webhook_receive(
|
|||||||
if signature.is_empty() {
|
if signature.is_empty() {
|
||||||
slog!("[whatsapp/twilio] Missing X-Twilio-Signature header; rejecting request");
|
slog!("[whatsapp/twilio] Missing X-Twilio-Signature header; rejecting request");
|
||||||
return Response::builder()
|
return Response::builder()
|
||||||
.status(StatusCode::UNAUTHORIZED)
|
.status(StatusCode::FORBIDDEN)
|
||||||
.body("Missing X-Twilio-Signature");
|
.body("Missing X-Twilio-Signature");
|
||||||
}
|
}
|
||||||
let url = reconstruct_request_url(req);
|
let url = reconstruct_request_url(req);
|
||||||
@@ -294,6 +295,173 @@ fn reconstruct_request_url(req: &Request) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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<crate::chat::MessageId, String> {
|
||||||
|
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<WhatsAppWebhookContext> {
|
||||||
|
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::<Sha256>::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 ────────────────────────────
|
// ── Existing webhook / transport tests ────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user