257 lines
7.6 KiB
Python
257 lines
7.6 KiB
Python
"""``GITEA_URL``, ``PUBLIC_BASE_URL`` and the other path-like settings are validated strictly (SEC-10)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from aegis_gitea_mcp import transports
|
|
from aegis_gitea_mcp.cli import main
|
|
from aegis_gitea_mcp.config import Settings
|
|
from aegis_gitea_mcp.oauth.provider import redirect_uri_problem
|
|
|
|
|
|
def make(**values: Any) -> Settings:
|
|
base: dict[str, Any] = {"gitea_url": "https://gitea.example.com"}
|
|
base.update(values)
|
|
return Settings(**base)
|
|
|
|
|
|
BAD_CHARACTERS = [
|
|
("tab", "\t"),
|
|
("newline", "\n"),
|
|
("carriage return", "\r"),
|
|
("space", " "),
|
|
("nul", "\x00"),
|
|
("vertical tab", "\x0b"),
|
|
("line separator", chr(0x2028)),
|
|
("no-break space", chr(0xA0)),
|
|
("bidi override", chr(0x202E)),
|
|
("bidi isolate", chr(0x2066)),
|
|
("zero width space", chr(0x200B)),
|
|
("backslash", "\\"),
|
|
]
|
|
POSITIONS = [
|
|
"https://gi{c}tea.example.com",
|
|
"https://gitea.example.com/a{c}b",
|
|
"https://gitea.example.com:44{c}3",
|
|
"https://gitea.example.com/{c}x",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("template", POSITIONS)
|
|
@pytest.mark.parametrize(("name", "char"), BAD_CHARACTERS, ids=[n for n, _ in BAD_CHARACTERS])
|
|
def test_gitea_url_rejects_whitespace_and_control_characters(
|
|
template: str, name: str, char: str
|
|
) -> None:
|
|
with pytest.raises(ValidationError):
|
|
make(gitea_url=template.format(c=char))
|
|
|
|
|
|
@pytest.mark.parametrize("template", POSITIONS)
|
|
@pytest.mark.parametrize(("name", "char"), BAD_CHARACTERS, ids=[n for n, _ in BAD_CHARACTERS])
|
|
def test_public_base_url_rejects_whitespace_and_control_characters(
|
|
template: str, name: str, char: str
|
|
) -> None:
|
|
with pytest.raises(ValidationError):
|
|
make(public_base_url=template.format(c=char).replace("gitea", "mcp"))
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"url",
|
|
[
|
|
"ftp://gitea.example.com",
|
|
"file:///etc/passwd",
|
|
"javascript:alert(1)",
|
|
"gitea.example.com",
|
|
"//gitea.example.com",
|
|
"https://",
|
|
"https:///path",
|
|
"https://user:pass@gitea.example.com",
|
|
"https://user@gitea.example.com",
|
|
"https://gitea.example.com?x=1",
|
|
"https://gitea.example.com/?",
|
|
"https://gitea.example.com#frag",
|
|
"https://gitea.example.com:99999",
|
|
"https://gitea.example.com:abc",
|
|
"",
|
|
],
|
|
)
|
|
def test_gitea_url_rejects_bad_schemes_credentials_query_fragment_port_and_missing_host(
|
|
url: str,
|
|
) -> None:
|
|
with pytest.raises(ValidationError):
|
|
make(gitea_url=url)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"url",
|
|
[
|
|
"http://mcp.example.com",
|
|
"http://203.0.113.5",
|
|
"ftp://mcp.example.com",
|
|
"https://user:pass@mcp.example.com",
|
|
"https://mcp.example.com?x=1",
|
|
"https://mcp.example.com#f",
|
|
"https://mcp.example.com:99999",
|
|
],
|
|
)
|
|
def test_public_base_url_rejects_http_on_public_hosts_and_the_shared_url_rules(url: str) -> None:
|
|
with pytest.raises(ValidationError):
|
|
make(public_base_url=url)
|
|
|
|
|
|
def test_http_public_base_url_is_only_allowed_on_loopback() -> None:
|
|
assert make(public_base_url="http://localhost:8080").public_base_url == "http://localhost:8080"
|
|
assert make(public_base_url="http://127.0.0.1:8080").public_base_url == "http://127.0.0.1:8080"
|
|
assert make(public_base_url="http://[::1]:8080").public_base_url == "http://[::1]:8080"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("given", "used"),
|
|
[
|
|
("https://gitea.example.com/", "https://gitea.example.com"),
|
|
("https://gitea.example.com/api/v1", "https://gitea.example.com"),
|
|
("https://gitea.example.com/api/v1/", "https://gitea.example.com"),
|
|
("https://gitea.example.com/git/api/v1", "https://gitea.example.com/git"),
|
|
(" https://gitea.example.com ", "https://gitea.example.com"),
|
|
("HTTPS://gitea.example.com:3000/sub/", "https://gitea.example.com:3000/sub"),
|
|
("http://127.0.0.1:3000", "http://127.0.0.1:3000"),
|
|
],
|
|
)
|
|
def test_the_validated_url_is_the_url_that_is_used(given: str, used: str) -> None:
|
|
settings = make(gitea_url=given)
|
|
|
|
assert settings.gitea_url == used
|
|
assert settings.gitea_api_url == f"{used}/api/v1"
|
|
|
|
|
|
def test_serve_warns_about_plain_http_gitea_on_a_non_loopback_host(
|
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
from aegis_gitea_mcp import cli
|
|
|
|
monkeypatch.setenv("GITEA_URL", "http://gitea.internal")
|
|
monkeypatch.setenv("GITEA_TOKEN", "t" * 20)
|
|
monkeypatch.setattr(cli, "configure_logging", lambda level: None)
|
|
monkeypatch.setattr(transports, "run_stdio", lambda settings: 0)
|
|
|
|
assert main(["serve"]) == 0
|
|
|
|
err = capsys.readouterr().err
|
|
assert "plain http" in err and "unencrypted" in err
|
|
assert "t" * 20 not in err
|
|
|
|
|
|
@pytest.mark.parametrize("url", ["https://gitea.example.com", "http://localhost:3000"])
|
|
def test_serve_does_not_warn_for_https_or_loopback(
|
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], url: str
|
|
) -> None:
|
|
from aegis_gitea_mcp import cli
|
|
|
|
monkeypatch.setenv("GITEA_URL", url)
|
|
monkeypatch.setenv("GITEA_TOKEN", "t" * 20)
|
|
monkeypatch.setattr(cli, "configure_logging", lambda level: None)
|
|
monkeypatch.setattr(transports, "run_stdio", lambda settings: 0)
|
|
|
|
assert main(["serve"]) == 0
|
|
|
|
assert "plain http" not in capsys.readouterr().err
|
|
|
|
|
|
# Other path-like and list settings
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"path",
|
|
["mcp", "//mcp", "/", "//", "/mcp?x=1", "/mcp#f", "/m cp", ""],
|
|
)
|
|
def test_mcp_path_rejections(path: str) -> None:
|
|
with pytest.raises(ValidationError):
|
|
make(mcp_path=path)
|
|
|
|
|
|
def test_mcp_path_trailing_slash_is_normalized() -> None:
|
|
assert make(mcp_path="/mcp/").mcp_path == "/mcp"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"value",
|
|
["not-an-ip", "10.0.0.0/33", "*,10.0.0.1", "10.0.0.1,*", "10.0.0.1;10.0.0.2", "10.0.0.256"],
|
|
)
|
|
def test_forwarded_allow_ips_rejections(value: str) -> None:
|
|
with pytest.raises(ValidationError):
|
|
make(forwarded_allow_ips=value)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"scopes",
|
|
[
|
|
["all"],
|
|
["read:unknown"],
|
|
["repo"],
|
|
["write:repository", "sudo"],
|
|
["admin"],
|
|
[""],
|
|
[],
|
|
],
|
|
)
|
|
def test_oauth_scopes_reject_unknown_scopes_because_gitea_would_grant_everything(
|
|
scopes: list[str],
|
|
) -> None:
|
|
with pytest.raises(ValidationError):
|
|
make(oauth_scopes=scopes)
|
|
|
|
|
|
def test_oauth_scopes_accept_granular_scopes_only() -> None:
|
|
assert make(oauth_scopes=["read:repository", "write:issue"]).oauth_scopes == [
|
|
"read:repository",
|
|
"write:issue",
|
|
]
|
|
|
|
|
|
def test_secret_length_minimums() -> None:
|
|
with pytest.raises(ValidationError):
|
|
make(mcp_bearer_token="short")
|
|
with pytest.raises(ValidationError):
|
|
make(token_encryption_key="x" * 31)
|
|
|
|
|
|
# Redirect URIs at client registration
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"uri",
|
|
[
|
|
"http://example.com/cb",
|
|
"http://localhost.evil.example/cb",
|
|
"https://example.com/cb#frag",
|
|
"https://user:pass@example.com/cb",
|
|
"javascript:alert(1)",
|
|
"data:text/html,hi",
|
|
"file:///etc/passwd",
|
|
"myapp://callback",
|
|
"claude://callback",
|
|
"https:///cb",
|
|
"https://example.com:99999/cb",
|
|
"",
|
|
],
|
|
)
|
|
def test_redirect_uri_rules(uri: str) -> None:
|
|
assert redirect_uri_problem(uri) is not None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"uri",
|
|
[
|
|
"https://claude.ai/api/mcp/auth_callback",
|
|
"http://localhost:3000/callback",
|
|
"http://127.0.0.1/cb",
|
|
"http://[::1]:5000/cb",
|
|
],
|
|
)
|
|
def test_acceptable_redirect_uris(uri: str) -> None:
|
|
assert redirect_uri_problem(uri) is None
|