Merge branch 'main' into pr-484-clean

This commit is contained in:
Will Sarg 2026-02-17 08:54:24 -05:00 committed by GitHub
commit ee05d62ce4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
90 changed files with 6937 additions and 1403 deletions

View file

@ -49,6 +49,13 @@ fn whatsapp_memory_key(msg: &crate::channels::traits::ChannelMessage) -> String
format!("whatsapp_{}_{}", msg.sender, msg.id)
}
fn hash_webhook_secret(value: &str) -> String {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(value.as_bytes());
hex::encode(digest)
}
/// How often the rate limiter sweeps stale IP entries from its map.
const RATE_LIMITER_SWEEP_INTERVAL_SECS: u64 = 300; // 5 minutes
@ -178,7 +185,8 @@ pub struct AppState {
pub temperature: f64,
pub mem: Arc<dyn Memory>,
pub auto_save: bool,
pub webhook_secret: Option<Arc<str>>,
/// SHA-256 hash of `X-Webhook-Secret` (hex-encoded), never plaintext.
pub webhook_secret_hash: Option<Arc<str>>,
pub pairing: Arc<PairingGuard>,
pub rate_limiter: Arc<GatewayRateLimiter>,
pub idempotency_store: Arc<IdempotencyStore>,
@ -208,6 +216,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
let provider: Arc<dyn Provider> = Arc::from(providers::create_resilient_provider(
config.default_provider.as_deref().unwrap_or("openrouter"),
config.api_key.as_deref(),
config.api_url.as_deref(),
&config.reliability,
)?);
let model = config
@ -251,12 +260,14 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
&config,
));
// Extract webhook secret for authentication
let webhook_secret: Option<Arc<str>> = config
.channels_config
.webhook
.as_ref()
.and_then(|w| w.secret.as_deref())
.map(Arc::from);
let webhook_secret_hash: Option<Arc<str>> =
config.channels_config.webhook.as_ref().and_then(|webhook| {
webhook.secret.as_ref().and_then(|raw_secret| {
let trimmed_secret = raw_secret.trim();
(!trimmed_secret.is_empty())
.then(|| Arc::<str>::from(hash_webhook_secret(trimmed_secret)))
})
});
// WhatsApp channel (if configured)
let whatsapp_channel: Option<Arc<WhatsAppChannel>> =
@ -342,9 +353,6 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
} else {
println!(" ⚠️ Pairing: DISABLED (all requests accepted)");
}
if webhook_secret.is_some() {
println!(" 🔒 Webhook secret: ENABLED");
}
println!(" Press Ctrl+C to stop.\n");
crate::health::mark_component_ok("gateway");
@ -356,7 +364,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
temperature,
mem,
auto_save: config.memory.auto_save,
webhook_secret,
webhook_secret_hash,
pairing,
rate_limiter,
idempotency_store,
@ -482,12 +490,15 @@ async fn handle_webhook(
}
// ── Webhook secret auth (optional, additional layer) ──
if let Some(ref secret) = state.webhook_secret {
let header_val = headers
if let Some(ref secret_hash) = state.webhook_secret_hash {
let header_hash = headers
.get("X-Webhook-Secret")
.and_then(|v| v.to_str().ok());
match header_val {
Some(val) if constant_time_eq(val, secret.as_ref()) => {}
.and_then(|v| v.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(hash_webhook_secret);
match header_hash {
Some(val) if constant_time_eq(&val, secret_hash.as_ref()) => {}
_ => {
tracing::warn!("Webhook: rejected request — invalid or missing X-Webhook-Secret");
let err = serde_json::json!({"error": "Unauthorized — invalid or missing X-Webhook-Secret header"});
@ -532,7 +543,7 @@ async fn handle_webhook(
let key = webhook_memory_key();
let _ = state
.mem
.store(&key, message, MemoryCategory::Conversation)
.store(&key, message, MemoryCategory::Conversation, None)
.await;
}
@ -685,7 +696,7 @@ async fn handle_whatsapp_message(
let key = whatsapp_memory_key(msg);
let _ = state
.mem
.store(&key, &msg.content, MemoryCategory::Conversation)
.store(&key, &msg.content, MemoryCategory::Conversation, None)
.await;
}
@ -697,7 +708,7 @@ async fn handle_whatsapp_message(
{
Ok(response) => {
// Send reply via WhatsApp
if let Err(e) = wa.send(&response, &msg.sender).await {
if let Err(e) = wa.send(&response, &msg.reply_target).await {
tracing::error!("Failed to send WhatsApp reply: {e}");
}
}
@ -706,7 +717,7 @@ async fn handle_whatsapp_message(
let _ = wa
.send(
"Sorry, I couldn't process your message right now.",
&msg.sender,
&msg.reply_target,
)
.await;
}
@ -798,7 +809,9 @@ mod tests {
.requests
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
guard.1 = Instant::now() - Duration::from_secs(RATE_LIMITER_SWEEP_INTERVAL_SECS + 1);
guard.1 = Instant::now()
.checked_sub(Duration::from_secs(RATE_LIMITER_SWEEP_INTERVAL_SECS + 1))
.unwrap();
// Clear timestamps for ip-2 and ip-3 to simulate stale entries
guard.0.get_mut("ip-2").unwrap().clear();
guard.0.get_mut("ip-3").unwrap().clear();
@ -848,6 +861,7 @@ mod tests {
let msg = ChannelMessage {
id: "wamid-123".into(),
sender: "+1234567890".into(),
reply_target: "+1234567890".into(),
content: "hello".into(),
channel: "whatsapp".into(),
timestamp: 1,
@ -871,11 +885,17 @@ mod tests {
_key: &str,
_content: &str,
_category: MemoryCategory,
_session_id: Option<&str>,
) -> anyhow::Result<()> {
Ok(())
}
async fn recall(&self, _query: &str, _limit: usize) -> anyhow::Result<Vec<MemoryEntry>> {
async fn recall(
&self,
_query: &str,
_limit: usize,
_session_id: Option<&str>,
) -> anyhow::Result<Vec<MemoryEntry>> {
Ok(Vec::new())
}
@ -886,6 +906,7 @@ mod tests {
async fn list(
&self,
_category: Option<&MemoryCategory>,
_session_id: Option<&str>,
) -> anyhow::Result<Vec<MemoryEntry>> {
Ok(Vec::new())
}
@ -938,6 +959,7 @@ mod tests {
key: &str,
_content: &str,
_category: MemoryCategory,
_session_id: Option<&str>,
) -> anyhow::Result<()> {
self.keys
.lock()
@ -946,7 +968,12 @@ mod tests {
Ok(())
}
async fn recall(&self, _query: &str, _limit: usize) -> anyhow::Result<Vec<MemoryEntry>> {
async fn recall(
&self,
_query: &str,
_limit: usize,
_session_id: Option<&str>,
) -> anyhow::Result<Vec<MemoryEntry>> {
Ok(Vec::new())
}
@ -957,6 +984,7 @@ mod tests {
async fn list(
&self,
_category: Option<&MemoryCategory>,
_session_id: Option<&str>,
) -> anyhow::Result<Vec<MemoryEntry>> {
Ok(Vec::new())
}
@ -991,7 +1019,7 @@ mod tests {
temperature: 0.0,
mem: memory,
auto_save: false,
webhook_secret: None,
webhook_secret_hash: None,
pairing: Arc::new(PairingGuard::new(false, &[])),
rate_limiter: Arc::new(GatewayRateLimiter::new(100, 100)),
idempotency_store: Arc::new(IdempotencyStore::new(Duration::from_secs(300))),
@ -1039,7 +1067,7 @@ mod tests {
temperature: 0.0,
mem: memory,
auto_save: true,
webhook_secret: None,
webhook_secret_hash: None,
pairing: Arc::new(PairingGuard::new(false, &[])),
rate_limiter: Arc::new(GatewayRateLimiter::new(100, 100)),
idempotency_store: Arc::new(IdempotencyStore::new(Duration::from_secs(300))),
@ -1077,6 +1105,125 @@ mod tests {
assert_eq!(provider_impl.calls.load(Ordering::SeqCst), 2);
}
#[test]
fn webhook_secret_hash_is_deterministic_and_nonempty() {
let one = hash_webhook_secret("secret-value");
let two = hash_webhook_secret("secret-value");
let other = hash_webhook_secret("other-value");
assert_eq!(one, two);
assert_ne!(one, other);
assert_eq!(one.len(), 64);
}
#[tokio::test]
async fn webhook_secret_hash_rejects_missing_header() {
let provider_impl = Arc::new(MockProvider::default());
let provider: Arc<dyn Provider> = provider_impl.clone();
let memory: Arc<dyn Memory> = Arc::new(MockMemory);
let state = AppState {
provider,
model: "test-model".into(),
temperature: 0.0,
mem: memory,
auto_save: false,
webhook_secret_hash: Some(Arc::from(hash_webhook_secret("super-secret"))),
pairing: Arc::new(PairingGuard::new(false, &[])),
rate_limiter: Arc::new(GatewayRateLimiter::new(100, 100)),
idempotency_store: Arc::new(IdempotencyStore::new(Duration::from_secs(300))),
whatsapp: None,
whatsapp_app_secret: None,
};
let response = handle_webhook(
State(state),
HeaderMap::new(),
Ok(Json(WebhookBody {
message: "hello".into(),
})),
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(provider_impl.calls.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn webhook_secret_hash_rejects_invalid_header() {
let provider_impl = Arc::new(MockProvider::default());
let provider: Arc<dyn Provider> = provider_impl.clone();
let memory: Arc<dyn Memory> = Arc::new(MockMemory);
let state = AppState {
provider,
model: "test-model".into(),
temperature: 0.0,
mem: memory,
auto_save: false,
webhook_secret_hash: Some(Arc::from(hash_webhook_secret("super-secret"))),
pairing: Arc::new(PairingGuard::new(false, &[])),
rate_limiter: Arc::new(GatewayRateLimiter::new(100, 100)),
idempotency_store: Arc::new(IdempotencyStore::new(Duration::from_secs(300))),
whatsapp: None,
whatsapp_app_secret: None,
};
let mut headers = HeaderMap::new();
headers.insert("X-Webhook-Secret", HeaderValue::from_static("wrong-secret"));
let response = handle_webhook(
State(state),
headers,
Ok(Json(WebhookBody {
message: "hello".into(),
})),
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(provider_impl.calls.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn webhook_secret_hash_accepts_valid_header() {
let provider_impl = Arc::new(MockProvider::default());
let provider: Arc<dyn Provider> = provider_impl.clone();
let memory: Arc<dyn Memory> = Arc::new(MockMemory);
let state = AppState {
provider,
model: "test-model".into(),
temperature: 0.0,
mem: memory,
auto_save: false,
webhook_secret_hash: Some(Arc::from(hash_webhook_secret("super-secret"))),
pairing: Arc::new(PairingGuard::new(false, &[])),
rate_limiter: Arc::new(GatewayRateLimiter::new(100, 100)),
idempotency_store: Arc::new(IdempotencyStore::new(Duration::from_secs(300))),
whatsapp: None,
whatsapp_app_secret: None,
};
let mut headers = HeaderMap::new();
headers.insert("X-Webhook-Secret", HeaderValue::from_static("super-secret"));
let response = handle_webhook(
State(state),
headers,
Ok(Json(WebhookBody {
message: "hello".into(),
})),
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(provider_impl.calls.load(Ordering::SeqCst), 1);
}
// ══════════════════════════════════════════════════════════
// WhatsApp Signature Verification Tests (CWE-345 Prevention)
// ══════════════════════════════════════════════════════════