[SEC] restrict CORS to authorized extension IDs#581
[SEC] restrict CORS to authorized extension IDs#581RaoufGhrissi wants to merge 1 commit intoActivityWatch:masterfrom
Conversation
325720e to
587bb8c
Compare
Greptile SummaryThis PR replaces the blanket Confidence Score: 4/5Safe to merge with minor validation gap; all previously raised P1 issues appear addressed. All major issues from prior review threads (wrong serde default for mozilla extension, |= write-once logic, missing in-memory config update, testing-mode regex in wrong list, trim_matches JSON parsing) are fixed in this revision. The only new findings are P2: the cors exact-origin list accepts entries without http/https validation (silently dropped at runtime), and the worker.rs commit fix has broader scope than CORS alone. Neither blocks correctness of the security feature itself. aw-server/src/endpoints/cors_config.rs — cors list validation before DB write; aw-datastore/src/worker.rs — verify no existing tests relied on non-persistence of key-value writes.
|
| Filename | Overview |
|---|---|
| aw-server/src/endpoints/cors.rs | Rewrites CORS fairing builder to honor the new cors_allow_aw_chrome_extension and cors_allow_all_mozilla_extension flags with a safe fallback on regex compilation failure; testing mode correctly pushes the Chrome wildcard to the regex list. |
| aw-server/src/endpoints/cors_config.rs | New GET/POST endpoints for CORS settings; cors_regex patterns are validated before saving, but cors exact-origin entries are stored without scheme validation and will be silently dropped at startup if they lack http/https. |
| aw-server/src/config.rs | Adds cors_allow_aw_chrome_extension (default true) and cors_allow_all_mozilla_extension (default false via default_false()), CORS field list, and DB-backed override logic in create_config; defaults and serialization are correct. |
| aw-datastore/src/worker.rs | Adds self.commit = true to SetKeyValue and DeleteKeyValue — fixing a pre-existing bug where all key-value writes (including the settings API) were never committed to disk; broader than the CORS scope but clearly correct. |
| aw-server/src/endpoints/mod.rs | Adds config: Mutex to ServerState, mounts the new /api/0/cors-config routes, and wires up the CORS endpoint module. |
| aw-server/src/main.rs | Reorders startup so the datastore is created before create_config (needed to load DB-persisted CORS overrides), then passes config.clone() into ServerState; logic is correct. |
| README.md | Documents the two new config flags, precedence rules (TOML vs DB), Web UI editability, and the restart-required notice for CORS changes. |
Sequence Diagram
sequenceDiagram
participant Client as Web UI / Extension
participant Rocket as Rocket Server
participant CorsConfig as /api/0/cors-config
participant State as state.config (Mutex)
participant DB as Datastore (SQLite)
participant Fairing as CORS Fairing (snapshot)
Note over Rocket,Fairing: Startup
DB-->>Rocket: load persisted cors.* keys
Rocket->>State: AWConfig (with DB overrides)
Rocket->>Fairing: cors::cors(&config) snapshot baked in
Note over Client,DB: GET /api/0/cors-config
Client->>CorsConfig: GET
CorsConfig->>State: lock, read AWConfig
CorsConfig->>CorsConfig: read TOML to find in_file fields
CorsConfig-->>Client: cors, cors_regex, flags, in_file, needs_restart true
Note over Client,DB: POST /api/0/cors-config
Client->>CorsConfig: POST cors, cors_regex, flags
CorsConfig->>State: lock, get testing flag
CorsConfig->>CorsConfig: validate cors_regex patterns
CorsConfig->>DB: set_key_value cors.* for each missing field
Note over DB: self.commit=true, transaction committed
CorsConfig->>State: lock, update in-memory AWConfig
CorsConfig-->>Client: 200 OK
Note over Fairing: Fairing snapshot NOT updated, restart required
Client->>Rocket: restart server
DB-->>Rocket: reload persisted cors.* keys
Rocket->>Fairing: new cors::cors with updated values
Reviews (7): Last reviewed commit: "[SEC] restrict CORS to authorized extens..." | Re-trigger Greptile
aw-server/src/endpoints/cors.rs
Outdated
| let parse_cors_list = |key: &str| -> Vec<String> { | ||
| db.get_key_value(key) | ||
| .map(|s| { | ||
| s.trim_matches('"') | ||
| .split(',') | ||
| .map(|s| s.trim().to_string()) | ||
| .filter(|s| !s.is_empty()) | ||
| .collect() | ||
| }) | ||
| .unwrap_or_default() | ||
| }; |
There was a problem hiding this comment.
Fragile JSON string unwrapping via
trim_matches
The settings API stores all values JSON-encoded (see settings.rs line 72: serde_json::to_string(&value.0)). So a string like "moz-extension://abc" is persisted in the database as "\"moz-extension://abc\"".
trim_matches('"') strips surrounding double-quote characters, which works for simple ASCII strings, but is not a correct JSON string deserializer. It will silently produce wrong results for values that contain escaped characters. It also won't handle the case where the user stores a JSON array (e.g., ["ext1", "ext2"]) instead of a comma-separated string.
The idiomatic fix is to use serde_json::from_str::<String>(&s):
let parse_cors_list = |key: &str| -> Vec<String> {
db.get_key_value(key)
.ok()
.and_then(|s| serde_json::from_str::<String>(&s).ok())
.map(|s| {
s.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
})
.unwrap_or_default()
};
aw-server/src/endpoints/cors.rs
Outdated
| let mut allowed_regex_origins = vec!["chrome-extension://nglaklhklhcoonedhgnpgddginnjdadi".to_string()]; | ||
|
|
||
| let settings_origins = parse_cors_list("settings.cors_origins"); | ||
| drop(db); | ||
|
|
||
| let all_origins = config.cors.iter().cloned().chain(settings_origins); | ||
| for origin in all_origins { | ||
| if origin.starts_with("http://") || origin.starts_with("https://") { | ||
| allowed_exact_origins.push(origin); | ||
| } else if origin.starts_with("chrome-extension://") || origin.starts_with("moz-extension://") { | ||
| allowed_regex_origins.push(origin); | ||
| } else { | ||
| log::warn!("Ignoring invalid CORS origin: '{}'", origin); | ||
| } | ||
| } |
There was a problem hiding this comment.
Firefox extension support is effectively broken for all existing users
The original code included the comment:
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
This is a fundamental Firefox security property: each installation (and each update) of a Firefox extension gets a new random UUID as its origin (moz-extension://<uuid>). This means:
- There is no single stable ID that a user can add to the allow-list for the ActivityWatch Firefox extension.
- After every extension update, the old ID is invalidated and the user would need to find their new UUID and update the server settings.
- Most users have no easy way to discover their current
moz-extension://UUID.
The PR removes moz-extension://.* without providing any workable alternative for Firefox users. Consider either:
- Keeping
moz-extension://.*as the default (preserving existing behavior) with clear documentation about the risk, or - Using a permanent addon ID via
browser_specific_settings.gecko.idinmanifest.jsonand documenting that users of unofficial builds need the wildcard.
aw-server/src/endpoints/mod.rs
Outdated
| config.address, config.port | ||
| ); | ||
| let cors = cors::cors(&config); | ||
| let cors = cors::cors(&config, &server_state.datastore); |
35a5fe8 to
61a534b
Compare
81acd82 to
b585230
Compare
| #[serde(default = "default_true")] | ||
| pub allow_aw_chrome_extension_from_settings: bool, | ||
|
|
||
| #[serde(default = "default_false")] |
There was a problem hiding this comment.
i'm hesitating to set default value to True, so it doesn't impact the actual users
b585230 to
c44c7df
Compare
aw-server/src/config.rs
Outdated
| cors_from_settings: default_cors(), | ||
| cors_regex_from_settings: default_cors(), | ||
| allow_aw_chrome_extension_from_settings: default_true(), | ||
| allow_all_mozilla_extension_from_settings: default_false(), |
There was a problem hiding this comment.
Not a fan of this many additional config keys. Should be prefixed consistently with cors_ such as cors_allow_aw_chrome_extension and cors_allow_all_mozilla_extension.
Curious why the _from_settings is passed along and not resolved/merged into the non-from_settings variants?
There was a problem hiding this comment.
merged with the existing vars, and explained in the readme file
aw-server/src/endpoints/mod.rs
Outdated
| if let Ok(raw) = db.get_key_value("settings.cors") { | ||
| config.cors_from_settings = parse_cors_list(raw); | ||
| } | ||
| if let Ok(raw) = db.get_key_value("settings.cors_regex") { | ||
| config.cors_regex_from_settings = parse_cors_list(raw); | ||
| } | ||
| if let Ok(raw) = db.get_key_value("settings.allow_aw_chrome_extension") { | ||
| config.allow_aw_chrome_extension_from_settings = parse_bool(raw); | ||
| } | ||
| if let Ok(raw) = db.get_key_value("settings.allow_all_mozilla_extension") { | ||
| config.allow_all_mozilla_extension_from_settings = parse_bool(raw); | ||
| } | ||
| } |
There was a problem hiding this comment.
I'm a little bit paranoid about letting web UI set CORS like that...
There was a problem hiding this comment.
do you have another suggestion to let non-devs configure cors ?
c44c7df to
4b8e893
Compare
4b8e893 to
102e1f1
Compare
2e6e544 to
e3c3227
Compare
72cdf68 to
51d5a4a
Compare
024a3bf to
8f285a5
Compare
823c6fb to
907e640
Compare
aw-server/src/endpoints/mod.rs
Outdated
| // 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). |
There was a problem hiding this comment.
This seems problematic. It's not acceptable to have file-level config options being ignored by auto-set db-values. Makes the config file mostly useless/unreliable. Not really any "sync" happening here.
imo, order of settings/config precedence in server should be cli options -> env vars -> config file -> webui/api settings -> defaults.
One way to resolve this would be to make the webui config write directly to the config file, would effectively put them in sync (but still not a fan of exposing server config like this in api/webui tbh). But that might require a API endpoint different from /settings, maybe /config - but really unsure about this, just an idea.
Simplest would probably be to just prefer the config over the db settings and disable the webui settings if they are explicitly set in config/env/cli.
There was a problem hiding this comment.
Thank you for the detailed feedback. I completely agree with the proposed precedence order (CLI > Env > Config File > WebUI/API > Defaults), and I have implemented the changes to reflect exactly this logic:
Config File Priority: the configuration file now takes absolute precedence over the database. If a CORS field is explicitly defined in config.toml, the server uses that value and ignores any existing database overrides for that specific field.
for CLI/Env: I verified that cors settings are not currently managed via CLI or Env variables in aw-server-rust. However, the current architecture is future-proof: since the database fallback only triggers for fields missing from the static configuration, any future addition of CLI/Env overrides would naturally take precedence like port and address.
UI Feedback & Safety: To address the concern about "unreliable" config, the Web UI now detects which fields are defined in the config.toml. These fields are marked in the UI as "Fixed in config file" (highlighted in orange) and the inputs are disabled. This ensures the user is aware that the file-level config is in control and cannot be bypassed via the API.
b175b15 to
32b20d7
Compare
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-server-rust#581 edited according to the last changes
|
Tip: Greploop — Automatically fix all review issues by running Use the Greptile plugin for Claude Code to query reviews, search comments, and manage custom context directly from your terminal. |
| #[post("/", data = "<new_cors>")] | ||
| pub fn cors_config_set( | ||
| state: &State<ServerState>, | ||
| new_cors: Json<CorsConfig>, | ||
| ) -> Result<Status, HttpErrorJson> { | ||
| let datastore = endpoints_get_lock!(state.datastore); | ||
| let fields = [ | ||
| ("cors", serde_json::to_string(&new_cors.cors).unwrap()), | ||
| ("cors_regex", serde_json::to_string(&new_cors.cors_regex).unwrap()), | ||
| ( | ||
| "cors_allow_aw_chrome_extension", | ||
| serde_json::to_string(&new_cors.cors_allow_aw_chrome_extension).unwrap(), | ||
| ), | ||
| ( | ||
| "cors_allow_all_mozilla_extension", | ||
| serde_json::to_string(&new_cors.cors_allow_all_mozilla_extension).unwrap(), | ||
| ), | ||
| ]; | ||
|
|
||
| for (field, value_str) in fields { | ||
| let key = format!("cors.{}", field); | ||
| datastore.set_key_value(&key, &value_str).map_err(|e| { | ||
| HttpErrorJson::new(Status::InternalServerError, format!("Failed to save {}: {:?}", field, e)) | ||
| })?; | ||
| } | ||
|
|
||
| Ok(Status::Ok) | ||
| } |
There was a problem hiding this comment.
CORS fairing is immutable at runtime — saved changes have no effect until restart
The rocket_cors::Cors fairing is built once in build_rocket (cors::cors(&config)) and attached via .attach(cors.clone()). Rocket fairings are not hot-reloadable; the fairing holds a snapshot of the allowed-origins set and does not re-read state.config on each request.
Consequently, any CORS rule saved through this endpoint takes effect only after the server restarts. Neither the API response nor the Web UI currently communicates this to the user.
At a minimum the API should return an informational flag (e.g. "restart_required": true) and the README's "Persistence and Settings UI" section should explicitly state that the server must be restarted for CORS changes to become active.
32b20d7 to
c8695d5
Compare
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-server-rust#581 edited according to the last changes
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-server-rust#581 edited according to the last changes
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-server-rust#581 edited according to the last changes
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
c8695d5 to
5b85c03
Compare
[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