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:
parent
cf0ca71fdc
commit
542bb80743
7 changed files with 287 additions and 6 deletions
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue