elizaos-plugin-browser 2.0.0

Browser automation plugin for elizaOS - enables AI agents to browse websites, interact with elements, and extract data
Documentation
use crate::types::{RateLimitConfig, RateLimitEntry, SecurityConfig};
use crate::utils::errors::{security_error, BrowserError};
use regex::Regex;
use std::collections::HashMap;
use std::sync::RwLock;
use std::time::{SystemTime, UNIX_EPOCH};
use url::Url;

#[derive(Debug)]
pub struct ValidationResult {
    pub valid: bool,
    pub sanitized: Option<String>,
    pub error: Option<String>,
}

pub struct UrlValidator {
    config: SecurityConfig,
}

impl UrlValidator {
    pub fn new(config: SecurityConfig) -> Self {
        Self { config }
    }

    pub fn validate(&self, url: &str) -> ValidationResult {
        if url.len() > self.config.max_url_length {
            return ValidationResult {
                valid: false,
                sanitized: None,
                error: Some("URL is too long".to_string()),
            };
        }

        let parsed = match Url::parse(url) {
            Ok(u) => u,
            Err(_) => {
                let url_with_scheme = format!("https://{}", url);
                match Url::parse(&url_with_scheme) {
                    Ok(u) => u,
                    Err(_) => {
                        return ValidationResult {
                            valid: false,
                            sanitized: None,
                            error: Some("Invalid URL format".to_string()),
                        };
                    }
                }
            }
        };

        let scheme = parsed.scheme();
        if scheme == "file" && !self.config.allow_file_protocol {
            return ValidationResult {
                valid: false,
                sanitized: None,
                error: Some("File protocol is not allowed".to_string()),
            };
        }

        if !["http", "https", "file"].contains(&scheme) {
            return ValidationResult {
                valid: false,
                sanitized: None,
                error: Some("Only HTTP(S) protocols are allowed".to_string()),
            };
        }

        if let Some(host) = parsed.host_str() {
            let is_localhost = ["localhost", "127.0.0.1", "::1"].contains(&host);
            if is_localhost && !self.config.allow_localhost {
                return ValidationResult {
                    valid: false,
                    sanitized: None,
                    error: Some("Localhost URLs are not allowed".to_string()),
                };
            }

            for blocked in &self.config.blocked_domains {
                if host.contains(blocked) {
                    return ValidationResult {
                        valid: false,
                        sanitized: None,
                        error: Some(format!("Domain {} is blocked", blocked)),
                    };
                }
            }

            if !self.config.allowed_domains.is_empty() {
                let allowed = self
                    .config
                    .allowed_domains
                    .iter()
                    .any(|domain| host == domain || host.ends_with(&format!(".{}", domain)));
                if !allowed {
                    return ValidationResult {
                        valid: false,
                        sanitized: None,
                        error: Some("Domain is not in the allowed list".to_string()),
                    };
                }
            }
        }

        ValidationResult {
            valid: true,
            sanitized: Some(parsed.to_string()),
            error: None,
        }
    }

    pub fn update_config(&mut self, config: SecurityConfig) {
        self.config = config;
    }
}

impl Default for UrlValidator {
    fn default() -> Self {
        Self::new(SecurityConfig::default())
    }
}

pub struct InputSanitizer;

impl InputSanitizer {
    pub fn sanitize_text(input: &str) -> String {
        let re_tags = Regex::new(r"[<>]").unwrap();
        let re_js = Regex::new(r"(?i)javascript:").unwrap();
        let re_handlers = Regex::new(r"(?i)on\w+\s*=").unwrap();

        let result = re_tags.replace_all(input, "");
        let result = re_js.replace_all(&result, "");
        let result = re_handlers.replace_all(&result, "");
        result.trim().to_string()
    }

    pub fn sanitize_selector(selector: &str) -> String {
        let re_quotes = Regex::new(r#"['"]"#).unwrap();
        let re_tags = Regex::new(r"[<>]").unwrap();

        let result = re_quotes.replace_all(selector, "");
        let result = re_tags.replace_all(&result, "");
        result.trim().to_string()
    }

    pub fn sanitize_file_path(path: &str) -> String {
        let re_traversal = Regex::new(r"\.\.").unwrap();
        let re_invalid = Regex::new(r#"[<>:"|?\*]"#).unwrap();

        let result = re_traversal.replace_all(path, "");
        let result = re_invalid.replace_all(&result, "");
        result.trim().to_string()
    }
}

pub fn validate_secure_action(
    url: Option<&str>,
    validator: &UrlValidator,
) -> Result<(), BrowserError> {
    if let Some(url) = url {
        let result = validator.validate(url);
        if !result.valid {
            return Err(security_error(format!(
                "URL validation failed: {}",
                result.error.unwrap_or_default()
            )));
        }
    }
    Ok(())
}

pub struct RateLimiter {
    config: RateLimitConfig,
    action_counts: RwLock<HashMap<String, RateLimitEntry>>,
    session_counts: RwLock<HashMap<String, RateLimitEntry>>,
}

impl RateLimiter {
    pub fn new(config: RateLimitConfig) -> Self {
        Self {
            config,
            action_counts: RwLock::new(HashMap::new()),
            session_counts: RwLock::new(HashMap::new()),
        }
    }

    fn current_time() -> i64 {
        SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_secs() as i64
    }

    pub fn check_action_limit(&self, user_id: &str) -> bool {
        let now = Self::current_time();
        let mut counts = self.action_counts.write().unwrap();

        let entry = counts.entry(user_id.to_string()).or_insert(RateLimitEntry {
            count: 0,
            reset_time: now + 60,
        });

        if now > entry.reset_time {
            entry.count = 1;
            entry.reset_time = now + 60;
            return true;
        }

        if entry.count >= self.config.max_actions_per_minute {
            return false;
        }

        entry.count += 1;
        true
    }

    pub fn check_session_limit(&self, user_id: &str) -> bool {
        let now = Self::current_time();
        let mut counts = self.session_counts.write().unwrap();

        let entry = counts.entry(user_id.to_string()).or_insert(RateLimitEntry {
            count: 0,
            reset_time: now + 3600,
        });

        if now > entry.reset_time {
            entry.count = 1;
            entry.reset_time = now + 3600;
            return true;
        }

        if entry.count >= self.config.max_sessions_per_hour {
            return false;
        }

        entry.count += 1;
        true
    }
}

pub fn default_url_validator() -> UrlValidator {
    UrlValidator::default()
}