fix: add OAUTH_REDIRECT_ALLOWLIST for client registration and waive the open default (SEC-05)
This commit is contained in:
1 parent
79aa4f7fc0
commit
8be859243d
7 files changed
+283
-11
No files matched your search
@@ -0,0 +1,167 @@
|
||||
"""Redirect URIs accepted at dynamic client registration (SEC-05 and the base rules)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from aegis_gitea_mcp.config import redirect_matches_allowlist
|
||||
from tests.test_oauth_helpers import browser, build_oauth_app, oauth_settings, register
|
||||
|
||||
ALLOWLIST = ["https://claude.ai/", "https://chat.mistral.ai/", "https://client.example/cb"]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _env(mock_env: None) -> None:
|
||||
"""The audit logger and the rate limiter read the process-wide settings."""
|
||||
|
||||
|
||||
def web(**settings: object) -> TestClient:
|
||||
return browser(build_oauth_app(oauth_settings(**settings)).app)
|
||||
|
||||
|
||||
def error_of(response: object) -> str:
|
||||
assert response.status_code == 400 # type: ignore[attr-defined]
|
||||
return str(response.json()["error"]) # type: ignore[attr-defined]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"uri",
|
||||
[
|
||||
"https://evil.example/cb",
|
||||
"https://claude.ai.evil.example/api/mcp/auth_callback",
|
||||
"https://evilclaude.ai/cb",
|
||||
"https://claude.ai@evil.example/cb",
|
||||
"https://client.example/cb2",
|
||||
"https://client.example/other",
|
||||
"http://claude.ai/api/mcp/auth_callback",
|
||||
],
|
||||
)
|
||||
def test_redirect_outside_the_allowlist_is_refused(uri: str) -> None:
|
||||
response = register(web(oauth_redirect_allowlist=ALLOWLIST), [uri])
|
||||
|
||||
assert error_of(response) == "invalid_redirect_uri"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"uri",
|
||||
[
|
||||
"https://claude.ai/api/mcp/auth_callback",
|
||||
"https://CLAUDE.ai/api/mcp/auth_callback",
|
||||
"https://chat.mistral.ai/oauth/callback",
|
||||
"https://client.example/cb",
|
||||
"http://localhost:3000/callback",
|
||||
"http://127.0.0.1:8123/cb",
|
||||
"http://[::1]:8123/cb",
|
||||
],
|
||||
)
|
||||
def test_allowlisted_and_loopback_redirects_register(uri: str) -> None:
|
||||
response = register(web(oauth_redirect_allowlist=ALLOWLIST), [uri])
|
||||
|
||||
assert response.status_code == 201, response.text
|
||||
|
||||
|
||||
def test_one_bad_uri_among_good_ones_refuses_the_registration() -> None:
|
||||
response = register(
|
||||
web(oauth_redirect_allowlist=ALLOWLIST),
|
||||
["https://claude.ai/cb", "https://evil.example/cb"],
|
||||
)
|
||||
|
||||
assert error_of(response) == "invalid_redirect_uri"
|
||||
|
||||
|
||||
def test_default_allowlist_is_empty_and_accepts_any_https_redirect() -> None:
|
||||
assert oauth_settings().oauth_redirect_allowlist == []
|
||||
assert register(web(), ["https://anything.example/cb"]).status_code == 201
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"uri",
|
||||
[
|
||||
"http://example.com/cb",
|
||||
"https://example.com/cb#frag",
|
||||
"https://user:pass@example.com/cb",
|
||||
"javascript:alert(1)",
|
||||
"custom-app://callback",
|
||||
"file:///etc/passwd",
|
||||
"data:text/html,x",
|
||||
"ftp://example.com/cb",
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("allowlist", [[], ALLOWLIST])
|
||||
def test_the_base_redirect_rules_hold_with_and_without_an_allowlist(
|
||||
uri: str, allowlist: list[str]
|
||||
) -> None:
|
||||
response = register(web(oauth_redirect_allowlist=allowlist), [uri])
|
||||
|
||||
assert response.status_code == 400, response.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"entry",
|
||||
[
|
||||
"https://claude.ai",
|
||||
"claude.ai/",
|
||||
"http://claude.ai/",
|
||||
"https://user@claude.ai/",
|
||||
"https://claude.ai/?x=1",
|
||||
"https://claude.ai/#f",
|
||||
"https://claude .ai/",
|
||||
"ftp://claude.ai/",
|
||||
"https:///",
|
||||
],
|
||||
)
|
||||
def test_malformed_allowlist_entries_stop_startup(entry: str) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
oauth_settings(oauth_redirect_allowlist=[entry])
|
||||
|
||||
|
||||
def test_allowlist_is_read_from_a_comma_separated_variable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from aegis_gitea_mcp.config import Settings
|
||||
|
||||
monkeypatch.setenv("OAUTH_REDIRECT_ALLOWLIST", "https://claude.ai/, https://chat.mistral.ai/")
|
||||
monkeypatch.setenv("GITEA_URL", "https://gitea.example.com")
|
||||
|
||||
assert Settings().oauth_redirect_allowlist == ["https://claude.ai/", "https://chat.mistral.ai/"]
|
||||
|
||||
|
||||
def test_prefix_matching_is_by_host_not_by_string_prefix() -> None:
|
||||
assert redirect_matches_allowlist("https://claude.ai/x", ["https://claude.ai/"])
|
||||
assert not redirect_matches_allowlist(
|
||||
"https://claude.ai.evil.example/x", ["https://claude.ai/"]
|
||||
)
|
||||
assert not redirect_matches_allowlist("https://claude.ai:8443/x", ["https://claude.ai/"])
|
||||
assert redirect_matches_allowlist("https://anything/x", [])
|
||||
|
||||
|
||||
def test_a_client_registered_before_the_allowlist_cannot_authorize() -> None:
|
||||
from tests.test_oauth_helpers import pkce
|
||||
|
||||
unrestricted = build_oauth_app(oauth_settings())
|
||||
client = browser(unrestricted.app)
|
||||
registration = register(client, ["https://evil.example/cb"]).json()
|
||||
assert registration["client_id"]
|
||||
|
||||
# Same store, allowlist now set: /authorize must refuse the old client.
|
||||
restricted = build_oauth_app(
|
||||
oauth_settings(oauth_redirect_allowlist=ALLOWLIST), clock=unrestricted.clock
|
||||
)
|
||||
restricted.store.clients.update(unrestricted.store.clients)
|
||||
response = browser(restricted.app).get(
|
||||
"/authorize",
|
||||
params={
|
||||
"client_id": registration["client_id"],
|
||||
"redirect_uri": "https://evil.example/cb",
|
||||
"response_type": "code",
|
||||
"code_challenge": pkce()[1],
|
||||
"code_challenge_method": "S256",
|
||||
},
|
||||
)
|
||||
|
||||
location = response.headers["location"]
|
||||
assert response.status_code == 302
|
||||
assert "error=invalid_request" in location
|
||||
assert "/oauth/consent" not in location and "code=" not in location
|
||||
Reference in new issue
Block a user