1pub const MAX_POPUPS_PER_PANE: usize = 8;
15
16#[derive(Debug, PartialEq, Eq)]
19pub enum Refusal {
20 NotWeb,
21 NoUserAction,
22 TooMany,
23 AnotherSite,
24}
25
26impl Refusal {
27 pub fn reason(&self) -> &'static str {
28 match self {
29 Refusal::NotWeb => "not an http(s) page",
30 Refusal::NoUserAction => "the page opened it without a click or key press",
31 Refusal::TooMany => "the pane already has as many popups open as it may",
32 Refusal::AnotherSite => "it is on another site, and no agent owns the pane that opened it",
33 }
34 }
35}
36
37pub fn decide(
47 url: &str,
48 opener_url: &str,
49 user_gesture: bool,
50 opener_owned: bool,
51 open_popups: usize,
52) -> Result<(), Refusal> {
53 if !is_web_url(url) {
54 return Err(Refusal::NotWeb);
55 }
56 if !user_gesture {
57 return Err(Refusal::NoUserAction);
58 }
59 if open_popups >= MAX_POPUPS_PER_PANE {
60 return Err(Refusal::TooMany);
61 }
62 if opener_owned || same_site(url, opener_url) {
63 Ok(())
64 } else {
65 Err(Refusal::AnotherSite)
66 }
67}
68
69fn is_web_url(url: &str) -> bool {
70 url::Url::parse(url).is_ok_and(|u| matches!(u.scheme(), "http" | "https"))
71}
72
73fn is_ip_host(url: &str) -> bool {
74 url::Url::parse(url).is_ok_and(|u| matches!(u.host(), Some(url::Host::Ipv4(_)) | Some(url::Host::Ipv6(_))))
75}
76
77fn host_of(url: &str) -> Option<String> {
78 let u = url::Url::parse(url).ok()?;
79 if !matches!(u.scheme(), "http" | "https") {
80 return None;
81 }
82 u.host_str().map(|h| h.trim_end_matches('.').to_ascii_lowercase())
83}
84
85pub fn same_site(a: &str, b: &str) -> bool {
90 if is_ip_host(a) || is_ip_host(b) {
94 return host_of(a).is_some() && host_of(a) == host_of(b);
95 }
96 let (Some(a), Some(b)) = (host_of(a), host_of(b)) else {
97 return false;
98 };
99 if a == b {
100 return true;
101 }
102 match (psl::domain_str(&a), psl::domain_str(&b)) {
103 (Some(da), Some(db)) => da == db,
104 _ => false,
105 }
106}
107
108pub fn origin_of(url: &str) -> String {
110 url::Url::parse(url)
111 .ok()
112 .filter(|u| matches!(u.scheme(), "http" | "https"))
113 .map(|u| u.origin().ascii_serialization())
114 .unwrap_or_default()
115}
116
117pub fn is_loopback(url: &str) -> bool {
119 let Ok(u) = url::Url::parse(url) else {
120 return false;
121 };
122 match u.host() {
123 Some(url::Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"),
124 Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
125 Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
126 None => false,
127 }
128}
129
130pub fn loopback_allowed(url: &str, opener_url: &str, own_origins: &[String]) -> bool {
136 if !is_loopback(url) {
137 return false;
138 }
139 let origin = origin_of(url);
140 !origin.is_empty()
141 && origin == origin_of(opener_url)
142 && !own_origins.iter().any(|o| o.trim_end_matches('/').eq_ignore_ascii_case(&origin))
143}
144
145pub fn popup_window_title(url: &str, page_title: &str) -> Option<String> {
151 let place = popup_place(url)?;
152 let page_title = page_title.trim();
153 Some(if page_title.is_empty() { place } else { format!("{place} — {page_title}") })
154}
155
156fn popup_place(url: &str) -> Option<String> {
161 let u = url::Url::parse(url).ok()?;
162 match u.scheme() {
163 "http" | "https" | "blob" => match u.origin() {
164 url::Origin::Tuple(scheme, host, port) => {
165 let default_port = if scheme == "https" { 443 } else { 80 };
166 let mut place = if scheme == "http" { format!("http://{host}") } else { host.to_string() };
167 if port != default_port {
168 place.push_str(&format!(":{port}"));
169 }
170 Some(place)
171 }
172 url::Origin::Opaque(_) => Some(format!("{}:", u.scheme())),
173 },
174 "about" => Some(format!("about:{}", u.path())),
175 "data" | "file" => Some(format!("{}:", u.scheme())),
176 _ => None,
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 #[test]
185 fn same_site_follows_the_public_suffix_list() {
186 assert!(same_site("https://console.example.com/x", "https://signin.example.com/y"));
187 assert!(same_site("https://example.com", "https://www.example.com"));
188 assert!(!same_site("https://alice.github.io", "https://bob.github.io"));
190 assert!(!same_site("https://a.example.co.uk", "https://b.other.co.uk"));
191 assert!(same_site("https://a.example.co.uk", "https://b.example.co.uk"));
192 assert!(!same_site("https://example.com", "https://example.org"));
193 assert!(same_site("https://Console.Example.COM.", "https://signin.example.com"));
195 }
196
197 #[test]
198 fn hosts_without_a_registrable_domain_match_only_themselves() {
199 assert!(same_site("http://localhost:3000/a", "http://localhost:4000/b"));
200 assert!(!same_site("http://localhost", "http://127.0.0.1"));
201 assert!(same_site("http://192.0.2.5/a", "http://192.0.2.5/b"));
202 assert!(!same_site("http://192.0.2.5", "http://192.0.2.6"));
203 assert!(!same_site("http://192.0.2.5/", "http://198.51.2.5/"));
205 assert!(!same_site("http://[2001:db8::1]/", "http://[2001:db8::2]/"));
206 assert!(same_site("http://[2001:db8::1]:80/a", "http://[2001:db8::1]/b"));
207 assert!(!same_site("http://example.com/", "http://192.0.2.5/"));
209 }
210
211 #[test]
212 fn non_web_urls_are_never_the_same_site() {
213 assert!(!same_site("file:///c:/x", "file:///c:/y"));
214 assert!(!same_site("https://example.com", "not a url"));
215 }
216
217 const OPENER: &str = "https://console.example.com/home";
218
219 #[test]
220 fn a_clicked_popup_on_the_openers_site_is_admitted() {
221 assert_eq!(decide("https://signin.example.com/x", OPENER, true, false, 0), Ok(()));
222 }
223
224 #[test]
225 fn another_site_needs_an_owned_opener() {
226 let other = "https://idp.other.net/login";
227 assert_eq!(decide(other, OPENER, true, false, 0), Err(Refusal::AnotherSite));
228 assert_eq!(decide(other, OPENER, true, true, 0), Ok(()));
229 }
230
231 #[test]
232 fn no_gesture_no_pane_even_for_an_owned_opener() {
233 assert_eq!(
234 decide("https://signin.example.com/x", OPENER, false, true, 0),
235 Err(Refusal::NoUserAction)
236 );
237 }
238
239 #[test]
240 fn the_cap_holds_for_owned_openers_too() {
241 let url = "https://signin.example.com/x";
242 assert_eq!(decide(url, OPENER, true, true, MAX_POPUPS_PER_PANE - 1), Ok(()));
243 assert_eq!(decide(url, OPENER, true, true, MAX_POPUPS_PER_PANE), Err(Refusal::TooMany));
244 }
245
246 #[test]
247 fn only_web_pages() {
248 assert_eq!(decide("file:///c:/secrets", OPENER, true, true, 0), Err(Refusal::NotWeb));
249 assert_eq!(decide("javascript:alert(1)", OPENER, true, true, 0), Err(Refusal::NotWeb));
250 }
251
252 #[test]
253 fn origin_of_keeps_scheme_host_and_port_only() {
254 assert_eq!(origin_of("https://console.example.com/home?x=1"), "https://console.example.com");
255 assert_eq!(origin_of("http://localhost:3000/a"), "http://localhost:3000");
256 assert_eq!(origin_of("file:///c:/x"), "");
257 }
258
259 #[test]
260 fn loopback_is_localhost_and_the_loopback_addresses() {
261 assert!(is_loopback("http://localhost:3000/"));
262 assert!(is_loopback("http://127.0.0.1/"));
263 assert!(is_loopback("http://127.0.0.2:8766/"));
264 assert!(is_loopback("http://[::1]:8080/"));
265 assert!(!is_loopback("https://example.com/"));
266 assert!(!is_loopback("http://192.0.2.5/"));
267 }
268
269 #[test]
270 fn loopback_popups_stay_on_their_own_origin_and_off_agentmuxs() {
271 let own = vec!["http://localhost:5300".to_string(), "http://127.0.0.1:29705/".to_string()];
272 assert!(loopback_allowed("http://localhost:3000/popup", "http://localhost:3000/", &own));
274 assert!(!loopback_allowed("http://localhost:3001/popup", "http://localhost:3000/", &own));
276 assert!(!loopback_allowed("http://localhost:3000/", "https://example.com/", &own));
278 assert!(!loopback_allowed("http://localhost:5300/x", "http://localhost:5300/", &own));
280 assert!(!loopback_allowed("http://127.0.0.1:29705/x", "http://127.0.0.1:29705/", &own));
281 assert!(!loopback_allowed("https://example.com/", "https://example.com/", &own));
283 }
284
285 #[test]
286 fn a_popup_title_starts_with_where_the_page_is() {
287 let t = |url, title| popup_window_title(url, title);
288 assert_eq!(t("https://accounts.example.com/signin?x=1", "Sign in").as_deref(), Some("accounts.example.com — Sign in"));
289 assert_eq!(
291 t("https://evil.example/login", "accounts.google.com — Sign in").as_deref(),
292 Some("evil.example — accounts.google.com — Sign in")
293 );
294 assert_eq!(t("https://example.com/", " ").as_deref(), Some("example.com"));
296 assert_eq!(t("http://example.com/", "A").as_deref(), Some("http://example.com — A"));
298 assert_eq!(t("https://example.com:8443/", "A").as_deref(), Some("example.com:8443 — A"));
299 assert_eq!(t("http://127.0.0.2:8000/p", "A").as_deref(), Some("http://127.0.0.2:8000 — A"));
300 assert_eq!(t("http://[::1]:3000/", "A").as_deref(), Some("http://[::1]:3000 — A"));
301 assert_eq!(t("https://аpple.com/", "A").as_deref(), Some("xn--pple-43d.com — A"));
303 assert_eq!(t("blob:https://example.com/0f0e", "A").as_deref(), Some("example.com — A"));
305 assert_eq!(t("about:blank", "Sign in").as_deref(), Some("about:blank — Sign in"));
307 assert_eq!(t("data:text/html,hi", "A").as_deref(), Some("data: — A"));
308 assert_eq!(t("devtools://devtools/bundled/inspector.html", "DevTools"), None);
310 assert_eq!(t("not a url", "A"), None);
311 }
312}