rustant_core/browser/
security.rs1use serde::{Deserialize, Serialize};
4use url::Url;
5
6#[derive(Debug, Clone, Default, Serialize, Deserialize)]
8pub struct BrowserSecurityGuard {
9 pub allowed_domains: Vec<String>,
11 pub blocked_domains: Vec<String>,
13}
14
15impl BrowserSecurityGuard {
16 pub fn new(allowed_domains: Vec<String>, blocked_domains: Vec<String>) -> Self {
18 Self {
19 allowed_domains,
20 blocked_domains,
21 }
22 }
23
24 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 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 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 pub fn mask_credentials(content: &str) -> String {
61 let mut result = content.to_string();
62
63 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 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 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 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 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 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}