Skip to main content

agentmux_srv/messaging/whatsapp/
webhook.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! WhatsApp Cloud API inbound webhook receiver — axum handlers for the
5//! one-time verification handshake and the per-event delivery POST.
6//!
7//! These are the genuinely new shape versus Discord/Telegram/Slack, which
8//! only ever make *outbound* connections: this is passive HTTP the public
9//! internet (Meta's servers) can reach, so every inbound POST must be
10//! authenticated via HMAC signature (spec §3.2/§9) rather than AgentMux's
11//! own `X-AuthKey` scheme, which Meta cannot supply. **These routes must be
12//! registered outside `auth_middleware`** — see `server/mod.rs`, merged at
13//! the top level alongside the unauthenticated `health` router, not inside
14//! `authed_routes`. Putting them behind `X-AuthKey` would make Meta's calls
15//! always fail (spec §8.2).
16//!
17//! ## Credential handling
18//!
19//! Two credentials pass through this module: the webhook verify token
20//! (`hub.verify_token` on the GET handshake) and the app secret (used to
21//! validate `X-Hub-Signature-256` on every POST). Neither is ever logged —
22//! failures log only the fact of a mismatch, never the header value or the
23//! secret. This mirrors the review findings baked into
24//! `telegram/rest.rs::redact()` and `slack/rest.rs::redact_ticket()`: any
25//! error string built from a value that might embed a credential must be
26//! redacted (or, here, simply never constructed from the credential in the
27//! first place) before it can reach a log line or an HTTP response body.
28
29use std::collections::HashMap;
30
31use axum::{
32    body::Bytes,
33    extract::Query,
34    http::{HeaderMap, StatusCode},
35    response::IntoResponse,
36};
37use agentmux_common::secret_eq::secret_eq;
38use hmac::{Hmac, Mac};
39use sha2::Sha256;
40
41use super::types::WebhookPayload;
42use super::WhatsAppBridge;
43use crate::backend::reactive::handler::get_global_handler;
44use crate::backend::reactive::types::InjectionRequest;
45
46type HmacSha256 = Hmac<Sha256>;
47
48/// `GET /webhook/whatsapp?hub.mode=subscribe&hub.verify_token=...&hub.challenge=...`
49///
50/// Meta's one-time (per-URL-change) verification handshake (spec §3.1). Must
51/// succeed before Meta starts delivering inbound POSTs, and Meta re-runs
52/// this any time the webhook URL or verify token changes in the App
53/// Dashboard.
54pub async fn handle_verify(Query(params): Query<HashMap<String, String>>) -> impl IntoResponse {
55    let Some(bridge) = WhatsAppBridge::get() else {
56        return (
57            StatusCode::SERVICE_UNAVAILABLE,
58            "whatsapp bridge not initialized".to_string(),
59        )
60            .into_response();
61    };
62
63    let mode = params.get("hub.mode").map(String::as_str).unwrap_or("");
64    let token = params
65        .get("hub.verify_token")
66        .map(String::as_str)
67        .unwrap_or("");
68    let challenge = params.get("hub.challenge").cloned().unwrap_or_default();
69
70    let token_matches = secret_eq(token.as_bytes(), bridge.verify_token().as_bytes());
71
72    if mode == "subscribe" && token_matches {
73        (StatusCode::OK, challenge).into_response()
74    } else {
75        // Never log `token` — it's the shared secret being validated.
76        tracing::warn!(
77            "whatsapp_bridge: webhook verification handshake rejected (mode={mode:?}, token_match={token_matches})"
78        );
79        (StatusCode::FORBIDDEN, "verification failed".to_string()).into_response()
80    }
81}
82
83/// `POST /webhook/whatsapp` — inbound message delivery.
84///
85/// Body MUST be read as raw bytes (`Bytes`, not `Json<T>`) so the exact
86/// signed payload is available for the HMAC check before any deserialization
87/// happens (spec §3.2/§9.1) — deserializing first and re-serializing to
88/// check the signature would validate a re-encoded payload, not what Meta
89/// actually signed.
90pub async fn handle_inbound(headers: HeaderMap, body: Bytes) -> impl IntoResponse {
91    let Some(bridge) = WhatsAppBridge::get() else {
92        return StatusCode::SERVICE_UNAVAILABLE;
93    };
94
95    let sig_header = headers
96        .get("X-Hub-Signature-256")
97        .and_then(|v| v.to_str().ok())
98        .unwrap_or("");
99
100    if !verify_signature(bridge.app_secret(), &body, sig_header) {
101        // Reject before any payload parsing or logging of body content
102        // (spec §3.2 step 4). Do not log `sig_header` — while the signature
103        // itself isn't directly reusable as a credential (it's a per-request
104        // HMAC over this specific body), treating anything derived from
105        // request headers as potentially sensitive and never echoing it into
106        // logs is the safer default, consistent with the redaction posture
107        // Telegram and Slack's bridges adopted after review.
108        tracing::warn!("whatsapp_bridge: rejected inbound webhook — signature mismatch");
109        return StatusCode::UNAUTHORIZED;
110    }
111
112    let payload: WebhookPayload = match serde_json::from_slice(&body) {
113        Ok(p) => p,
114        Err(e) => {
115            tracing::warn!("whatsapp_bridge: webhook payload parse error: {e}");
116            return StatusCode::BAD_REQUEST;
117        }
118    };
119
120    for msg in payload.extract_messages() {
121        bridge.record_inbound(&msg.from);
122
123        let Some(target) = bridge.target_agent() else {
124            tracing::debug!(
125                "whatsapp_bridge: message from {} (no target agent configured)",
126                msg.from
127            );
128            continue;
129        };
130
131        let envelope = format!("[WhatsApp {}]: {}", msg.from, msg.text);
132        let handler = get_global_handler();
133        let req = InjectionRequest {
134            target_agent: target.clone(),
135            message: envelope,
136            source_agent: Some("whatsapp".to_string()),
137            request_id: Some(msg.id.clone()),
138            priority: None,
139            jekt_tier: None,
140            delivery_tier: Some("wan".to_string()),
141            forward_hops: 0,
142            ..Default::default()
143        };
144        let result = handler.inject_message(req);
145        if !result.success {
146            tracing::warn!(
147                "whatsapp_bridge: inject to agent {target} failed: {:?}",
148                result.error
149            );
150        }
151    }
152
153    // Always 200 for a successfully-parsed batch, even if local injection
154    // failed for one or more messages — per Meta's delivery semantics, a
155    // non-2xx response causes retries and eventual (after ~7 days of
156    // failures) event drop; local injection failures are our problem to
157    // observe via logs, not something that should make Meta think delivery
158    // itself failed (spec §5.2).
159    StatusCode::OK
160}
161
162/// Validates `X-Hub-Signature-256: sha256=<hex>` = HMAC-SHA256(app_secret,
163/// raw_body). Uses `Mac::verify_slice`, which performs a constant-time
164/// comparison internally (backed by the `subtle` crate, already present
165/// transitively via `hmac` in this workspace's dependency graph — no new
166/// crate added).
167fn verify_signature(app_secret: &str, body: &[u8], header: &str) -> bool {
168    let Some(hex_sig) = header.strip_prefix("sha256=") else {
169        return false;
170    };
171    let Ok(expected) = hex::decode(hex_sig) else {
172        return false;
173    };
174    let Ok(mut mac) = HmacSha256::new_from_slice(app_secret.as_bytes()) else {
175        return false;
176    };
177    mac.update(body);
178    mac.verify_slice(&expected).is_ok()
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    fn sign(secret: &str, body: &[u8]) -> String {
186        let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
187        mac.update(body);
188        format!("sha256={}", hex::encode(mac.finalize().into_bytes()))
189    }
190
191    #[test]
192    fn verify_signature_accepts_known_good_vector() {
193        let secret = "app_secret_123";
194        let body = b"{\"entry\":[]}";
195        let header = sign(secret, body);
196        assert!(verify_signature(secret, body, &header));
197    }
198
199    #[test]
200    fn verify_signature_rejects_wrong_secret() {
201        let header = sign("right_secret", b"hello");
202        assert!(!verify_signature("wrong_secret", b"hello", &header));
203    }
204
205    #[test]
206    fn verify_signature_rejects_tampered_body() {
207        let header = sign("secret", b"original");
208        assert!(!verify_signature("secret", b"tampered", &header));
209    }
210
211    #[test]
212    fn verify_signature_rejects_missing_sha256_prefix() {
213        assert!(!verify_signature("secret", b"hello", "deadbeef"));
214    }
215
216    #[test]
217    fn verify_signature_rejects_malformed_hex() {
218        assert!(!verify_signature("secret", b"hello", "sha256=not_valid_hex"));
219    }
220
221    #[test]
222    fn verify_signature_rejects_empty_header() {
223        assert!(!verify_signature("secret", b"hello", ""));
224    }
225}