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:
Latte committed 2026-10-14 16:24:51 +02:00
1 parent a3222d5ebe
commit cf3d2e17d3
16 files changed
+532 -115

No files matched your search

-2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+98 -5
View File
@@ -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(