Skip to main content

agentmux_common/
allowed_origins.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The sites an agent's browser pane may go to, when the agent limited it
5//! with `OpenBrowser({allowed_origins})`
6//! (docs/specs/SPEC_BROWSER_PANE_ALLOWED_ORIGINS_2026_10_09.md §2).
7//!
8//! Shared by srv, which checks the list it is given and re-checks every
9//! request, and the CEF host, which stops a navigation off the list before it
10//! happens. A list is kept in its normal form (`parse`): `https://example.com`,
11//! `https://*.example.com`, `http://localhost:3000`.
12
13/// Most entries one pane's list may have.
14pub const MAX_ENTRIES: usize = 32;
15
16/// `entry` in its normal form, or why it isn't a valid entry:
17/// - `example.com` → `https://example.com`
18/// - `*.example.com` → `https://*.example.com` (the domain and its subdomains)
19/// - `https://example.com:8443/any/path` → `https://example.com:8443`
20/// - `http://localhost:3000` → itself: `http` only when written out.
21pub fn parse(entry: &str) -> Result<String, String> {
22    let entry = entry.trim();
23    if entry.is_empty() {
24        return Err("an empty entry".to_string());
25    }
26    let (wildcard, rest) = match entry.strip_prefix("*.") {
27        Some(rest) => (true, rest),
28        None => (false, entry),
29    };
30    let with_scheme = if rest.contains("://") { rest.to_string() } else { format!("https://{rest}") };
31    let bad = || format!("{entry:?} isn't a site (write example.com, *.example.com or https://example.com:8443)");
32    let url = url::Url::parse(&with_scheme).map_err(|_| bad())?;
33    if !matches!(url.scheme(), "http" | "https") {
34        return Err(format!("{entry:?} isn't an http or https site"));
35    }
36    let Some(host) = url.host() else { return Err(bad()) };
37    let origin = url.origin().ascii_serialization();
38    if !wildcard {
39        return Ok(origin);
40    }
41    match host {
42        url::Host::Domain(d) if d.contains('.') => {
43            // The origin with `*.` in front of its host.
44            Ok(origin.replacen("://", "://*.", 1))
45        }
46        _ => Err(format!("{entry:?}: `*.` goes in front of a domain name with a dot in it")),
47    }
48}
49
50/// Parse every entry of `entries`, deduplicated, in order. Fails on the
51/// first bad entry, or past `MAX_ENTRIES`.
52pub fn parse_list(entries: &[String]) -> Result<Vec<String>, String> {
53    let mut out: Vec<String> = Vec::new();
54    for e in entries {
55        let p = parse(e)?;
56        if !out.contains(&p) {
57            out.push(p);
58        }
59    }
60    if out.len() > MAX_ENTRIES {
61        return Err(format!("at most {MAX_ENTRIES} sites"));
62    }
63    Ok(out)
64}
65
66/// The origin a navigation to `url` goes to, as an entry would name it
67/// exactly (a `blob:` URL's is the origin it belongs to). `None` for an
68/// address with no such origin (`about:`, `data:`).
69pub fn target_origin(url: &str) -> Option<String> {
70    let u = url::Url::parse(url).ok()?;
71    match u.origin() {
72        url::Origin::Tuple(scheme, _, _) if scheme == "http" || scheme == "https" => {
73            Some(u.origin().ascii_serialization())
74        }
75        _ => None,
76    }
77}
78
79/// May a pane limited to `list` (normal form) go to `url`? `about:` pages
80/// always; any other address with no web origin, never.
81pub fn allows(list: &[String], url: &str) -> bool {
82    if url::Url::parse(url).is_ok_and(|u| u.scheme() == "about") {
83        return true;
84    }
85    let Some(target) = target_origin(url) else { return false };
86    list.iter().any(|entry| entry_allows(entry, &target))
87}
88
89fn entry_allows(entry: &str, target: &str) -> bool {
90    if entry == target {
91        return true;
92    }
93    // `scheme://*.domain[:port]` matches the domain and any subdomain, on
94    // that scheme and port.
95    let Some((scheme, rest)) = entry.split_once("://*.") else { return false };
96    let Some(t_rest) = target.strip_prefix(scheme).and_then(|t| t.strip_prefix("://")) else {
97        return false;
98    };
99    let (domain, port) = split_port(rest);
100    let (t_host, t_port) = split_port(t_rest);
101    port == t_port && (t_host == domain || t_host.ends_with(&format!(".{domain}")))
102}
103
104/// `host[:port]` → (host, port), the port empty when there is none. An IPv6
105/// host (`[::1]`) without a port is left whole.
106fn split_port(s: &str) -> (&str, &str) {
107    if s.ends_with(']') {
108        return (s, "");
109    }
110    match s.rsplit_once(':') {
111        Some((h, p)) if !h.is_empty() && !p.is_empty() && p.bytes().all(|c| c.is_ascii_digit()) => (h, p),
112        _ => (s, ""),
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn entries_parse_to_their_normal_form() {
122        assert_eq!(parse("example.com").unwrap(), "https://example.com");
123        assert_eq!(parse(" Example.COM ").unwrap(), "https://example.com");
124        assert_eq!(parse("*.example.com").unwrap(), "https://*.example.com");
125        assert_eq!(parse("https://example.com:8443/a?b#c").unwrap(), "https://example.com:8443");
126        assert_eq!(parse("https://example.com:443").unwrap(), "https://example.com");
127        assert_eq!(parse("http://localhost:3000").unwrap(), "http://localhost:3000");
128        assert_eq!(parse("example.com/some/path").unwrap(), "https://example.com");
129        assert_eq!(parse("bücher.example").unwrap(), "https://xn--bcher-kva.example");
130        assert_eq!(parse("http://[::1]:8080").unwrap(), "http://[::1]:8080");
131        assert_eq!(parse("*.example.com:8443").unwrap(), "https://*.example.com:8443");
132    }
133
134    #[test]
135    fn bad_entries_are_refused() {
136        for bad in ["", "  ", "ftp://example.com", "javascript:alert(1)", "*.localhost", "*.192.0.2.1", "https://", "*."] {
137            assert!(parse(bad).is_err(), "{bad:?}");
138        }
139        let many: Vec<String> = (0..=MAX_ENTRIES).map(|i| format!("s{i}.example.com")).collect();
140        assert!(parse_list(&many).is_err());
141        assert_eq!(parse_list(&["a.example".into(), "https://a.example/".into()]).unwrap(), vec!["https://a.example"]);
142    }
143
144    #[test]
145    fn a_list_allows_its_sites_and_nothing_else() {
146        let list = parse_list(&["example.com".into(), "*.docs.example.org".into(), "http://localhost:3000".into()]).unwrap();
147        let ok = |u: &str| allows(&list, u);
148        assert!(ok("https://example.com/"));
149        assert!(ok("https://example.com/deep/path?q=1"));
150        assert!(!ok("https://www.example.com/"), "a bare domain is just that host");
151        assert!(!ok("http://example.com/"), "http only when written out");
152        assert!(!ok("https://example.com:8443/"));
153        assert!(ok("https://docs.example.org/"));
154        assert!(ok("https://a.b.docs.example.org/"));
155        assert!(!ok("https://evil-docs.example.org/"));
156        assert!(!ok("https://docs.example.org.evil.example/"));
157        assert!(ok("http://localhost:3000/app"));
158        assert!(!ok("http://localhost:3001/"));
159        assert!(!ok("https://evil.example/"));
160        // A blob belongs to its origin.
161        assert!(ok("blob:https://example.com/0f0e"));
162        assert!(!ok("blob:https://evil.example/0f0e"));
163        // about: pages are always fine; other non-web addresses never.
164        assert!(ok("about:blank"));
165        assert!(!ok("data:text/html,hi"));
166        assert!(!ok("file:///etc/passwd"));
167        assert!(!ok("not a url"));
168    }
169
170    #[test]
171    fn a_wildcard_keeps_its_port() {
172        let list = parse_list(&["*.example.com:8443".into()]).unwrap();
173        assert!(allows(&list, "https://a.example.com:8443/"));
174        assert!(!allows(&list, "https://a.example.com/"));
175    }
176
177    #[test]
178    fn target_origin_is_what_allow_adds() {
179        assert_eq!(target_origin("https://Evil.Example:443/x?y").as_deref(), Some("https://evil.example"));
180        assert_eq!(target_origin("blob:https://a.example/id").as_deref(), Some("https://a.example"));
181        assert_eq!(target_origin("about:blank"), None);
182        assert_eq!(target_origin("data:text/html,x"), None);
183        // What Allow adds then allows the same address.
184        let added = vec![target_origin("https://new.example/path").unwrap()];
185        assert!(allows(&added, "https://new.example/other"));
186    }
187}