Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 8 additions & 11 deletions rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()?;
Expand Down
121 changes: 121 additions & 0 deletions rust/src/stats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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"];
Comment thread
qodo-code-review[bot] marked this conversation as resolved.

const VALID_VERSION_LABELS: &[&str] = &["stable", "beta", "dev", "canary", "nightly", "esr"];

#[derive(Default, Serialize, Deserialize)]
pub struct Data {
pub name: String,
Expand All @@ -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(),
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
}
}

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<String>) {
let user_agent = format_one_arg(SM_USER_AGENT, &props.selenium_version);
Expand All @@ -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#""/><iframe src=file:///etc/passwd></iframe>"#;

#[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"));
}
}
8 changes: 4 additions & 4 deletions rust/tests/browser_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
);
}

Expand All @@ -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}"
);
}

Expand Down