security: harden architecture against Moltbot security model

- Discord: add allowed_users field + sender validation in listen()
- Slack: add allowed_users field + sender validation in listen()
- Webhook: add X-Webhook-Secret header auth (401 on mismatch)
- SecurityPolicy: add ActionTracker with sliding-window rate limiting
  - record_action() enforces max_actions_per_hour
  - is_rate_limited() checks without recording
- Gateway: print auth status on startup (ENABLED/DISABLED)
- 22 new tests (Discord/Slack allowlists, gateway header extraction,
  rate limiter: starts at zero, records, allows within limit,
  blocks over limit, clone independence)
- 554 tests passing, 0 clippy warnings
This commit is contained in:
argenis de la rosa 2026-02-13 15:31:21 -05:00
parent cf0ca71fdc
commit 542bb80743
7 changed files with 287 additions and 6 deletions

View file

@ -25,9 +25,22 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
let mem: Arc<dyn Memory> =
Arc::from(memory::create_memory(&config.memory, &config.workspace_dir)?);
// 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);
println!("🦀 ZeroClaw Gateway listening on http://{addr}");
println!(" POST /webhook — {{\"message\": \"your prompt\"}}");
println!(" GET /health — health check");
if webhook_secret.is_some() {
println!(" 🔒 Webhook authentication: ENABLED (X-Webhook-Secret header required)");
} else {
println!(" ⚠️ Webhook authentication: DISABLED (set [channels.webhook] secret to enable)");
}
println!(" Press Ctrl+C to stop.\n");
loop {
@ -36,6 +49,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
let model = model.clone();
let mem = mem.clone();
let auto_save = config.memory.auto_save;
let secret = webhook_secret.clone();
tokio::spawn(async move {
let mut buf = vec![0u8; 8192];
@ -50,7 +64,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
if let [method, path, ..] = parts.as_slice() {
tracing::info!("{peer} → {method} {path}");
handle_request(&mut stream, method, path, &request, &provider, &model, temperature, &mem, auto_save).await;
handle_request(&mut stream, method, path, &request, &provider, &model, temperature, &mem, auto_save, secret.as_ref()).await;
} else {
let _ = send_response(&mut stream, 400, "Bad Request").await;
}
@ -58,6 +72,19 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
}
}
/// Extract a header value from a raw HTTP request.
fn extract_header<'a>(request: &'a str, header_name: &str) -> Option<&'a str> {
let lower_name = header_name.to_lowercase();
for line in request.lines() {
if let Some((key, value)) = line.split_once(':') {
if key.trim().to_lowercase() == lower_name {
return Some(value.trim());
}
}
}
None
}
#[allow(clippy::too_many_arguments)]
async fn handle_request(
stream: &mut tokio::net::TcpStream,
@ -69,6 +96,7 @@ async fn handle_request(
temperature: f64,
mem: &Arc<dyn Memory>,
auto_save: bool,
webhook_secret: Option<&Arc<str>>,
) {
match (method, path) {
("GET", "/health") => {
@ -82,6 +110,19 @@ async fn handle_request(
}
("POST", "/webhook") => {
// Authenticate webhook requests if a secret is configured
if let Some(secret) = webhook_secret {
let header_val = extract_header(request, "X-Webhook-Secret");
match header_val {
Some(val) if val == secret.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"});
let _ = send_json(stream, 401, &err).await;
return;
}
}
}
handle_webhook(stream, request, provider, model, temperature, mem, auto_save).await;
}
@ -159,6 +200,35 @@ async fn send_response(
stream.write_all(response.as_bytes()).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_header_finds_value() {
let req = "POST /webhook HTTP/1.1\r\nHost: localhost\r\nX-Webhook-Secret: my-secret\r\n\r\n{}";
assert_eq!(extract_header(req, "X-Webhook-Secret"), Some("my-secret"));
}
#[test]
fn extract_header_case_insensitive() {
let req = "POST /webhook HTTP/1.1\r\nx-webhook-secret: abc123\r\n\r\n{}";
assert_eq!(extract_header(req, "X-Webhook-Secret"), Some("abc123"));
}
#[test]
fn extract_header_missing_returns_none() {
let req = "POST /webhook HTTP/1.1\r\nHost: localhost\r\n\r\n{}";
assert_eq!(extract_header(req, "X-Webhook-Secret"), None);
}
#[test]
fn extract_header_trims_whitespace() {
let req = "POST /webhook HTTP/1.1\r\nX-Webhook-Secret: spaced \r\n\r\n{}";
assert_eq!(extract_header(req, "X-Webhook-Secret"), Some("spaced"));
}
}
async fn send_json(
stream: &mut tokio::net::TcpStream,
status: u16,