From 907e640ec6c930c27af91d473b96f8589a933bbf Mon Sep 17 00:00:00 2001 From: "Abderraouf Ghrissi (abgh)" Date: Thu, 2 Apr 2026 17:13:11 +0200 Subject: [PATCH] [SEC] restrict CORS to authorized extension IDs Fixes a security issue where any Firefox extension (moz-extension://.*) could access the ActivityWatch server without any restriction. Previously, the CORS configuration included a wildcard for all Mozilla extensions by default. This commit removes that blanket permission and introduces granular control through both static configuration and the Web UI. We've added 2 new fields to the file configuration (cors_allow_aw_chrome_extension and cors_allow_all_mozilla_extension) and 4 new settings to the Web UI (Fixed origins, Regex origins, and extension-specific shortcuts). The server now merges these settings to determine the final set of authorized origins, ensuring a more secure and flexible configuration. The TOML configuration file values are now used only as an initial seed for the database during the first run. On subsequent runs, any values changed and persisted via the Web UI will take precedence over the config file defaults. Fixed a bug in the web-ui store where changing one setting would cause all other settings to be re-saved with their initial client-side values, unintentionally overwriting database settings with stale defaults. Dependent on: https://github.com/ActivityWatch/aw-webui/pull/795 --- .gitignore | 2 ++ README.md | 21 ++++++++++++--- aw-server/src/config.rs | 16 ++++++++++++ aw-server/src/endpoints/cors.rs | 21 +++++++++------ aw-server/src/endpoints/mod.rs | 46 ++++++++++++++++++++++++++++++++- 5 files changed, 94 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index e68a3e1e..8ee21062 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ NDK *.sqlite* *.db *.db-journal + +.vscode diff --git a/README.md b/README.md index 4766e574..2f86b69d 100644 --- a/README.md +++ b/README.md @@ -60,16 +60,31 @@ Available options: # Additional regex CORS origins to allow (e.g. for sideloaded browser extensions) #cors_regex = ["chrome-extension://yourextensionidhere"] + +# Allow official ActivityWatch Chrome extension? (default: true) +#cors_allow_aw_chrome_extension = true + +# Allow all Firefox extensions? (default: false, DANGEROUS) +#cors_allow_all_mozilla_extension = false ``` +#### Persistence and Settings UI + +The CORS-related settings (`cors`, `cors_regex`, `cors_allow_aw_chrome_extension`, and `cors_allow_all_mozilla_extension`) follow a specific persistence logic: + +- **First Start**: These variables are only taken into account on the first server start, at which point they are added to the database. +- **Management**: Once added, they can be managed and edited via the **Settings UI** in the web interface. +- **Precedence**: Values in the database take precedence over the configuration file on subsequent starts. +- **Warning**: If these keys are deleted from the database, the server will again use the values from the configuration file to re-populate them. This ensures that the fields are always present, either from your manual settings or your initial configuration. + #### Custom CORS Origins By default, the server allows requests from: - The server's own origin (`http://127.0.0.1:`, `http://localhost:`) -- The official Chrome extension (`chrome-extension://nglaklhklhcoonedhgnpgddginnjdadi`) -- All Firefox extensions (`moz-extension://.*`) +- The official Chrome extension (`chrome-extension://nglaklhklhcoonedhgnpgddginnjdadi`) if `cors_allow_aw_chrome_extension` is true (default). +- All Firefox extensions (`moz-extension://.*`) ONLY IF `cors_allow_all_mozilla_extension` is set to true. -To allow additional origins (e.g. a sideloaded Chrome extension), add them to your config: +To allow additional origins (e.g. a sideloaded Chrome extension), add them to your `cors` or `cors_regex` config: ```toml # Allow a specific sideloaded Chrome extension diff --git a/aw-server/src/config.rs b/aw-server/src/config.rs index 35ac435c..c4c8bdea 100644 --- a/aw-server/src/config.rs +++ b/aw-server/src/config.rs @@ -36,6 +36,12 @@ pub struct AWConfig { #[serde(default = "default_cors")] pub cors_regex: Vec, + #[serde(default = "default_true")] + pub cors_allow_aw_chrome_extension: bool, + + #[serde(default = "default_false")] + pub cors_allow_all_mozilla_extension: bool, + // A mapping of watcher names to paths where the // custom visualizations are located. #[serde(default = "default_custom_static")] @@ -50,6 +56,8 @@ impl Default for AWConfig { testing: default_testing(), cors: default_cors(), cors_regex: default_cors(), + cors_allow_aw_chrome_extension: default_true(), + cors_allow_all_mozilla_extension: default_false(), custom_static: default_custom_static(), } } @@ -91,6 +99,14 @@ fn default_testing() -> bool { is_testing() } +fn default_true() -> bool { + true +} + +fn default_false() -> bool { + false +} + fn default_port() -> u16 { if is_testing() { 5666 diff --git a/aw-server/src/endpoints/cors.rs b/aw-server/src/endpoints/cors.rs index 530be147..12e9da8a 100644 --- a/aw-server/src/endpoints/cors.rs +++ b/aw-server/src/endpoints/cors.rs @@ -9,18 +9,23 @@ pub fn cors(config: &AWConfig) -> rocket_cors::Cors { let mut allowed_exact_origins = vec![root_url, root_url_localhost]; allowed_exact_origins.extend(config.cors.clone()); - if config.testing { - allowed_exact_origins.push("http://127.0.0.1:27180".to_string()); - allowed_exact_origins.push("http://localhost:27180".to_string()); + let mut allowed_regex_origins = config.cors_regex.clone(); + + if config.cors_allow_aw_chrome_extension { + allowed_regex_origins.push("chrome-extension://nglaklhklhcoonedhgnpgddginnjdadi".to_string()); } - let mut allowed_regex_origins = vec![ - "chrome-extension://nglaklhklhcoonedhgnpgddginnjdadi".to_string(), + + if config.cors_allow_all_mozilla_extension { // Every version of a mozilla extension has its own ID to avoid fingerprinting, so we // unfortunately have to allow all extensions to have access to aw-server - "moz-extension://.*".to_string(), - ]; - allowed_regex_origins.extend(config.cors_regex.clone()); + allowed_regex_origins.push("moz-extension://.*".to_string()); + } + if config.testing { + allowed_exact_origins.extend(vec![ + "http://127.0.0.1:27180".to_string(), + "http://localhost:27180".to_string(), + ]); allowed_regex_origins.push("chrome-extension://.*".to_string()); } diff --git a/aw-server/src/endpoints/mod.rs b/aw-server/src/endpoints/mod.rs index f6c9271e..f6cbd2b2 100644 --- a/aw-server/src/endpoints/mod.rs +++ b/aw-server/src/endpoints/mod.rs @@ -127,11 +127,55 @@ fn get_file(file: PathBuf, state: &State) -> Option<(ContentType, V Some((content_type, asset)) } -pub fn build_rocket(server_state: ServerState, config: AWConfig) -> rocket::Rocket { +pub fn build_rocket( + server_state: ServerState, + mut config: AWConfig, +) -> rocket::Rocket { info!( "Starting aw-server-rust at {}:{}", config.address, config.port ); + { + let db = server_state.datastore.lock().unwrap(); + let parse_cors_list = |raw: &str| -> Vec { + serde_json::from_str::(raw) + .unwrap_or_else(|_| raw.trim_matches('"').to_string()) + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + }; + let parse_bool = |raw: &str| -> bool { + serde_json::from_str::(raw).unwrap_or_else(|_| raw.trim_matches('"') == "true") + }; + // Sync settings between Config file and Database. + // On the first run (when a key is missing in the DB), we seed the DB with the value from the config file. + // On subsequent runs, we always prefer the DB value (which might have been changed via the UI). + let sync = + |key: &str, current_val: &mut String, to_save: String| match db.get_key_value(key) { + Ok(raw) => *current_val = raw, + Err(_) => { + db.set_key_value(key, &to_save).ok(); + *current_val = to_save; + } + }; + + let mut raw_cors = String::new(); + sync("settings.cors", &mut raw_cors, serde_json::to_string(&config.cors.join(",")).unwrap()); + config.cors = parse_cors_list(&raw_cors); + + let mut raw_cors_regex = String::new(); + sync("settings.cors_regex", &mut raw_cors_regex, serde_json::to_string(&config.cors_regex.join(",")).unwrap()); + config.cors_regex = parse_cors_list(&raw_cors_regex); + + let mut raw_chrome = String::new(); + sync("settings.cors_allow_aw_chrome_extension", &mut raw_chrome, serde_json::to_string(&config.cors_allow_aw_chrome_extension).unwrap()); + config.cors_allow_aw_chrome_extension = parse_bool(&raw_chrome); + + let mut raw_mozilla = String::new(); + sync("settings.cors_allow_all_mozilla_extension", &mut raw_mozilla, serde_json::to_string(&config.cors_allow_all_mozilla_extension).unwrap()); + config.cors_allow_all_mozilla_extension = parse_bool(&raw_mozilla); + } let cors = cors::cors(&config); let hostcheck = hostcheck::HostCheck::new(&config); let custom_static = config.custom_static.clone();