agentmux_common/
allowed_origins.rs1pub const MAX_ENTRIES: usize = 32;
15
16pub 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 Ok(origin.replacen("://", "://*.", 1))
45 }
46 _ => Err(format!("{entry:?}: `*.` goes in front of a domain name with a dot in it")),
47 }
48}
49
50pub 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
66pub 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
79pub 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 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
104fn 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 assert!(ok("blob:https://example.com/0f0e"));
162 assert!(!ok("blob:https://evil.example/0f0e"));
163 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 let added = vec![target_origin("https://new.example/path").unwrap()];
185 assert!(allows(&added, "https://new.example/other"));
186 }
187}