snapshot of https://github.com/ActivityWatch/aw-server.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build / Test on ubuntu-latest (push) Successful in 3m6s
Lint / lint (push) Successful in 22s
Build / typecheck (ubuntu-latest, 3.9) (push) Successful in 1m49s
Lint / format (push) Successful in 42s
Build / Test on macOS-latest (push) Has been cancelled
Build / Test on windows-latest (push) Has been cancelled
CodeQL / Analyze (python) (push) Failing after 1m31s
Some checks failed
Build / Test on ubuntu-latest (push) Successful in 3m6s
Lint / lint (push) Successful in 22s
Build / typecheck (ubuntu-latest, 3.9) (push) Successful in 1m49s
Lint / format (push) Successful in 42s
Build / Test on macOS-latest (push) Has been cancelled
Build / Test on windows-latest (push) Has been cancelled
CodeQL / Analyze (python) (push) Failing after 1m31s
This commit is contained in:
134
aw_server/extension_cors.py
Normal file
134
aw_server/extension_cors.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
Endpoint scoping for the moz-extension:// CORS wildcard.
|
||||
|
||||
Firefox assigns every extension its own random origin, so aw-server uses
|
||||
``moz-extension://*`` to allow aw-watcher-web without knowing the ID in advance.
|
||||
That wildcard also permits every other installed extension to reach the full API —
|
||||
including ``/api/0/export``, ``/api/0/import``, queries, and settings — with no
|
||||
host permission and therefore no install-time browser prompt naming ActivityWatch.
|
||||
|
||||
This module registers a ``before_request`` hook that restricts wildcard-matched
|
||||
extension origins to the three endpoints aw-watcher-web actually needs:
|
||||
|
||||
GET /api/0/info — hostname/version detection
|
||||
POST /api/0/buckets/aw-watcher-web-<id> — ensure its bucket exists
|
||||
POST /api/0/buckets/aw-watcher-web-<id>/heartbeat — heartbeat recording
|
||||
|
||||
All other paths return 403 before the handler executes. flask-cors CORS headers are
|
||||
still added by the after_request hook, but the 403 status prevents JavaScript from
|
||||
treating the response as a successful cross-origin fetch (and more importantly, the
|
||||
server side never processes the request).
|
||||
|
||||
User-configured origins (``cors_origins`` / ``cors_regex`` in config) are exempted:
|
||||
those are explicit opt-ins by the server owner, unlike the built-in wildcard.
|
||||
|
||||
Path matching uses split segments rather than the raw path string to avoid
|
||||
percent-encoding bypasses — the same bug class as aw-server-rust#588 and #636.
|
||||
|
||||
See also: ActivityWatch/aw-server-rust#637 (the Rust sibling of this fix).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import List, Optional
|
||||
|
||||
from flask import Flask, abort, request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_EXTENSION_SCHEME = "moz-extension://"
|
||||
|
||||
|
||||
def register(app: Flask, user_origins: List[str]) -> None:
|
||||
"""Register the extension CORS scope hook on *app*.
|
||||
|
||||
*user_origins* — origins the user configured explicitly (captured before
|
||||
the built-in ``moz-extension://*`` wildcard is appended). These are
|
||||
explicit opt-ins and bypass the scope narrowing.
|
||||
"""
|
||||
|
||||
@app.before_request
|
||||
def _restrict_extension_cors() -> Optional[object]:
|
||||
origin = request.headers.get("Origin", "")
|
||||
if not origin.lower().startswith(_EXTENSION_SCHEME):
|
||||
return None # not a moz-extension origin — let flask-cors handle it
|
||||
|
||||
# flask-cors 4 treats strings containing regex metacharacters as regular
|
||||
# expressions and otherwise compares them case-insensitively. Keep this
|
||||
# exemption consistent with that contract.
|
||||
for pattern in user_origins:
|
||||
if _matches_configured_origin(origin, pattern):
|
||||
return None
|
||||
|
||||
segments = [s for s in request.path.split("/") if s]
|
||||
if _is_allowed(request.method, segments):
|
||||
return None
|
||||
|
||||
abort(403)
|
||||
|
||||
|
||||
def _matches_configured_origin(origin: str, pattern: str) -> bool:
|
||||
"""Match an origin using flask-cors 4's configured-origin semantics."""
|
||||
regex_chars = "*\\]?$^[()"
|
||||
if any(char in pattern for char in regex_chars):
|
||||
try:
|
||||
return re.match(pattern, origin, flags=re.IGNORECASE) is not None
|
||||
except re.error:
|
||||
return False
|
||||
return origin.lower() == pattern.lower()
|
||||
|
||||
|
||||
def _is_allowed(method: str, segments: List[str]) -> bool:
|
||||
"""Return True if *method* + *segments* is a path aw-watcher-web actually uses.
|
||||
|
||||
For OPTIONS preflights the path is checked against the set of allowed paths
|
||||
(not the Access-Control-Request-Method header) to keep the logic simple while
|
||||
still blocking preflights for disallowed paths such as ``/api/0/export``.
|
||||
"""
|
||||
if method == "OPTIONS":
|
||||
return _is_allowed_path(segments)
|
||||
|
||||
# GET /api/0/info
|
||||
if method == "GET" and segments == ["api", "0", "info"]:
|
||||
return True
|
||||
|
||||
# POST /api/0/buckets/aw-watcher-web-<id>
|
||||
if (
|
||||
method == "POST"
|
||||
and len(segments) == 4
|
||||
and segments[:3] == ["api", "0", "buckets"]
|
||||
and segments[3].startswith("aw-watcher-web-")
|
||||
):
|
||||
return True
|
||||
|
||||
# POST /api/0/buckets/aw-watcher-web-<id>/heartbeat
|
||||
if (
|
||||
method == "POST"
|
||||
and len(segments) == 5
|
||||
and segments[:3] == ["api", "0", "buckets"]
|
||||
and segments[3].startswith("aw-watcher-web-")
|
||||
and segments[4] == "heartbeat"
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _is_allowed_path(segments: List[str]) -> bool:
|
||||
"""Return True if *segments* is on any allowed endpoint (for OPTIONS checks)."""
|
||||
if segments == ["api", "0", "info"]:
|
||||
return True
|
||||
if (
|
||||
len(segments) == 4
|
||||
and segments[:3] == ["api", "0", "buckets"]
|
||||
and segments[3].startswith("aw-watcher-web-")
|
||||
):
|
||||
return True
|
||||
if (
|
||||
len(segments) == 5
|
||||
and segments[:3] == ["api", "0", "buckets"]
|
||||
and segments[3].startswith("aw-watcher-web-")
|
||||
and segments[4] == "heartbeat"
|
||||
):
|
||||
return True
|
||||
return False
|
||||
Reference in New Issue
Block a user