diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index bfd97c5..a442a74 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -240,7 +240,17 @@ async fn handle_request( // WhatsApp incoming message webhook ("POST", "/whatsapp") => { - handle_whatsapp_message(stream, request, provider, model, temperature, mem, auto_save, whatsapp).await; + handle_whatsapp_message( + stream, + request, + provider, + model, + temperature, + mem, + auto_save, + whatsapp, + ) + .await; } ("POST", "/webhook") => { @@ -770,10 +780,7 @@ mod tests { #[test] fn urlencoding_decode_challenge_token() { // Typical Meta webhook challenge - assert_eq!( - urlencoding_decode("1234567890"), - "1234567890" - ); + assert_eq!(urlencoding_decode("1234567890"), "1234567890"); } #[test] @@ -781,4 +788,104 @@ mod tests { // URL-encoded UTF-8 bytes for emoji (simplified test) assert_eq!(urlencoding_decode("%41%42%43"), "ABC"); } + + // ══════════════════════════════════════════════════════════════════════════════ + // SECURITY TESTS — HTTP/1.1 compliance and attack prevention + // ══════════════════════════════════════════════════════════════════════════════ + + #[test] + fn max_body_size_constant_is_reasonable() { + // 64KB is a reasonable limit for webhook payloads + assert_eq!(MAX_BODY_SIZE, 65_536); + assert!(MAX_BODY_SIZE >= 1024, "Body limit should be at least 1KB"); + assert!( + MAX_BODY_SIZE <= 1_048_576, + "Body limit should be at most 1MB" + ); + } + + #[test] + fn request_timeout_constant_is_reasonable() { + // 30 seconds is reasonable for webhook processing + assert_eq!(REQUEST_TIMEOUT_SECS, 30); + assert!(REQUEST_TIMEOUT_SECS >= 5, "Timeout should be at least 5s"); + assert!( + REQUEST_TIMEOUT_SECS <= 120, + "Timeout should be at most 120s" + ); + } + + #[test] + fn header_injection_newline_in_value_rejected() { + // Header values with embedded newlines could be used for header injection + // axum's HeaderMap rejects these at parse time, but we verify our + // extract_header helper doesn't propagate malicious values + let req = "POST /webhook HTTP/1.1\r\nX-Evil: value\r\nInjected: header\r\n\r\n{}"; + // The raw parser sees "X-Evil" with value "value" and "Injected" as separate header + assert_eq!(extract_header(req, "X-Evil"), Some("value")); + assert_eq!(extract_header(req, "Injected"), Some("header")); + } + + #[test] + fn webhook_body_struct_requires_message_field() { + // Verify the WebhookBody struct enforces required fields + let valid = r#"{"message": "hello"}"#; + let parsed: Result = serde_json::from_str(valid); + assert!(parsed.is_ok()); + assert_eq!(parsed.unwrap().message, "hello"); + + let missing_field = r#"{"other": "field"}"#; + let parsed: Result = serde_json::from_str(missing_field); + assert!( + parsed.is_err(), + "Should reject JSON without 'message' field" + ); + + let empty_json = r#"{}"#; + let parsed: Result = serde_json::from_str(empty_json); + assert!(parsed.is_err(), "Should reject empty JSON object"); + } + + #[test] + fn whatsapp_verify_query_params_optional() { + // All query params should be optional to handle malformed requests gracefully + let empty = ""; + let parsed: Result = serde_urlencoded::from_str(empty); + assert!(parsed.is_ok()); + let q = parsed.unwrap(); + assert!(q.mode.is_none()); + assert!(q.verify_token.is_none()); + assert!(q.challenge.is_none()); + } + + #[test] + fn whatsapp_verify_query_params_parse_correctly() { + let query = "hub.mode=subscribe&hub.verify_token=mytoken&hub.challenge=123456"; + let parsed: Result = serde_urlencoded::from_str(query); + assert!(parsed.is_ok()); + let q = parsed.unwrap(); + assert_eq!(q.mode.as_deref(), Some("subscribe")); + assert_eq!(q.verify_token.as_deref(), Some("mytoken")); + assert_eq!(q.challenge.as_deref(), Some("123456")); + } + + #[test] + fn app_state_is_clone() { + // AppState must be Clone for axum's State extractor + fn assert_clone() {} + assert_clone::(); + } + + #[test] + fn constants_prevent_resource_exhaustion() { + // Verify constants are set to prevent DoS attacks + // Body limit prevents memory exhaustion + assert!( + MAX_BODY_SIZE < 10 * 1024 * 1024, + "Body limit should be < 10MB" + ); + // Timeout prevents connection exhaustion (slow-loris) + assert!(REQUEST_TIMEOUT_SECS > 0, "Timeout must be positive"); + assert!(REQUEST_TIMEOUT_SECS < 300, "Timeout should be < 5 minutes"); + } } diff --git a/src/main.rs b/src/main.rs index cb720c0..15fb75e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,7 +36,6 @@ mod skills; mod tools; mod tunnel; - use config::Config; /// `ZeroClaw` - Zero overhead. Zero compromise. 100% Rust. diff --git a/src/skills/symlink_tests.rs b/src/skills/symlink_tests.rs index 3aa4474..5968174 100644 --- a/src/skills/symlink_tests.rs +++ b/src/skills/symlink_tests.rs @@ -1,99 +1,105 @@ #[cfg(test)] mod symlink_tests { - use tempfile::TempDir; - use std::path::Path; use crate::skills::skills_dir; + use std::path::Path; + use tempfile::TempDir; #[test] fn test_skills_symlink_unix_edge_cases() { let tmp = TempDir::new().unwrap(); let workspace_dir = tmp.path().join("workspace"); std::fs::create_dir_all(&workspace_dir).unwrap(); - + let skills_path = skills_dir(&workspace_dir); std::fs::create_dir_all(&skills_path).unwrap(); - + // Test case 1: Valid symlink creation on Unix #[cfg(unix)] { let source_dir = tmp.path().join("source_skill"); std::fs::create_dir_all(&source_dir).unwrap(); std::fs::write(source_dir.join("SKILL.md"), "# Test Skill\nContent").unwrap(); - + let dest_link = skills_path.join("linked_skill"); - + // Create symlink let result = std::os::unix::fs::symlink(&source_dir, &dest_link); assert!(result.is_ok(), "Symlink creation should succeed"); - + // Verify symlink works assert!(dest_link.exists()); assert!(dest_link.is_symlink()); - + // Verify we can read through symlink let content = std::fs::read_to_string(dest_link.join("SKILL.md")); assert!(content.is_ok()); assert!(content.unwrap().contains("Test Skill")); - + // Test case 2: Symlink to non-existent target should fail gracefully let broken_link = skills_path.join("broken_skill"); let non_existent = tmp.path().join("non_existent"); let result = std::os::unix::fs::symlink(&non_existent, &broken_link); - assert!(result.is_ok(), "Symlink creation should succeed even if target doesn't exist"); - + assert!( + result.is_ok(), + "Symlink creation should succeed even if target doesn't exist" + ); + // But reading through it should fail let content = std::fs::read_to_string(broken_link.join("SKILL.md")); assert!(content.is_err()); } - + // Test case 3: Non-Unix platforms should handle symlink errors gracefully #[cfg(not(unix))] { let source_dir = tmp.path().join("source_skill"); std::fs::create_dir_all(&source_dir).unwrap(); - + let dest_link = skills_path.join("linked_skill"); - + // Symlink should fail on non-Unix let result = std::os::unix::fs::symlink(&source_dir, &dest_link); assert!(result.is_err()); - + // Directory should not exist assert!(!dest_link.exists()); } - + // Test case 4: skills_dir function edge cases let workspace_with_trailing_slash = format!("{}/", workspace_dir.display()); let path_from_str = skills_dir(Path::new(&workspace_with_trailing_slash)); assert_eq!(path_from_str, skills_path); - + // Test case 5: Empty workspace directory let empty_workspace = tmp.path().join("empty"); let empty_skills_path = skills_dir(&empty_workspace); assert_eq!(empty_skills_path, empty_workspace.join("skills")); assert!(!empty_skills_path.exists()); } - + #[test] fn test_skills_symlink_permissions_and_safety() { let tmp = TempDir::new().unwrap(); let workspace_dir = tmp.path().join("workspace"); std::fs::create_dir_all(&workspace_dir).unwrap(); - + let skills_path = skills_dir(&workspace_dir); std::fs::create_dir_all(&skills_path).unwrap(); - + #[cfg(unix)] { // Test case: Symlink outside workspace should be allowed (user responsibility) let outside_dir = tmp.path().join("outside_skill"); std::fs::create_dir_all(&outside_dir).unwrap(); std::fs::write(outside_dir.join("SKILL.md"), "# Outside Skill\nContent").unwrap(); - + let dest_link = skills_path.join("outside_skill"); let result = std::os::unix::fs::symlink(&outside_dir, &dest_link); - assert!(result.is_ok(), "Should allow symlinking to directories outside workspace"); - + assert!( + result.is_ok(), + "Should allow symlinking to directories outside workspace" + ); + // Should still be readable let content = std::fs::read_to_string(dest_link.join("SKILL.md")); assert!(content.is_ok());