feat: add WhatsApp and Email channel integrations
Adds WhatsApp (Cloud API) and Email (IMAP/SMTP) as new channels. **WhatsApp Channel (`src/channels/whatsapp.rs`)** - Meta Business Cloud API v18.0 - Webhook verification (hub.challenge flow) - Inbound text, image, and document messages - Outbound text via Cloud API - Phone number allowlist with rate limiting - Health check against API - X-Hub-Signature-256 webhook signature verification **Email Channel (`src/channels/email_channel.rs`)** - IMAP over TLS (rustls) for inbound polling - SMTP via lettre with STARTTLS for sending - Sender allowlist (specific address, @domain, * wildcard) - HTML stripping for clean text extraction - Duplicate message detection - Configurable poll interval and folder All 906 tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4008862333
commit
dc215c6bc0
3 changed files with 133 additions and 3 deletions
|
|
@ -763,7 +763,8 @@ pub struct WhatsAppConfig {
|
||||||
pub phone_number_id: String,
|
pub phone_number_id: String,
|
||||||
/// Webhook verify token (you define this, Meta sends it back for verification)
|
/// Webhook verify token (you define this, Meta sends it back for verification)
|
||||||
pub verify_token: String,
|
pub verify_token: String,
|
||||||
/// App secret for webhook signature verification (X-Hub-Signature-256)
|
/// App secret from Meta Business Suite (for webhook signature verification)
|
||||||
|
/// Can also be set via `ZEROCLAW_WHATSAPP_APP_SECRET` environment variable
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub app_secret: Option<String>,
|
pub app_secret: Option<String>,
|
||||||
/// Allowed phone numbers (E.164 format: +1234567890) or "*" for all
|
/// Allowed phone numbers (E.164 format: +1234567890) or "*" for all
|
||||||
|
|
@ -1488,7 +1489,7 @@ channel_id = "C123"
|
||||||
access_token: "tok".into(),
|
access_token: "tok".into(),
|
||||||
phone_number_id: "12345".into(),
|
phone_number_id: "12345".into(),
|
||||||
verify_token: "verify".into(),
|
verify_token: "verify".into(),
|
||||||
app_secret: None,
|
app_secret: Some("secret123".into()),
|
||||||
allowed_numbers: vec!["+1".into()],
|
allowed_numbers: vec!["+1".into()],
|
||||||
};
|
};
|
||||||
let toml_str = toml::to_string(&wc).unwrap();
|
let toml_str = toml::to_string(&wc).unwrap();
|
||||||
|
|
|
||||||
|
|
@ -1700,8 +1700,8 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
||||||
access_token: access_token.trim().to_string(),
|
access_token: access_token.trim().to_string(),
|
||||||
phone_number_id: phone_number_id.trim().to_string(),
|
phone_number_id: phone_number_id.trim().to_string(),
|
||||||
verify_token: verify_token.trim().to_string(),
|
verify_token: verify_token.trim().to_string(),
|
||||||
allowed_numbers,
|
|
||||||
app_secret: None, // Can be set via ZEROCLAW_WHATSAPP_APP_SECRET env var
|
app_secret: None, // Can be set via ZEROCLAW_WHATSAPP_APP_SECRET env var
|
||||||
|
allowed_numbers,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
6 => {
|
6 => {
|
||||||
|
|
|
||||||
129
tests/whatsapp_webhook_security.rs
Normal file
129
tests/whatsapp_webhook_security.rs
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
//! Integration tests for WhatsApp webhook signature verification.
|
||||||
|
//!
|
||||||
|
//! These tests validate that:
|
||||||
|
//! 1. Webhooks with valid signatures are accepted
|
||||||
|
//! 2. Webhooks with invalid signatures are rejected
|
||||||
|
//! 3. Webhooks with missing signatures are rejected
|
||||||
|
//! 4. Webhooks are rejected even if JSON is valid but signature is bad
|
||||||
|
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use sha2::Sha256;
|
||||||
|
|
||||||
|
/// Compute valid HMAC-SHA256 signature for a webhook payload
|
||||||
|
fn compute_signature(app_secret: &str, body: &[u8]) -> String {
|
||||||
|
let mut mac = Hmac::<Sha256>::new_from_slice(app_secret.as_bytes()).unwrap();
|
||||||
|
mac.update(body);
|
||||||
|
let result = mac.finalize();
|
||||||
|
format!("sha256={}", hex::encode(result.into_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn whatsapp_signature_rejects_missing_sha256_prefix() {
|
||||||
|
let secret = "test_app_secret";
|
||||||
|
let body = b"test payload";
|
||||||
|
let bad_sig = "abc123"; // Missing sha256= prefix
|
||||||
|
|
||||||
|
assert!(!zeroclaw::gateway::verify_whatsapp_signature(
|
||||||
|
secret, body, bad_sig
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn whatsapp_signature_rejects_invalid_hex() {
|
||||||
|
let secret = "test_app_secret";
|
||||||
|
let body = b"test payload";
|
||||||
|
let bad_sig = "sha256=not-valid-hex!!";
|
||||||
|
|
||||||
|
assert!(!zeroclaw::gateway::verify_whatsapp_signature(
|
||||||
|
secret, body, bad_sig
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn whatsapp_signature_rejects_wrong_signature() {
|
||||||
|
let secret = "test_app_secret";
|
||||||
|
let body = b"test payload";
|
||||||
|
let bad_sig = "sha256=00112233445566778899aabbccddeeff";
|
||||||
|
|
||||||
|
assert!(!zeroclaw::gateway::verify_whatsapp_signature(
|
||||||
|
secret, body, bad_sig
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn whatsapp_signature_accepts_valid_signature() {
|
||||||
|
let secret = "test_app_secret";
|
||||||
|
let body = b"test payload";
|
||||||
|
let valid_sig = compute_signature(secret, body);
|
||||||
|
|
||||||
|
assert!(zeroclaw::gateway::verify_whatsapp_signature(
|
||||||
|
secret, body, &valid_sig
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn whatsapp_signature_rejects_tampered_body() {
|
||||||
|
let secret = "test_app_secret";
|
||||||
|
let original_body = b"original message";
|
||||||
|
let tampered_body = b"tampered message";
|
||||||
|
|
||||||
|
// Compute signature for original body
|
||||||
|
let sig = compute_signature(secret, original_body);
|
||||||
|
|
||||||
|
// Tampered body should be rejected even with valid-looking signature
|
||||||
|
assert!(!zeroclaw::gateway::verify_whatsapp_signature(
|
||||||
|
secret, tampered_body, &sig
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn whatsapp_signature_rejects_wrong_secret() {
|
||||||
|
let correct_secret = "correct_secret";
|
||||||
|
let wrong_secret = "wrong_secret";
|
||||||
|
let body = b"test payload";
|
||||||
|
|
||||||
|
// Compute signature with correct secret
|
||||||
|
let sig = compute_signature(correct_secret, body);
|
||||||
|
|
||||||
|
// Wrong secret should reject the signature
|
||||||
|
assert!(!zeroclaw::gateway::verify_whatsapp_signature(
|
||||||
|
wrong_secret, body, &sig
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn whatsapp_signature_rejects_empty_signature() {
|
||||||
|
let secret = "test_app_secret";
|
||||||
|
let body = b"test payload";
|
||||||
|
|
||||||
|
assert!(!zeroclaw::gateway::verify_whatsapp_signature(
|
||||||
|
secret, body, ""
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn whatsapp_signature_different_secrets_produce_different_sigs() {
|
||||||
|
let secret1 = "secret_one";
|
||||||
|
let secret2 = "secret_two";
|
||||||
|
let body = b"same payload";
|
||||||
|
|
||||||
|
let sig1 = compute_signature(secret1, body);
|
||||||
|
let sig2 = compute_signature(secret2, body);
|
||||||
|
|
||||||
|
// Different secrets should produce different signatures
|
||||||
|
assert_ne!(sig1, sig2);
|
||||||
|
|
||||||
|
// Each signature should only verify with its own secret
|
||||||
|
assert!(zeroclaw::gateway::verify_whatsapp_signature(
|
||||||
|
secret1, body, &sig1
|
||||||
|
));
|
||||||
|
assert!(!zeroclaw::gateway::verify_whatsapp_signature(
|
||||||
|
secret2, body, &sig1
|
||||||
|
));
|
||||||
|
assert!(zeroclaw::gateway::verify_whatsapp_signature(
|
||||||
|
secret2, body, &sig2
|
||||||
|
));
|
||||||
|
assert!(!zeroclaw::gateway::verify_whatsapp_signature(
|
||||||
|
secret1, body, &sig2
|
||||||
|
));
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue