Skip to main content

rustant_core/browser/
security.rs

1//! Browser security guard — URL allowlist/blocklist filtering and credential masking.
2
3use serde::{Deserialize, Serialize};
4use url::Url;
5
6/// Security guard that enforces URL restrictions and masks sensitive content.
7#[derive(Debug, Clone, Default, Serialize, Deserialize)]
8pub struct BrowserSecurityGuard {
9    /// If non-empty, only these domains are allowed.
10    pub allowed_domains: Vec<String>,
11    /// These domains are always blocked.
12    pub blocked_domains: Vec<String>,
13}
14
15impl BrowserSecurityGuard {
16    /// Create a new security guard with the given allowlist and blocklist.
17    pub fn new(allowed_domains: Vec<String>, blocked_domains: Vec<String>) -> Self {
18        Self {
19            allowed_domains,
20            blocked_domains,
21        }
22    }
23
24    /// Check whether a URL is allowed by the security policy.
25    ///
26    /// Rules:
27    /// 1. If blocked_domains is non-empty and the URL's host matches, it is blocked.
28    /// 2. If allowed_domains is non-empty and the URL's host does NOT match, it is blocked.
29    /// 3. Otherwise, the URL is allowed.
30    pub fn check_url(&self, url_str: &str) -> Result<(), String> {
31        let url = Url::parse(url_str).map_err(|e| format!("Invalid URL: {}", e))?;
32        let host = url.host_str().unwrap_or("");
33
34        // Check blocklist first
35        if self
36            .blocked_domains
37            .iter()
38            .any(|d| host == d.as_str() || host.ends_with(&format!(".{}", d)))
39        {
40            return Err(format!("URL blocked by security policy: {}", url_str));
41        }
42
43        // Check allowlist (if non-empty, URL must match)
44        if !self.allowed_domains.is_empty()
45            && !self
46                .allowed_domains
47                .iter()
48                .any(|d| host == d.as_str() || host.ends_with(&format!(".{}", d)))
49        {
50            return Err(format!("URL not in allowlist: {}", url_str));
51        }
52
53        Ok(())
54    }
55
56    /// Mask credentials and sensitive content in a string.
57    ///
58    /// Replaces patterns like `password=...`, `token=...`, `secret=...`,
59    /// `Authorization: Bearer ...` with masked versions.
60    pub fn mask_credentials(content: &str) -> String {
61        let mut result = content.to_string();
62
63        // Mask common credential patterns in query strings / form data
64        let sensitive_keys = [
65            "password",
66            "passwd",
67            "pwd",
68            "token",
69            "secret",
70            "api_key",
71            "apikey",
72            "access_token",
73            "refresh_token",
74            "auth",
75        ];
76
77        for key in &sensitive_keys {
78            // Match key=value patterns (url-encoded or plain)
79            let patterns = [
80                format!("{}=", key),
81                format!("{}%3D", key),
82                format!("{}%3d", key),
83            ];
84            for pattern in &patterns {
85                if let Some(start) = result.to_lowercase().find(&pattern.to_lowercase()) {
86                    let value_start = start + pattern.len();
87                    // Find end of value (space, &, or end of string)
88                    let value_end = result[value_start..]
89                        .find(['&', ' ', '\n', '"'])
90                        .map(|i| value_start + i)
91                        .unwrap_or(result.len());
92                    if value_end > value_start {
93                        result.replace_range(value_start..value_end, "***MASKED***");
94                    }
95                }
96            }
97        }
98
99        // Mask Authorization headers
100        if let Some(start) = result.find("Authorization:") {
101            let header_start = start + "Authorization:".len();
102            let header_end = result[header_start..]
103                .find('\n')
104                .map(|i| header_start + i)
105                .unwrap_or(result.len());
106            result.replace_range(header_start..header_end, " ***MASKED***");
107        }
108
109        result
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn test_check_url_allowed_when_no_lists() {
119        let guard = BrowserSecurityGuard::default();
120        assert!(guard.check_url("https://example.com").is_ok());
121        assert!(guard.check_url("https://any-site.org/path?q=1").is_ok());
122    }
123
124    #[test]
125    fn test_check_url_blocked_by_blocklist() {
126        let guard = BrowserSecurityGuard::new(vec![], vec!["evil.com".to_string()]);
127        assert!(guard.check_url("https://evil.com/malware").is_err());
128        assert!(guard.check_url("https://sub.evil.com").is_err());
129        // Other domains should be allowed
130        assert!(guard.check_url("https://example.com").is_ok());
131    }
132
133    #[test]
134    fn test_check_url_allowed_by_allowlist() {
135        let guard = BrowserSecurityGuard::new(
136            vec!["example.com".to_string(), "docs.rs".to_string()],
137            vec![],
138        );
139        assert!(guard.check_url("https://example.com/page").is_ok());
140        assert!(guard.check_url("https://docs.rs/tokio").is_ok());
141        assert!(guard.check_url("https://sub.example.com").is_ok());
142    }
143
144    #[test]
145    fn test_check_url_not_in_allowlist_rejected() {
146        let guard = BrowserSecurityGuard::new(vec!["example.com".to_string()], vec![]);
147        assert!(guard.check_url("https://other-site.com").is_err());
148        let err = guard.check_url("https://other-site.com").unwrap_err();
149        assert!(err.contains("not in allowlist"));
150    }
151
152    #[test]
153    fn test_mask_credentials_hides_passwords() {
154        let input = "login: user password=SuperSecret123&next=/dashboard";
155        let masked = BrowserSecurityGuard::mask_credentials(input);
156        assert!(!masked.contains("SuperSecret123"));
157        assert!(masked.contains("***MASKED***"));
158        assert!(masked.contains("login: user"));
159    }
160
161    #[test]
162    fn test_mask_credentials_preserves_other_content() {
163        let input = "Hello world, no secrets here.";
164        let masked = BrowserSecurityGuard::mask_credentials(input);
165        assert_eq!(masked, input);
166    }
167
168    #[test]
169    fn test_mask_credentials_hides_authorization_header() {
170        let input = "Authorization: Bearer eyJtoken123\nContent-Type: text/html";
171        let masked = BrowserSecurityGuard::mask_credentials(input);
172        assert!(!masked.contains("eyJtoken123"));
173        assert!(masked.contains("***MASKED***"));
174        assert!(masked.contains("Content-Type: text/html"));
175    }
176
177    #[test]
178    fn test_mask_credentials_hides_api_keys() {
179        let input = "api_key=sk-12345abc&format=json";
180        let masked = BrowserSecurityGuard::mask_credentials(input);
181        assert!(!masked.contains("sk-12345abc"));
182        assert!(masked.contains("***MASKED***"));
183        assert!(masked.contains("format=json"));
184    }
185
186    #[test]
187    fn test_check_url_invalid_url() {
188        let guard = BrowserSecurityGuard::default();
189        assert!(guard.check_url("not a valid url").is_err());
190    }
191
192    #[test]
193    fn test_blocklist_takes_priority_over_allowlist() {
194        let guard =
195            BrowserSecurityGuard::new(vec!["evil.com".to_string()], vec!["evil.com".to_string()]);
196        // Blocklist should win even if domain is in allowlist
197        assert!(guard.check_url("https://evil.com").is_err());
198        let err = guard.check_url("https://evil.com").unwrap_err();
199        assert!(err.contains("blocked"));
200    }
201}