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 refusal_for_url(&self, text: &str) -> &str {
63        if self.rules.unparseable == "protected" && self.unparseable(text) {
64            &self.rules.unparseable_refusal
65        } else {
66            self.refusal()
67        }
68    }
69
70    pub fn unparseable(&self, text: &str) -> bool {
71        !matches!(text, "" | "null" | "://") && self.unwrap(parse(text)).is_none()
72    }
73
74    pub fn url(&self, text: &str) -> bool {
75        self.judge_url(text, false)
76    }
77
78    pub fn resource_key(&self, text: &str) -> Option<String> {
79        let mut url = self.unwrap(parse(text)).map(|url| self.rewrite_about(url))?;
80        url.set_username("").ok()?;
81        url.set_password(None).ok()?;
82        url.set_query(None);
83        url.set_fragment(None);
84        let path = url.path().trim_end_matches('/').to_string();
85        url.set_path(if path.is_empty() { "/" } else { &path });
86        Some(url.to_string())
87    }
88
89    pub fn redaction_schemes(&self) -> impl Iterator<Item = &str> {
90        self.rules.schemes.iter()
91            .chain(&self.rules.protected_schemes)
92            .chain(&self.rules.any_schemes)
93            .chain(&self.rules.inner_schemes)
94            .map(String::as_str)
95            .chain(self.extra.iter().filter_map(|origin| origin.split_once(':').map(|(scheme, _)| scheme)))
96    }
97
98    pub fn url_read_only(&self, text: &str) -> bool {
99        self.judge_url(text, true)
100    }
101
102    fn judge_url(&self, text: &str, read_only: bool) -> bool {
103        let Some(url) = self.unwrap(parse(text)).map(|url| self.rewrite_about(url)) else {
104            return !read_only && self.rules.unparseable == "protected" && !matches!(text, "" | "null" | "://");
105        };
106        let scheme = url.scheme();
107        if self
108            .rules
109            .protected_schemes
110            .iter()
111            .any(|item| item == scheme)
112        {
113            let pair = format!("{scheme}://{}", host_of(&url));
114            return !self.rules.scheme_exceptions.contains(&pair);
115        }
116        if self.rules.any_schemes.iter().any(|item| item == scheme) {
117            return true;
118        }
119        if self.rules.schemes.iter().any(|item| item == scheme) {
120            let host = host_of(&url);
121            if self.rules.hosts.contains(&host) {
122                return true;
123            }
124            if self.rules.domains.iter().any(|domain| under(&host, domain)) {
125                return true;
126            }
127        }
128        origin_of(&url).is_some_and(|origin| self.extra.contains(&origin))
129    }
130
131    pub fn target_type(&self, kind: &str) -> bool {
132        self.rules.target_types.iter().any(|item| item == kind)
133    }
134
135    pub fn error_page_url(&self, document_url: &str) -> bool {
136        self.rules.error_page.target == "previous"
137            && !self.rules.error_page.url.is_empty()
138            && document_url == self.rules.error_page.url
139    }
140
141    pub(crate) fn opaque_document(&self, text: &str, root: bool) -> bool {
142        root && (self.inheriting(text) || self.error_page_url(text))
143            || self.unwrap(parse(text)).map(|url| self.rewrite_about(url)).is_some_and(|url| {
144                self.rules.scheme_exceptions.contains(&format!("{}://{}", url.scheme(), host_of(&url)))
145            })
146    }
147
148    pub(crate) fn data_document(&self, text: &str) -> bool {
149        parse(text).is_some_and(|url| url.scheme() == "data")
150    }
151
152    pub fn error_document(&self, document_url: &str, origin: &str) -> bool {
153        self.error_page_url(document_url) && origin == "://"
154    }
155
156    pub fn error_redaction(&self) -> bool {
157        self.rules.error_page.redact == "origin"
158    }
159
160    pub fn target(&self, text: &str, previous: bool, opener: Opener) -> bool {
161        if self.inheriting(text) {
162            return previous || matches!(opener, Opener::Protected | Opener::Unknown);
163        }
164        self.url(text)
165    }
166
167    pub fn target_with_document(&self, text: &str, previous: bool, opener: Opener, document_url: Option<&str>, unreachable_url: Option<&str>, navigating: bool) -> bool {
168        if document_url.is_some_and(|url| self.error_page_url(url))
169            && unreachable_url == Some(text)
170            && !navigating
171        {
172            return previous;
173        }
174        self.target(text, previous, opener)
175    }
176
177    pub fn cookie_domain(&self, domain: &str) -> bool {
178        let mut domain = domain.to_ascii_lowercase();
179        if let Some(rest) = domain.strip_prefix('.') {
180            domain = rest.to_string();
181        }
182        if let Some(rest) = domain.strip_suffix('.') {
183            domain = rest.to_string();
184        }
185        if domain.is_empty() {
186            return false;
187        }
188        if self.rules.domains.iter().any(|item| under(&domain, item)) {
189            return true;
190        }
191        self.rules
192            .domains
193            .iter()
194            .chain(self.rules.hosts.iter())
195            .chain(self.extra_hosts.iter())
196            .any(|host| under(host, &domain))
197    }
198
199    pub fn cookie_scope(&self, domain: &str) -> bool {
200        canonical_cookie_domain(domain).is_none_or(|host| self.cookie_domain(&host))
201    }
202
203    pub fn cookie_url(&self, text: &str) -> bool {
204        let Some(url) = self.unwrap(parse(text)) else {
205            return true;
206        };
207        let host = host_of(&url);
208        if host.is_empty() || self.url(text) {
209            return true;
210        }
211        if !matches!(url.host(), Some(url::Host::Domain(_))) {
212            return self.cookie_domain(&host);
213        }
214        let labels: Vec<&str> = host.split('.').collect();
215        (0..labels.len()).any(|at| self.cookie_scope(&labels[at..].join(".")))
216    }
217
218    pub fn storage_key(&self, key: &str) -> bool {
219        let mut parts = key.split('^');
220        let first = parts.next().unwrap_or("");
221        if self.url_read_only(first) {
222            return true;
223        }
224        parts
225            .filter_map(|part| part.strip_prefix('0'))
226            .any(|site| self.url_read_only(site))
227    }
228
229    pub fn pattern(&self, pattern: &str) -> bool {
230        let lowered = pattern.to_ascii_lowercase();
231        if self
232            .rules
233            .any_schemes
234            .iter()
235            .any(|scheme| lowered.starts_with(&format!("{scheme}:")))
236        {
237            return true;
238        }
239        let names: Vec<&String> = self
240            .rules
241            .domains
242            .iter()
243            .chain(self.rules.hosts.iter())
244            .chain(self.extra_hosts.iter())
245            .collect();
246        if pattern_hosts(&lowered)
247            .iter()
248            .any(|host| names.iter().any(|name| host_reaches(host, name)))
249        {
250            return true;
251        }
252        let literal: String = pattern
253            .chars()
254            .take_while(|ch| !matches!(ch, '*' | '?' | '{' | '}' | '[' | ']' | '\\'))
255            .collect();
256        literal.contains("://") && self.url_read_only(&literal)
257    }
258
259    pub(crate) fn inheriting(&self, text: &str) -> bool {
260        if text.is_empty() {
261            return true;
262        }
263        let Some(url) = parse(text) else {
264            return false;
265        };
266        url.scheme() == "about"
267            && self
268                .rules
269                .inheriting
270                .iter()
271                .any(|item| *item == format!("about:{}", url.path().to_ascii_lowercase()))
272    }
273
274    fn rewrite_about(&self, url: Url) -> Url {
275        if url.scheme() != "about"
276            || self.rules.about_scheme.is_empty()
277            || self.inheriting(url.as_str())
278        {
279            return url;
280        }
281        let rest = &url.as_str()[url.scheme().len() + 1..];
282        parse(&format!("{}://{rest}", self.rules.about_scheme)).unwrap_or(url)
283    }
284
285    fn unwrap(&self, mut url: Option<Url>) -> Option<Url> {
286        let mut depth = 0;
287        while let Some(current) = url.as_ref() {
288            if !self
289                .rules
290                .inner_schemes
291                .iter()
292                .any(|item| item == current.scheme())
293            {
294                break;
295            }
296            if depth >= 8 {
297                return None;
298            }
299            let rest = current.as_str()[current.scheme().len() + 1..].to_string();
300            url = parse(&rest);
301            depth += 1;
302        }
303        url
304    }
305}
306
307fn pattern_hosts(pattern: &str) -> Vec<String> {
308    if let Some(index) = pattern.find("://") {
309        let scheme = &pattern[..index];
310        if !scheme.is_empty()
311            && scheme.chars().all(|ch| {
312                ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.' | '*' | '{' | '}' | ',')
313            })
314        {
315            let rest = &pattern[index + 3..];
316            let end = rest.find(['/', '#', '?']).unwrap_or(rest.len());
317            return authority_host(&rest[..end]).into_iter().collect();
318        }
319    }
320    let end = pattern.find(['#', '?']).unwrap_or(pattern.len());
321    pattern[..end]
322        .split('/')
323        .filter_map(authority_host)
324        .filter(|host| host.contains('.'))
325        .collect()
326}
327
328fn authority_host(authority: &str) -> Option<String> {
329    let host = authority.rsplit('@').next().unwrap_or(authority);
330    let host = match host.rfind(':') {
331        Some(index)
332            if !host.starts_with('[')
333                && host[index + 1..]
334                    .chars()
335                    .all(|ch| ch.is_ascii_digit() || ch == '*') =>
336        {
337            &host[..index]
338        }
339        _ => host,
340    };
341    let host = host.strip_suffix('.').unwrap_or(host);
342    if host.is_empty()
343        || host
344            .chars()
345            .all(|ch| matches!(ch, '*' | '?' | '{' | '}' | ','))
346    {
347        return None;
348    }
349    Some(host.to_string())
350}
351
352fn host_reaches(glob: &str, name: &str) -> bool {
353    let first: String = glob
354        .split('.')
355        .next()
356        .unwrap_or("")
357        .chars()
358        .filter(|ch| ch.is_ascii_alphanumeric() || *ch == '-')
359        .collect();
360    let label = if first.is_empty() {
361        "x".to_string()
362    } else {
363        first
364    };
365    [
366        name.to_string(),
367        format!("x.{name}"),
368        format!("{label}.{name}"),
369    ]
370    .iter()
371    .any(|candidate| host_glob(glob.as_bytes(), candidate.as_bytes()))
372}
373
374fn host_glob(glob: &[u8], text: &[u8]) -> bool {
375    let Some((&first, rest)) = glob.split_first() else {
376        return text.is_empty();
377    };
378    match first {
379        b'*' => (0..=text.len()).any(|taken| host_glob(rest, &text[taken..])),
380        b'{' => match rest.iter().position(|ch| *ch == b'}') {
381            Some(close) => {
382                (0..=text.len()).any(|taken| host_glob(&rest[close + 1..], &text[taken..]))
383            }
384            None => text.first() == Some(&b'{') && host_glob(rest, &text[1..]),
385        },
386        b'?' => !text.is_empty() && host_glob(rest, &text[1..]),
387        b'\\' => match rest.split_first() {
388            Some((&literal, after)) => {
389                text.first() == Some(&literal) && host_glob(after, &text[1..])
390            }
391            None => text.is_empty(),
392        },
393        literal => text.first() == Some(&literal) && host_glob(rest, &text[1..]),
394    }
395}
396
397pub fn canonical_cookie_domain(domain: &str) -> Option<String> {
398    if domain.is_empty() || domain.chars().any(|ch| ch.is_whitespace() || matches!(ch, '/' | '\\' | '@' | '?' | '#')) {
399        return None;
400    }
401    let normalized = domain.replace(['。', '.', '。'], ".");
402    let normalized = normalized.trim_start_matches('.');
403    let url = parse(&format!("http://{normalized}/"))?;
404    let host = host_of(&url).trim_matches('.').to_string();
405    if matches!(url.host(), Some(url::Host::Domain(_))) && host.split('.').any(|label| {
406        label.is_empty() || label.starts_with('-') || label.ends_with('-')
407            || !label.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
408    }) {
409        return None;
410    }
411    Some(host)
412}
413
414fn parse(text: &str) -> Option<Url> {
415    Url::parse(text).ok()
416}
417
418fn host_of(url: &Url) -> String {
419    let host = url.host_str().unwrap_or("").to_ascii_lowercase();
420    match host.strip_suffix('.') {
421        Some(stripped) => stripped.to_string(),
422        None => host,
423    }
424}
425
426fn origin_of(url: &Url) -> Option<String> {
427    url.host_str().filter(|host| !host.is_empty())?;
428    let host = host_of(url);
429    Some(match url.port() {
430        Some(port) => format!("{}://{host}:{port}", url.scheme()),
431        None => format!("{}://{host}", url.scheme()),
432    })
433}
434
435fn under(host: &str, domain: &str) -> bool {
436    host == domain || host.ends_with(&format!(".{domain}"))
437}
438
439#[cfg(test)]
440mod tests {
441    use super::{Opener, Protected};
442    use slicc_browsers::protected::{
443        vectors, OPENER_PROTECTED, OPENER_UNKNOWN, OPENER_UNPROTECTED,
444    };
445
446    fn guard() -> Protected {
447        let vectors = vectors();
448        let origins: Vec<&str> = vectors.origins.iter().map(String::as_str).collect();
449        Protected::new(&origins)
450    }
451
452    #[test]
453    fn unparseable_policy_and_refusal_are_catalog_driven() {
454        let mut rules = slicc_browsers::catalog().protected_origins;
455        rules.unparseable_refusal = "custom unparseable refusal".into();
456        let guard = Protected::with_rules(rules.clone(), &[]);
457        for text in ["https://exa%20mple.example/", "blob:invalid", "view-source:invalid"] {
458            assert!(guard.unparseable(text));
459            assert!(guard.url(text));
460            assert!(!guard.url_read_only(text));
461            assert!(!guard.storage_key(text));
462            assert_eq!(guard.refusal_for_url(text), "custom unparseable refusal");
463        }
464        for text in ["", "null", "://"] {
465            assert!(!guard.unparseable(text));
466            assert!(!guard.url(text));
467        }
468        let at_limit = format!("{}https://example.test/", "view-source:".repeat(8));
469        assert!(!guard.unparseable(&at_limit));
470        assert!(!guard.url(&at_limit));
471        let over_limit = format!("view-source:{at_limit}");
472        assert!(guard.unparseable(&over_limit));
473        assert!(guard.url(&over_limit));
474        rules.unparseable = "not protected".into();
475        let guard = Protected::with_rules(rules, &[]);
476        assert!(!guard.url("https://exa%20mple.example/"));
477        assert_eq!(guard.refusal_for_url("https://exa%20mple.example/"), guard.refusal());
478        for url in ["http://xn--1ug.sliccy.ai/", "http://xn--a.sliccy.ai/"] {
479            let guard = Protected::default();
480            assert!(guard.url(url));
481            assert!(guard.unparseable(url));
482        }
483    }
484
485    #[test]
486    fn privileged_rules_are_catalog_driven() {
487        let mut rules = slicc_browsers::catalog().protected_origins;
488        rules.protected_schemes = vec!["privileged".into()];
489        rules.scheme_exceptions = vec!["privileged://newtab".into()];
490        rules.about_scheme = "privileged".into();
491        rules.target_types = vec!["custom_ui".into()];
492        let guard = Protected::with_rules(rules.clone(), &[]);
493        for url in ["privileged://settings", "about:settings", "view-source:about:settings", "blob:privileged://settings/id", "filesystem:privileged://settings/temporary/x"] {
494            assert!(guard.url(url), "{url}");
495        }
496        for url in ["privileged://newtab/path", "about:newtab/path", "about:blank", "about:srcdoc", "chrome://settings"] {
497            assert!(!guard.url(url), "{url}");
498        }
499        assert!(guard.target_type("custom_ui"));
500        assert!(!guard.target_type("browser_ui"));
501        rules.about_scheme.clear();
502        assert!(!Protected::with_rules(rules, &[]).url("about:settings"));
503    }
504
505    #[test]
506    fn every_url_vector_matches_slicc_browsers() {
507        let guard = guard();
508        for vector in vectors().urls {
509            assert_eq!(guard.url(&vector.url), vector.protected, "{:?}", vector.url);
510        }
511    }
512
513    #[test]
514    fn every_target_vector_matches_slicc_browsers() {
515        let guard = guard();
516        for vector in vectors().targets {
517            let opener = match vector.opener.as_deref() {
518                None => Opener::None,
519                Some(OPENER_PROTECTED) => Opener::Protected,
520                Some(OPENER_UNPROTECTED) => Opener::Unprotected,
521                Some(OPENER_UNKNOWN) => Opener::Unknown,
522                Some(other) => panic!("unknown opener {other}"),
523            };
524            assert_eq!(
525                guard.target_type(vector.kind.as_deref().unwrap_or(""))
526                    || guard.target_with_document(&vector.url, vector.previous, opener, vector.document_url.as_deref(), vector.unreachable_url.as_deref(), vector.navigating),
527                vector.protected,
528                "{vector:?}"
529            );
530        }
531    }
532
533    #[test]
534    fn every_cookie_and_storage_vector_matches_slicc_browsers() {
535        let guard = guard();
536        let all = vectors();
537        for vector in all.cookie_domains {
538            assert_eq!(
539                guard.cookie_domain(&vector.domain),
540                vector.protected,
541                "{vector:?}"
542            );
543        }
544        for vector in all.storage_keys {
545            assert_eq!(
546                guard.storage_key(&vector.key),
547                vector.protected,
548                "{vector:?}"
549            );
550        }
551    }
552
553    #[test]
554    fn patterns_that_name_seven_or_the_vault_are_protected() {
555        let guard = Protected::default();
556        for pattern in [
557            "https://vault.sliccy.ai/*",
558            "**://*.sliccy.ai/**",
559            "*sliccy.ai*",
560            "https://SEVEN.SLICCY.AI/api/*",
561            "https://www.sliccy.ai/auth/callback*",
562            "devtools://*",
563            "https://sliccy.ai/",
564        ] {
565            assert!(guard.pattern(pattern), "{pattern}");
566        }
567        for pattern in [
568            "**://*.sliccy.ai/**",
569            "**/*.sliccy.ai/**",
570            "https://seven.sliccy.*/x",
571            "https://user@vault.sliccy.ai:443/x",
572            "https://vault.sliccy.ai./x",
573            "*://{seven,other}.sliccy.ai/*",
574        ] {
575            assert!(guard.pattern(pattern), "{pattern}");
576        }
577        for pattern in [
578            "*",
579            "**/*",
580            "**/api/users",
581            "https://*/api",
582            "https://example.com/*",
583            "https://vault-sliccy.example/*",
584            "https://notsliccy.ai/*",
585            "https://vault.sliccy.ai.example/*",
586            "https://vault-sliccy.ai/*",
587            "**/x?next=https://seven.sliccy.ai/",
588            "https://example.com/?next=https://seven.sliccy.ai/",
589            "*/blocked*",
590            "**/api/*/details",
591        ] {
592            assert!(!guard.pattern(pattern), "{pattern}");
593        }
594        assert_eq!(
595            guard.refusal(),
596            "slicc: refused: target is on seven's origin"
597        );
598    }
599}