Skip to content

Commit a693cac

Browse files
fix(cors): scope moz-extension wildcard to aw-watcher-web endpoints (#166)
* fix(cors): scope moz-extension wildcard to aw-watcher-web endpoints 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> * fix(cors): match configured origin regex semantics * ci: pin Ruff action to known-good version --------- Co-authored-by: Bob <TimeToBuildBob@users.noreply.github.com>
1 parent 0beeea4 commit a693cac

4 files changed

Lines changed: 316 additions & 2 deletions

File tree

.github/workflows/lint.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ jobs:
1212
steps:
1313
- uses: actions/checkout@v3
1414
- uses: actions/setup-python@v4
15-
- uses: jpetrucciani/ruff-check@main
15+
# Keep lint reproducible; the floating action tag can adopt new default rules.
16+
- uses: jpetrucciani/ruff-check@5839e3c65007bdb626c1f3362153e45be347654f # ruff 0.15.22
1617

1718
format:
1819
runs-on: ubuntu-latest

aw_server/extension_cors.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"""
2+
Endpoint scoping for the moz-extension:// CORS wildcard.
3+
4+
Firefox assigns every extension its own random origin, so aw-server uses
5+
``moz-extension://*`` to allow aw-watcher-web without knowing the ID in advance.
6+
That wildcard also permits every other installed extension to reach the full API —
7+
including ``/api/0/export``, ``/api/0/import``, queries, and settings — with no
8+
host permission and therefore no install-time browser prompt naming ActivityWatch.
9+
10+
This module registers a ``before_request`` hook that restricts wildcard-matched
11+
extension origins to the three endpoints aw-watcher-web actually needs:
12+
13+
GET /api/0/info — hostname/version detection
14+
POST /api/0/buckets/aw-watcher-web-<id> — ensure its bucket exists
15+
POST /api/0/buckets/aw-watcher-web-<id>/heartbeat — heartbeat recording
16+
17+
All other paths return 403 before the handler executes. flask-cors CORS headers are
18+
still added by the after_request hook, but the 403 status prevents JavaScript from
19+
treating the response as a successful cross-origin fetch (and more importantly, the
20+
server side never processes the request).
21+
22+
User-configured origins (``cors_origins`` / ``cors_regex`` in config) are exempted:
23+
those are explicit opt-ins by the server owner, unlike the built-in wildcard.
24+
25+
Path matching uses split segments rather than the raw path string to avoid
26+
percent-encoding bypasses — the same bug class as aw-server-rust#588 and #636.
27+
28+
See also: ActivityWatch/aw-server-rust#637 (the Rust sibling of this fix).
29+
"""
30+
31+
import logging
32+
import re
33+
from typing import List, Optional
34+
35+
from flask import Flask, abort, request
36+
37+
logger = logging.getLogger(__name__)
38+
39+
_EXTENSION_SCHEME = "moz-extension://"
40+
41+
42+
def register(app: Flask, user_origins: List[str]) -> None:
43+
"""Register the extension CORS scope hook on *app*.
44+
45+
*user_origins* — origins the user configured explicitly (captured before
46+
the built-in ``moz-extension://*`` wildcard is appended). These are
47+
explicit opt-ins and bypass the scope narrowing.
48+
"""
49+
50+
@app.before_request
51+
def _restrict_extension_cors() -> Optional[object]:
52+
origin = request.headers.get("Origin", "")
53+
if not origin.lower().startswith(_EXTENSION_SCHEME):
54+
return None # not a moz-extension origin — let flask-cors handle it
55+
56+
# flask-cors 4 treats strings containing regex metacharacters as regular
57+
# expressions and otherwise compares them case-insensitively. Keep this
58+
# exemption consistent with that contract.
59+
for pattern in user_origins:
60+
if _matches_configured_origin(origin, pattern):
61+
return None
62+
63+
segments = [s for s in request.path.split("/") if s]
64+
if _is_allowed(request.method, segments):
65+
return None
66+
67+
abort(403)
68+
69+
70+
def _matches_configured_origin(origin: str, pattern: str) -> bool:
71+
"""Match an origin using flask-cors 4's configured-origin semantics."""
72+
regex_chars = "*\\]?$^[()"
73+
if any(char in pattern for char in regex_chars):
74+
try:
75+
return re.match(pattern, origin, flags=re.IGNORECASE) is not None
76+
except re.error:
77+
return False
78+
return origin.lower() == pattern.lower()
79+
80+
81+
def _is_allowed(method: str, segments: List[str]) -> bool:
82+
"""Return True if *method* + *segments* is a path aw-watcher-web actually uses.
83+
84+
For OPTIONS preflights the path is checked against the set of allowed paths
85+
(not the Access-Control-Request-Method header) to keep the logic simple while
86+
still blocking preflights for disallowed paths such as ``/api/0/export``.
87+
"""
88+
if method == "OPTIONS":
89+
return _is_allowed_path(segments)
90+
91+
# GET /api/0/info
92+
if method == "GET" and segments == ["api", "0", "info"]:
93+
return True
94+
95+
# POST /api/0/buckets/aw-watcher-web-<id>
96+
if (
97+
method == "POST"
98+
and len(segments) == 4
99+
and segments[:3] == ["api", "0", "buckets"]
100+
and segments[3].startswith("aw-watcher-web-")
101+
):
102+
return True
103+
104+
# POST /api/0/buckets/aw-watcher-web-<id>/heartbeat
105+
if (
106+
method == "POST"
107+
and len(segments) == 5
108+
and segments[:3] == ["api", "0", "buckets"]
109+
and segments[3].startswith("aw-watcher-web-")
110+
and segments[4] == "heartbeat"
111+
):
112+
return True
113+
114+
return False
115+
116+
117+
def _is_allowed_path(segments: List[str]) -> bool:
118+
"""Return True if *segments* is on any allowed endpoint (for OPTIONS checks)."""
119+
if segments == ["api", "0", "info"]:
120+
return True
121+
if (
122+
len(segments) == 4
123+
and segments[:3] == ["api", "0", "buckets"]
124+
and segments[3].startswith("aw-watcher-web-")
125+
):
126+
return True
127+
if (
128+
len(segments) == 5
129+
and segments[:3] == ["api", "0", "buckets"]
130+
and segments[3].startswith("aw-watcher-web-")
131+
and segments[4] == "heartbeat"
132+
):
133+
return True
134+
return False

aw_server/server.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
)
1515
from flask_cors import CORS
1616

