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

+66 -8
View File
@@ -31,9 +31,10 @@ from starlette.routing import Route
from starlette.types import ASGIApp, Receive, Scope, Send
from aegis_gitea_mcp import __version__
from aegis_gitea_mcp.audit import get_audit_logger
from aegis_gitea_mcp.auth import CompositeVerifier, IdentityResolver, StaticBearerVerifier
from aegis_gitea_mcp.config import Settings
from aegis_gitea_mcp.rate_limit import get_rate_limiter
from aegis_gitea_mcp.rate_limit import RequestRateLimiter
from aegis_gitea_mcp.server import (
SERVER_NAME,
AegisServer,
@@ -134,9 +135,10 @@ class GiteaProbe:
class RateLimitMiddleware:
"""ASGI middleware applying the per-IP and per-token limits to the MCP path."""
def __init__(self, app: ASGIApp, mcp_path: str) -> None:
def __init__(self, app: ASGIApp, mcp_path: str, limiter: RequestRateLimiter) -> None:
self.app = app
self._mcp_path = mcp_path
self._limiter = limiter
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http" or scope["path"].rstrip("/") != self._mcp_path:
@@ -149,7 +151,7 @@ class RateLimitMiddleware:
if name == b"authorization":
authorization = value.decode("latin-1")
break
decision = get_rate_limiter().check(client_ip, authorization)
decision = self._limiter.check(client_ip, authorization)
if decision.allowed:
await self.app(scope, receive, send)
return
@@ -307,7 +309,9 @@ def build_http_app(
await gitea_probe.aclose()
app.router.lifespan_context = lifespan
app.add_middleware(RateLimitMiddleware, mcp_path=settings.mcp_path)
app.add_middleware(
RateLimitMiddleware, mcp_path=settings.mcp_path, limiter=RequestRateLimiter(settings)
)
return app
@@ -333,10 +337,56 @@ def http_startup_errors(settings: Settings, extras: HttpAuthExtras | None) -> li
return errors
def http_startup_warnings(settings: Settings, extras: HttpAuthExtras | None) -> list[str]:
"""List non-fatal startup problems of the HTTP server (printed to stderr)."""
warnings: list[str] = []
if (
not settings.is_loopback_bind()
and not settings.public_base_url
and not settings.allowed_hosts
):
warnings.append(
f"MCP_HOST={settings.mcp_host} is not loopback and neither PUBLIC_BASE_URL nor "
"ALLOWED_HOSTS is set: requests whose Host header is not a loopback name or the "
"bind address are rejected with 421. Set PUBLIC_BASE_URL (the public https URL) "
"or ALLOWED_HOSTS (comma separated host names clients use)"
)
if settings.oauth_enabled and extras is None and settings.mcp_bearer_token is not None:
warnings.append(
"OAUTH_ENABLED=true but per-user OAuth is not available in this build: only "
"MCP_BEARER_TOKEN is accepted and every call runs as the GITEA_TOKEN owner"
)
if settings.forwarded_allow_ips == "*":
warnings.append(
"FORWARDED_ALLOW_IPS=*: X-Forwarded-For is trusted from any peer, so clients can "
"spoof their IP and dodge the per-IP rate limit. List the proxy addresses instead"
)
return warnings
def _audit_startup_error(settings: Settings) -> str | None:
"""Open the audit log now; return an error message when it is not writable.
A server that cannot record its calls must not start: the dispatcher refuses
calls without an audit log, so every call would fail anyway.
"""
try:
get_audit_logger()
except OSError as exc:
reason = exc.strerror or type(exc).__name__
return (
f"cannot open the audit log {settings.resolved_audit_log_path} for append "
f"({reason}). Fix the permissions or point AUDIT_LOG_PATH (or DATA_DIR) at a "
"writable location"
)
return None
def run_http(settings: Settings) -> int:
"""Run the streamable HTTP server; return a process exit code.
Returns 2 without starting when a startup check fails.
Returns 2 without starting when a startup check fails (including an audit
log that cannot be opened for append).
"""
extras = load_oauth_components(settings) if settings.oauth_enabled else None
errors = http_startup_errors(settings, extras)
@@ -344,6 +394,11 @@ def run_http(settings: Settings) -> int:
for message in errors:
_fail(message)
return EXIT_USAGE
audit_error = _audit_startup_error(settings)
if audit_error is not None:
return _fail(audit_error)
for message in http_startup_warnings(settings, extras):
print(f"aegis-gitea-mcp: warning: {message}", file=sys.stderr)
try:
server = build_server(settings, resolver=extras.resolver if extras else None)
except ServerConfigError as exc:
@@ -363,7 +418,7 @@ def run_http(settings: Settings) -> int:
port=settings.mcp_port,
log_level=settings.log_level.lower(),
proxy_headers=True,
forwarded_allow_ips=getattr(settings, "forwarded_allow_ips", None) or "127.0.0.1",
forwarded_allow_ips=settings.forwarded_allow_ips,
server_header=False,
)
return 0
@@ -377,11 +432,14 @@ async def _serve_stdio(server: AegisServer) -> None:
def run_stdio(settings: Settings) -> int:
"""Run the local stdio server (single identity: the owner of ``GITEA_TOKEN``).
Returns 2 without starting when ``GITEA_TOKEN`` is missing or the tool
filters are invalid. All logging goes to stderr; stdout carries JSON-RPC only.
Returns 2 without starting when ``GITEA_TOKEN`` is missing, the audit log is
not writable or the tool filters are invalid. All logging goes to stderr; stdout carries JSON-RPC only.
"""
if settings.gitea_token is None:
return _fail("stdio needs GITEA_TOKEN; create a Gitea access token and set it in .env")
audit_error = _audit_startup_error(settings)
if audit_error is not None:
return _fail(audit_error)
try:
server = build_server(settings)
except ServerConfigError as exc: