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
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user