huskies: merge 807
This commit is contained in:
@@ -25,6 +25,8 @@ serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
serde_urlencoded = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
hmac = { workspace = true }
|
||||
subtle = { workspace = true }
|
||||
serde_yaml = { workspace = true }
|
||||
strip-ansi-escapes = { workspace = true }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "process"] }
|
||||
|
||||
@@ -136,6 +136,12 @@ pub struct BotConfig {
|
||||
/// fail-closed).
|
||||
#[serde(default)]
|
||||
pub whatsapp_allowed_phones: Vec<String>,
|
||||
/// Meta app secret used to verify `X-Hub-Signature-256` HMAC on inbound
|
||||
/// webhook requests. When set, every POST to `/webhook/whatsapp` must
|
||||
/// carry a valid signature or the request is rejected. When absent or
|
||||
/// empty, signature verification is skipped (backwards compatible).
|
||||
#[serde(default)]
|
||||
pub whatsapp_app_secret: Option<String>,
|
||||
|
||||
// ── Slack Bot API fields ─────────────────────────────────────────
|
||||
// These are only required when `transport = "slack"`.
|
||||
|
||||
@@ -320,6 +320,7 @@ mod tests {
|
||||
history_size: 20,
|
||||
window_tracker: tracker,
|
||||
allowed_phones,
|
||||
app_secret: String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod format;
|
||||
pub mod history;
|
||||
pub mod meta;
|
||||
pub mod twilio;
|
||||
pub mod verify;
|
||||
|
||||
pub use history::{MessagingWindowTracker, WhatsAppConversationHistory, load_whatsapp_history};
|
||||
pub use meta::WhatsAppTransport;
|
||||
@@ -121,6 +122,10 @@ pub struct WhatsAppWebhookContext {
|
||||
/// Phone numbers allowed to send messages to the bot.
|
||||
/// When empty, all numbers are allowed (backwards compatible).
|
||||
pub allowed_phones: Vec<String>,
|
||||
/// Meta app secret for `X-Hub-Signature-256` HMAC verification.
|
||||
/// When non-empty, every inbound POST is verified against this secret.
|
||||
/// When empty, signature verification is skipped.
|
||||
pub app_secret: String,
|
||||
}
|
||||
|
||||
/// GET /webhook/whatsapp — webhook verification.
|
||||
@@ -158,13 +163,16 @@ pub async fn webhook_verify(
|
||||
/// - `"twilio"`: parses Twilio's `application/x-www-form-urlencoded` body.
|
||||
///
|
||||
/// 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.
|
||||
#[handler]
|
||||
pub async fn webhook_receive(
|
||||
req: &Request,
|
||||
body: poem::Body,
|
||||
ctx: poem::web::Data<&Arc<WhatsAppWebhookContext>>,
|
||||
) -> Response {
|
||||
let _ = req;
|
||||
let bytes = match body.into_bytes().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
@@ -175,6 +183,23 @@ pub async fn webhook_receive(
|
||||
}
|
||||
};
|
||||
|
||||
// Verify HMAC-SHA256 signature for Meta webhooks when an app secret is configured.
|
||||
if ctx.provider != "twilio" && !ctx.app_secret.is_empty() {
|
||||
let signature = req.header("X-Hub-Signature-256").unwrap_or("");
|
||||
if signature.is_empty() {
|
||||
slog!("[whatsapp] Missing X-Hub-Signature-256 header; rejecting request");
|
||||
return Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
.body("Missing signature");
|
||||
}
|
||||
if !verify::verify_meta_signature(&ctx.app_secret, &bytes, signature) {
|
||||
slog!("[whatsapp] X-Hub-Signature-256 verification failed; rejecting request");
|
||||
return Response::builder()
|
||||
.status(StatusCode::FORBIDDEN)
|
||||
.body("Invalid signature");
|
||||
}
|
||||
}
|
||||
|
||||
let messages = if ctx.provider == "twilio" {
|
||||
let msgs = extract_twilio_text_messages(&bytes);
|
||||
if msgs.is_empty() {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! HMAC-SHA256 signature verification for Meta WhatsApp webhooks.
|
||||
//!
|
||||
//! Meta signs every inbound webhook request with the app secret and sends
|
||||
//! the result as `X-Hub-Signature-256: sha256=<hex_digest>`. This module
|
||||
//! verifies that signature using the `hmac` + `sha2` crates and a
|
||||
//! constant-time comparison from `subtle`.
|
||||
|
||||
use std::fmt::Write as FmtWrite;
|
||||
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use sha2::Sha256;
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// Verify the `X-Hub-Signature-256` header sent by Meta.
|
||||
///
|
||||
/// Meta computes `HMAC-SHA256(app_secret, raw_body)` and sends the result
|
||||
/// as `sha256=<hex_digest>` in the `X-Hub-Signature-256` header.
|
||||
///
|
||||
/// Returns `true` if the signature is valid, `false` otherwise (including
|
||||
/// when the header is missing or malformed).
|
||||
pub(super) fn verify_meta_signature(app_secret: &str, body: &[u8], signature: &str) -> bool {
|
||||
// The header value must start with "sha256=".
|
||||
let Some(hex_sig) = signature.strip_prefix("sha256=") else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Compute HMAC-SHA256 over the raw request body.
|
||||
// `new_from_slice` accepts any key length, so `expect` is unreachable.
|
||||
let mut mac =
|
||||
HmacSha256::new_from_slice(app_secret.as_bytes()).expect("HMAC accepts any key size");
|
||||
mac.update(body);
|
||||
let computed = mac.finalize().into_bytes();
|
||||
|
||||
// Hex-encode the computed digest (32 bytes → 64 hex chars).
|
||||
let mut expected_hex = String::with_capacity(64);
|
||||
for byte in computed.iter() {
|
||||
write!(expected_hex, "{byte:02x}").unwrap();
|
||||
}
|
||||
|
||||
// Constant-time comparison to prevent timing attacks.
|
||||
expected_hex.as_bytes().ct_eq(hex_sig.as_bytes()).into()
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Compute a reference signature using the same algorithm.
|
||||
fn make_signature(secret: &str, body: &[u8]) -> String {
|
||||
let mut mac = HmacSha256::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.iter() {
|
||||
write!(hex, "{b:02x}").unwrap();
|
||||
}
|
||||
hex
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_signature_passes() {
|
||||
let secret = "my-app-secret";
|
||||
let body = b"test webhook body";
|
||||
let sig = make_signature(secret, body);
|
||||
assert!(verify_meta_signature(secret, body, &sig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_signature_is_rejected() {
|
||||
let secret = "my-app-secret";
|
||||
let body = b"test webhook body";
|
||||
assert!(!verify_meta_signature(
|
||||
secret,
|
||||
body,
|
||||
"sha256=0000000000000000000000000000000000000000000000000000000000000000"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_header_prefix_is_rejected() {
|
||||
let secret = "my-app-secret";
|
||||
let body = b"test webhook body";
|
||||
// No "sha256=" prefix.
|
||||
assert!(!verify_meta_signature(secret, body, "badhash"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_signature_is_rejected() {
|
||||
assert!(!verify_meta_signature("secret", b"body", ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_secret_is_rejected() {
|
||||
let body = b"test webhook body";
|
||||
let sig = make_signature("correct-secret", body);
|
||||
assert!(!verify_meta_signature("wrong-secret", body, &sig));
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,7 @@ pub(crate) fn build_bot_contexts(
|
||||
history_size: cfg.history_size,
|
||||
window_tracker: Arc::new(chat::transport::whatsapp::MessagingWindowTracker::new()),
|
||||
allowed_phones: cfg.whatsapp_allowed_phones.clone(),
|
||||
app_secret: cfg.whatsapp_app_secret.clone().unwrap_or_default(),
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user