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:
171
tests/test_extension_cors.py
Normal file
171
tests/test_extension_cors.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""Tests for moz-extension CORS endpoint scoping (extension_cors module)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from aw_server.extension_cors import (
|
||||
_is_allowed,
|
||||
_is_allowed_path,
|
||||
_matches_configured_origin,
|
||||
)
|
||||
from aw_server.server import AWFlask
|
||||
|
||||
_EXT_ORIGIN = "moz-extension://aabbccddeeff00112233445566778899"
|
||||
_HOST = "127.0.0.1"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
app = AWFlask(_HOST, testing=True)
|
||||
return app.test_client()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests for the path-matching helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method,path,expected",
|
||||
[
|
||||
("GET", "/api/0/info", True),
|
||||
("POST", "/api/0/buckets/aw-watcher-web-hostname", True),
|
||||
("POST", "/api/0/buckets/aw-watcher-web-hostname/heartbeat", True),
|
||||
# Blocked paths
|
||||
("GET", "/api/0/export", False),
|
||||
("POST", "/api/0/export", False),
|
||||
("GET", "/api/0/buckets/", False),
|
||||
("GET", "/api/0/buckets/aw-watcher-web-hostname/events", False),
|
||||
("POST", "/api/0/query/", False),
|
||||
("GET", "/api/0/settings", False),
|
||||
# Non-watcher bucket
|
||||
("POST", "/api/0/buckets/aw-watcher-window-hostname", False),
|
||||
# Heartbeat on non-watcher bucket
|
||||
("POST", "/api/0/buckets/aw-watcher-afk-hostname/heartbeat", False),
|
||||
# Percent-encoding should not bypass via raw path (segments are used)
|
||||
("GET", "/api/0/%65xport", False), # %65 = 'e', decodes to 'export'
|
||||
],
|
||||
)
|
||||
def test_is_allowed_unit(method, path, expected):
|
||||
segments = [s for s in path.split("/") if s]
|
||||
assert _is_allowed(method, segments) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path,expected",
|
||||
[
|
||||
("/api/0/info", True),
|
||||
("/api/0/buckets/aw-watcher-web-hostname", True),
|
||||
("/api/0/buckets/aw-watcher-web-hostname/heartbeat", True),
|
||||
("/api/0/export", False),
|
||||
("/api/0/buckets/", False),
|
||||
],
|
||||
)
|
||||
def test_is_allowed_path_unit(path, expected):
|
||||
segments = [s for s in path.split("/") if s]
|
||||
assert _is_allowed_path(segments) is expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests via Flask test client
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method,path",
|
||||
[
|
||||
("GET", "/api/0/info"),
|
||||
# Bucket creation: 400 without body is expected but NOT 403
|
||||
("POST", "/api/0/buckets/aw-watcher-web-testhost"),
|
||||
# Heartbeat: 404 (bucket doesn't exist) is expected but NOT 403
|
||||
("POST", "/api/0/buckets/aw-watcher-web-testhost/heartbeat"),
|
||||
],
|
||||
)
|
||||
def test_extension_allowed(client, method, path):
|
||||
"""moz-extension origins are permitted at aw-watcher-web's endpoints."""
|
||||
headers = {"Origin": _EXT_ORIGIN}
|
||||
r = client.open(path, method=method, headers=headers)
|
||||
assert (
|
||||
r.status_code != 403
|
||||
), f"{method} {path} should not be blocked; got {r.status_code}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method,path",
|
||||
[
|
||||
("GET", "/api/0/export"),
|
||||
("GET", "/api/0/buckets/"),
|
||||
("POST", "/api/0/import"),
|
||||
("POST", "/api/0/query/"),
|
||||
("GET", "/api/0/settings"),
|
||||
# Events read from a watcher bucket
|
||||
("GET", "/api/0/buckets/aw-watcher-web-testhost/events"),
|
||||
# Non-watcher bucket
|
||||
("POST", "/api/0/buckets/aw-watcher-window-testhost"),
|
||||
("POST", "/api/0/buckets/aw-watcher-afk-testhost/heartbeat"),
|
||||
],
|
||||
)
|
||||
def test_extension_blocked(client, method, path):
|
||||
"""moz-extension origins are blocked at endpoints beyond aw-watcher-web's needs."""
|
||||
headers = {"Origin": _EXT_ORIGIN}
|
||||
r = client.open(path, method=method, headers=headers)
|
||||
assert (
|
||||
r.status_code == 403
|
||||
), f"{method} {path} should be blocked (403); got {r.status_code}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"pattern,origin,expected",
|
||||
[
|
||||
("moz-extension://aabbcc", "moz-extension://aabbcc", True),
|
||||
("MOZ-EXTENSION://AABBCC", "moz-extension://aabbcc", True),
|
||||
(r"moz-extension://.*", "moz-extension://aabbcc", True),
|
||||
(r"moz-extension://[a-f0-9]+", "moz-extension://aabbcc", True),
|
||||
(r"moz-extension://[0-9]+", "moz-extension://aabbcc", False),
|
||||
("moz-extension://other", "moz-extension://aabbcc", False),
|
||||
],
|
||||
)
|
||||
def test_matches_configured_origin(pattern, origin, expected):
|
||||
assert _matches_configured_origin(origin, pattern) is expected
|
||||
|
||||
|
||||
def test_regex_configured_extension_origin_bypasses_scope_guard():
|
||||
"""Owner-configured regex origins retain unrestricted endpoint access."""
|
||||
app = AWFlask(_HOST, testing=False, cors_origins=[r"moz-extension://.*"])
|
||||
client = app.test_client()
|
||||
|
||||
response = client.get("/api/0/export", headers={"Origin": _EXT_ORIGIN})
|
||||
|
||||
assert response.status_code != 403
|
||||
|
||||
|
||||
def test_non_extension_origin_passthrough(client):
|
||||
"""Non-moz-extension origins are not affected by the scope guard."""
|
||||
headers = {"Origin": "http://127.0.0.1:27180"}
|
||||
r = client.get("/api/0/info", headers=headers)
|
||||
assert r.status_code != 403
|
||||
|
||||
|
||||
def test_no_origin_passthrough(client):
|
||||
"""Requests without an Origin header (native watchers, curl) are not blocked."""
|
||||
r = client.get("/api/0/info")
|
||||
assert r.status_code != 403
|
||||
|
||||
|
||||
def test_options_allowed_path(client):
|
||||
"""OPTIONS preflight on an allowed path is permitted."""
|
||||
headers = {
|
||||
"Origin": _EXT_ORIGIN,
|
||||
"Access-Control-Request-Method": "GET",
|
||||
}
|
||||
r = client.options("/api/0/info", headers=headers)
|
||||
assert r.status_code != 403
|
||||
|
||||
|
||||
def test_options_blocked_path(client):
|
||||
"""OPTIONS preflight on a blocked path is rejected."""
|
||||
headers = {
|
||||
"Origin": _EXT_ORIGIN,
|
||||
"Access-Control-Request-Method": "GET",
|
||||
}
|
||||
r = client.options("/api/0/export", headers=headers)
|
||||
assert r.status_code == 403
|
||||
Reference in New Issue
Block a user