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)) else {
64            return false;
65        };
66        let scheme = url.scheme();
67        if self.rules.any_schemes.iter().any(|item| item == scheme) {
68            return true;
69        }
70        if self.rules.schemes.iter().any(|item| item == scheme) {
71            let host = host_of(&url);
72            if self.rules.hosts.contains(&host) {
73                return true;
74            }
75            if self.rules.domains.iter().any(|domain| under(&host, domain)) {
76                return true;
77            }
78        }
79        origin_of(&url).is_some_and(|origin| self.extra.contains(&origin))
80    }
81
82    pub fn target(&self, text: &str, previous: bool, opener: Opener) -> bool {
83        if self.inheriting(text) {
84            return previous || matches!(opener, Opener::Protected | Opener::Unknown);
85        }
86        self.url(text)
87    }
88
89    pub fn cookie_domain(&self, domain: &str) -> bool {
90        let mut domain = domain.to_ascii_lowercase();
91        if let Some(rest) = domain.strip_prefix('.') {
92            domain = rest.to_string();
93        }
94        if let Some(rest) = domain.strip_suffix('.') {
95            domain = rest.to_string();
96        }
97        if domain.is_empty() {
98            return false;
99        }
100        if self.rules.domains.iter().any(|item| under(&domain, item)) {
101            return true;
102        }
103        self.rules
104            .domains
105            .iter()
106            .chain(self.rules.hosts.iter())
107            .chain(self.extra_hosts.iter())
108            .any(|host| under(host, &domain))
109    }
110
111    pub fn storage_key(&self, key: &str) -> bool {
112        let mut parts = key.split('^');
113        let first = parts.next().unwrap_or("");
114        if self.url(first) {
115            return true;
116        }
117        parts
118            .filter_map(|part| part.strip_prefix('0'))
119            .any(|site| self.url(site))
120    }
121
122    pub fn pattern(&self, pattern: &str) -> bool {
123        let lowered = pattern.to_ascii_lowercase();
124        if self
125            .rules
126            .any_schemes
127            .iter()
128            .any(|scheme| lowered.starts_with(&format!("{scheme}:")))
129        {
130            return true;
131        }
132        let names: Vec<&String> = self
133            .rules
134            .domains
135            .iter()
136            .chain(self.rules.hosts.iter())
137            .chain(self.extra_hosts.iter())
138            .collect();
139        if pattern_hosts(&lowered)
140            .iter()
141            .any(|host| names.iter().any(|name| host_reaches(host, name)))
142        {
143            return true;
144        }
145        let literal: String = pattern
146            .chars()
147            .take_while(|ch| !matches!(ch, '*' | '?' | '{' | '}' | '[' | ']' | '\\'))
148            .collect();
149        literal.contains("://") && self.url(&literal)
150    }
151
152    fn inheriting(&self, text: &str) -> bool {
153        if text.is_empty() {
154            return true;
155        }
156        let Some(url) = parse(text) else {
157            return false;
158        };
159        url.scheme() == "about"
160            && self
161                .rules
162                .inheriting
163                .iter()
164                .any(|item| *item == format!("about:{}", url.path().to_ascii_lowercase()))
165    }
166
167    fn unwrap(&self, mut url: Option<Url>) -> Option<Url> {
168        let mut depth = 0;
169        while let Some(current) = url.as_ref() {
170            if depth >= 8
171                || !self
172                    .rules
173                    .inner_schemes
174                    .iter()
175                    .any(|item| item == current.scheme())
176            {
177                break;
178            }
179            let rest = current.as_str()[current.scheme().len() + 1..].to_string();
180            url = parse(&rest);
181            depth += 1;
182        }
183        url
184    }
185}
186
187fn pattern_hosts(pattern: &str) -> Vec<String> {
188    if let Some(index) = pattern.find("://") {
189        let scheme = &pattern[..index];
190        if !scheme.is_empty()
191            && scheme.chars().all(|ch| {
192                ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.' | '*' | '{' | '}' | ',')
193            })
194        {
195            let rest = &pattern[index + 3..];
196            let end = rest.find(['/', '#', '?']).unwrap_or(rest.len());
197            return authority_host(&rest[..end]).into_iter().collect();
198        }
199    }
200    let end = pattern.find(['#', '?']).unwrap_or(pattern.len());
201    pattern[..end]
202        .split('/')
203        .filter_map(authority_host)
204        .filter(|host| host.contains('.'))
205        .collect()
206}
207
208fn authority_host(authority: &str) -> Option<String> {
209    let host = authority.rsplit('@').next().unwrap_or(authority);
210    let host = match host.rfind(':') {
211        Some(index)
212            if !host.starts_with('[')
213                && host[index + 1..]
214                    .chars()
215                    .all(|ch| ch.is_ascii_digit() || ch == '*') =>
216        {
217            &host[..index]
218        }
219        _ => host,
220    };
221    let host = host.strip_suffix('.').unwrap_or(host);
222    if host.is_empty()
223        || host
224            .chars()
225            .all(|ch| matches!(ch, '*' | '?' | '{' | '}' | ','))
226    {
227        return None;
228    }
229    Some(host.to_string())
230}
231
232fn host_reaches(glob: &str, name: &str) -> bool {
233    let first: String = glob
234        .split('.')
235        .next()
236        .unwrap_or("")
237        .chars()
238        .filter(|ch| ch.is_ascii_alphanumeric() || *ch == '-')
239        .collect();
240    let label = if first.is_empty() {
241        "x".to_string()
242    } else {
243        first
244    };
245    [
246        name.to_string(),
247        format!("x.{name}"),
248        format!("{label}.{name}"),
249    ]
250    .iter()
251    .any(|candidate| host_glob(glob.as_bytes(), candidate.as_bytes()))
252}
253
254fn host_glob(glob: &[u8], text: &[u8]) -> bool {
255    let Some((&first, rest)) = glob.split_first() else {
256        return text.is_empty();
257    };
258    match first {
259        b'*' => (0..=text.len()).any(|taken| host_glob(rest, &text[taken..])),
260        b'{' => match rest.iter().position(|ch| *ch == b'}') {
261            Some(close) => {
262                (0..=text.len()).any(|taken| host_glob(&rest[close + 1..], &text[taken..]))
263            }
264            None => text.first() == Some(&b'{') && host_glob(rest, &text[1..]),
265        },
266        b'?' => !text.is_empty() && host_glob(rest, &text[1..]),
267        b'\\' => match rest.split_first() {
268            Some((&literal, after)) => {
269                text.first() == Some(&literal) && host_glob(after, &text[1..])
270            }
271            None => text.is_empty(),
272        },
273        literal => text.first() == Some(&literal) && host_glob(rest, &text[1..]),
274    }
275}
276
277fn parse(text: &str) -> Option<Url> {
278    Url::parse(text).ok()
279}
280
281fn host_of(url: &Url) -> String {
282    let host = url.host_str().unwrap_or("").to_ascii_lowercase();
283    match host.strip_suffix('.') {
284        Some(stripped) => stripped.to_string(),
285        None => host,
286    }
287}
288
289fn origin_of(url: &Url) -> Option<String> {
290    url.host_str().filter(|host| !host.is_empty())?;
291    let host = host_of(url);
292    Some(match url.port() {
293        Some(port) => format!("{}://{host}:{port}", url.scheme()),
294        None => format!("{}://{host}", url.scheme()),
295    })
296}
297
298fn under(host: &str, domain: &str) -> bool {
299    host == domain || host.ends_with(&format!(".{domain}"))
300}
301
302#[cfg(test)]
303mod tests {
304    use super::{Opener, Protected};
305    use slicc_browsers::protected::{
306        vectors, OPENER_PROTECTED, OPENER_UNKNOWN, OPENER_UNPROTECTED,
307    };
308
309    fn guard() -> Protected {
310        let vectors = vectors();
311        let origins: Vec<&str> = vectors.origins.iter().map(String::as_str).collect();
312        Protected::new(&origins)
313    }
314
315    #[test]
316    fn every_url_vector_matches_slicc_browsers() {
317        let guard = guard();
318        for vector in vectors().urls {
319            assert_eq!(guard.url(&vector.url), vector.protected, "{:?}", vector.url);
320        }
321    }
322
323    #[test]
324    fn every_target_vector_matches_slicc_browsers() {
325        let guard = guard();
326        for vector in vectors().targets {
327            let opener = match vector.opener.as_deref() {
328                None => Opener::None,
329                Some(OPENER_PROTECTED) => Opener::Protected,
330                Some(OPENER_UNPROTECTED) => Opener::Unprotected,
331                Some(OPENER_UNKNOWN) => Opener::Unknown,
332                Some(other) => panic!("unknown opener {other}"),
333            };
334            assert_eq!(
335                guard.target(&vector.url, vector.previous, opener),
336                vector.protected,
337                "{vector:?}"
338            );
339        }
340    }
341
342    #[test]
343    fn every_cookie_and_storage_vector_matches_slicc_browsers() {
344        let guard = guard();
345        let all = vectors();
346        for vector in all.cookie_domains {
347            assert_eq!(
348                guard.cookie_domain(&vector.domain),
349                vector.protected,
350                "{vector:?}"
351            );
352        }
353        for vector in all.storage_keys {
354            assert_eq!(
355                guard.storage_key(&vector.key),
356                vector.protected,
357                "{vector:?}"
358            );
359        }
360    }
361
362    #[test]
363    fn patterns_that_name_seven_or_the_vault_are_protected() {
364        let guard = Protected::default();
365        for pattern in [
366            "https://vault.sliccy.ai/*",
367            "**://*.sliccy.ai/**",
368            "*sliccy.ai*",
369            "https://SEVEN.SLICCY.AI/api/*",
370            "https://www.sliccy.ai/auth/callback*",
371            "devtools://*",
372            "https://sliccy.ai/",
373        ] {
374            assert!(guard.pattern(pattern), "{pattern}");
375        }
376        for pattern in [
377            "**://*.sliccy.ai/**",
378            "**/*.sliccy.ai/**",
379            "https://seven.sliccy.*/x",
380            "https://user@vault.sliccy.ai:443/x",
381            "https://vault.sliccy.ai./x",
382            "*://{seven,other}.sliccy.ai/*",
383        ] {
384            assert!(guard.pattern(pattern), "{pattern}");
385        }
386        for pattern in [
387            "*",
388            "**/*",
389            "**/api/users",
390            "https://*/api",
391            "https://example.com/*",
392            "https://vault-sliccy.example/*",
393            "https://notsliccy.ai/*",
394            "https://vault.sliccy.ai.example/*",
395            "https://vault-sliccy.ai/*",
396            "**/x?next=https://seven.sliccy.ai/",
397            "https://example.com/?next=https://seven.sliccy.ai/",
398            "*/blocked*",
399            "**/api/*/details",
400        ] {
401            assert!(!guard.pattern(pattern), "{pattern}");
402        }
403        assert_eq!(
404            guard.refusal(),
405            "slicc: refused: target is on seven's origin"
406        );
407    }
408}