diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 0914a34f89e2b..c0436c8da5852 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -989,17 +989,14 @@ pub trait SeleniumManager { fn stats(&self) -> Result<(), Error> { if !self.is_avoid_stats() && !self.is_offline() { - let props = Props { - browser: self.get_browser_name().to_ascii_lowercase(), - browser_version: self.get_browser_version().to_ascii_lowercase(), - os: self.get_os().to_ascii_lowercase(), - arch: self - .get_normalized_arch() - .unwrap_or(ARCH_OTHER) - .to_ascii_lowercase(), - lang: self.get_language_binding().to_ascii_lowercase(), - selenium_version: self.get_selenium_version().to_ascii_lowercase(), - }; + let props = Props::sanitized( + self.get_browser_name(), + self.get_browser_version(), + self.get_os(), + self.get_normalized_arch().unwrap_or(ARCH_OTHER), + self.get_language_binding(), + self.get_selenium_version(), + ); let http_client = self.get_http_client().to_owned(); let sender = self.get_sender().to_owned(); let cache_path = self.get_cache_path()?; diff --git a/rust/src/stats.rs b/rust/src/stats.rs index f8ecca4ccd6ff..411db1a1d9a74 100644 --- a/rust/src/stats.rs +++ b/rust/src/stats.rs @@ -15,6 +15,7 @@ // specific language governing permissions and limitations // under the License. +use crate::config::str_to_os; use crate::format_one_arg; use reqwest::Client; use reqwest::header::CONTENT_TYPE; @@ -31,6 +32,13 @@ const SELENIUM_DOMAIN: &str = "manager.selenium.dev"; const SM_STATS_URL: &str = "https://{}/sm-usage"; const REQUEST_TIMEOUT_SEC: u64 = 3; +const STATS_OTHER: &str = "other"; + +const VALID_LANGUAGE_BINDINGS: &[&str] = + &["java", "javascript", "python", "csharp", "ruby", "rust"]; + +const VALID_VERSION_LABELS: &[&str] = &["stable", "beta", "dev", "canary", "nightly", "esr"]; + #[derive(Default, Serialize, Deserialize)] pub struct Data { pub name: String, @@ -49,6 +57,63 @@ pub struct Props { pub selenium_version: String, } +impl Props { + // browser, arch and selenium_version are already bounded upstream (unrecognized browsers + // error out, arch is bucketed by get_normalized_arch, selenium_version is the crate version). + // os, browser_version and language_binding still carry raw CLI/env input here, so they are + // constrained to a vetted vocabulary before being reported to Plausible. + pub fn sanitized( + browser: &str, + browser_version: &str, + os: &str, + arch: &str, + language_binding: &str, + selenium_version: &str, + ) -> Self { + Props { + browser: browser.to_ascii_lowercase(), + browser_version: sanitize_browser_version(browser_version), + os: sanitize_os(os), + arch: arch.to_ascii_lowercase(), + lang: sanitize_language_binding(language_binding), + selenium_version: selenium_version.to_ascii_lowercase(), + } + } +} + +fn sanitize_os(os: &str) -> String { + match str_to_os(os.trim()) { + Ok(parsed_os) => parsed_os.to_str_vector()[0].to_string(), + Err(_) => STATS_OTHER.to_string(), + } +} + +fn sanitize_language_binding(language_binding: &str) -> String { + let lang = language_binding.trim().to_ascii_lowercase(); + if VALID_LANGUAGE_BINDINGS.contains(&lang.as_str()) { + lang + } else { + STATS_OTHER.to_string() + } +} + +fn sanitize_browser_version(browser_version: &str) -> String { + let version = browser_version.trim().to_ascii_lowercase(); + if version.is_empty() { + return String::new(); + } + if VALID_VERSION_LABELS.contains(&version.as_str()) { + return version; + } + // Report only the numeric major component, never a full version or free text + let major = version.split('.').next().unwrap_or_default(); + if !major.is_empty() && major.bytes().all(|b| b.is_ascii_digit()) { + major.to_string() + } else { + STATS_OTHER.to_string() + } +} + #[tokio::main] pub async fn send_stats_to_plausible(http_client: Client, props: Props, sender: Sender) { let user_agent = format_one_arg(SM_USER_AGENT, &props.selenium_version); @@ -74,3 +139,59 @@ pub async fn send_stats_to_plausible(http_client: Client, props: Props, sender: .unwrap_or_default(); } } + +#[cfg(test)] +mod tests { + use super::*; + + const XSS_PAYLOAD: &str = r#""/>"#; + + #[test] + fn os_is_canonicalized_or_other() { + assert_eq!(sanitize_os("windows"), "windows"); + assert_eq!(sanitize_os("WIN"), "windows"); + assert_eq!(sanitize_os("mac"), "macos"); + assert_eq!(sanitize_os("gnu/linux"), "linux"); + assert_eq!(sanitize_os("WIN "), "windows"); + assert_eq!(sanitize_os(XSS_PAYLOAD), STATS_OTHER); + assert_eq!(sanitize_os(""), STATS_OTHER); + } + + #[test] + fn browser_version_is_reduced_to_major() { + assert_eq!(sanitize_browser_version("120.0.6099.109"), "120"); + assert_eq!(sanitize_browser_version("115"), "115"); + assert_eq!(sanitize_browser_version("BETA"), "beta"); + assert_eq!(sanitize_browser_version("stable"), "stable"); + assert_eq!(sanitize_browser_version(""), ""); + assert_eq!(sanitize_browser_version("12a.0"), STATS_OTHER); + assert_eq!(sanitize_browser_version(XSS_PAYLOAD), STATS_OTHER); + } + + #[test] + fn language_binding_is_vetted() { + assert_eq!(sanitize_language_binding("Java"), "java"); + assert_eq!(sanitize_language_binding("Java "), "java"); + assert_eq!(sanitize_language_binding("csharp"), "csharp"); + assert_eq!(sanitize_language_binding("cobol"), STATS_OTHER); + assert_eq!(sanitize_language_binding(XSS_PAYLOAD), STATS_OTHER); + } + + #[test] + fn free_form_fields_reject_untrusted_input() { + // os, browser_version and language_binding are the only fields still holding raw input + assert_eq!(sanitize_os(XSS_PAYLOAD), STATS_OTHER); + assert_eq!(sanitize_browser_version(XSS_PAYLOAD), STATS_OTHER); + assert_eq!(sanitize_language_binding(XSS_PAYLOAD), STATS_OTHER); + + let props = Props::sanitized( + "MicrosoftEdge", + XSS_PAYLOAD, + XSS_PAYLOAD, + "arm64", + XSS_PAYLOAD, + "4.47-nightly", + ); + assert!(!serde_json::to_string(&props).unwrap().contains("iframe")); + } +} diff --git a/rust/tests/browser_tests.rs b/rust/tests/browser_tests.rs index a6d25982a5674..47757e881dada 100644 --- a/rust/tests/browser_tests.rs +++ b/rust/tests/browser_tests.rs @@ -199,11 +199,11 @@ fn browser_path_version_mismatch_test() { // The mismatch is always reported, either as ERROR (no cached driver) or WARN (fallback used) assert!( stdout_str.contains("131.0.6778.264"), - "Should mention detected version" + "Should mention detected version; got:\n{stdout_str}" ); assert!( stdout_str.contains("999.0.0.0"), - "Should mention requested version" + "Should mention requested version; got:\n{stdout_str}" ); } @@ -230,11 +230,11 @@ fn browser_path_major_version_mismatch_test() { // Major-only version mismatch must also be reported assert!( stdout_str.contains("131.0.6778.264"), - "Should mention detected version" + "Should mention detected version; got:\n{stdout_str}" ); assert!( stdout_str.contains("999"), - "Should mention requested version" + "Should mention requested version; got:\n{stdout_str}" ); }