fix: add WhatsApp webhook signature verification (X-Hub-Signature-256)

Closes #51

- Add HMAC-SHA256 signature verification for WhatsApp webhooks
- Prevents message spoofing attacks (CWE-345)
- Add whatsapp_app_secret config field with ZEROCLAW_WHATSAPP_APP_SECRET env override
- Add 13 comprehensive unit tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Argenis 2026-02-15 06:17:24 -05:00 committed by GitHub
parent 026a917544
commit 5cc02c5813
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 453 additions and 17 deletions

View file

@ -82,8 +82,7 @@ impl Provider for AnthropicProvider {
.await?;
if !response.status().is_success() {
let error = response.text().await?;
anyhow::bail!("Anthropic API error: {error}");
return Err(super::api_error("Anthropic", response).await);
}
let chat_response: ChatResponse = response.json().await?;

View file

@ -128,8 +128,7 @@ impl Provider for OpenAiCompatibleProvider {
let response = req.send().await?;
if !response.status().is_success() {
let error = response.text().await?;
anyhow::bail!("{} API error: {error}", self.name);
return Err(super::api_error(&self.name, response).await);
}
let chat_response: ChatResponse = response.json().await?;

View file

@ -11,6 +11,84 @@ pub use traits::Provider;
use compatible::{AuthStyle, OpenAiCompatibleProvider};
use reliable::ReliableProvider;
const MAX_API_ERROR_CHARS: usize = 200;
fn is_secret_char(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':')
}
fn token_end(input: &str, from: usize) -> usize {
let mut end = from;
for (i, c) in input[from..].char_indices() {
if is_secret_char(c) {
end = from + i + c.len_utf8();
} else {
break;
}
}
end
}
/// Scrub known secret-like token prefixes from provider error strings.
///
/// Redacts tokens with prefixes like `sk-`, `xoxb-`, and `xoxp-`.
pub fn scrub_secret_patterns(input: &str) -> String {
const PREFIXES: [&str; 3] = ["sk-", "xoxb-", "xoxp-"];
let mut scrubbed = input.to_string();
for prefix in PREFIXES {
let mut search_from = 0;
loop {
let Some(rel) = scrubbed[search_from..].find(prefix) else {
break;
};
let start = search_from + rel;
let content_start = start + prefix.len();
let end = token_end(&scrubbed, content_start);
// Bare prefixes like "sk-" should not stop future scans.
if end == content_start {
search_from = content_start;
continue;
}
scrubbed.replace_range(start..end, "[REDACTED]");
search_from = start + "[REDACTED]".len();
}
}
scrubbed
}
/// Sanitize API error text by scrubbing secrets and truncating length.
pub fn sanitize_api_error(input: &str) -> String {
let scrubbed = scrub_secret_patterns(input);
if scrubbed.chars().count() <= MAX_API_ERROR_CHARS {
return scrubbed;
}
let mut end = MAX_API_ERROR_CHARS;
while end > 0 && !scrubbed.is_char_boundary(end) {
end -= 1;
}
format!("{}...", &scrubbed[..end])
}
/// Build a sanitized provider error from a failed HTTP response.
pub async fn api_error(provider: &str, response: reqwest::Response) -> anyhow::Error {
let status = response.status();
let body = response
.text()
.await
.unwrap_or_else(|_| "<failed to read provider error body>".to_string());
let sanitized = sanitize_api_error(&body);
anyhow::anyhow!("{provider} API error ({status}): {sanitized}")
}
/// Factory: create the right provider from config
#[allow(clippy::too_many_lines)]
pub fn create_provider(name: &str, api_key: Option<&str>) -> anyhow::Result<Box<dyn Provider>> {
@ -394,4 +472,92 @@ mod tests {
);
}
}
// ── API error sanitization ───────────────────────────────
#[test]
fn sanitize_scrubs_sk_prefix() {
let input = "request failed: sk-1234567890abcdef";
let out = sanitize_api_error(input);
assert!(!out.contains("sk-1234567890abcdef"));
assert!(out.contains("[REDACTED]"));
}
#[test]
fn sanitize_scrubs_multiple_prefixes() {
let input = "keys sk-abcdef xoxb-12345 xoxp-67890";
let out = sanitize_api_error(input);
assert!(!out.contains("sk-abcdef"));
assert!(!out.contains("xoxb-12345"));
assert!(!out.contains("xoxp-67890"));
}
#[test]
fn sanitize_short_prefix_then_real_key() {
let input = "error with sk- prefix and key sk-1234567890";
let result = sanitize_api_error(input);
assert!(!result.contains("sk-1234567890"));
assert!(result.contains("[REDACTED]"));
}
#[test]
fn sanitize_sk_proj_comment_then_real_key() {
let input = "note: sk- then sk-proj-abc123def456";
let result = sanitize_api_error(input);
assert!(!result.contains("sk-proj-abc123def456"));
assert!(result.contains("[REDACTED]"));
}
#[test]
fn sanitize_keeps_bare_prefix() {
let input = "only prefix sk- present";
let result = sanitize_api_error(input);
assert!(result.contains("sk-"));
}
#[test]
fn sanitize_handles_json_wrapped_key() {
let input = r#"{"error":"invalid key sk-abc123xyz"}"#;
let result = sanitize_api_error(input);
assert!(!result.contains("sk-abc123xyz"));
}
#[test]
fn sanitize_handles_delimiter_boundaries() {
let input = "bad token xoxb-abc123}; next";
let result = sanitize_api_error(input);
assert!(!result.contains("xoxb-abc123"));
assert!(result.contains("};"));
}
#[test]
fn sanitize_truncates_long_error() {
let long = "a".repeat(400);
let result = sanitize_api_error(&long);
assert!(result.len() <= 203);
assert!(result.ends_with("..."));
}
#[test]
fn sanitize_truncates_after_scrub() {
let input = format!("{} sk-abcdef123456 {}", "a".repeat(190), "b".repeat(190));
let result = sanitize_api_error(&input);
assert!(!result.contains("sk-abcdef123456"));
assert!(result.len() <= 203);
}
#[test]
fn sanitize_preserves_unicode_boundaries() {
let input = format!("{} sk-abcdef123", "こんにちは".repeat(80));
let result = sanitize_api_error(&input);
assert!(std::str::from_utf8(result.as_bytes()).is_ok());
assert!(!result.contains("sk-abcdef123"));
}
#[test]
fn sanitize_no_secret_no_change() {
let input = "simple upstream timeout";
let result = sanitize_api_error(input);
assert_eq!(result, input);
}
}

View file

@ -88,10 +88,8 @@ impl Provider for OllamaProvider {
let response = self.client.post(&url).json(&request).send().await?;
if !response.status().is_success() {
let error = response.text().await?;
anyhow::bail!(
"Ollama error: {error}. Is Ollama running? (brew install ollama && ollama serve)"
);
let err = super::api_error("Ollama", response).await;
anyhow::bail!("{err}. Is Ollama running? (brew install ollama && ollama serve)");
}
let chat_response: ChatResponse = response.json().await?;

View file

@ -91,8 +91,7 @@ impl Provider for OpenAiProvider {
.await?;
if !response.status().is_success() {
let error = response.text().await?;
anyhow::bail!("OpenAI API error: {error}");
return Err(super::api_error("OpenAI", response).await);
}
let chat_response: ChatResponse = response.json().await?;

View file

@ -109,8 +109,7 @@ impl Provider for OpenRouterProvider {
.await?;
if !response.status().is_success() {
let error = response.text().await?;
anyhow::bail!("OpenRouter API error: {error}");
return Err(super::api_error("OpenRouter", response).await);
}
let chat_response: ChatResponse = response.json().await?;