From 63634ceb0dc483c599b736b6ccb1a5eb582b9ed2 Mon Sep 17 00:00:00 2001 From: bdbai Date: Fri, 2 Feb 2024 23:03:37 +0800 Subject: [PATCH] Add more tests against ss link parser --- Cargo.lock | 1 + ytflow-app-util/Cargo.toml | 1 + .../src/proxy/protocol/shadowsocks.rs | 4 +- ytflow-app-util/src/share_link/decode.rs | 10 +- ytflow-app-util/src/share_link/shadowsocks.rs | 164 +++++++++++++----- 5 files changed, 132 insertions(+), 48 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0a90f92..89d4327 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3389,6 +3389,7 @@ dependencies = [ "cbor4ii", "percent-encoding", "serde", + "serde_bytes", "thiserror", "url", "ytflow", diff --git a/ytflow-app-util/Cargo.toml b/ytflow-app-util/Cargo.toml index a963af2..9a383f0 100644 --- a/ytflow-app-util/Cargo.toml +++ b/ytflow-app-util/Cargo.toml @@ -15,4 +15,5 @@ base64 = "0.21" thiserror = "1" cbor4ii = { version = "0.3", features = ["use_std", "serde1"] } serde = { version = "1", features = ["derive"] } +serde_bytes = "0.11" ytflow = { path = "../ytflow" } diff --git a/ytflow-app-util/src/proxy/protocol/shadowsocks.rs b/ytflow-app-util/src/proxy/protocol/shadowsocks.rs index 6325fcc..9bc2cf4 100644 --- a/ytflow-app-util/src/proxy/protocol/shadowsocks.rs +++ b/ytflow-app-util/src/proxy/protocol/shadowsocks.rs @@ -1,7 +1,9 @@ +use serde_bytes::ByteBuf; + use ytflow::plugin::shadowsocks::SupportedCipher; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ShadowsocksProxy { pub cipher: SupportedCipher, - pub password: Vec, + pub password: ByteBuf, } diff --git a/ytflow-app-util/src/share_link/decode.rs b/ytflow-app-util/src/share_link/decode.rs index befcb9e..ca57c8f 100644 --- a/ytflow-app-util/src/share_link/decode.rs +++ b/ytflow-app-util/src/share_link/decode.rs @@ -18,10 +18,12 @@ pub static BASE64_ENGINE: base64::engine::GeneralPurpose = base64::engine::Gener pub enum DecodeError { #[error("invalid URL")] InvalidUrl, - #[error("invalid URL or base64 encoding")] + #[error("invalid URL, UTF-8 or Base64 encoding")] InvalidEncoding, - #[error("invalid value")] - InvalidValue, + #[error(r#""{0}" is required, but is missing"#)] + MissingInfo(&'static str), + #[error(r#"unknown value for field "{0}"#)] + UnknownValue(&'static str), #[error("unknown URL scheme")] UnknownScheme, #[error(r#"extra parameter "{0}""#)] @@ -33,7 +35,7 @@ pub type DecodeResult = Result; pub(super) type QueryMap<'a> = BTreeMap, Cow<'a, str>>; pub fn decode_share_link(link: &str) -> Result { - let url = url::Url::parse(link).map_err(|_| DecodeError::InvalidUrl)?; + let url = url::Url::parse(link.trim()).map_err(|_| DecodeError::InvalidUrl)?; let mut queries = url.query_pairs().collect::(); let proxy = match url.scheme() { diff --git a/ytflow-app-util/src/share_link/shadowsocks.rs b/ytflow-app-util/src/share_link/shadowsocks.rs index 3ac3170..2e81ee0 100644 --- a/ytflow-app-util/src/share_link/shadowsocks.rs +++ b/ytflow-app-util/src/share_link/shadowsocks.rs @@ -2,7 +2,9 @@ use std::collections::BTreeMap; use base64::Engine; use percent_encoding::percent_decode_str; +use serde_bytes::ByteBuf; use url::{Host, Url}; + use ytflow::{ config::plugin::parse_supported_cipher, flow::{DestinationAddr, HostName}, @@ -24,16 +26,16 @@ fn decode_legacy(url: &Url, _queries: &mut QueryMap) -> DecodeResult { }; let mut split = b64.rsplitn(2, |&b| b == b'@'); let dest = { - let host_port = split.next().unwrap(); + let host_port = split.next().expect("first split must exist"); let host_port = std::str::from_utf8(host_port).map_err(|_| DecodeError::InvalidEncoding)?; let mut split = host_port.rsplitn(2, ':'); - let port = split.next().unwrap(); - let host = Host::parse(split.next().ok_or(DecodeError::InvalidEncoding)?) + let port = split.next().expect("first split must exist"); + let host = Host::parse(split.next().ok_or(DecodeError::MissingInfo("port"))?) .map_err(|_| DecodeError::InvalidEncoding)?; DestinationAddr { host: match host { Host::Domain(domain) => { - HostName::from_domain_name(domain).map_err(|_| DecodeError::InvalidEncoding)? + HostName::from_domain_name(domain).expect("a valid domain name") } Host::Ipv4(ip) => HostName::Ip(ip.into()), Host::Ipv6(ip) => HostName::Ip(ip.into()), @@ -42,12 +44,12 @@ fn decode_legacy(url: &Url, _queries: &mut QueryMap) -> DecodeResult { } }; let (cipher, password) = { - let method_pass = split.next().ok_or(DecodeError::InvalidEncoding)?; + let method_pass = split.next().ok_or(DecodeError::MissingInfo("method"))?; let mut split = method_pass.splitn(2, |&b| b == b':'); - let method = split.next().unwrap(); - let cipher = parse_supported_cipher(method).ok_or(DecodeError::InvalidValue)?; - let pass = split.next().ok_or(DecodeError::InvalidEncoding)?; - (cipher, pass.into()) + let method = split.next().expect("first split must exist"); + let cipher = parse_supported_cipher(method).ok_or(DecodeError::UnknownValue("method"))?; + let pass = split.next().ok_or(DecodeError::MissingInfo("password"))?; + (cipher, ByteBuf::from(pass)) }; Ok(ProxyLeg { @@ -69,17 +71,20 @@ fn decode_sip002(url: &Url, queries: &mut QueryMap) -> DecodeResult { }; let (cipher, password) = { let mut split = b64.splitn(2, |&b| b == b':'); - let method = split.next().ok_or(DecodeError::InvalidEncoding)?; - let cipher = parse_supported_cipher(method).ok_or(DecodeError::InvalidValue)?; - let pass = split.next().ok_or(DecodeError::InvalidEncoding)?; + let method = split.next().expect("first split must exist"); + let cipher = parse_supported_cipher(method).ok_or(DecodeError::UnknownValue("method"))?; + let pass = split.next().ok_or(DecodeError::MissingInfo("password"))?; (cipher, pass) }; + // Parse the host part again without scheme information. + // ss URLs are not ["special"](https://url.spec.whatwg.org/#is-special), hence IPv4 hosts are + // treated as domain names. Parsing again is necessary to handle IPv4 hosts correctly. let host = match Host::parse(url.host_str().unwrap_or_default()) - .map_err(|_| DecodeError::InvalidUrl)? + .map_err(|_| DecodeError::InvalidEncoding)? { Host::Domain(domain) => { - HostName::from_domain_name(domain.into()).map_err(|_| DecodeError::InvalidEncoding)? + HostName::from_domain_name(domain.into()).expect("a valid domain name") } Host::Ipv4(ip) => HostName::Ip(ip.into()), Host::Ipv6(ip) => HostName::Ip(ip.into()), @@ -88,12 +93,12 @@ fn decode_sip002(url: &Url, queries: &mut QueryMap) -> DecodeResult { let plugin_param = queries.remove("plugin").unwrap_or_default(); let mut obfs_split = plugin_param.split(";"); - let obfs = match obfs_split.next().unwrap() { + let obfs = match obfs_split.next().expect("first split must exist") { "obfs-local" => { let mut obfs_params = obfs_split .map(|kv| { let mut split = kv.splitn(2, '='); - let k = split.next().unwrap(); + let k = split.next().expect("first split must exist"); let v = split.next().unwrap_or_default(); (k, v) }) @@ -102,12 +107,12 @@ fn decode_sip002(url: &Url, queries: &mut QueryMap) -> DecodeResult { let host = obfs_params .remove("obfs-host") .filter(|s| !s.is_empty()) - .unwrap_or(url.host_str().unwrap()) + .unwrap_or(url.host_str().expect("host has been validated")) .into(); let r#type = obfs_params .remove("obfs") .filter(|s| !s.is_empty()) - .ok_or(DecodeError::InvalidUrl)?; + .ok_or(DecodeError::MissingInfo("obfs"))?; let obfs = match r#type { "http" => { let path = obfs_params @@ -118,7 +123,7 @@ fn decode_sip002(url: &Url, queries: &mut QueryMap) -> DecodeResult { ProxyObfsType::HttpObfs(HttpObfsObfs { host, path }) } "tls" => ProxyObfsType::TlsObfs(TlsObfsObfs { host }), - _ => return Err(DecodeError::InvalidValue), + _ => return Err(DecodeError::UnknownValue("obfs")), }; if let Some((first_extra_key, _)) = obfs_params.pop_first() { @@ -127,13 +132,13 @@ fn decode_sip002(url: &Url, queries: &mut QueryMap) -> DecodeResult { Some(obfs) } "" => None, - _ => return Err(DecodeError::InvalidValue), + _ => return Err(DecodeError::UnknownValue("plugin")), }; Ok(ProxyLeg { protocol: ProxyProtocolType::Shadowsocks(ShadowsocksProxy { cipher, - password: password.into(), + password: ByteBuf::from(password), }), dest: DestinationAddr { host, port }, obfs, @@ -162,6 +167,8 @@ mod tests { use std::net::Ipv6Addr; use base64::engine::general_purpose::STANDARD; + use percent_encoding::{percent_encode, NON_ALPHANUMERIC}; + use ytflow::plugin::shadowsocks::SupportedCipher; use super::*; @@ -177,7 +184,13 @@ mod tests { for (host_part, expected_host) in hosts { let url = Url::parse(&format!( "ss://{}", - STANDARD.encode(format!("aes-256-cfb:UYL1EvkfI0cT6NOY@{host_part}:34187")) + percent_encode( + STANDARD + .encode(format!("aes-256-cfb:UYL1EvkfI0cT6NOY@{host_part}:34187")) + .as_bytes(), + NON_ALPHANUMERIC + ) + .to_string() )) .unwrap(); let mut queries = QueryMap::new(); @@ -187,7 +200,7 @@ mod tests { ProxyLeg { protocol: ProxyProtocolType::Shadowsocks(ShadowsocksProxy { cipher: SupportedCipher::Aes256Cfb, - password: "UYL1EvkfI0cT6NOY".into(), + password: ByteBuf::from("UYL1EvkfI0cT6NOY"), }), dest: DestinationAddr { host: expected_host, @@ -195,7 +208,8 @@ mod tests { }, obfs: None, tls: None, - } + }, + "{host_part}" ); assert!(queries.is_empty()); } @@ -210,7 +224,7 @@ mod tests { assert!(queries.is_empty()); } #[test] - fn test_decode_legacy_invalid_cipher() { + fn test_decode_legacy_unknown_cipher() { let url = Url::parse(&format!( "ss://{}", STANDARD.encode("114514:UYL1EvkfI0cT6NOY@3.187.225.7:34187") @@ -218,7 +232,7 @@ mod tests { .unwrap(); let mut queries = QueryMap::new(); let leg = decode_legacy(&url, &mut queries); - assert_eq!(leg.unwrap_err(), DecodeError::InvalidValue); + assert_eq!(leg.unwrap_err(), DecodeError::UnknownValue("method")); assert!(queries.is_empty()); } #[test] @@ -236,27 +250,52 @@ mod tests { let url = Url::parse(raw_url).unwrap(); let mut queries = QueryMap::new(); let leg = decode_legacy(&url, &mut queries); - assert_eq!(leg.unwrap_err(), DecodeError::InvalidEncoding); + assert_eq!(leg.unwrap_err(), DecodeError::InvalidEncoding, "{raw_url}"); assert!(queries.is_empty()); } } #[test] fn test_decode_legacy_invalid_encoding() { - let raw_values: [&[u8]; 8] = [ - b"rc4:a@", - b"rc4:a@a", + let cases: [&[u8]; 4] = [ b"rc4:a@:114", b"rc4:a@\xff\xff:114", b"rc4:a@ :114", b"rc4:a@a.co:cc", - b"a.co:114", - b"rc4@a.co:114", ]; - for raw_value in raw_values { + for raw_value in cases { + let url = Url::parse(&format!( + "ss://{}", + percent_encode(STANDARD.encode(raw_value).as_bytes(), NON_ALPHANUMERIC).to_string() + )) + .unwrap(); + let mut queries = QueryMap::new(); + let leg = decode_legacy(&url, &mut queries); + assert_eq!( + leg.unwrap_err(), + DecodeError::InvalidEncoding, + "{}", + String::from_utf8_lossy(raw_value) + ); + assert!(queries.is_empty()); + } + } + #[test] + fn test_decode_legacy_missing_info() { + let cases: [(&str, &str); 4] = [ + ("rc4:a@", "port"), + ("rc4:a@a", "port"), + ("a.co:114", "method"), + ("rc4@a.co:114", "password"), + ]; + for (raw_value, expected_field) in cases { let url = Url::parse(&format!("ss://{}", STANDARD.encode(raw_value))).unwrap(); let mut queries = QueryMap::new(); let leg = decode_legacy(&url, &mut queries); - assert_eq!(leg.unwrap_err(), DecodeError::InvalidEncoding); + assert_eq!( + leg.unwrap_err(), + DecodeError::MissingInfo(expected_field), + "{raw_value}" + ); assert!(queries.is_empty()); } } @@ -279,7 +318,7 @@ mod tests { ProxyLeg { protocol: ProxyProtocolType::Shadowsocks(ShadowsocksProxy { cipher: SupportedCipher::Aes256Cfb, - password: "UYL1EvkfI0cT6NOY".into(), + password: ByteBuf::from("UYL1EvkfI0cT6NOY"), }), dest: DestinationAddr { host: expected_host, @@ -287,7 +326,8 @@ mod tests { }, obfs: None, tls: None, - } + }, + "{host_part}" ); assert!(queries.is_empty()); } @@ -362,12 +402,12 @@ mod tests { .unwrap(); let mut queries = url.query_pairs().collect::(); let leg = decode_sip002(&url, &mut queries).unwrap(); - assert_eq!(leg.obfs.unwrap(), expected_obfs); + assert_eq!(leg.obfs.unwrap(), expected_obfs, "{obfs_param}"); assert!(queries.is_empty()); } } #[test] - fn test_decode_sip002_invalid_cipher() { + fn test_decode_sip002_unknown_cipher() { let url = Url::parse(&format!( "ss://{}@3.187.225.7:34187", STANDARD.encode("114514:UYL1EvkfI0cT6NOY") @@ -375,17 +415,17 @@ mod tests { .unwrap(); let mut queries = QueryMap::new(); let leg = decode_sip002(&url, &mut queries); - assert_eq!(leg.unwrap_err(), DecodeError::InvalidValue); + assert_eq!(leg.unwrap_err(), DecodeError::UnknownValue("method")); assert!(queries.is_empty()); } #[test] - fn test_decode_sip002_invalid_plugin() { + fn test_decode_sip002_unknown_plugin() { let url = Url::parse("ss://YWVzLTI1Ni1jZmI6VVlMMUV2a2ZJMGNUNk5PWQ==@3.187.225.7:34187?plugin=aa") .unwrap(); let mut queries = url.query_pairs().collect::(); let leg = decode_sip002(&url, &mut queries); - assert_eq!(leg.unwrap_err(), DecodeError::InvalidValue); + assert_eq!(leg.unwrap_err(), DecodeError::UnknownValue("plugin")); assert!(queries.is_empty()); } #[test] @@ -396,7 +436,7 @@ mod tests { .unwrap(); let mut queries = url.query_pairs().collect::(); let leg = decode_sip002(&url, &mut queries); - assert_eq!(leg.unwrap_err(), DecodeError::InvalidUrl); + assert_eq!(leg.unwrap_err(), DecodeError::MissingInfo("obfs")); assert!(queries.is_empty()); } #[test] @@ -406,7 +446,7 @@ mod tests { .unwrap(); let mut queries = url.query_pairs().collect::(); let leg = decode_sip002(&url, &mut queries); - assert_eq!(leg.unwrap_err(), DecodeError::InvalidValue); + assert_eq!(leg.unwrap_err(), DecodeError::UnknownValue("obfs")); assert!(queries.is_empty()); } #[test] @@ -419,4 +459,42 @@ mod tests { assert_eq!(leg.unwrap_err(), DecodeError::ExtraParameters("aa".into())); assert!(queries.is_empty()); } + #[test] + fn test_decode_sip002_invalid_url() { + let raw_urls = ["ss://YWVzLTI1Ni1jZmI6VVlMMUV2a2ZJMGNUNk5PWQ==@a.co"]; + for raw_url in raw_urls { + let url = Url::parse(raw_url).unwrap(); + let mut queries = QueryMap::new(); + let leg = decode_sip002(&url, &mut queries); + assert_eq!(leg.unwrap_err(), DecodeError::InvalidUrl, "{raw_url}"); + assert!(queries.is_empty()); + } + } + #[test] + fn test_decode_sip002_missing_password() { + let url = Url::parse(&format!( + "ss://{}@3.187.225.7:34187", + STANDARD.encode("aes-128-gcm") + )) + .unwrap(); + let mut queries = QueryMap::new(); + let leg = decode_sip002(&url, &mut queries); + assert_eq!(leg.unwrap_err(), DecodeError::MissingInfo("password")); + assert!(queries.is_empty()); + } + #[test] + fn test_decode_sip002_invalid_encoding() { + let cases: [&str; 3] = [ + "ss://%ff%ff@a.com:114", + "ss://あ@a.com:514", + "ss://YWVzLTI1Ni1jZmI6VVlMMUV2a2ZJMGNUNk5PWQ==@a%25b:1919", + ]; + for raw_url in cases { + let url = Url::parse(raw_url).unwrap(); + let mut queries = QueryMap::new(); + let leg = decode_sip002(&url, &mut queries); + assert_eq!(leg.unwrap_err(), DecodeError::InvalidEncoding, "{raw_url}",); + assert!(queries.is_empty()); + } + } }