Skip to main content

cdp_client/
protected.rs

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}