17-
from . import rest
17+
from . import extension_cors, rest
1818
from .api import ServerAPI
1919
from .custom_static import get_custom_static_blueprint
2020
from .log import FlaskLogHandler
@@ -105,13 +105,21 @@ def _config_cors(cors_origins: List[str], testing: bool):
105105
# Used for development of aw-webui
106106
cors_origins.append("http://127.0.0.1:27180/*")
107107

108+
# Capture user-configured origins before appending the built-in wildcard.
109+
# extension_cors uses this list to exempt explicit opt-ins from scope narrowing.
110+
user_origins = list(cors_origins)
111+
108112
# TODO: This could probably be more specific
109113
# See https://github.com/ActivityWatch/aw-server/pull/43#issuecomment-386888769
110114
cors_origins.append("moz-extension://*")
111115

112116
# See: https://flask-cors.readthedocs.org/en/latest/
113117
CORS(current_app, resources={r"/api/*": {"origins": cors_origins}})
114118

119+
# Narrow the moz-extension wildcard to only the endpoints aw-watcher-web needs.
120+
# See aw_server/extension_cors.py and ActivityWatch/aw-server-rust#637.
121+
extension_cors.register(current_app._get_current_object(), user_origins)
122+
115123

116124
# Only to be called from aw_server.main function!
117125
def _start(

tests/test_extension_cors.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"""Tests for moz-extension CORS endpoint scoping (extension_cors module)."""
2+
3+
import pytest
4+
5+
from aw_server.extension_cors import (
6+
_is_allowed,
7+
_is_allowed_path,
8+
_matches_configured_origin,
9+
)
10+
from aw_server.server import AWFlask
11+
12+
_EXT_ORIGIN = "moz-extension://aabbccddeeff00112233445566778899"
13+
_HOST = "127.0.0.1"
14+
15+
16+
@pytest.fixture(scope="module")
17+
def client():
18+
app = AWFlask(_HOST, testing=True)
19+
return app.test_client()
20+
21+
22+
# ---------------------------------------------------------------------------
23+
# Unit tests for the path-matching helpers
24+
# ---------------------------------------------------------------------------
25+
26+
27+
@pytest.mark.parametrize(
28+
"method,path,expected",
29+
[
30+
("GET", "/api/0/info", True),
31+
("POST", "/api/0/buckets/aw-watcher-web-hostname", True),
32+
("POST", "/api/0/buckets/aw-watcher-web-hostname/heartbeat", True),
33+
# Blocked paths
34+
("GET", "/api/0/export", False),
35+
("POST", "/api/0/export", False),
36+
("GET", "/api/0/buckets/", False),
37+
("GET", "/api/0/buckets/aw-watcher-web-hostname/events", False),
38+
("POST", "/api/0/query/", False),
39+
("GET", "/api/0/settings", False),
40+
# Non-watcher bucket
41+
("POST", "/api/0/buckets/aw-watcher-window-hostname", False),
42+
# Heartbeat on non-watcher bucket
43+
("POST", "/api/0/buckets/aw-watcher-afk-hostname/heartbeat", False),
44+
# Percent-encoding should not bypass via raw path (segments are used)
45+
("GET", "/api/0/%65xport", False), # %65 = 'e', decodes to 'export'
46+
],
47+
)
48+
def test_is_allowed_unit(method, path, expected):
49+
segments = [s for s in path.split("/") if s]
50+
assert _is_allowed(method, segments) is expected
51+
52+
53+
@pytest.mark.parametrize(
54+
"path,expected",
55+
[
56+
("/api/0/info", True),
57+
("/api/0/buckets/aw-watcher-web-hostname", True),
58+
("/api/0/buckets/aw-watcher-web-hostname/heartbeat", True),
59+
("/api/0/export", False),
60+
("/api/0/buckets/", False),
61+
],
62+
)
63+
def test_is_allowed_path_unit(path, expected):
64+
segments = [s for s in path.split("/") if s]
65+
assert _is_allowed_path(segments) is expected
66+
67+
68+
# ---------------------------------------------------------------------------
69+
# Integration tests via Flask test client
70+
# ---------------------------------------------------------------------------
71+
72+
73+
@pytest.mark.parametrize(
74+
"method,path",
75+
[
76+
("GET", "/api/0/info"),
77+
# Bucket creation: 400 without body is expected but NOT 403
78+
("POST", "/api/0/buckets/aw-watcher-web-testhost"),
79+
# Heartbeat: 404 (bucket doesn't exist) is expected but NOT 403
80+
("POST", "/api/0/buckets/aw-watcher-web-testhost/heartbeat"),
81+
],
82+
)
83+
def test_extension_allowed(client, method, path):
84+
"""moz-extension origins are permitted at aw-watcher-web's endpoints."""
85+
headers = {"Origin": _EXT_ORIGIN}
86+
r = client.open(path, method=method, headers=headers)
87+
assert (
88+
r.status_code != 403
89+
), f"{method} {path} should not be blocked; got {r.status_code}"
90+
91+
92+
@pytest.mark.parametrize(
93+
"method,path",
94+
[
95+
("GET", "/api/0/export"),
96+
("GET", "/api/0/buckets/"),
97+
("POST", "/api/0/import"),
98+
("POST", "/api/0/query/"),
99+
("GET", "/api/0/settings"),
100+
# Events read from a watcher bucket
101+
("GET", "/api/0/buckets/aw-watcher-web-testhost/events"),
102+
# Non-watcher bucket
103+
("POST", "/api/0/buckets/aw-watcher-window-testhost"),
104+
("POST", "/api/0/buckets/aw-watcher-afk-testhost/heartbeat"),
105+
],
106+
)
107+
def test_extension_blocked(client, method, path):
108+
"""moz-extension origins are blocked at endpoints beyond aw-watcher-web's needs."""
109+
headers = {"Origin": _EXT_ORIGIN}
110+
r = client.open(path, method=method, headers=headers)
111+
assert (
112+
r.status_code == 403
113+
), f"{method} {path} should be blocked (403); got {r.status_code}"
114+
115+
116+
@pytest.mark.parametrize(
117+
"pattern,origin,expected",
118+
[
119+
("moz-extension://aabbcc", "moz-extension://aabbcc", True),
120+
("MOZ-EXTENSION://AABBCC", "moz-extension://aabbcc", True),
121+
(r"moz-extension://.*", "moz-extension://aabbcc", True),
122+
(r"moz-extension://[a-f0-9]+", "moz-extension://aabbcc", True),
123+
(r"moz-extension://[0-9]+", "moz-extension://aabbcc", False),
124+
("moz-extension://other", "moz-extension://aabbcc", False),
125+
],
126+
)
127+
def test_matches_configured_origin(pattern, origin, expected):
128+
assert _matches_configured_origin(origin, pattern) is expected
129+
130+
131+
def test_regex_configured_extension_origin_bypasses_scope_guard():
132+
"""Owner-configured regex origins retain unrestricted endpoint access."""
133+
app = AWFlask(_HOST, testing=False, cors_origins=[r"moz-extension://.*"])
134+
client = app.test_client()
135+
136+
response = client.get("/api/0/export", headers={"Origin": _EXT_ORIGIN})
137+
138+
assert response.status_code != 403
139+
140+
141+
def test_non_extension_origin_passthrough(client):
142+
"""Non-moz-extension origins are not affected by the scope guard."""
143+
headers = {"Origin": "http://127.0.0.1:27180"}
144+
r = client.get("/api/0/info", headers=headers)
145+
assert r.status_code != 403
146+
147+
148+
def test_no_origin_passthrough(client):
149+
"""Requests without an Origin header (native watchers, curl) are not blocked."""
150+
r = client.get("/api/0/info")
151+
assert r.status_code != 403
152+
153+
154+
def test_options_allowed_path(client):
155+
"""OPTIONS preflight on an allowed path is permitted."""
156+
headers = {
157+
"Origin": _EXT_ORIGIN,
158+
"Access-Control-Request-Method": "GET",
159+
}
160+
r = client.options("/api/0/info", headers=headers)
161+
assert r.status_code != 403
162+
163+
164+
def test_options_blocked_path(client):
165+
"""OPTIONS preflight on a blocked path is rejected."""
166+
headers = {
167+
"Origin": _EXT_ORIGIN,
168+
"Access-Control-Request-Method": "GET",
169+
}
170+
r = client.options("/api/0/export", headers=headers)
171+
assert r.status_code == 403

0 commit comments

Comments
 (0)