Skip to content

Commit 4b48d81

Browse files
committed
[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 (allow_aw_chrome_extension and 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. Dependent on: ActivityWatch/aw-webui#795 edited according to the last changes
1 parent 9a8802a commit 4b48d81

11 files changed

Lines changed: 291 additions & 49 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,5 @@ NDK
1010
*.sqlite*
1111
*.db
1212
*.db-journal
13+
14+
.vscode

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,16 +60,33 @@ Available options:
6060

6161
# Additional regex CORS origins to allow (e.g. for sideloaded browser extensions)
6262
#cors_regex = ["chrome-extension://yourextensionidhere"]
63+
64+
# Allow official ActivityWatch Chrome extension? (default: true)
65+
#cors_allow_aw_chrome_extension = true
66+
67+
# Allow all Firefox extensions? (default: false, DANGEROUS)
68+
#cors_allow_all_mozilla_extension = false
6369
```
6470

71+
#### Persistence and Settings UI
72+
73+
The CORS-related settings (`cors`, `cors_regex`, `cors_allow_aw_chrome_extension`, and `cors_allow_all_mozilla_extension`) follow a precedence logic between the configuration file and the database:
74+
75+
- **TOML Precedence**: If a field is explicitly defined in your `config.toml`, it takes absolute precedence. The server will use the value from the file, and that setting will be **read-only** in the Web UI (marked as "Fixed in config file").
76+
- **Database Fallback**: If a field is **missing** or commented out in the `config.toml`, the server will look for it in the database. These can be managed and edited via the **Security & CORS** modal in the Settings page.
77+
- **Initial Setup**: On the first start, a default `config.toml` is created with all settings commented out, allowing the Web UI to take control of the configuration by default while providing a template for manual overrides.
78+
79+
> [!IMPORTANT]
80+
> **Server Restart Required**: Changing any CORS-related settings (whether via `config.toml` or the Web UI) requires stopping and restarting the server for the changes to take effect. These settings are loaded into memory once during the server's initialization and are not hot-reloadable.
81+
6582
#### Custom CORS Origins
6683

6784
By default, the server allows requests from:
6885
- The server's own origin (`http://127.0.0.1:<port>`, `http://localhost:<port>`)
69-
- The official Chrome extension (`chrome-extension://nglaklhklhcoonedhgnpgddginnjdadi`)
70-
- All Firefox extensions (`moz-extension://.*`)
86+
- The official Chrome extension (`chrome-extension://nglaklhklhcoonedhgnpgddginnjdadi`) if `cors_allow_aw_chrome_extension` is true (default).
87+
- All Firefox extensions (`moz-extension://.*`) ONLY IF `cors_allow_all_mozilla_extension` is set to true.
7188

72-
To allow additional origins (e.g. a sideloaded Chrome extension), add them to your config:
89+
To allow additional origins (e.g. a sideloaded Chrome extension), add them to your `cors` or `cors_regex` config:
7390

7491
```toml
7592
# Allow a specific sideloaded Chrome extension

aw-datastore/src/worker.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -294,15 +294,21 @@ impl DatastoreWorker {
294294
Err(e) => Err(e),
295295
},
296296
Command::SetKeyValue(key, data) => match ds.insert_key_value(tx, &key, &data) {
297-
Ok(()) => Ok(Response::Empty()),
297+
Ok(()) => {
298+
self.commit = true;
299+
Ok(Response::Empty())
300+
}
298301
Err(e) => Err(e),
299302
},
300303
Command::GetKeyValue(key) => match ds.get_key_value(tx, &key) {
301304
Ok(result) => Ok(Response::KeyValue(result)),
302305
Err(e) => Err(e),
303306
},
304307
Command::DeleteKeyValue(key) => match ds.delete_key_value(tx, &key) {
305-
Ok(()) => Ok(Response::Empty()),
308+
Ok(()) => {
309+
self.commit = true;
310+
Ok(Response::Empty())
311+
}
306312
Err(e) => Err(e),
307313
},
308314
Command::Close() => {

aw-server/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ uuid = { version = "1.3", features = ["serde", "v4"] }
2929
clap = { version = "4.1", features = ["derive", "cargo"] }
3030
log-panics = { version = "2", features = ["with-backtrace"]}
3131
rust-embed = { version = "8.0.0", features = ["interpolate-folder-path", "debug-embed"] }
32+
regex = "1"
3233

3334
aw-datastore = { path = "../aw-datastore" }
3435
aw-models = { path = "../aw-models" }

aw-server/src/config.rs

Lines changed: 72 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
1-
use std::fs::File;
2-
use std::io::{Read, Write};
1+
use std::collections::HashSet;
2+
use std::fs::{self, File};
3+
use std::io::Write;
34

45
use rocket::config::Config;
56
use rocket::data::{Limits, ToByteUnit};
67
use rocket::log::LogLevel;
78
use serde::{Deserialize, Serialize};
89

910
use crate::dirs;
11+
use serde_json;
12+
13+
pub const CORS_FIELDS: &[&str] = &[
14+
"cors",
15+
"cors_regex",
16+
"cors_allow_aw_chrome_extension",
17+
"cors_allow_all_mozilla_extension",
18+
];
1019

1120
// Far from an optimal way to solve it, but works and is simple
1221
static mut TESTING: bool = true;
@@ -19,7 +28,7 @@ pub fn is_testing() -> bool {
1928
unsafe { TESTING }
2029
}
2130

22-
#[derive(Serialize, Deserialize)]
31+
#[derive(Serialize, Deserialize, Clone)]
2332
pub struct AWConfig {
2433
#[serde(default = "default_address")]
2534
pub address: String,
@@ -36,6 +45,12 @@ pub struct AWConfig {
3645
#[serde(default = "default_cors")]
3746
pub cors_regex: Vec<String>,
3847

48+
#[serde(default = "default_true")]
49+
pub cors_allow_aw_chrome_extension: bool,
50+
51+
#[serde(default = "default_false")]
52+
pub cors_allow_all_mozilla_extension: bool,
53+
3954
// A mapping of watcher names to paths where the
4055
// custom visualizations are located.
4156
#[serde(default = "default_custom_static")]
@@ -50,6 +65,8 @@ impl Default for AWConfig {
5065
testing: default_testing(),
5166
cors: default_cors(),
5267
cors_regex: default_cors(),
68+
cors_allow_aw_chrome_extension: default_true(),
69+
cors_allow_all_mozilla_extension: default_false(),
5370
custom_static: default_custom_static(),
5471
}
5572
}
@@ -91,6 +108,14 @@ fn default_testing() -> bool {
91108
is_testing()
92109
}
93110

111+
fn default_true() -> bool {
112+
true
113+
}
114+
115+
fn default_false() -> bool {
116+
false
117+
}
118+
94119
fn default_port() -> u16 {
95120
if is_testing() {
96121
5666
@@ -103,14 +128,40 @@ fn default_custom_static() -> std::collections::HashMap<String, String> {
103128
std::collections::HashMap::new()
104129
}
105130

106-
pub fn create_config(testing: bool) -> AWConfig {
107-
set_testing(testing);
131+
pub fn get_config_path(testing: bool) -> (std::path::PathBuf, Vec<String>) {
108132
let mut config_path = dirs::get_config_dir().unwrap();
109133
if !testing {
110134
config_path.push("config.toml")
111135
} else {
112136
config_path.push("config-testing.toml")
113137
}
138+
if !config_path.is_file() {
139+
return (
140+
config_path,
141+
CORS_FIELDS.iter().map(|f| f.to_string()).collect(),
142+
);
143+
}
144+
let content = fs::read_to_string(&config_path).unwrap_or_default();
145+
let toml_value: toml::Value =
146+
toml::from_str(&content).unwrap_or_else(|_| toml::Value::Table(toml::Table::new()));
147+
148+
let file_keys: HashSet<String> = toml_value
149+
.as_table()
150+
.map(|t| t.keys().cloned().collect())
151+
.unwrap_or_default();
152+
153+
let missing = CORS_FIELDS
154+
.iter()
155+
.filter(|f| !file_keys.contains(&f.to_string()))
156+
.map(|f| f.to_string())
157+
.collect();
158+
159+
(config_path, missing)
160+
}
161+
162+
pub fn create_config(testing: bool, datastore: &aw_datastore::Datastore) -> AWConfig {
163+
set_testing(testing);
164+
let (config_path, missing_cors_fields) = get_config_path(testing);
114165

115166
/* If there is no config file, create a new config file with default values but every value is
116167
* commented out by default in case we would change a default value at some point in the future */
@@ -132,12 +183,22 @@ pub fn create_config(testing: bool) -> AWConfig {
132183
}
133184

134185
debug!("Reading config at {:?}", config_path);
135-
let mut rfile = File::open(config_path).expect("Failed to open config file for reading");
136-
let mut content = String::new();
137-
rfile
138-
.read_to_string(&mut content)
139-
.expect("Failed to read config as a string");
140-
let aw_config: AWConfig = toml::from_str(&content).expect("Failed to parse config file");
186+
let content = fs::read_to_string(config_path).expect("Failed to read config file");
187+
let toml_value: toml::Value = toml::from_str(&content).expect("Failed to parse config file");
141188

189+
let mut aw_config: AWConfig =
190+
toml_value.try_into().expect("Failed to convert TOML value to AWConfig");
191+
192+
for field in missing_cors_fields {
193+
let Ok(value_str) = datastore.get_key_value(&format!("cors.{field}")) else { continue };
194+
195+
match field.as_str() {
196+
"cors" => aw_config.cors = serde_json::from_str(&value_str).unwrap_or_default(),
197+
"cors_regex" => aw_config.cors_regex = serde_json::from_str(&value_str).unwrap_or_default(),
198+
"cors_allow_aw_chrome_extension" => aw_config.cors_allow_aw_chrome_extension = serde_json::from_str(&value_str).unwrap_or_default(),
199+
"cors_allow_all_mozilla_extension" => aw_config.cors_allow_all_mozilla_extension = serde_json::from_str(&value_str).unwrap_or_default(),
200+
_ => {}
201+
}
202+
}
142203
aw_config
143204
}

aw-server/src/endpoints/cors.rs

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,26 @@ use crate::config::AWConfig;
66
pub fn cors(config: &AWConfig) -> rocket_cors::Cors {
77
let root_url = format!("http://127.0.0.1:{}", config.port);
88
let root_url_localhost = format!("http://localhost:{}", config.port);
9-
let mut allowed_exact_origins = vec![root_url, root_url_localhost];
9+
let mut allowed_exact_origins = vec![root_url.clone(), root_url_localhost.clone()];
1010
allowed_exact_origins.extend(config.cors.clone());
1111

12-
if config.testing {
13-
allowed_exact_origins.push("http://127.0.0.1:27180".to_string());
14-
allowed_exact_origins.push("http://localhost:27180".to_string());
12+
let mut allowed_regex_origins = config.cors_regex.clone();
13+
14+
if config.cors_allow_aw_chrome_extension {
15+
allowed_regex_origins.push("chrome-extension://nglaklhklhcoonedhgnpgddginnjdadi".to_string());
1516
}
16-
let mut allowed_regex_origins = vec![
17-
"chrome-extension://nglaklhklhcoonedhgnpgddginnjdadi".to_string(),
17+
18+
if config.cors_allow_all_mozilla_extension {
1819
// Every version of a mozilla extension has its own ID to avoid fingerprinting, so we
1920
// unfortunately have to allow all extensions to have access to aw-server
20-
"moz-extension://.*".to_string(),
21-
];
22-
allowed_regex_origins.extend(config.cors_regex.clone());
21+
allowed_regex_origins.push("moz-extension://.*".to_string());
22+
}
23+
2324
if config.testing {
25+
allowed_exact_origins.extend(vec![
26+
"http://127.0.0.1:27180".to_string(),
27+
"http://localhost:27180".to_string(),
28+
]);
2429
allowed_regex_origins.push("chrome-extension://.*".to_string());
2530
}
2631

@@ -32,13 +37,29 @@ pub fn cors(config: &AWConfig) -> rocket_cors::Cors {
3237
let allowed_headers = AllowedHeaders::all(); // TODO: is this unsafe?
3338

3439
// You can also deserialize this
35-
rocket_cors::CorsOptions {
40+
let cors_options = rocket_cors::CorsOptions {
3641
allowed_origins,
3742
allowed_methods,
3843
allowed_headers,
3944
allow_credentials: false,
4045
..Default::default()
46+
};
47+
48+
match cors_options.to_cors() {
49+
Ok(cors) => cors,
50+
Err(e) => {
51+
error!("Failed to set up CORS with provided origins: {:?}", e);
52+
error!("Exact origins: {:?}", allowed_exact_origins);
53+
error!("Regex origins: {:?}", allowed_regex_origins);
54+
// Fallback to a safe default to allow the server to at least start
55+
let fallback_origins = vec![root_url, root_url_localhost];
56+
let empty_regex: &[String] = &[];
57+
rocket_cors::CorsOptions {
58+
allowed_origins: AllowedOrigins::some(&fallback_origins, empty_regex),
59+
..Default::default()
60+
}
61+
.to_cors()
62+
.expect("Safe default CORS should always work")
63+
}
4164
}
42-
.to_cors()
43-
.expect("Failed to set up CORS")
4465
}

0 commit comments

Comments
 (0)