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:
parent
026a917544
commit
5cc02c5813
13 changed files with 453 additions and 17 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue