290 lines
9.0 KiB
Python
290 lines
9.0 KiB
Python
"""Route and request-boundary contracts for the Atlas Gitea client."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import urllib.request
|
||
|
||
import pytest
|
||
|
||
from testing.tests.test_hermes_gitea_support import HEAD_SHA, Response, _load
|
||
|
||
def test_redirect_handler_rejects_cross_origin_with_sentinel_authorization():
|
||
client = _load()
|
||
source = urllib.request.Request(
|
||
"https://scm.bstein.dev/api/v1/repos/atlas/cassandra",
|
||
headers={"Authorization": "token redirect-sentinel"},
|
||
)
|
||
|
||
with pytest.raises(client.PolicyError, match="redirects are not allowed") as exc:
|
||
client.RejectRedirectHandler().redirect_request(
|
||
source,
|
||
None,
|
||
302,
|
||
"Found",
|
||
{},
|
||
"https://evil.example/collect",
|
||
)
|
||
|
||
assert "redirect-sentinel" not in str(exc.value)
|
||
assert any(
|
||
isinstance(handler, client.RejectRedirectHandler)
|
||
for handler in client._SAFE_OPENER.handlers
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("base_url", "path"),
|
||
[
|
||
("https://evil.example", "/api/v1/repos/atlas/cassandra"),
|
||
("http://scm.bstein.dev", "/api/v1/repos/atlas/cassandra"),
|
||
("https://scm.bstein.dev:443", "/api/v1/repos/atlas/cassandra"),
|
||
("https://scm.bstein.dev/", "/api/v1/repos/atlas/cassandra"),
|
||
("HTTPS://scm.bstein.dev", "/api/v1/repos/atlas/cassandra"),
|
||
("https://SCM.bstein.dev", "/api/v1/repos/atlas/cassandra"),
|
||
("https://user@scm.bstein.dev", "/api/v1/repos/atlas/cassandra"),
|
||
("https://scm.bstein.dev", "https://evil.example/api/v1/repos/atlas/cassandra"),
|
||
("https://scm.bstein.dev", "/api/v1/repos/evil/cassandra"),
|
||
("https://scm.bstein.dev", "/api/v1/repos/%61tlas/cassandra"),
|
||
("https://scm.bstein.dev", "/api/v1/repos/atlas/../admin"),
|
||
],
|
||
)
|
||
def test_host_owner_and_path_escape_attempts_are_rejected(base_url: str, path: str):
|
||
client = _load()
|
||
|
||
with pytest.raises(client.PolicyError):
|
||
client.build_request("GET", path, base_url=base_url, token="secret")
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"path",
|
||
[
|
||
"/api/v1/repos/atlas/cassandra/pulls/1\nHost: evil.example",
|
||
"/api/v1/repos/atlas/cassandra/pulls/1\r\nX-Test: value",
|
||
"/api/v1/repos/atlas/cassandra/pulls/1\tignored",
|
||
"/api/v1/repos/atlas/cassandra/pulls/1\x00ignored",
|
||
"/api/v1/repos/atlas/cassandra/pulls/1\x1fignored",
|
||
"/api/v1/repos/atlas/cassandra/pulls/1\x7fignored",
|
||
"/api/v1/repos/atlas/cassandra\\pulls\\1",
|
||
"/api/v1/repos/atlas/cassandra/pulls/%31",
|
||
"/api/v1/repos/atlas/cassandra/pulls/1",
|
||
" https://scm.bstein.dev/api/v1/repos/atlas/cassandra",
|
||
"https://scm.bstein.dev/api/v1/repos/atlas/cassandra",
|
||
],
|
||
)
|
||
def test_raw_noncanonical_target_is_rejected_before_urlsplit_and_opener(
|
||
path: str, monkeypatch
|
||
):
|
||
client = _load()
|
||
split_called = False
|
||
opener_called = False
|
||
original_urlsplit = client.urllib.parse.urlsplit
|
||
|
||
def urlsplit(*args, **kwargs):
|
||
nonlocal split_called
|
||
split_called = True
|
||
return original_urlsplit(*args, **kwargs)
|
||
|
||
def opener(*_args, **_kwargs):
|
||
nonlocal opener_called
|
||
opener_called = True
|
||
return Response(b"{}")
|
||
|
||
monkeypatch.setattr(client.urllib.parse, "urlsplit", urlsplit)
|
||
with pytest.raises(client.PolicyError):
|
||
client.read(path, token="runtime", opener=opener)
|
||
assert split_called is False
|
||
assert opener_called is False
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"path",
|
||
[
|
||
"/api/v1/repos/atlas/cassandra/pulls/1?",
|
||
"//scm.bstein.dev/api/v1/repos/atlas/cassandra",
|
||
"/api/v1/repos/atlas/cassandra/./pulls/1",
|
||
"/api/v1/repos/atlas/cassandra/../admin",
|
||
"/api/v1/repos/atlas/cassandra//pulls/1",
|
||
"/api/v1/repos/atlas/cassandra/pulls/1?limit=01",
|
||
],
|
||
)
|
||
def test_noncanonical_round_trip_or_segments_never_reach_opener(path: str):
|
||
client = _load()
|
||
opener_called = False
|
||
|
||
def opener(*_args, **_kwargs):
|
||
nonlocal opener_called
|
||
opener_called = True
|
||
return Response(b"{}")
|
||
|
||
with pytest.raises(client.PolicyError):
|
||
client.read(path, token="runtime", opener=opener)
|
||
assert opener_called is False
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"path",
|
||
[
|
||
"/api/v1/repos/atlas/cassandra",
|
||
"/api/v1/repos/atlas/cassandra/pulls?state=open&limit=20&page=1",
|
||
"/api/v1/repos/atlas/cassandra/pulls/7",
|
||
"/api/v1/repos/atlas/cassandra/pulls/7/commits?limit=20",
|
||
"/api/v1/repos/atlas/cassandra/pulls/7/files?page=1",
|
||
"/api/v1/repos/atlas/cassandra/branches",
|
||
"/api/v1/repos/atlas/cassandra/branches/main",
|
||
"/api/v1/repos/atlas/cassandra/commits?limit=10",
|
||
f"/api/v1/repos/atlas/cassandra/git/commits/{HEAD_SHA}",
|
||
f"/api/v1/repos/atlas/cassandra/commits/{HEAD_SHA}/status",
|
||
f"/api/v1/repos/atlas/cassandra/commits/{HEAD_SHA}/statuses?limit=10",
|
||
f"/api/v1/repos/atlas/cassandra/statuses/{HEAD_SHA}?page=1",
|
||
],
|
||
)
|
||
def test_explicit_read_allowlist_accepts_only_engineering_metadata(path: str):
|
||
client = _load()
|
||
|
||
request = client.build_request(
|
||
"GET", path, base_url=client.CANONICAL_BASE_URL, token="runtime"
|
||
)
|
||
assert request.method == "GET"
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"path",
|
||
[
|
||
"/api/v1/repos/atlas/cassandra/hooks",
|
||
"/api/v1/repos/atlas/cassandra/actions/secrets",
|
||
"/api/v1/repos/atlas/cassandra/actions/variables",
|
||
"/api/v1/repos/atlas/cassandra/collaborators",
|
||
"/api/v1/repos/atlas/cassandra/branch_protections",
|
||
"/api/v1/repos/atlas/cassandra/keys",
|
||
"/api/v1/repos/atlas/cassandra/pulls/7/reviews",
|
||
"/api/v1/repos/atlas/cassandra/pulls/7/merge",
|
||
"/api/v1/repos/atlas/cassandra/pulls/7.diff",
|
||
"/api/v1/repos/atlas/cassandra/releases",
|
||
],
|
||
)
|
||
def test_privileged_or_content_routes_are_denied_even_for_get(path: str):
|
||
client = _load()
|
||
|
||
with pytest.raises(client.PolicyError, match="outside the metadata read allowlist"):
|
||
client.authorize_request("GET", path, None)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"path",
|
||
[
|
||
"/api/v1/repos/atlas/cassandra/pulls?limit=51",
|
||
"/api/v1/repos/atlas/cassandra/pulls?state=merged",
|
||
"/api/v1/repos/atlas/cassandra/pulls?private=true",
|
||
"/api/v1/repos/atlas/cassandra/pulls?limit=1&limit=2",
|
||
"/api/v1/repos/atlas/cassandra?p=1",
|
||
],
|
||
)
|
||
def test_read_query_is_bounded(path: str):
|
||
client = _load()
|
||
|
||
with pytest.raises(client.PolicyError):
|
||
client.authorize_request("GET", path, None)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"query",
|
||
[
|
||
"page=0",
|
||
"page=10001",
|
||
"page=01",
|
||
"limit=0",
|
||
"limit=51",
|
||
"limit=01",
|
||
"page=" + "9" * 4000,
|
||
"page=0",
|
||
"page=%EF%BC%90",
|
||
"state=%6fpen",
|
||
"p%61ge=1",
|
||
"page=1&page=2",
|
||
"page=1&" + "x" * 17 + "=1",
|
||
"state=" + "x" * 17,
|
||
"page=1&limit=2&state=open&extra=3",
|
||
],
|
||
)
|
||
def test_read_query_requires_canonical_bounded_ascii(query: str):
|
||
client = _load()
|
||
|
||
with pytest.raises(client.PolicyError):
|
||
client.authorize_request(
|
||
"GET", f"/api/v1/repos/atlas/cassandra/pulls?{query}", None
|
||
)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"suffix",
|
||
[
|
||
"pulls/{number}",
|
||
"pulls/{number}.patch",
|
||
"pulls/{number}.diff",
|
||
"pulls/{number}/commits",
|
||
"pulls/{number}/files",
|
||
"commits/{number}/status",
|
||
"commits/{number}/statuses",
|
||
"statuses/{number}",
|
||
"branches/{number}",
|
||
],
|
||
)
|
||
def test_oversized_numeric_or_captured_path_never_reaches_opener(suffix: str):
|
||
client = _load()
|
||
called = False
|
||
|
||
def opener(*_args, **_kwargs):
|
||
nonlocal called
|
||
called = True
|
||
return Response(b"{}")
|
||
|
||
path = "/api/v1/repos/atlas/cassandra/" + suffix.format(number="9" * 4000)
|
||
with pytest.raises(client.PolicyError):
|
||
client.read(path, token="runtime", opener=opener)
|
||
assert called is False
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"number",
|
||
["0", "01", "2147483648", "12", "%31", "12345678901"],
|
||
)
|
||
@pytest.mark.parametrize("tail", ["", ".patch", ".diff", "/commits", "/files"])
|
||
def test_noncanonical_or_out_of_range_pr_number_never_reaches_opener(
|
||
number: str, tail: str
|
||
):
|
||
client = _load()
|
||
called = False
|
||
|
||
def opener(*_args, **_kwargs):
|
||
nonlocal called
|
||
called = True
|
||
return Response(b"{}")
|
||
|
||
with pytest.raises(client.PolicyError):
|
||
client.read(
|
||
f"/api/v1/repos/atlas/cassandra/pulls/{number}{tail}",
|
||
token="runtime",
|
||
opener=opener,
|
||
)
|
||
assert called is False
|
||
|
||
|
||
def test_maximum_bounded_pr_number_is_readable():
|
||
client = _load()
|
||
called = False
|
||
|
||
def opener(*_args, **_kwargs):
|
||
nonlocal called
|
||
called = True
|
||
return Response(b"{}")
|
||
|
||
assert (
|
||
client.read(
|
||
"/api/v1/repos/atlas/cassandra/pulls/2147483647",
|
||
token="runtime",
|
||
opener=opener,
|
||
)
|
||
== b"{}"
|
||
)
|
||
assert called is True
|