analytics.rs
1026 lignes · 37111 octets
//! Audience-measurement (analytics) configuration. //! //! Provider-agnostic support for a third-party tracking script, modelled as a //! script URL plus a set of `data-*` attributes. That shape covers Rybbit, //! Plausible, Umami, Matomo-tag and similar vendors without shipping any //! vendor-specific code: only the attribute names differ between them. //! //! # Split between environment and database //! //! The **script URL** comes from the environment or the builder and is frozen //! at startup, because it feeds the `Content-Security-Policy` emitted by //! `security_headers_middleware`, which captures its configuration once when //! the router is built (see `RustwardenBuilder::build`). A URL editable at //! runtime would leave the CSP out of sync until the next restart. //! //! Everything else — whether tracking is on, the site identifier, the extra //! attributes — may be overridden from the `app_settings` table and takes //! effect without a restart, through [`AnalyticsState::reload`]. //! //! Keeping the origin out of the database is also a security property: a //! compromised administrator account cannot widen the CSP towards an //! arbitrary domain, which would be an exfiltration primitive. //! //! # Example //! //! ``` //! use rustwarden_core::config::analytics::AnalyticsConfig; //! //! let cfg = AnalyticsConfig::default() //! .with_script_url("https://stats.example.org/api/script.js") //! .with_site_id("abc123") //! .enabled(true); //! //! assert!(cfg.validate().is_ok()); //! assert_eq!(cfg.script_origin().as_deref(), Some("https://stats.example.org")); //! //! let snippet = cfg.render_snippet().expect("enabled config renders"); //! assert!(snippet.contains(r#"data-site-id="abc123""#)); //! ``` use crate::error::AppError; use crate::services::app_settings_service::AppSettingsService; use sea_orm::DatabaseConnection; use std::collections::BTreeMap; use std::sync::{Arc, RwLock}; /// Attribute carrying the site identifier for most vendors (Rybbit, Umami /// uses `data-website-id`, Plausible uses `data-domain`). pub const DEFAULT_SITE_ID_ATTRIBUTE: &str = "data-site-id"; /// `app_settings` key toggling audience measurement at runtime. pub const SETTING_ANALYTICS_ENABLED: &str = "analytics_enabled"; /// `app_settings` key holding the site identifier at runtime. pub const SETTING_ANALYTICS_SITE_ID: &str = "analytics_site_id"; /// `app_settings` key holding extra `data-*` attributes, as a JSON object. pub const SETTING_ANALYTICS_ATTRIBUTES: &str = "analytics_attributes"; /// Reasons an [`AnalyticsConfig`] can be rejected. /// /// Validation is the primary security gate of this module: values reaching it /// may originate from the administration panel, and end up inside rendered /// HTML. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum AnalyticsConfigError { /// Tracking is enabled but no script URL was supplied. #[error("analytics is enabled but no script URL is configured")] MissingScriptUrl, /// The script URL is not an absolute `https://` URL (or `http://` on a /// loopback host). #[error("invalid analytics script URL: {0}")] InvalidScriptUrl(String), /// An attribute name is not a plain `data-*` name. #[error("invalid analytics attribute name `{0}`: expected `data-` followed by [a-z0-9-]")] InvalidAttributeName(String), /// An attribute value, or the site identifier, contains a character /// outside the allow-list. #[error("invalid value for analytics attribute `{name}`: {reason}")] InvalidAttributeValue { /// Attribute the offending value belongs to. name: String, /// Why the value was rejected. reason: String, }, /// The same attribute is declared twice (site-id attribute and extras). #[error("analytics attribute `{0}` is declared both as site-id attribute and as an extra")] DuplicateAttribute(String), /// The opt-out localStorage key contains unsupported characters. #[error("invalid opt-out storage key `{0}`: expected [A-Za-z0-9._-]")] InvalidOptOutKey(String), /// The JSON payload of extra attributes could not be parsed. #[error("invalid analytics attributes JSON: {0}")] InvalidAttributesJson(String), } /// Generic audience-measurement configuration. /// /// [`Default`] is deliberately conservative: tracking is **off** and nothing /// is rendered, so a consumer that never configures analytics observes no /// change whatsoever — neither in the page nor in the CSP. #[derive(Clone, Debug, PartialEq, Eq)] pub struct AnalyticsConfig { /// Whether the tracking script is rendered. Default: `false`. pub enabled: bool, /// Absolute URL of the tracking script. Feeds the CSP, therefore only /// settable from the environment or the builder. pub script_url: Option<String>, /// Site identifier handed to the vendor. pub site_id: Option<String>, /// Attribute carrying [`Self::site_id`]. Default: /// [`DEFAULT_SITE_ID_ATTRIBUTE`]. pub site_id_attribute: String, /// Extra `data-*` attributes, e.g. `data-consent` → `opt-in`. Ordered, so /// the rendered snippet is deterministic. pub attributes: BTreeMap<String, String>, /// `localStorage` key a visitor can set to opt out. Vendor-specific, e.g. /// `disable-rybbit`. `None` disables the opt-out component. pub opt_out_storage_key: Option<String>, /// Whether to render the `defer` attribute. Default: `true`. pub defer: bool, } impl Default for AnalyticsConfig { fn default() -> Self { Self { enabled: false, script_url: None, site_id: None, site_id_attribute: DEFAULT_SITE_ID_ATTRIBUTE.to_string(), attributes: BTreeMap::new(), opt_out_storage_key: None, defer: true, } } } impl AnalyticsConfig { /// Load the configuration from the environment. /// /// Reads `ANALYTICS_ENABLED`, `ANALYTICS_SCRIPT_URL`, `ANALYTICS_SITE_ID`, /// `ANALYTICS_SITE_ID_ATTRIBUTE`, `ANALYTICS_ATTRIBUTES` (a JSON object), /// `ANALYTICS_OPT_OUT_STORAGE_KEY` and `ANALYTICS_DEFER`. /// /// With none of them set, returns [`Default`] — tracking disabled. /// /// # Errors /// /// Returns [`AnalyticsConfigError`] if the resulting configuration does /// not pass [`Self::validate`], or if `ANALYTICS_ATTRIBUTES` is not a /// JSON object of strings. pub fn from_env() -> Result<Self, AnalyticsConfigError> { let mut cfg = Self::default(); if let Ok(raw) = std::env::var("ANALYTICS_ENABLED") { cfg.enabled = matches!( raw.trim().to_ascii_lowercase().as_str(), "true" | "1" | "on" ); } if let Ok(url) = std::env::var("ANALYTICS_SCRIPT_URL") { let url = url.trim().to_string(); if !url.is_empty() { cfg.script_url = Some(url); } } if let Ok(site_id) = std::env::var("ANALYTICS_SITE_ID") { let site_id = site_id.trim().to_string(); if !site_id.is_empty() { cfg.site_id = Some(site_id); } } if let Ok(attr) = std::env::var("ANALYTICS_SITE_ID_ATTRIBUTE") { let attr = attr.trim().to_string(); if !attr.is_empty() { cfg.site_id_attribute = attr; } } if let Ok(raw) = std::env::var("ANALYTICS_ATTRIBUTES") { if !raw.trim().is_empty() { cfg.attributes = parse_attributes_json(&raw)?; } } if let Ok(key) = std::env::var("ANALYTICS_OPT_OUT_STORAGE_KEY") { let key = key.trim().to_string(); if !key.is_empty() { cfg.opt_out_storage_key = Some(key); } } if let Ok(raw) = std::env::var("ANALYTICS_DEFER") { cfg.defer = !matches!( raw.trim().to_ascii_lowercase().as_str(), "false" | "0" | "off" ); } cfg.validate()?; Ok(cfg) } /// Builder-style setter for [`Self::script_url`]. pub fn with_script_url(mut self, url: impl Into<String>) -> Self { self.script_url = Some(url.into()); self } /// Builder-style setter for [`Self::site_id`]. pub fn with_site_id(mut self, site_id: impl Into<String>) -> Self { self.site_id = Some(site_id.into()); self } /// Builder-style setter for [`Self::site_id_attribute`], e.g. /// `data-domain` for Plausible or `data-website-id` for Umami. pub fn with_site_id_attribute(mut self, attribute: impl Into<String>) -> Self { self.site_id_attribute = attribute.into(); self } /// Add one extra `data-*` attribute. pub fn with_attribute(mut self, name: impl Into<String>, value: impl Into<String>) -> Self { self.attributes.insert(name.into(), value.into()); self } /// Replace the extra attributes from a JSON object of string values. /// /// This is the format stored in `app_settings` and submitted by the /// administration form. /// /// # Errors /// /// Returns [`AnalyticsConfigError::InvalidAttributesJson`] if the payload /// is not a JSON object whose values are all strings. pub fn with_attributes_json(mut self, raw: &str) -> Result<Self, AnalyticsConfigError> { self.attributes = parse_attributes_json(raw)?; Ok(self) } /// Builder-style setter for [`Self::opt_out_storage_key`]. pub fn with_opt_out_storage_key(mut self, key: impl Into<String>) -> Self { self.opt_out_storage_key = Some(key.into()); self } /// Builder-style setter for [`Self::enabled`]. pub fn enabled(mut self, enabled: bool) -> Self { self.enabled = enabled; self } /// Validate the configuration. /// /// A disabled configuration with no script URL is always valid: that is /// the default, and it renders nothing. /// /// # Errors /// /// See [`AnalyticsConfigError`]. pub fn validate(&self) -> Result<(), AnalyticsConfigError> { if let Some(url) = &self.script_url { validate_script_url(url)?; } else if self.enabled { return Err(AnalyticsConfigError::MissingScriptUrl); } validate_attribute_name(&self.site_id_attribute)?; if let Some(site_id) = &self.site_id { validate_attribute_value(&self.site_id_attribute, site_id)?; } for (name, value) in &self.attributes { if name == &self.site_id_attribute { return Err(AnalyticsConfigError::DuplicateAttribute(name.clone())); } validate_attribute_name(name)?; validate_attribute_value(name, value)?; } if let Some(key) = &self.opt_out_storage_key { if key.is_empty() || !key.chars().all(is_storage_key_char) { return Err(AnalyticsConfigError::InvalidOptOutKey(key.clone())); } } Ok(()) } /// Origin (`scheme://host[:port]`) of the tracking script, for the CSP. /// /// Returns `None` when no script URL is configured, whether or not /// tracking is enabled — the origin is what the CSP must allow, and it is /// frozen at startup regardless of the runtime toggle. pub fn script_origin(&self) -> Option<String> { self.script_url.as_deref().and_then(script_origin_of) } /// Render the `<script>` tag, or `None` when tracking is disabled or no /// script URL is configured. /// /// Every interpolated value is HTML-escaped, on top of the allow-list /// enforced by [`Self::validate`]. The returned string is therefore safe /// to emit through Askama's `|safe` filter. pub fn render_snippet(&self) -> Option<String> { if !self.enabled { return None; } let url = self.script_url.as_deref()?; let mut tag = String::from("<script src=\""); tag.push_str(&escape_html_attribute(url)); tag.push('"'); if let Some(site_id) = &self.site_id { tag.push(' '); tag.push_str(&escape_html_attribute(&self.site_id_attribute)); tag.push_str("=\""); tag.push_str(&escape_html_attribute(site_id)); tag.push('"'); } for (name, value) in &self.attributes { tag.push(' '); tag.push_str(&escape_html_attribute(name)); tag.push_str("=\""); tag.push_str(&escape_html_attribute(value)); tag.push('"'); } if self.defer { tag.push_str(" defer"); } tag.push_str("></script>"); Some(tag) } } /// Parse a JSON object of string values into an attribute map. fn parse_attributes_json(raw: &str) -> Result<BTreeMap<String, String>, AnalyticsConfigError> { let parsed: serde_json::Value = serde_json::from_str(raw) .map_err(|e| AnalyticsConfigError::InvalidAttributesJson(e.to_string()))?; let object = parsed.as_object().ok_or_else(|| { AnalyticsConfigError::InvalidAttributesJson("expected a JSON object".to_string()) })?; let mut attributes = BTreeMap::new(); for (name, value) in object { let value = value.as_str().ok_or_else(|| { AnalyticsConfigError::InvalidAttributesJson(format!( "value of `{}` is not a string", name )) })?; attributes.insert(name.clone(), value.to_string()); } Ok(attributes) } /// Characters accepted in attribute values and site identifiers. Wide enough /// for Plausible's comma-separated `data-domain`, narrow enough to exclude /// every HTML metacharacter. fn is_attribute_value_char(c: char) -> bool { c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | ':' | '/' | ',') } /// Characters accepted in a `localStorage` key. fn is_storage_key_char(c: char) -> bool { c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') } /// Reject anything that is not a plain `data-*` attribute name. /// /// This is what keeps `onload`, `src` and `integrity` out of the rendered /// tag: an administrator can add attributes, never behaviour. fn validate_attribute_name(name: &str) -> Result<(), AnalyticsConfigError> { let Some(suffix) = name.strip_prefix("data-") else { return Err(AnalyticsConfigError::InvalidAttributeName(name.to_string())); }; let valid = !suffix.is_empty() && suffix .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'); if valid { Ok(()) } else { Err(AnalyticsConfigError::InvalidAttributeName(name.to_string())) } } fn validate_attribute_value(name: &str, value: &str) -> Result<(), AnalyticsConfigError> { if value.is_empty() { return Err(AnalyticsConfigError::InvalidAttributeValue { name: name.to_string(), reason: "value is empty".to_string(), }); } if let Some(bad) = value.chars().find(|c| !is_attribute_value_char(*c)) { return Err(AnalyticsConfigError::InvalidAttributeValue { name: name.to_string(), reason: format!("unsupported character `{}`", bad), }); } Ok(()) } /// Accept absolute `https://` URLs, plus `http://` on a loopback host so the /// feature can be exercised against a local collector in development. fn validate_script_url(url: &str) -> Result<(), AnalyticsConfigError> { if url.chars().any(|c| c.is_control() || c.is_whitespace()) { return Err(AnalyticsConfigError::InvalidScriptUrl( "URL contains whitespace or control characters".to_string(), )); } if url.contains("..") { return Err(AnalyticsConfigError::InvalidScriptUrl( "URL contains a path traversal sequence".to_string(), )); } let (scheme, rest) = url.split_once("://").ok_or_else(|| { AnalyticsConfigError::InvalidScriptUrl("URL must be absolute".to_string()) })?; let authority = rest.split(['/', '?', '#']).next().unwrap_or_default(); if authority.is_empty() { return Err(AnalyticsConfigError::InvalidScriptUrl( "URL has no host".to_string(), )); } if authority.contains('@') { return Err(AnalyticsConfigError::InvalidScriptUrl( "URL must not carry credentials".to_string(), )); } let host = host_of_authority(authority); match scheme { "https" => Ok(()), "http" if is_loopback_host(host) => Ok(()), "http" => Err(AnalyticsConfigError::InvalidScriptUrl( "plain http is only allowed on a loopback host".to_string(), )), other => Err(AnalyticsConfigError::InvalidScriptUrl(format!( "unsupported scheme `{}`", other ))), } } /// Strip the port from an authority, handling bracketed IPv6 literals. fn host_of_authority(authority: &str) -> &str { if let Some(end) = authority.strip_prefix('[').and_then(|r| r.find(']')) { return &authority[1..=end]; } authority.split(':').next().unwrap_or(authority) } fn is_loopback_host(host: &str) -> bool { matches!(host, "localhost" | "127.0.0.1" | "::1") } /// Extract `scheme://host[:port]` from an absolute URL. fn script_origin_of(url: &str) -> Option<String> { let (scheme, rest) = url.split_once("://")?; let authority = rest.split(['/', '?', '#']).next().unwrap_or_default(); if authority.is_empty() { return None; } Some(format!("{}://{}", scheme, authority)) } /// Escape the five characters that could break out of a double-quoted HTML /// attribute. Defence in depth: [`AnalyticsConfig::validate`] already rejects /// every one of them. fn escape_html_attribute(value: &str) -> String { let mut out = String::with_capacity(value.len()); for c in value.chars() { match c { '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '"' => out.push_str("""), '\'' => out.push_str("'"), _ => out.push(c), } } out } /// Shared analytics state, injected as an Axum `Extension`. /// /// The snippet is rendered when the state is loaded and whenever /// [`Self::reload`] runs — never while serving a request. [`Self::snippet`] /// is therefore a plain `Arc` clone with no database round-trip, which is /// what makes the helper affordable on every page render. #[derive(Debug)] pub struct AnalyticsState { /// Configuration from the environment or the builder. Holds the script /// URL, which the database may not override. base: AnalyticsConfig, /// Pre-rendered snippet, `None` when tracking is off. rendered: RwLock<Arc<Option<String>>>, } impl AnalyticsState { /// Build the state from `base`, applying the `app_settings` overrides. /// /// # Errors /// /// Returns [`AppError`] if the settings cannot be read. pub async fn load(db: &DatabaseConnection, base: AnalyticsConfig) -> Result<Self, AppError> { let effective = Self::resolve(db, &base).await?; Ok(Self { base, rendered: RwLock::new(Arc::new(effective.render_snippet())), }) } /// Build the state without consulting the database. /// /// Used when the framework runs without persisted settings, and in tests. pub fn from_config(base: AnalyticsConfig) -> Self { let rendered = base.render_snippet(); Self { base, rendered: RwLock::new(Arc::new(rendered)), } } /// Re-read the `app_settings` overrides and re-render the snippet. /// /// Call this after an administrator saves the settings form so the change /// takes effect without a restart. /// /// # Errors /// /// Returns [`AppError`] if the settings cannot be read. pub async fn reload(&self, db: &DatabaseConnection) -> Result<(), AppError> { let effective = Self::resolve(db, &self.base).await?; let snippet = Arc::new(effective.render_snippet()); match self.rendered.write() { Ok(mut guard) => *guard = snippet, // A poisoned lock means a previous writer panicked. The value is // still structurally sound (an Arc<Option<String>>), so recover // rather than propagate the panic to every subsequent request. Err(poisoned) => *poisoned.into_inner() = snippet, } Ok(()) } /// The pre-rendered snippet, or `None` when tracking is off. pub fn snippet(&self) -> Arc<Option<String>> { match self.rendered.read() { Ok(guard) => Arc::clone(&guard), Err(poisoned) => Arc::clone(&poisoned.into_inner()), } } /// The `localStorage` key a visitor can set to opt out, if any. pub fn opt_out_storage_key(&self) -> Option<&str> { self.base.opt_out_storage_key.as_deref() } /// The script URL, which the database cannot override. Exposed so the /// administration screen can display it read-only. pub fn script_url(&self) -> Option<&str> { self.base.script_url.as_deref() } /// The environment/builder configuration, before any database override. /// /// The administration screen uses it as the starting point when checking /// a submitted form: the candidate must be validated against the real /// script URL and site-id attribute, not against defaults. pub fn base_config(&self) -> &AnalyticsConfig { &self.base } /// Merge `base` with the `app_settings` overrides. /// /// `script_url` is deliberately absent from the overrides: see the module /// documentation. An override that fails validation is discarded with a /// warning rather than taking the application down. async fn resolve( db: &DatabaseConnection, base: &AnalyticsConfig, ) -> Result<AnalyticsConfig, AppError> { let mut cfg = base.clone(); cfg.enabled = AppSettingsService::get_bool(db, SETTING_ANALYTICS_ENABLED, base.enabled) .await .unwrap_or(base.enabled); if let Some(setting) = AppSettingsService::get_setting(db, SETTING_ANALYTICS_SITE_ID).await? { let value = setting.value.trim().to_string(); cfg.site_id = if value.is_empty() { None } else { Some(value) }; } if let Some(setting) = AppSettingsService::get_setting(db, SETTING_ANALYTICS_ATTRIBUTES).await? { let raw = setting.value.trim(); if !raw.is_empty() { match parse_attributes_json(raw) { Ok(attributes) => cfg.attributes = attributes, Err(e) => tracing::warn!( error = %e, "ignoring stored analytics attributes: invalid payload" ), } } } if let Err(e) = cfg.validate() { tracing::warn!( error = %e, "stored analytics settings are invalid; falling back to the environment configuration" ); return Ok(base.clone()); } Ok(cfg) } } /// Everything a template needs to render audience measurement. /// /// Obtained as an Axum extractor, then placed in the handler's template /// struct. Both fields are empty strings when the corresponding feature is /// off, which keeps the Askama side free of `Option` handling. /// /// # Rendering /// /// ```text /// {% if analytics.is_enabled() %}{{ analytics.snippet|safe }}{% endif %} /// ``` /// /// The `|safe` filter is correct here: [`AnalyticsConfig::render_snippet`] /// HTML-escapes every interpolated value, on top of the allow-list enforced /// by [`AnalyticsConfig::validate`]. Never put unvalidated user input into /// this field. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct AnalyticsContext { /// The `<script>` tag, or an empty string when tracking is off. pub snippet: String, /// The `localStorage` opt-out key, or an empty string when the configured /// vendor exposes none. pub opt_out_storage_key: String, /// The configured script URL, or an empty string. Exposed so the /// administration screen can display it read-only; it is not a secret, /// since it appears in the page as soon as tracking is on. pub script_url: String, } impl AnalyticsContext { /// Build the context from the shared state, if the framework installed it. pub fn from_state(state: Option<&Arc<AnalyticsState>>) -> Self { let Some(state) = state else { return Self::default(); }; Self { snippet: state.snippet().as_deref().unwrap_or_default().to_string(), opt_out_storage_key: state.opt_out_storage_key().unwrap_or_default().to_string(), script_url: state.script_url().unwrap_or_default().to_string(), } } /// Whether a script URL is configured at all, whichever the runtime /// toggle. When false, the administration screen has nothing to offer. pub fn is_configured(&self) -> bool { !self.script_url.is_empty() } /// Whether a tracking script will be rendered. pub fn is_enabled(&self) -> bool { !self.snippet.is_empty() } /// Whether an opt-out control can be offered to the visitor. pub fn has_opt_out(&self) -> bool { !self.opt_out_storage_key.is_empty() } } impl<S> axum::extract::FromRequestParts<S> for AnalyticsContext where S: Send + Sync, { type Rejection = std::convert::Infallible; /// Never fails: an application built without analytics simply yields the /// empty context, so templates shared between configurations keep working. async fn from_request_parts( parts: &mut axum::http::request::Parts, _state: &S, ) -> Result<Self, Self::Rejection> { Ok(Self::from_state( parts.extensions.get::<Arc<AnalyticsState>>(), )) } } /// Handle on the shared [`AnalyticsState`], for the rare callers that need to /// mutate it — chiefly the administration screen, which calls /// [`AnalyticsState::reload`] after saving so the change takes effect without /// a restart. /// /// `None` when the application was not built through `RustwardenBuilder`, so /// a consumer mounting the UI by hand still compiles and runs. #[derive(Clone, Debug)] pub struct AnalyticsHandle(pub Option<Arc<AnalyticsState>>); impl AnalyticsHandle { /// The environment/builder configuration, when analytics is wired. pub fn base_config(&self) -> Option<&AnalyticsConfig> { self.0.as_ref().map(|state| state.base_config()) } /// Re-read the settings, if analytics is wired. A missing handle is not /// an error: there is simply nothing to refresh. /// /// # Errors /// /// Returns [`AppError`] if the settings cannot be read. pub async fn reload(&self, db: &DatabaseConnection) -> Result<(), AppError> { match &self.0 { Some(state) => state.reload(db).await, None => Ok(()), } } } impl<S> axum::extract::FromRequestParts<S> for AnalyticsHandle where S: Send + Sync, { type Rejection = std::convert::Infallible; async fn from_request_parts( parts: &mut axum::http::request::Parts, _state: &S, ) -> Result<Self, Self::Rejection> { Ok(Self(parts.extensions.get::<Arc<AnalyticsState>>().cloned())) } } #[cfg(test)] mod tests { use super::*; fn enabled_config() -> AnalyticsConfig { AnalyticsConfig::default() .with_script_url("https://stats.example.org/api/script.js") .with_site_id("abc123") .enabled(true) } #[test] fn default_is_disabled_and_renders_nothing() { let cfg = AnalyticsConfig::default(); assert!(!cfg.enabled); assert!(cfg.validate().is_ok()); assert!(cfg.render_snippet().is_none()); assert!(cfg.script_origin().is_none()); } #[test] fn disabled_config_renders_nothing_even_with_a_url() { let cfg = enabled_config().enabled(false); assert!(cfg.render_snippet().is_none()); } #[test] fn enabled_without_url_is_rejected() { let cfg = AnalyticsConfig::default().enabled(true); assert_eq!(cfg.validate(), Err(AnalyticsConfigError::MissingScriptUrl)); } #[test] fn renders_expected_tag() { let cfg = enabled_config().with_attribute("data-consent", "opt-in"); assert_eq!( cfg.render_snippet().as_deref(), Some( r#"<script src="https://stats.example.org/api/script.js" data-site-id="abc123" data-consent="opt-in" defer></script>"# ) ); } #[test] fn defer_can_be_turned_off() { let mut cfg = enabled_config(); cfg.defer = false; let snippet = cfg.render_snippet().unwrap_or_default(); assert!(!snippet.contains("defer")); } #[test] fn attribute_order_is_deterministic() { let cfg = enabled_config() .with_attribute("data-zulu", "1") .with_attribute("data-alpha", "2"); let snippet = cfg.render_snippet().unwrap_or_default(); let alpha = snippet.find("data-alpha").unwrap_or(usize::MAX); let zulu = snippet.find("data-zulu").unwrap_or(0); assert!(alpha < zulu, "attributes must render in sorted order"); } // --- Security: injection ------------------------------------------------ #[test] fn site_id_carrying_markup_is_rejected() { let cfg = enabled_config().with_site_id(r#""><script>alert(1)</script>"#); assert!(matches!( cfg.validate(), Err(AnalyticsConfigError::InvalidAttributeValue { .. }) )); } #[test] fn markup_that_bypasses_validation_is_still_escaped() { // Defence in depth: build the struct directly, skipping `validate`. let mut cfg = enabled_config(); cfg.site_id = Some(r#""><script>alert(1)</script>"#.to_string()); let snippet = cfg.render_snippet().unwrap_or_default(); assert!(!snippet.contains("<script>alert(1)")); assert!(snippet.contains(""><script>")); } #[test] fn event_handler_attributes_are_rejected() { for name in ["onload", "onerror", "src", "integrity", "data-x onload"] { let cfg = enabled_config().with_attribute(name, "x"); assert!( matches!( cfg.validate(), Err(AnalyticsConfigError::InvalidAttributeName(_)) ), "attribute `{}` must be rejected", name ); } } #[test] fn site_id_attribute_cannot_be_duplicated() { let cfg = enabled_config().with_attribute(DEFAULT_SITE_ID_ATTRIBUTE, "other"); assert!(matches!( cfg.validate(), Err(AnalyticsConfigError::DuplicateAttribute(_)) )); } // --- Security: URL ------------------------------------------------------ #[test] fn plain_http_is_rejected_off_loopback() { let cfg = AnalyticsConfig::default() .with_script_url("http://stats.example.org/script.js") .enabled(true); assert!(matches!( cfg.validate(), Err(AnalyticsConfigError::InvalidScriptUrl(_)) )); } #[test] fn plain_http_is_allowed_on_loopback() { for url in [ "http://localhost:3001/script.js", "http://127.0.0.1:3001/script.js", ] { let cfg = AnalyticsConfig::default() .with_script_url(url) .enabled(true); assert!(cfg.validate().is_ok(), "{} should be accepted", url); } } #[test] fn malformed_urls_are_rejected() { for url in [ "/relative/script.js", "https://", "javascript:alert(1)", "https://user:pass@evil.example/script.js", "https://stats.example.org/../script.js", "https://stats.example.org/script.js\" onload=\"alert(1)", ] { let cfg = AnalyticsConfig::default() .with_script_url(url) .enabled(true); assert!( matches!( cfg.validate(), Err(AnalyticsConfigError::InvalidScriptUrl(_)) ), "{} should be rejected", url ); } } // --- Origin ------------------------------------------------------------- #[test] fn script_origin_drops_path_and_keeps_port() { let cases = [ ( "https://stats.example.org/api/script.js", "https://stats.example.org", ), ( "https://stats.example.org:8443/s.js", "https://stats.example.org:8443", ), ( "https://a.b.c.example.org/s.js?v=1", "https://a.b.c.example.org", ), ("http://localhost:3001/s.js", "http://localhost:3001"), ]; for (url, expected) in cases { let cfg = AnalyticsConfig::default().with_script_url(url); assert_eq!( cfg.script_origin().as_deref(), Some(expected), "for {}", url ); } } #[test] fn script_origin_is_available_even_when_disabled() { // The CSP is built at startup and must allow the origin regardless of // the runtime toggle, which may be flipped on later from the DB. let cfg = enabled_config().enabled(false); assert_eq!( cfg.script_origin().as_deref(), Some("https://stats.example.org") ); } // --- Opt-out key -------------------------------------------------------- #[test] fn opt_out_key_charset_is_enforced() { let ok = enabled_config().with_opt_out_storage_key("disable-rybbit"); assert!(ok.validate().is_ok()); let bad = enabled_config().with_opt_out_storage_key("disable rybbit<>"); assert!(matches!( bad.validate(), Err(AnalyticsConfigError::InvalidOptOutKey(_)) )); } // --- JSON attributes ---------------------------------------------------- #[test] fn attributes_json_is_parsed() { let parsed = parse_attributes_json(r#"{"data-consent":"opt-in"}"#).unwrap_or_default(); assert_eq!( parsed.get("data-consent").map(String::as_str), Some("opt-in") ); } #[test] fn attributes_json_rejects_non_objects_and_non_strings() { assert!(parse_attributes_json("[1,2]").is_err()); assert!(parse_attributes_json(r#"{"data-x":42}"#).is_err()); assert!(parse_attributes_json("not json").is_err()); } // --- State -------------------------------------------------------------- #[test] fn state_pre_renders_the_snippet() { let state = AnalyticsState::from_config(enabled_config()); assert!(state.snippet().is_some()); assert_eq!( state.script_url(), Some("https://stats.example.org/api/script.js") ); } #[test] fn state_of_a_disabled_config_yields_nothing() { let state = AnalyticsState::from_config(AnalyticsConfig::default()); assert!(state.snippet().is_none()); assert!(state.opt_out_storage_key().is_none()); } // --- Template context --------------------------------------------------- #[test] fn context_without_state_is_empty() { let ctx = AnalyticsContext::from_state(None); assert!(!ctx.is_enabled()); assert!(!ctx.has_opt_out()); assert_eq!(ctx.snippet, ""); assert_eq!(ctx.opt_out_storage_key, ""); } #[test] fn context_carries_snippet_and_opt_out_key() { let state = Arc::new(AnalyticsState::from_config( enabled_config().with_opt_out_storage_key("disable-rybbit"), )); let ctx = AnalyticsContext::from_state(Some(&state)); assert!(ctx.is_enabled()); assert!(ctx.has_opt_out()); assert!(ctx.snippet.contains("data-site-id=\"abc123\"")); assert_eq!(ctx.opt_out_storage_key, "disable-rybbit"); } #[test] fn context_of_disabled_analytics_still_exposes_nothing() { let state = Arc::new(AnalyticsState::from_config(enabled_config().enabled(false))); let ctx = AnalyticsContext::from_state(Some(&state)); assert!(!ctx.is_enabled()); } }
GitRust