huskies: merge 807

This commit is contained in:
dave
2026-04-28 20:44:31 +00:00
parent 2a77f73ba4
commit 97b9eaa39d
8 changed files with 175 additions and 9 deletions
+2
View File
@@ -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(),
})
}
+26 -1
View File
@@ -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));
}
}
+1
View File
@@ -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(),
})
});