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
172 lines
5.8 KiB
Python
172 lines
5.8 KiB
Python
"""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
|