Skip to content

fix(cors): scope moz-extension wildcard to aw-watcher-web endpoints - #166

Merged
ErikBjare merged 3 commits into
ActivityWatch:masterfrom
TimeToBuildBob:fix/moz-extension-cors-scope
Jul 28, 2026
Merged

fix(cors): scope moz-extension wildcard to aw-watcher-web endpoints#166
ErikBjare merged 3 commits into
ActivityWatch:masterfrom
TimeToBuildBob:fix/moz-extension-cors-scope

Conversation

@TimeToBuildBob

@TimeToBuildBob TimeToBuildBob commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Firefox assigns every extension a unique random origin, so aw-server uses moz-extension://* to allow aw-watcher-web without knowing the ID in advance. That wildcard also permits any other installed Firefox extension to reach the full API (export, import, query, settings) with no host permission and no install-time browser prompt naming ActivityWatch.

Reported privately by pal0x, who also found the api_key bypass fixed in ActivityWatch/aw-server-rust#636. See ActivityWatch/aw-server-rust#637 (the Rust sibling, now merged) for full background and the verified exploit trace; pal0x specifically asked that the Python server get parity, since the README still lists it as the default implementation.

Changes

Adds aw_server/extension_cors.py — a before_request hook that restricts wildcard-matched extension origins to the three endpoints aw-watcher-web actually uses:

Endpoint Method Purpose
/api/0/info GET Hostname/version detection
/api/0/buckets/aw-watcher-web-<id> POST Ensure its bucket
/api/0/buckets/aw-watcher-web-<id>/heartbeat POST Heartbeat recording

Everything else (export, import, query, settings, event reads, bucket deletion, non-watcher buckets) returns 403 before the handler executes.

Design notes

  • Requests without an Origin header (native watchers, curl) are unaffected
  • Non-moz-extension:// origins are unaffected (handled by flask-cors as before)
  • User-configured cors_origins are exempt (explicit opt-ins by the server owner)
  • Path matching uses split segments, not the raw path string, to avoid percent-encoding bypasses — the same bug class as aw-server-rust#588 and #636

Accepted limitation

Same as the Rust side: the bucket prefix (aw-watcher-web-) is a coarse scope, not an origin-to-bucket ownership boundary. A malicious extension can still send heartbeats to an existing aw-watcher-web-* bucket. Eliminating that requires a future pairing/code-exchange protocol (the long-term Step 2 in the security write-up).

Tests

32 new tests covering:

  • Unit tests for _is_allowed() and _is_allowed_path() with parametrized cases (allowed paths, blocked paths, percent-encoded bypass attempt)
  • Integration tests via Flask test client verifying 403 for blocked endpoints and passthrough for allowed ones, non-extension origins, and no-origin requests
  • OPTIONS preflight tests

All 41 tests pass (9 existing + 32 new).

Related

Firefox gives every extension a unique random origin, so aw-server uses
`moz-extension://*` to allow aw-watcher-web. That wildcard also permits any
other installed Firefox extension to reach the full API — export, import,
queries, settings — with no host permission and no install-time browser prompt
naming ActivityWatch. This was reported and confirmed in 2026-07.

Add `aw_server/extension_cors.py` which registers a `before_request` hook that
restricts wildcard-matched extension origins to the three endpoints
aw-watcher-web actually needs:

  GET  /api/0/info                               — version/hostname detection
  POST /api/0/buckets/aw-watcher-web-<id>        — ensure its bucket
  POST /api/0/buckets/aw-watcher-web-<id>/heartbeat — heartbeats

All other paths return 403 before the handler executes. Requests without an
Origin header (native watchers, curl) and non-moz-extension origins are
unaffected. Origins the user explicitly configured via `cors_origins` or
`cors_regex` are also exempted (deliberate opt-ins).

Path matching uses split segments, not the raw path string, to avoid
percent-encoding bypasses — the bug class from aw-server-rust#588 and #636.

Accepted limitation (same as the Rust side, #637): the bucket prefix is a
coarse scope — a malicious extension can still send heartbeats to an existing
`aw-watcher-web-*` bucket. Full origin-to-bucket ownership binding requires a
future pairing/code-exchange protocol.

Mirrors ActivityWatch/aw-server-rust#637 for the Python server.

Co-Authored-By: Bob <TimeToBuildBob@users.noreply.github.com>
Comment thread aw_server/extension_cors.py Outdated
@greptile-apps

greptile-apps Bot commented Jul 28, 2026

Copy link
Copy Markdown

Greptile Summary

The PR scopes wildcard Firefox-extension access to the API endpoints required by aw-watcher-web.

  • Adds a pre-request guard for moz-extension:// origins while preserving explicit user-configured origin exemptions.
  • Allows only info discovery, watcher-web bucket creation, and heartbeat submission through the built-in wildcard.
  • Adds unit, integration, and preflight coverage for allowed, blocked, and explicitly configured origins.

Confidence Score: 5/5

The PR appears safe to merge.

The previously reported configured-origin matching mismatch is resolved, and no blocking failure remains.

Important Files Changed

Filename Overview
aw_server/extension_cors.py Adds endpoint scoping and aligns explicit-origin matching with flask-cors 4 semantics; the previously reported mismatch is fixed.
aw_server/server.py Captures owner-configured origins before adding the built-in wildcard and registers the new request guard.
tests/test_extension_cors.py Covers path restrictions, passthrough behavior, preflights, and literal and regular-expression origin exemptions.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Incoming request] --> B{moz-extension origin?}
    B -- No --> F[Continue normally]
    B -- Yes --> C{Explicitly configured origin?}
    C -- Yes --> F
    C -- No --> D{Allowed watcher-web method and path?}
    D -- Yes --> F
    D -- No --> E[Return HTTP 403]
Loading

Reviews (2): Last reviewed commit: "fix(cors): match configured origin regex..." | Re-trigger Greptile

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

@ErikBjare

Copy link
Copy Markdown
Member

@TimeToBuildBob What happened to lint CI? passed on master 5d ago.

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

The repository was using the floating jpetrucciani/ruff-check@main action. Master passed on July 23 with action SHA 5839e3c (Ruff 0.15.22); later that day main advanced to 2211154 (Ruff 0.16.0), which enables a much broader default ruleset here and reports 99 pre-existing errors across the repository. The PR-specific test/format/typecheck jobs are all green.

I pinned the lint action to the last known-good SHA in 9647d62, so CI is reproducible instead of changing underneath the repo. Verified locally: Ruff 0.15.22 passes the whole tree; Ruff 0.16.0 reproduces the CI failure. Fresh CI is running now.

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

CI-green and mergeable (Greptile 5/5) — waiting only on a maintainer click.

This PR is ready to merge, but the bot has pull-only access to this repo and can't self-merge — surfacing it here so it isn't lost. The monitoring loop will stop re-flagging it now that this note is posted.

@ErikBjare
ErikBjare merged commit a693cac into ActivityWatch:master Jul 28, 2026
6 checks passed
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