fix: fail closed on an unwritable audit log, bound the rate limiter and harden startup
The audit logger is opened at startup (exit 2 on failure) and obtained before every call, which is refused with audit_unavailable otherwise. Audit records only take validated path and sudo values and use the request id as correlation id. The rate limiter evicts idle keys, caps tracked keys, audits the first excess per key and window and uses the settings passed to the app. FORWARDED_ALLOW_IPS is now a setting, and startup warns about a missing host allow-list and unavailable OAuth.
This commit is contained in:
1 parent
a3222d5ebe
commit
cf3d2e17d3
16 files changed
+532
-115
No files matched your search
@@ -14,7 +14,6 @@ import pytest
|
||||
|
||||
from aegis_gitea_mcp.audit import reset_audit_logger
|
||||
from aegis_gitea_mcp.config import Settings, reset_settings
|
||||
from aegis_gitea_mcp.rate_limit import reset_rate_limiter
|
||||
from aegis_gitea_mcp.request_context import clear_gitea_auth_context
|
||||
|
||||
|
||||
@@ -22,7 +21,6 @@ def _reset_singletons() -> None:
|
||||
"""Reset every module-level singleton."""
|
||||
reset_settings()
|
||||
reset_audit_logger()
|
||||
reset_rate_limiter()
|
||||
clear_gitea_auth_context()
|
||||
|
||||
|
||||
|
||||
+86
-21
@@ -2,21 +2,30 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from aegis_gitea_mcp.audit import validate_audit_log_integrity
|
||||
from aegis_gitea_mcp.audit import get_audit_logger, validate_audit_log_integrity
|
||||
from aegis_gitea_mcp.config import get_settings
|
||||
from aegis_gitea_mcp.rate_limit import (
|
||||
RequestRateLimiter,
|
||||
SlidingWindowLimiter,
|
||||
get_rate_limiter,
|
||||
reset_rate_limiter,
|
||||
)
|
||||
from aegis_gitea_mcp.rate_limit import RequestRateLimiter, SlidingWindowLimiter
|
||||
from tests.test_server_helpers import server_settings
|
||||
|
||||
|
||||
def test_sliding_window_blocks_after_limit(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@pytest.fixture
|
||||
def clock(monkeypatch: pytest.MonkeyPatch) -> list[float]:
|
||||
now = [1000.0]
|
||||
monkeypatch.setattr("aegis_gitea_mcp.rate_limit.time.time", lambda: now[0])
|
||||
return now
|
||||
|
||||
|
||||
def security_events() -> list[dict[str, object]]:
|
||||
path = get_audit_logger().log_path
|
||||
entries = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()]
|
||||
return [e["payload"] for e in entries if e["event_type"] == "security_event"]
|
||||
|
||||
|
||||
def test_sliding_window_blocks_after_limit(clock: list[float]) -> None:
|
||||
limiter = SlidingWindowLimiter(max_requests=2, window_seconds=60)
|
||||
|
||||
assert limiter.allow("k")
|
||||
@@ -24,22 +33,56 @@ def test_sliding_window_blocks_after_limit(monkeypatch: pytest.MonkeyPatch) -> N
|
||||
assert not limiter.allow("k")
|
||||
assert limiter.allow("other")
|
||||
|
||||
now[0] += 61
|
||||
clock[0] += 61
|
||||
assert limiter.allow("k")
|
||||
|
||||
|
||||
def test_request_limiter_per_ip_and_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("GITEA_URL", "https://g.example.com")
|
||||
monkeypatch.setenv("RATE_LIMIT_PER_MINUTE", "2")
|
||||
limiter = RequestRateLimiter()
|
||||
def test_idle_keys_are_evicted(clock: list[float]) -> None:
|
||||
limiter = SlidingWindowLimiter(max_requests=5, window_seconds=60)
|
||||
for index in range(100):
|
||||
limiter.allow(f"client-{index}")
|
||||
assert len(limiter) == 100
|
||||
|
||||
clock[0] += 61
|
||||
limiter.allow("fresh")
|
||||
|
||||
assert len(limiter) == 1
|
||||
|
||||
|
||||
def test_key_count_is_capped_and_least_recently_used_keys_go_first(clock: list[float]) -> None:
|
||||
limiter = SlidingWindowLimiter(max_requests=1, window_seconds=60, max_keys=3)
|
||||
for key in ("a", "b", "c"):
|
||||
limiter.allow(key)
|
||||
limiter.allow("a") # touch: "b" is now the oldest
|
||||
limiter.allow("d")
|
||||
|
||||
assert len(limiter) == 3
|
||||
assert limiter.allow("b") # evicted earlier, so it starts with a clean window
|
||||
assert not limiter.allow("d")
|
||||
|
||||
|
||||
def test_first_excess_reports_once_per_window(clock: list[float]) -> None:
|
||||
limiter = SlidingWindowLimiter(max_requests=1, window_seconds=60)
|
||||
limiter.allow("k")
|
||||
|
||||
assert not limiter.allow("k") and limiter.first_excess("k")
|
||||
assert not limiter.allow("k") and not limiter.first_excess("k")
|
||||
clock[0] += 30
|
||||
assert not limiter.allow("k") and not limiter.first_excess("k")
|
||||
clock[0] += 31
|
||||
assert limiter.allow("k") # the old event left the window
|
||||
assert not limiter.allow("k") and limiter.first_excess("k")
|
||||
|
||||
|
||||
def test_request_limiter_per_ip_and_token(mock_env: None) -> None:
|
||||
limiter = RequestRateLimiter(server_settings(rate_limit_per_minute=2))
|
||||
|
||||
assert limiter.check("1.1.1.1", "tok").allowed
|
||||
assert limiter.check("1.1.1.1", None).allowed
|
||||
assert not limiter.check("1.1.1.1", "tok").allowed
|
||||
|
||||
# A different IP with the same token trips the per-token limiter.
|
||||
other = limiter.check("2.2.2.2", "tok")
|
||||
assert other.allowed or other.reason == "Per-token rate limit exceeded"
|
||||
# The same token from other IPs trips the per-token limiter.
|
||||
assert limiter.check("2.2.2.2", "tok").allowed
|
||||
third = limiter.check("3.3.3.3", "tok")
|
||||
assert not third.allowed
|
||||
assert third.reason == "Per-token rate limit exceeded"
|
||||
@@ -48,8 +91,30 @@ def test_request_limiter_per_ip_and_token(monkeypatch: pytest.MonkeyPatch) -> No
|
||||
assert valid, errors
|
||||
|
||||
|
||||
def test_global_limiter_singleton(mock_env: None) -> None:
|
||||
first = get_rate_limiter()
|
||||
assert get_rate_limiter() is first
|
||||
reset_rate_limiter()
|
||||
assert get_rate_limiter() is not first
|
||||
def test_only_the_first_excess_per_key_is_audited(mock_env: None, clock: list[float]) -> None:
|
||||
limiter = RequestRateLimiter(server_settings(rate_limit_per_minute=1))
|
||||
limiter.check("1.1.1.1", None)
|
||||
for _ in range(20):
|
||||
assert not limiter.check("1.1.1.1", None).allowed
|
||||
|
||||
events = security_events()
|
||||
assert [e["event_type"] for e in events] == ["rate_limit_ip_exceeded"]
|
||||
|
||||
clock[0] += 61
|
||||
limiter.check("1.1.1.1", None)
|
||||
assert not limiter.check("1.1.1.1", None).allowed
|
||||
assert len(security_events()) == 2
|
||||
|
||||
|
||||
def test_unwritable_audit_log_does_not_break_rate_limiting(
|
||||
mock_env: None, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
def broken() -> None:
|
||||
raise OSError("read-only file system")
|
||||
|
||||
monkeypatch.setattr("aegis_gitea_mcp.rate_limit.get_audit_logger", broken)
|
||||
limiter = RequestRateLimiter(server_settings(rate_limit_per_minute=1))
|
||||
limiter.check("1.1.1.1", None)
|
||||
|
||||
assert not limiter.check("1.1.1.1", None).allowed
|
||||
assert "OSError" in caplog.text
|
||||
+65
-1
@@ -469,7 +469,7 @@ async def test_audit_event_per_call_never_contains_argument_values(
|
||||
assert validate_audit_log_integrity(log_path) == (True, [])
|
||||
|
||||
|
||||
async def test_audit_failure_does_not_break_the_call(
|
||||
async def test_audit_write_failure_after_execution_does_not_break_the_call(
|
||||
httpx_mock: HTTPXMock, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
mock_user(httpx_mock)
|
||||
@@ -482,11 +482,75 @@ async def test_audit_failure_does_not_break_the_call(
|
||||
async with connected(audit=BrokenAudit()) as client: # type: ignore[arg-type]
|
||||
result = await client.call_tool("gitea_repo_get", REPO_ARGS)
|
||||
|
||||
# The call already ran (its side effect cannot be undone), so the result still goes out.
|
||||
assert not result.is_error
|
||||
assert "OSError" in caplog.text
|
||||
assert GITEA_TOKEN not in caplog.text
|
||||
|
||||
|
||||
async def test_unavailable_audit_log_refuses_the_call_before_gitea_is_contacted(
|
||||
httpx_mock: HTTPXMock, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
def broken() -> None:
|
||||
raise OSError("permission denied: " + GITEA_TOKEN)
|
||||
|
||||
monkeypatch.setattr("aegis_gitea_mcp.server.get_audit_logger", broken)
|
||||
async with connected() as client:
|
||||
result = await client.call_tool("gitea_repo_get", REPO_ARGS)
|
||||
written = await client.call_tool("gitea_issue_create_issue", {**REPO_ARGS, "body": {}})
|
||||
|
||||
assert result.is_error and written.is_error
|
||||
error = payload_of(result)["error"]
|
||||
assert error["kind"] == "audit_unavailable"
|
||||
assert GITEA_TOKEN not in json.dumps(payload_of(result)) + caplog.text
|
||||
assert httpx_mock.get_requests() == []
|
||||
|
||||
|
||||
async def test_audit_correlation_id_is_the_request_id(httpx_mock: HTTPXMock) -> None:
|
||||
mock_user(httpx_mock)
|
||||
httpx_mock.add_response(url=f"{API}/repos/octo/demo", json={})
|
||||
async with connected() as client:
|
||||
await client.call_tool("gitea_repo_get", REPO_ARGS)
|
||||
|
||||
from aegis_gitea_mcp.audit import get_audit_logger
|
||||
|
||||
line = get_audit_logger().log_path.read_text(encoding="utf-8").splitlines()[-1]
|
||||
entry = json.loads(line)
|
||||
assert entry["payload"]["correlation_id"] == entry["request_id"] != "-"
|
||||
|
||||
|
||||
async def test_rejected_raw_call_audits_no_unvalidated_path_or_sudo() -> None:
|
||||
secret = "SECRET-IN-PATH-9f8e7d6c5b"
|
||||
async with connected() as client:
|
||||
bad_path = await client.call_tool(
|
||||
"gitea_api_request", {"method": "GET", "path": secret, "sudo": secret}
|
||||
)
|
||||
bad_sudo = await client.call_tool(
|
||||
"gitea_api_request", {"method": "GET", "path": "/user", "sudo": "bad name!"}
|
||||
)
|
||||
|
||||
assert bad_path.is_error and bad_sudo.is_error
|
||||
first, second = audit_entries()
|
||||
assert first["outcome"] == second["outcome"] == "invalid_argument"
|
||||
for entry in (first, second):
|
||||
assert "path" not in entry and "sudo" not in entry
|
||||
from aegis_gitea_mcp.audit import get_audit_logger
|
||||
|
||||
assert secret not in get_audit_logger().log_path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
async def test_raw_call_audits_the_validated_method_and_path(httpx_mock: HTTPXMock) -> None:
|
||||
mock_user(httpx_mock)
|
||||
httpx_mock.add_response(url=f"{API}/version", json={})
|
||||
async with connected() as client:
|
||||
await client.call_tool(
|
||||
"gitea_api_request", {"method": "GET", "path": "/version", "sudo": "alice"}
|
||||
)
|
||||
|
||||
entry = audit_entries()[-1]
|
||||
assert (entry["method"], entry["path"], entry["sudo"]) == ("GET", "/version", "alice")
|
||||
|
||||
|
||||
# Internal errors
|
||||
|
||||
|
||||
|
||||
+18
-11
@@ -25,6 +25,7 @@ from aegis_gitea_mcp.gitea import RAW_TOOL_NAME
|
||||
from aegis_gitea_mcp.identity import Identity
|
||||
from aegis_gitea_mcp.transports import (
|
||||
GiteaProbe,
|
||||
RateLimitMiddleware,
|
||||
build_http_app,
|
||||
transport_security,
|
||||
)
|
||||
@@ -336,11 +337,7 @@ def test_transport_security_wildcard_bind_adds_no_bind_host() -> None:
|
||||
# Rate limiting
|
||||
|
||||
|
||||
def test_rate_limit_returns_429_on_the_mcp_path_only(
|
||||
httpx_mock: HTTPXMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# The limiter reads the process-wide settings.
|
||||
monkeypatch.setenv("RATE_LIMIT_PER_MINUTE", "2")
|
||||
def test_rate_limit_returns_429_on_the_mcp_path_only(httpx_mock: HTTPXMock) -> None:
|
||||
httpx_mock.add_response(url=f"{API}/version", json={}, is_reusable=True)
|
||||
settings = http_settings(rate_limit_per_minute=2)
|
||||
with TestClient(build_http_app(settings), base_url=BASE) as c:
|
||||
@@ -357,22 +354,32 @@ def test_rate_limit_returns_429_on_the_mcp_path_only(
|
||||
assert health.status_code == 200
|
||||
|
||||
|
||||
def test_rate_limit_is_keyed_per_token(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("RATE_LIMIT_PER_MINUTE", "1")
|
||||
def test_rate_limit_is_keyed_per_token_hash() -> None:
|
||||
settings = http_settings(rate_limit_per_minute=1)
|
||||
with TestClient(build_http_app(settings), base_url=BASE) as c:
|
||||
app = build_http_app(settings)
|
||||
with TestClient(app, base_url=BASE) as c:
|
||||
first = c.post("/mcp", json=INITIALIZE, headers={**MCP_HEADERS, **auth()})
|
||||
limiter = next(m for m in app.user_middleware if m.cls is RateLimitMiddleware).kwargs["limiter"]
|
||||
|
||||
assert first.status_code == 200
|
||||
from aegis_gitea_mcp.rate_limit import get_rate_limiter
|
||||
|
||||
limiter = get_rate_limiter()
|
||||
assert limiter._token_limiter.max_requests == 1
|
||||
# The raw header is never used as a key, only its hash.
|
||||
keys = list(limiter._token_limiter._events)
|
||||
assert keys and all(BEARER_TOKEN not in key for key in keys)
|
||||
|
||||
|
||||
def test_rate_limit_uses_the_settings_passed_to_the_app(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("RATE_LIMIT_PER_MINUTE", "500") # the process-wide value must not matter
|
||||
app = build_http_app(http_settings(rate_limit_per_minute=1))
|
||||
with TestClient(app, base_url=BASE) as c:
|
||||
statuses = [
|
||||
c.post("/mcp", json=INITIALIZE, headers={**MCP_HEADERS, **auth()}).status_code
|
||||
for _ in range(2)
|
||||
]
|
||||
|
||||
assert statuses == [200, 429]
|
||||
|
||||
|
||||
# Auth wiring
|
||||
|
||||
|
||||
|
||||
@@ -95,14 +95,107 @@ def test_loopback_without_auth_runs(uvicorn_spy: UvicornSpy) -> None:
|
||||
assert uvicorn_spy.calls[0][1]["log_level"] == "debug"
|
||||
|
||||
|
||||
def test_forwarded_allow_ips_setting_is_honoured_when_present(
|
||||
uvicorn_spy: UvicornSpy, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(Settings, "forwarded_allow_ips", "10.0.0.0/8", raising=False)
|
||||
def test_forwarded_allow_ips_setting_is_passed_to_uvicorn(uvicorn_spy: UvicornSpy) -> None:
|
||||
assert run_http(server_settings(forwarded_allow_ips="10.0.0.0/8, 172.18.0.2")) == 0
|
||||
|
||||
assert uvicorn_spy.calls[0][1]["forwarded_allow_ips"] == "10.0.0.0/8,172.18.0.2"
|
||||
|
||||
|
||||
def test_forwarded_allow_ips_wildcard_needs_an_explicit_value_and_warns(
|
||||
uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
assert server_settings().forwarded_allow_ips == "127.0.0.1"
|
||||
|
||||
assert run_http(server_settings(forwarded_allow_ips="*")) == 0
|
||||
|
||||
assert uvicorn_spy.calls[0][1]["forwarded_allow_ips"] == "*"
|
||||
assert "FORWARDED_ALLOW_IPS=*" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_non_loopback_bind_without_public_host_warns_which_variables_to_set(
|
||||
uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
assert run_http(server_settings(mcp_host=ANY_HOST, mcp_bearer_token=BEARER_TOKEN)) == 0
|
||||
|
||||
err = capsys.readouterr().err
|
||||
assert "PUBLIC_BASE_URL" in err and "ALLOWED_HOSTS" in err and "421" in err
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"extra",
|
||||
[{"public_base_url": "https://mcp.example.com"}, {"allowed_hosts": ["mcp.example.com"]}],
|
||||
)
|
||||
def test_no_host_warning_when_a_public_host_is_configured(
|
||||
uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str], extra: dict[str, Any]
|
||||
) -> None:
|
||||
settings = server_settings(mcp_host=ANY_HOST, mcp_bearer_token=BEARER_TOKEN, **extra)
|
||||
|
||||
assert run_http(settings) == 0
|
||||
|
||||
assert "421" not in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_no_host_warning_on_loopback(
|
||||
uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
assert run_http(server_settings()) == 0
|
||||
|
||||
assert uvicorn_spy.calls[0][1]["forwarded_allow_ips"] == "10.0.0.0/8"
|
||||
assert capsys.readouterr().err == ""
|
||||
|
||||
|
||||
def test_oauth_enabled_with_a_bearer_warns_that_oauth_is_unavailable(
|
||||
uvicorn_spy: UvicornSpy, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
assert run_http(oauth_settings(mcp_bearer_token=BEARER_TOKEN)) == 0
|
||||
|
||||
assert "per-user OAuth is not available" in capsys.readouterr().err
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["*,10.0.0.1", "not-an-ip", "10.0.0.1/99"])
|
||||
def test_invalid_forwarded_allow_ips_is_rejected(value: str) -> None:
|
||||
with pytest.raises(ValueError, match="FORWARDED_ALLOW_IPS"):
|
||||
server_settings(forwarded_allow_ips=value)
|
||||
|
||||
|
||||
def test_blank_forwarded_allow_ips_means_the_default() -> None:
|
||||
assert server_settings(forwarded_allow_ips=" ").forwarded_allow_ips == "127.0.0.1"
|
||||
|
||||
|
||||
# Audit log at startup
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unwritable_audit(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""An audit path whose parent is a regular file, so it can never be opened."""
|
||||
blocker = tmp_path / "blocker"
|
||||
blocker.write_text("x", encoding="utf-8")
|
||||
path = blocker / "audit.log"
|
||||
monkeypatch.setenv("AUDIT_LOG_PATH", str(path))
|
||||
return path
|
||||
|
||||
|
||||
def test_http_exits_2_when_the_audit_log_cannot_be_opened(
|
||||
uvicorn_spy: UvicornSpy, unwritable_audit: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
assert run_http(server_settings(audit_log_path=unwritable_audit)) == 2
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert uvicorn_spy.calls == []
|
||||
assert "audit log" in captured.err and "AUDIT_LOG_PATH" in captured.err
|
||||
assert str(unwritable_audit) in captured.err
|
||||
assert captured.out == ""
|
||||
|
||||
|
||||
def test_stdio_exits_2_when_the_audit_log_cannot_be_opened(
|
||||
unwritable_audit: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
monkeypatch.setattr(transports.anyio, "run", lambda *a: pytest.fail("must not serve"))
|
||||
|
||||
assert run_stdio(server_settings(audit_log_path=unwritable_audit)) == 2
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "AUDIT_LOG_PATH" in captured.err
|
||||
assert captured.out == ""
|
||||
|
||||
|
||||
def test_oauth_misconfiguration_lists_every_missing_setting(
|
||||
|
||||
Reference in new issue
Block a user