Files
AegisGitea-MCP/tests/security/test_base_url_validation.py
T

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