From 3ddfde91171783b97456c43f19f9051613aac715 Mon Sep 17 00:00:00 2001 From: Matt Mastracci Date: Tue, 6 Feb 2024 09:35:17 -0700 Subject: [PATCH 1/7] feat(cli): Add IP address support to DENO_AUTH_TOKEN --- cli/auth_tokens.rs | 155 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 147 insertions(+), 8 deletions(-) diff --git a/cli/auth_tokens.rs b/cli/auth_tokens.rs index 5143ea6046694f..d6ed58e6067d51 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,83 @@ 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(); + match SocketAddr::from_str(&s) { + Ok(ip) => return AuthDomain::IPPort(ip), + Err(_) => {} + }; + if s.starts_with('[') && s.ends_with(']') { + match Ipv6Addr::from_str(&s[1..s.len() - 1]) { + Ok(ip) => return AuthDomain::IP(ip.into()), + Err(_) => {} + }; + } else { + match Ipv4Addr::from_str(&s) { + Ok(ip) => return AuthDomain::IP(ip.into()), + Err(_) => {} + }; + } + if s.starts_with('.') { + AuthDomain::Suffix(Cow::Owned(s[1..].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; + } + + return 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 +132,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 +164,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 +260,65 @@ 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_matches() { + let candidates = [ + "example.com", + "www.example.com", + "notexample.com", + "www.notexample.com", + "1.1.1.1", + "[2001:db8:a::123]", + ]; + let domains = [ + ("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(); + + let candidates = candidates + .into_iter() + .map(|c| [url(c), url_port(c)]) + .flatten() + .collect::>(); + + for (domain, expected_domain) in domains { + 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); + + 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); + } + } } From 27a5a5d3ce00af65fc801e96399cc3c0224d9cb7 Mon Sep 17 00:00:00 2001 From: Matt Mastracci Date: Tue, 6 Feb 2024 09:39:40 -0700 Subject: [PATCH 2/7] Add test for leading dot --- cli/auth_tokens.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/cli/auth_tokens.rs b/cli/auth_tokens.rs index d6ed58e6067d51..9c2f3b0436bd3c 100644 --- a/cli/auth_tokens.rs +++ b/cli/auth_tokens.rs @@ -283,6 +283,7 @@ mod tests { ]; 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]"]), From 7b2e6341f67663ba01fbade896a12d3d6710f484 Mon Sep 17 00:00:00 2001 From: Matt Mastracci Date: Tue, 6 Feb 2024 09:41:15 -0700 Subject: [PATCH 3/7] comments --- cli/auth_tokens.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cli/auth_tokens.rs b/cli/auth_tokens.rs index 9c2f3b0436bd3c..9999b02b8c2c2b 100644 --- a/cli/auth_tokens.rs +++ b/cli/auth_tokens.rs @@ -292,6 +292,7 @@ mod tests { 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() .map(|c| [url(c), url_port(c)]) @@ -299,6 +300,7 @@ mod tests { .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() @@ -309,6 +311,7 @@ mod tests { 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() From a68e6141a966c51860146adb8ddae5920fa95d2e Mon Sep 17 00:00:00 2001 From: Matt Mastracci Date: Tue, 6 Feb 2024 09:57:50 -0700 Subject: [PATCH 4/7] Add some prefix tests --- cli/auth_tokens.rs | 40 +++++++++++++++++++--------------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/cli/auth_tokens.rs b/cli/auth_tokens.rs index 9999b02b8c2c2b..3d04771ff1ec35 100644 --- a/cli/auth_tokens.rs +++ b/cli/auth_tokens.rs @@ -55,23 +55,20 @@ pub enum AuthDomain { impl From for AuthDomain { fn from(value: T) -> Self { let s = value.to_string().to_lowercase(); - match SocketAddr::from_str(&s) { - Ok(ip) => return AuthDomain::IPPort(ip), - Err(_) => {} + if let Ok(ip) = SocketAddr::from_str(&s) { + return AuthDomain::IPPort(ip); }; if s.starts_with('[') && s.ends_with(']') { - match Ipv6Addr::from_str(&s[1..s.len() - 1]) { - Ok(ip) => return AuthDomain::IP(ip.into()), - Err(_) => {} - }; + if let Ok(ip) = Ipv6Addr::from_str(&s[1..s.len() - 1]) { + return AuthDomain::IP(ip.into()); + } } else { - match Ipv4Addr::from_str(&s) { - Ok(ip) => return AuthDomain::IP(ip.into()), - Err(_) => {} - }; + if let Ok(ip) = Ipv4Addr::from_str(&s) { + return AuthDomain::IP(ip.into()); + } } - if s.starts_with('.') { - AuthDomain::Suffix(Cow::Owned(s[1..].to_owned())) + if let Some(s) = s.strip_prefix('.') { + AuthDomain::Suffix(Cow::Owned(s.to_owned())) } else { AuthDomain::Suffix(Cow::Owned(s)) } @@ -114,7 +111,7 @@ impl AuthDomain { return true; } - return false; + false } } } @@ -276,10 +273,13 @@ mod tests { let candidates = [ "example.com", "www.example.com", - "notexample.com", - "www.notexample.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"]), @@ -295,8 +295,7 @@ mod tests { // Generate each candidate with and without a port let candidates = candidates .into_iter() - .map(|c| [url(c), url_port(c)]) - .flatten() + .flat_map(|c| [url(c), url_port(c)]) .collect::>(); for (domain, expected_domain) in domains { @@ -307,8 +306,7 @@ mod tests { .filter(|c| auth_domain.matches(c)) .cloned() .collect::>(); - let expected = - expected_domain.iter().map(|u| url(*u)).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 @@ -320,7 +318,7 @@ mod tests { .collect::>(); let expected = expected_domain .iter() - .map(|u| url_port(*u)) + .map(|u| url_port(u)) .collect::>(); assert_eq!(actual, expected); } From 3260522c1c44393210bf81168560ccabf5926ebe Mon Sep 17 00:00:00 2001 From: Matt Mastracci Date: Tue, 6 Feb 2024 09:58:20 -0700 Subject: [PATCH 5/7] Ip,IpPort --- cli/auth_tokens.rs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/cli/auth_tokens.rs b/cli/auth_tokens.rs index 3d04771ff1ec35..370636c0099995 100644 --- a/cli/auth_tokens.rs +++ b/cli/auth_tokens.rs @@ -46,8 +46,8 @@ 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), + Ip(IpAddr), + IpPort(SocketAddr), /// Suffix match, no dot. May include a port. Suffix(Cow<'static, str>), } @@ -56,15 +56,15 @@ 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); + 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()); + return AuthDomain::Ip(ip.into()); } } else { if let Ok(ip) = Ipv4Addr::from_str(&s) { - return AuthDomain::IP(ip.into()); + return AuthDomain::Ip(ip.into()); } } if let Some(s) = s.strip_prefix('.') { @@ -81,14 +81,14 @@ impl AuthDomain { return false; }; match *self { - Self::IP(ip) => { - let AuthDomain::IP(parsed) = AuthDomain::from(host) else { + 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 { + Self::IpPort(ip) => { + let AuthDomain::Ip(parsed) = AuthDomain::from(host) else { return false; }; ip.ip() == parsed && specifier.port() == Some(ip.port()) From d53bcca77c56eb0ac8dbcec920f546e74eb85332 Mon Sep 17 00:00:00 2001 From: Matt Mastracci Date: Tue, 6 Feb 2024 10:03:35 -0700 Subject: [PATCH 6/7] clippy --- cli/auth_tokens.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/cli/auth_tokens.rs b/cli/auth_tokens.rs index 370636c0099995..ccd1c49c71e5d7 100644 --- a/cli/auth_tokens.rs +++ b/cli/auth_tokens.rs @@ -62,10 +62,8 @@ impl From for AuthDomain { 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()); - } + } 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())) From 71d7387a02ec6a4fdb4789bd308d3dc21e9b4be5 Mon Sep 17 00:00:00 2001 From: Matt Mastracci Date: Tue, 6 Feb 2024 10:13:24 -0700 Subject: [PATCH 7/7] add case insensitive test --- cli/auth_tokens.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/cli/auth_tokens.rs b/cli/auth_tokens.rs index ccd1c49c71e5d7..42009ef27be539 100644 --- a/cli/auth_tokens.rs +++ b/cli/auth_tokens.rs @@ -259,11 +259,22 @@ mod tests { #[test] fn test_parse_ip() { let ip = AuthDomain::from("[2001:db8:a::123]"); - assert_eq!("IP(2001:db8:a::123)", format!("{ip:?}")); + 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:?}")); + 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:?}")); + 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]