Merge branch 'main' into pr-484-clean
This commit is contained in:
commit
ee05d62ce4
90 changed files with 6937 additions and 1403 deletions
|
|
@ -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)
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue