1use slicc_browsers::ProtectedOrigins;
2use std::sync::OnceLock;
3use url::Url;
4
5#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6pub enum Opener {
7 None,
8 Protected,
9 Unprotected,
10 Unknown,
11}
12
13#[derive(Clone, Debug)]
14pub struct Protected {
15 rules: ProtectedOrigins,
16 extra: Vec<String>,
17 extra_hosts: Vec<String>,
18}
19
20fn catalog_rules() -> &'static ProtectedOrigins {
21 static RULES: OnceLock<ProtectedOrigins> = OnceLock::new();
22 RULES.get_or_init(|| slicc_browsers::catalog().protected_origins)
23}
24
25pub fn refusal() -> &'static str {
26 &catalog_rules().refusal
27}
28
29impl Default for Protected {
30 fn default() -> Self {
31 Self::new(&[])
32 }
33}
34
35impl Protected {
36 pub fn new(extra_origins: &[&str]) -> Self {
37 Self::with_rules(catalog_rules().clone(), extra_origins)
38 }
39
40 pub fn with_rules(rules: ProtectedOrigins, extra_origins: &[&str]) -> Self {
41 let parsed: Vec<Url> = extra_origins
42 .iter()
43 .filter_map(|text| parse(text))
44 .collect();
45 let extra = parsed.iter().filter_map(origin_of).collect();
46 let extra_hosts = parsed
47 .iter()
48 .filter(|url| url.host_str().is_some_and(|host| !host.is_empty()))
49 .map(host_of)
50 .collect();
51 Self {
52 rules,
53 extra,
54 extra_hosts,
55 }
56 }
57
58 pub fn refusal(&self) -> &str {
59 &self.rules.refusal
60 }
61
62 pub fn url(&self, text: &str) -> bool {
63 let Some(url) = self.unwrap(parse(text)).map(|url| self.rewrite_about(url)) else {
64 return false;
65 };
66 let scheme = url.scheme();
67 if self
68 .rules
69 .protected_schemes
70 .iter()
71 .any(|item| item == scheme)
72 {
73 let pair = format!("{scheme}://{}", host_of(&url));
74 return !self.rules.scheme_exceptions.contains(&pair);
75 }
76 if self.rules.any_schemes.iter().any(|item| item == scheme) {
77 return true;
78 }
79 if self.rules.schemes.iter().any(|item| item == scheme) {
80 let host = host_of(&url);
81 if self.rules.hosts.contains(&host) {
82 return true;
83 }
84 if self.rules.domains.iter().any(|domain| under(&host, domain)) {
85 return true;
86 }
87 }
88 origin_of(&url).is_some_and(|origin| self.extra.contains(&origin))
89 }
90
91 pub fn target_type(&self, kind: &str) -> bool {
92 self.rules.target_types.iter().any(|item| item == kind)
93 }
94
95 pub fn target(&self, text: &str, previous: bool, opener: Opener) -> bool {
96 if self.inheriting(text) {
97 return previous || matches!(opener, Opener::Protected | Opener::Unknown);
98 }
99 self.url(text)
100 }
101
102 pub fn cookie_domain(&self, domain: &str) -> bool {
103 let mut domain = domain.to_ascii_lowercase();
104 if let Some(rest) = domain.strip_prefix('.') {
105 domain = rest.to_string();
106 }
107 if let Some(rest) = domain.strip_suffix('.') {
108 domain = rest.to_string();
109 }
110 if domain.is_empty() {
111 return false;
112 }
113 if self.rules.domains.iter().any(|item| under(&domain, item)) {
114 return true;
115 }
116 self.rules
117 .domains
118 .iter()
119 .chain(self.rules.hosts.iter())
120 .chain(self.extra_hosts.iter())
121 .any(|host| under(host, &domain))
122 }
123
124 pub fn storage_key(&self, key: &str) -> bool {
125 let mut parts = key.split('^');
126 let first = parts.next().unwrap_or("");
127 if self.url(first) {
128 return true;
129 }
130 parts
131 .filter_map(|part| part.strip_prefix('0'))
132 .any(|site| self.url(site))
133 }
134
135 pub fn pattern(&self, pattern: &str) -> bool {
136 let lowered = pattern.to_ascii_lowercase();
137 if self
138 .rules
139 .any_schemes
140 .iter()
141 .any(|scheme| lowered.starts_with(&format!("{scheme}:")))
142 {
143 return true;
144 }
145 let names: Vec<&String> = self
146 .rules
147 .domains
148 .iter()
149 .chain(self.rules.hosts.iter())
150 .chain(self.extra_hosts.iter())
151 .collect();
152 if pattern_hosts(&lowered)
153 .iter()
154 .any(|host| names.iter().any(|name| host_reaches(host, name)))
155 {
156 return true;
157 }
158 let literal: String = pattern
159 .chars()
160 .take_while(|ch| !matches!(ch, '*' | '?' | '{' | '}' | '[' | ']' | '\\'))
161 .collect();
162 literal.contains("://") && self.url(&literal)
163 }
164
165 fn inheriting(&self, text: &str) -> bool {
166 if text.is_empty() {
167 return true;
168 }
169 let Some(url) = parse(text) else {
170 return false;
171 };
172 url.scheme() == "about"
173 && self
174 .rules
175 .inheriting
176 .iter()
177 .any(|item| *item == format!("about:{}", url.path().to_ascii_lowercase()))
178 }
179
180 fn rewrite_about(&self, url: Url) -> Url {
181 if url.scheme() != "about"
182 || self.rules.about_scheme.is_empty()
183 || self.inheriting(url.as_str())
184 {
185 return url;
186 }
187 let rest = &url.as_str()[url.scheme().len() + 1..];
188 parse(&format!("{}://{rest}", self.rules.about_scheme)).unwrap_or(url)
189 }
190
191 fn unwrap(&self, mut url: Option<Url>) -> Option<Url> {
192 let mut depth = 0;
193 while let Some(current) = url.as_ref() {
194 if depth >= 8
195 || !self
196 .rules
197 .inner_schemes
198 .iter()
199 .any(|item| item == current.scheme())
200 {
201 break;
202 }
203 let rest = current.as_str()[current.scheme().len() + 1..].to_string();
204 url = parse(&rest);
205 depth += 1;
206 }
207 url
208 }
209}
210
211fn pattern_hosts(pattern: &str) -> Vec<String> {
212 if let Some(index) = pattern.find("://") {
213 let scheme = &pattern[..index];
214 if !scheme.is_empty()
215 && scheme.chars().all(|ch| {
216 ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.' | '*' | '{' | '}' | ',')
217 })
218 {
219 let rest = &pattern[index + 3..];
220 let end = rest.find(['/', '#', '?']).unwrap_or(rest.len());
221 return authority_host(&rest[..end]).into_iter().collect();
222 }
223 }
224 let end = pattern.find(['#', '?']).unwrap_or(pattern.len());
225 pattern[..end]
226 .split('/')
227 .filter_map(authority_host)
228 .filter(|host| host.contains('.'))
229 .collect()
230}
231
232fn authority_host(authority: &str) -> Option<String> {
233 let host = authority.rsplit('@').next().unwrap_or(authority);
234 let host = match host.rfind(':') {
235 Some(index)
236 if !host.starts_with('[')
237 && host[index + 1..]
238 .chars()
239 .all(|ch| ch.is_ascii_digit() || ch == '*') =>
240 {
241 &host[..index]
242 }
243 _ => host,
244 };
245 let host = host.strip_suffix('.').unwrap_or(host);
246 if host.is_empty()
247 || host
248 .chars()
249 .all(|ch| matches!(ch, '*' | '?' | '{' | '}' | ','))
250 {
251 return None;
252 }
253 Some(host.to_string())
254}
255
256fn host_reaches(glob: &str, name: &str) -> bool {
257 let first: String = glob
258 .split('.')
259 .next()
260 .unwrap_or("")
261 .chars()
262 .filter(|ch| ch.is_ascii_alphanumeric() || *ch == '-')
263 .collect();
264 let label = if first.is_empty() {
265 "x".to_string()
266 } else {
267 first
268 };
269 [
270 name.to_string(),
271 format!("x.{name}"),
272 format!("{label}.{name}"),
273 ]
274 .iter()
275 .any(|candidate| host_glob(glob.as_bytes(), candidate.as_bytes()))
276}
277
278fn host_glob(glob: &[u8], text: &[u8]) -> bool {
279 let Some((&first, rest)) = glob.split_first() else {
280 return text.is_empty();
281 };
282 match first {
283 b'*' => (0..=text.len()).any(|taken| host_glob(rest, &text[taken..])),
284 b'{' => match rest.iter().position(|ch| *ch == b'}') {
285 Some(close) => {
286 (0..=text.len()).any(|taken| host_glob(&rest[close + 1..], &text[taken..]))
287 }
288 None => text.first() == Some(&b'{') && host_glob(rest, &text[1..]),
289 },
290 b'?' => !text.is_empty() && host_glob(rest, &text[1..]),
291 b'\\' => match rest.split_first() {
292 Some((&literal, after)) => {
293 text.first() == Some(&literal) && host_glob(after, &text[1..])
294 }
295 None => text.is_empty(),
296 },
297 literal => text.first() == Some(&literal) && host_glob(rest, &text[1..]),
298 }
299}
300
301fn parse(text: &str) -> Option<Url> {
302 Url::parse(text).ok()
303}
304
305fn host_of(url: &Url) -> String {
306 let host = url.host_str().unwrap_or("").to_ascii_lowercase();
307 match host.strip_suffix('.') {
308 Some(stripped) => stripped.to_string(),
309 None => host,
310 }
311}
312
313fn origin_of(url: &Url) -> Option<String> {
314 url.host_str().filter(|host| !host.is_empty())?;
315 let host = host_of(url);
316 Some(match url.port() {
317 Some(port) => format!("{}://{host}:{port}", url.scheme()),
318 None => format!("{}://{host}", url.scheme()),
319 })
320}
321
322fn under(host: &str, domain: &str) -> bool {
323 host == domain || host.ends_with(&format!(".{domain}"))
324}
325
326#[cfg(test)]
327mod tests {
328 use super::{Opener, Protected};
329 use slicc_browsers::protected::{
330 vectors, OPENER_PROTECTED, OPENER_UNKNOWN, OPENER_UNPROTECTED,
331 };
332
333 fn guard() -> Protected {
334 let vectors = vectors();
335 let origins: Vec<&str> = vectors.origins.iter().map(String::as_str).collect();
336 Protected::new(&origins)
337 }
338
339 #[test]
340 fn privileged_rules_are_catalog_driven() {
341 let mut rules = slicc_browsers::catalog().protected_origins;
342 rules.protected_schemes = vec!["privileged".into()];
343 rules.scheme_exceptions = vec!["privileged://newtab".into()];
344 rules.about_scheme = "privileged".into();
345 rules.target_types = vec!["custom_ui".into()];
346 let guard = Protected::with_rules(rules.clone(), &[]);
347 for url in ["privileged://settings", "about:settings", "view-source:about:settings", "blob:privileged://settings/id", "filesystem:privileged://settings/temporary/x"] {
348 assert!(guard.url(url), "{url}");
349 }
350 for url in ["privileged://newtab/path", "about:newtab/path", "about:blank", "about:srcdoc", "chrome://settings"] {
351 assert!(!guard.url(url), "{url}");
352 }
353 assert!(guard.target_type("custom_ui"));
354 assert!(!guard.target_type("browser_ui"));
355 rules.about_scheme.clear();
356 assert!(!Protected::with_rules(rules, &[]).url("about:settings"));
357 }
358
359 #[test]
360 fn every_url_vector_matches_slicc_browsers() {
361 let guard = guard();
362 for vector in vectors().urls {
363 assert_eq!(guard.url(&vector.url), vector.protected, "{:?}", vector.url);
364 }
365 }
366
367 #[test]
368 fn every_target_vector_matches_slicc_browsers() {
369 let guard = guard();
370 for vector in vectors().targets {
371 let opener = match vector.opener.as_deref() {
372 None => Opener::None,
373 Some(OPENER_PROTECTED) => Opener::Protected,
374 Some(OPENER_UNPROTECTED) => Opener::Unprotected,
375 Some(OPENER_UNKNOWN) => Opener::Unknown,
376 Some(other) => panic!("unknown opener {other}"),
377 };
378 assert_eq!(
379 guard.target_type(vector.kind.as_deref().unwrap_or(""))
380 || guard.target(&vector.url, vector.previous, opener),
381 vector.protected,
382 "{vector:?}"
383 );
384 }
385 }
386
387 #[test]
388 fn every_cookie_and_storage_vector_matches_slicc_browsers() {
389 let guard = guard();
390 let all = vectors();
391 for vector in all.cookie_domains {
392 assert_eq!(
393 guard.cookie_domain(&vector.domain),
394 vector.protected,
395 "{vector:?}"
396 );
397 }
398 for vector in all.storage_keys {
399 assert_eq!(
400 guard.storage_key(&vector.key),
401 vector.protected,
402 "{vector:?}"
403 );
404 }
405 }
406
407 #[test]
408 fn patterns_that_name_seven_or_the_vault_are_protected() {
409 let guard = Protected::default();
410 for pattern in [
411 "https://vault.sliccy.ai/*",
412 "**://*.sliccy.ai/**",
413 "*sliccy.ai*",
414 "https://SEVEN.SLICCY.AI/api/*",
415 "https://www.sliccy.ai/auth/callback*",
416 "devtools://*",
417 "https://sliccy.ai/",
418 ] {
419 assert!(guard.pattern(pattern), "{pattern}");
420 }
421 for pattern in [
422 "**://*.sliccy.ai/**",
423 "**/*.sliccy.ai/**",
424 "https://seven.sliccy.*/x",
425 "https://user@vault.sliccy.ai:443/x",
426 "https://vault.sliccy.ai./x",
427 "*://{seven,other}.sliccy.ai/*",
428 ] {
429 assert!(guard.pattern(pattern), "{pattern}");
430 }
431 for pattern in [
432 "*",
433 "**/*",
434 "**/api/users",
435 "https://*/api",
436 "https://example.com/*",
437 "https://vault-sliccy.example/*",
438 "https://notsliccy.ai/*",
439 "https://vault.sliccy.ai.example/*",
440 "https://vault-sliccy.ai/*",
441 "**/x?next=https://seven.sliccy.ai/",
442 "https://example.com/?next=https://seven.sliccy.ai/",
443 "*/blocked*",
444 "**/api/*/details",
445 ] {
446 assert!(!guard.pattern(pattern), "{pattern}");
447 }
448 assert_eq!(
449 guard.refusal(),
450 "slicc: refused: target is on seven's origin"
451 );
452 }
453}