Skip to content

[SEC] restrict CORS to authorized extension IDs#581

Open
RaoufGhrissi wants to merge 1 commit intoActivityWatch:masterfrom
odoo:security/configure-allowed-origins
Open

[SEC] restrict CORS to authorized extension IDs#581
RaoufGhrissi wants to merge 1 commit intoActivityWatch:masterfrom
odoo:security/configure-allowed-origins

Conversation

@RaoufGhrissi
Copy link
Copy Markdown

@RaoufGhrissi RaoufGhrissi commented Apr 2, 2026

[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

@RaoufGhrissi RaoufGhrissi force-pushed the security/configure-allowed-origins branch 5 times, most recently from 325720e to 587bb8c Compare April 2, 2026 11:47
@RaoufGhrissi RaoufGhrissi marked this pull request as ready for review April 2, 2026 11:50
@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Apr 2, 2026

Greptile Summary

This PR replaces the blanket moz-extension://.* CORS wildcard with two explicit flags (cors_allow_aw_chrome_extension, cors_allow_all_mozilla_extension) and adds a new /api/0/cors-config endpoint for managing these settings via the Web UI, backed by datastore persistence. Alongside the CORS work, aw-datastore/src/worker.rs gains self.commit = true on SetKeyValue and DeleteKeyValue — a fix that was previously preventing all key-value writes (including the existing /api/0/settings API) from being committed to disk across restarts.

Confidence Score: 4/5

Safe 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.

Vulnerabilities

  • CORS misconfiguration via stored invalid origins (cors_config.rs): cors list entries are stored without http/https scheme validation; at startup they are silently dropped, meaning a user who mistakenly adds an extension origin to the wrong field gets no feedback and the intended restriction has no effect.
  • Regex patterns as CORS allow-list (cors.rs, cors_config.rs): cors_regex values from the DB are passed directly to rocket_cors as regex patterns. Validation guards against syntax errors, but a permissive pattern (e.g. .*) is accepted and would bypass CORS for all origins. This is by design (admin-controlled), but the Web UI should surface a clear warning when a pattern is broad.
  • No secrets exposure, SQL injection, or auth-boundary issues identified in the changed code.

Important Files Changed

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
Loading

Reviews (7): Last reviewed commit: "[SEC] restrict CORS to authorized extens..." | Re-trigger Greptile

Comment on lines +14 to +24
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()
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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()
};

Comment on lines +26 to +40
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);
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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:

  1. There is no single stable ID that a user can add to the allow-list for the ActivityWatch Firefox extension.
  2. After every extension update, the old ID is invalidated and the user would need to find their new UUID and update the server settings.
  3. 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.id in manifest.json and documenting that users of unofficial builds need the wildcard.

config.address, config.port
);
let cors = cors::cors(&config);
let cors = cors::cors(&config, &server_state.datastore);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't pass datastore here

@RaoufGhrissi RaoufGhrissi force-pushed the security/configure-allowed-origins branch 2 times, most recently from 35a5fe8 to 61a534b Compare April 2, 2026 14:50
@RaoufGhrissi RaoufGhrissi marked this pull request as draft April 2, 2026 15:05
@RaoufGhrissi RaoufGhrissi force-pushed the security/configure-allowed-origins branch from 81acd82 to b585230 Compare April 2, 2026 15:16
#[serde(default = "default_true")]
pub allow_aw_chrome_extension_from_settings: bool,

#[serde(default = "default_false")]
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm hesitating to set default value to True, so it doesn't impact the actual users

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(),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

merged with the existing vars, and explained in the readme file

Comment on lines +158 to +170
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);
}
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a little bit paranoid about letting web UI set CORS like that...

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you have another suggestion to let non-devs configure cors ?

@RaoufGhrissi RaoufGhrissi force-pushed the security/configure-allowed-origins branch from c44c7df to 4b8e893 Compare April 2, 2026 17:07
@RaoufGhrissi RaoufGhrissi marked this pull request as ready for review April 2, 2026 17:11
@RaoufGhrissi RaoufGhrissi force-pushed the security/configure-allowed-origins branch from 4b8e893 to 102e1f1 Compare April 2, 2026 17:13
@RaoufGhrissi RaoufGhrissi force-pushed the security/configure-allowed-origins branch 2 times, most recently from 2e6e544 to e3c3227 Compare April 2, 2026 17:26
@RaoufGhrissi RaoufGhrissi marked this pull request as draft April 2, 2026 17:45
@RaoufGhrissi RaoufGhrissi force-pushed the security/configure-allowed-origins branch 2 times, most recently from 72cdf68 to 51d5a4a Compare April 2, 2026 21:21
@RaoufGhrissi RaoufGhrissi marked this pull request as ready for review April 2, 2026 21:22
@RaoufGhrissi RaoufGhrissi force-pushed the security/configure-allowed-origins branch 2 times, most recently from 024a3bf to 8f285a5 Compare April 2, 2026 21:40
@RaoufGhrissi RaoufGhrissi marked this pull request as draft April 2, 2026 21:49
@RaoufGhrissi RaoufGhrissi marked this pull request as ready for review April 2, 2026 21:49
@RaoufGhrissi RaoufGhrissi force-pushed the security/configure-allowed-origins branch 2 times, most recently from 823c6fb to 907e640 Compare April 3, 2026 05:56
@RaoufGhrissi RaoufGhrissi requested a review from ErikBjare April 8, 2026 08:39
Comment on lines +151 to +153
// 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).
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@RaoufGhrissi RaoufGhrissi force-pushed the security/configure-allowed-origins branch 2 times, most recently from b175b15 to 32b20d7 Compare April 9, 2026 14:24
RaoufGhrissi added a commit to odoo/aw-webui that referenced this pull request Apr 9, 2026
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
@RaoufGhrissi RaoufGhrissi marked this pull request as draft April 9, 2026 14:33
@RaoufGhrissi RaoufGhrissi marked this pull request as ready for review April 9, 2026 14:33
@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Apr 9, 2026

Tip:

Greploop — Automatically fix all review issues by running /greploops in Claude Code. It iterates: fix, push, re-review, repeat until 5/5 confidence.

Use the Greptile plugin for Claude Code to query reviews, search comments, and manage custom context directly from your terminal.

Comment on lines +38 to +65
#[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)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

@RaoufGhrissi RaoufGhrissi force-pushed the security/configure-allowed-origins branch from 32b20d7 to c8695d5 Compare April 9, 2026 15:02
RaoufGhrissi added a commit to odoo/aw-webui that referenced this pull request Apr 9, 2026
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
@RaoufGhrissi RaoufGhrissi marked this pull request as draft April 9, 2026 15:03
@RaoufGhrissi RaoufGhrissi marked this pull request as ready for review April 9, 2026 15:03
RaoufGhrissi added a commit to odoo/aw-webui that referenced this pull request Apr 9, 2026
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
RaoufGhrissi added a commit to odoo/aw-webui that referenced this pull request Apr 9, 2026
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
@RaoufGhrissi RaoufGhrissi marked this pull request as draft April 9, 2026 15:32
@RaoufGhrissi RaoufGhrissi marked this pull request as draft April 9, 2026 15:32
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
@RaoufGhrissi RaoufGhrissi force-pushed the security/configure-allowed-origins branch from c8695d5 to 5b85c03 Compare April 9, 2026 15:34
@RaoufGhrissi RaoufGhrissi marked this pull request as ready for review April 9, 2026 15:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants