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

+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