Skip to content

Commit 32b20d7

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 32b20d7

File tree

9 files changed

+218
-47
lines changed

9 files changed

+218
-47
lines changed

.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

README.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,16 +60,30 @@ 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+
6579
#### Custom CORS Origins
6680

6781
By default, the server allows requests from:
6882
- 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://.*`)
83+
- The official Chrome extension (`chrome-extension://nglaklhklhcoonedhgnpgddginnjdadi`) if `cors_allow_aw_chrome_extension` is true (default).
84+
- All Firefox extensions (`moz-extension://.*`) ONLY IF `cors_allow_all_mozilla_extension` is set to true.
7185

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

7488
```toml
7589
# 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/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: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,32 @@ 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];
10-
allowed_exact_origins.extend(config.cors.clone());
9+
let mut allowed_exact_origins = vec![root_url.clone(), root_url_localhost.clone()];
10+
for origin in config.cors.clone() {
11+
if origin.starts_with("http://") || origin.starts_with("https://") {
12+
allowed_exact_origins.push(origin);
13+
} else {
14+
warn!("Ignoring invalid CORS origin (missing scheme): {}", origin);
15+
}
16+
}
1117

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());
18+
let mut allowed_regex_origins = config.cors_regex.clone();
19+
20+
if config.cors_allow_aw_chrome_extension {
21+
allowed_regex_origins.push("chrome-extension://nglaklhklhcoonedhgnpgddginnjdadi".to_string());
1522
}
16-
let mut allowed_regex_origins = vec![
17-
"chrome-extension://nglaklhklhcoonedhgnpgddginnjdadi".to_string(),
23+
24+
if config.cors_allow_all_mozilla_extension {
1825
// Every version of a mozilla extension has its own ID to avoid fingerprinting, so we
1926
// 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());
27+
allowed_regex_origins.push("moz-extension://.*".to_string());
28+
}
29+
2330
if config.testing {
31+
allowed_exact_origins.extend(vec![
32+
"http://127.0.0.1:27180".to_string(),
33+
"http://localhost:27180".to_string(),
34+
]);
2435
allowed_regex_origins.push("chrome-extension://.*".to_string());
2536
}
2637

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
use crate::endpoints::ServerState;
2+
use rocket::http::Status;
3+
use rocket::serde::json::Json;
4+
use rocket::State;
5+
use serde::{Deserialize, Serialize};
6+
use crate::endpoints::HttpErrorJson;
7+
use crate::config;
8+
9+
#[derive(Serialize, Deserialize)]
10+
pub struct CorsConfig {
11+
pub cors: Vec<String>,
12+
pub cors_regex: Vec<String>,
13+
pub cors_allow_aw_chrome_extension: bool,
14+
pub cors_allow_all_mozilla_extension: bool,
15+
pub in_file: Vec<String>,
16+
}
17+
18+
19+
20+
#[get("/")]
21+
pub fn cors_config_get(state: &State<ServerState>) -> Result<Json<CorsConfig>, HttpErrorJson> {
22+
let config = endpoints_get_lock!(state.config);
23+
let (_, missing_fields) = config::get_config_path(config.testing);
24+
let in_file = config::CORS_FIELDS
25+
.iter()
26+
.filter(|&&f| !missing_fields.contains(&f.to_string()))
27+
.map(|&f| f.to_string())
28+
.collect();
29+
Ok(Json(CorsConfig {
30+
cors: config.cors.clone(),
31+
cors_regex: config.cors_regex.clone(),
32+
cors_allow_aw_chrome_extension: config.cors_allow_aw_chrome_extension,
33+
cors_allow_all_mozilla_extension: config.cors_allow_all_mozilla_extension,
34+
in_file,
35+
}))
36+
}
37+
38+
#[post("/", data = "<new_cors>")]
39+
pub fn cors_config_set(
40+
state: &State<ServerState>,
41+
new_cors: Json<CorsConfig>,
42+
) -> Result<Status, HttpErrorJson> {
43+
let datastore = endpoints_get_lock!(state.datastore);
44+
let fields = [
45+
("cors", serde_json::to_string(&new_cors.cors).unwrap()),
46+
("cors_regex", serde_json::to_string(&new_cors.cors_regex).unwrap()),
47+
(
48+
"cors_allow_aw_chrome_extension",
49+
serde_json::to_string(&new_cors.cors_allow_aw_chrome_extension).unwrap(),
50+
),
51+
(
52+
"cors_allow_all_mozilla_extension",
53+
serde_json::to_string(&new_cors.cors_allow_all_mozilla_extension).unwrap(),
54+
),
55+
];
56+
57+
for (field, value_str) in fields {
58+
let key = format!("cors.{}", field);
59+
datastore.set_key_value(&key, &value_str).map_err(|e| {
60+
HttpErrorJson::new(Status::InternalServerError, format!("Failed to save {}: {:?}", field, e))
61+
})?;
62+
}
63+
64+
Ok(Status::Ok)
65+
}

aw-server/src/endpoints/mod.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ pub struct ServerState {
4242
pub datastore: Mutex<Datastore>,
4343
pub asset_resolver: AssetResolver,
4444
pub device_id: String,
45+
pub config: Mutex<AWConfig>,
4546
}
4647

4748
#[macro_use]
@@ -53,6 +54,7 @@ mod hostcheck;
5354
mod import;
5455
mod query;
5556
mod settings;
57+
mod cors_config;
5658

5759
pub use util::HttpErrorJson;
5860

@@ -189,6 +191,13 @@ pub fn build_rocket(server_state: ServerState, config: AWConfig) -> rocket::Rock
189191
settings::settings_get,
190192
],
191193
)
194+
.mount(
195+
"/api/0/cors-config",
196+
routes![
197+
cors_config::cors_config_get,
198+
cors_config::cors_config_set,
199+
],
200+
)
192201
.mount("/", rocket_cors::catch_all_options_routes());
193202

194203
// for each custom static directory, mount it at the given name

0 commit comments

Comments
 (0)