diff --git a/cli/auth_tokens.rs b/cli/auth_tokens.rs index 5143ea6046..42009ef27b 100644 --- a/cli/auth_tokens.rs +++ b/cli/auth_tokens.rs @@ -5,7 +5,13 @@ use base64::Engine; use deno_core::ModuleSpecifier; use log::debug; use log::error; +use std::borrow::Cow; use std::fmt; +use std::net::IpAddr; +use std::net::Ipv4Addr; +use std::net::Ipv6Addr; +use std::net::SocketAddr; +use std::str::FromStr; #[derive(Debug, Clone, PartialEq, Eq)] pub enum AuthTokenData { @@ -15,7 +21,7 @@ pub enum AuthTokenData { #[derive(Debug, Clone, PartialEq, Eq)] pub struct AuthToken { - host: String, + host: AuthDomain, token: AuthTokenData, } @@ -37,6 +43,78 @@ impl fmt::Display for AuthToken { #[derive(Debug, Clone)] pub struct AuthTokens(Vec); +/// An authorization domain, either an exact or suffix match. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AuthDomain { + Ip(IpAddr), + IpPort(SocketAddr), + /// Suffix match, no dot. May include a port. + Suffix(Cow<'static, str>), +} + +impl From for AuthDomain { + fn from(value: T) -> Self { + let s = value.to_string().to_lowercase(); + if let Ok(ip) = SocketAddr::from_str(&s) { + return AuthDomain::IpPort(ip); + }; + if s.starts_with('[') && s.ends_with(']') { + if let Ok(ip) = Ipv6Addr::from_str(&s[1..s.len() - 1]) { + return AuthDomain::Ip(ip.into()); + } + } else if let Ok(ip) = Ipv4Addr::from_str(&s) { + return AuthDomain::Ip(ip.into()); + } + if let Some(s) = s.strip_prefix('.') { + AuthDomain::Suffix(Cow::Owned(s.to_owned())) + } else { + AuthDomain::Suffix(Cow::Owned(s)) + } + } +} + +impl AuthDomain { + pub fn matches(&self, specifier: &ModuleSpecifier) -> bool { + let Some(host) = specifier.host_str() else { + return false; + }; + match *self { + Self::Ip(ip) => { + let AuthDomain::Ip(parsed) = AuthDomain::from(host) else { + return false; + }; + ip == parsed && specifier.port().is_none() + } + Self::IpPort(ip) => { + let AuthDomain::Ip(parsed) = AuthDomain::from(host) else { + return false; + }; + ip.ip() == parsed && specifier.port() == Some(ip.port()) + } + Self::Suffix(ref suffix) => { + let hostname = if let Some(port) = specifier.port() { + Cow::Owned(format!("{}:{}", host, port)) + } else { + Cow::Borrowed(host) + }; + + if suffix.len() == hostname.len() { + return suffix == &hostname; + } + + // If it's a suffix match, ensure a dot + if hostname.ends_with(suffix.as_ref()) + && hostname.ends_with(&format!(".{suffix}")) + { + return true; + } + + false + } + } + } +} + impl AuthTokens { /// Create a new set of tokens based on the provided string. It is intended /// that the string be the value of an environment variable and the string is @@ -49,7 +127,7 @@ impl AuthTokens { if token_str.contains('@') { let pair: Vec<&str> = token_str.rsplitn(2, '@').collect(); let token = pair[1]; - let host = pair[0].to_lowercase(); + let host = AuthDomain::from(pair[0]); if token.contains(':') { let pair: Vec<&str> = token.rsplitn(2, ':').collect(); let username = pair[1].to_string(); @@ -81,12 +159,7 @@ impl AuthTokens { /// matching is case insensitive. pub fn get(&self, specifier: &ModuleSpecifier) -> Option { self.0.iter().find_map(|t| { - let hostname = if let Some(port) = specifier.port() { - format!("{}:{}", specifier.host_str()?, port) - } else { - specifier.host_str()?.to_string() - }; - if hostname.to_lowercase().ends_with(&t.host) { + if t.host.matches(specifier) { Some(t.clone()) } else { None @@ -182,4 +255,81 @@ mod tests { let fixture = resolve_url("https://deno.land:8080/x/mod.ts").unwrap(); assert_eq!(auth_tokens.get(&fixture), None); } + + #[test] + fn test_parse_ip() { + let ip = AuthDomain::from("[2001:db8:a::123]"); + assert_eq!("Ip(2001:db8:a::123)", format!("{ip:?}")); + let ip = AuthDomain::from("[2001:db8:a::123]:8080"); + assert_eq!("IpPort([2001:db8:a::123]:8080)", format!("{ip:?}")); + let ip = AuthDomain::from("1.1.1.1"); + assert_eq!("Ip(1.1.1.1)", format!("{ip:?}")); + } + + #[test] + fn test_case_insensitive() { + let domain = AuthDomain::from("EXAMPLE.com"); + assert!( + domain.matches(&ModuleSpecifier::parse("http://example.com").unwrap()) + ); + assert!( + domain.matches(&ModuleSpecifier::parse("http://example.COM").unwrap()) + ); + } + + #[test] + fn test_matches() { + let candidates = [ + "example.com", + "www.example.com", + "1.1.1.1", + "[2001:db8:a::123]", + // These will never match + "example.com.evil.com", + "1.1.1.1.evil.com", + "notexample.com", + "www.notexample.com", + ]; + let domains = [ + ("example.com", vec!["example.com", "www.example.com"]), + (".example.com", vec!["example.com", "www.example.com"]), + ("www.example.com", vec!["www.example.com"]), + ("1.1.1.1", vec!["1.1.1.1"]), + ("[2001:db8:a::123]", vec!["[2001:db8:a::123]"]), + ]; + let url = |c: &str| ModuleSpecifier::parse(&format!("http://{c}")).unwrap(); + let url_port = + |c: &str| ModuleSpecifier::parse(&format!("http://{c}:8080")).unwrap(); + + // Generate each candidate with and without a port + let candidates = candidates + .into_iter() + .flat_map(|c| [url(c), url_port(c)]) + .collect::>(); + + for (domain, expected_domain) in domains { + // Test without a port -- all candidates return without a port + let auth_domain = AuthDomain::from(domain); + let actual = candidates + .iter() + .filter(|c| auth_domain.matches(c)) + .cloned() + .collect::>(); + let expected = expected_domain.iter().map(|u| url(u)).collect::>(); + assert_eq!(actual, expected); + + // Test with a port, all candidates return with a port + let auth_domain = AuthDomain::from(&format!("{domain}:8080")); + let actual = candidates + .iter() + .filter(|c| auth_domain.matches(c)) + .cloned() + .collect::>(); + let expected = expected_domain + .iter() + .map(|u| url_port(u)) + .collect::>(); + assert_eq!(actual, expected); + } + } }